diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml
index c9448b094e89..395ffc395950 100644
--- a/.github/workflows/build-pull-request.yml
+++ b/.github/workflows/build-pull-request.yml
@@ -7,7 +7,7 @@ permissions:
 jobs:
   build:
     name: Build pull request
-    runs-on: ubuntu-latest
+    runs-on: ubuntu22-8-32
     if: ${{ github.repository == 'spring-projects/spring-boot' }}
     steps:
       - name: Set up JDK 17
@@ -17,13 +17,13 @@ jobs:
           distribution: 'liberica'
 
       - name: Check out code
-        uses: actions/checkout@v3
+        uses: actions/checkout@v4
 
       - name: Validate Gradle wrapper
         uses: gradle/wrapper-validation-action@v1
 
       - name: Set up Gradle
-        uses: gradle/gradle-build-action@bd5760595778326ba7f1441bcf7e88b49de61a25
+        uses: gradle/gradle-build-action@842c587ad8aa4c68eeba24c396e15af4c2e9f30a
 
       - name: Build
         env:
@@ -37,6 +37,7 @@ jobs:
 
       - name: Upload build reports
         uses: actions/upload-artifact@v3
+        if: failure()
         with:
           name: build-reports
           path: '**/build/reports/'
diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml
index c80a7e5278d0..c87c892efbf0 100644
--- a/.github/workflows/gradle-wrapper-validation.yml
+++ b/.github/workflows/gradle-wrapper-validation.yml
@@ -9,5 +9,5 @@ jobs:
     name: "Validation"
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
       - uses: gradle/wrapper-validation-action@v1
diff --git a/.gitignore b/.gitignore
index 6edbdcda3124..a5256ecef4fa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,7 +12,6 @@
 .classpath
 .factorypath
 .gradle
-.idea
 .metadata
 .project
 .recommenders
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 000000000000..f1e07ef8c39f
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,10 @@
+.name
+*.xml
+/modules/
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
new file mode 100644
index 000000000000..854b5bf05230
--- /dev/null
+++ b/.idea/codeStyles/Project.xml
@@ -0,0 +1,121 @@
+<component name="ProjectCodeStyleConfiguration">
+  <code_scheme name="Project" version="173">
+    <option name="AUTODETECT_INDENTS" value="false" />
+    <option name="OTHER_INDENT_OPTIONS">
+      <value>
+        <option name="USE_TAB_CHARACTER" value="true" />
+      </value>
+    </option>
+    <option name="CLASS_COUNT_TO_USE_IMPORT_ON_DEMAND" value="500" />
+    <option name="NAMES_COUNT_TO_USE_IMPORT_ON_DEMAND" value="500" />
+    <option name="IMPORT_LAYOUT_TABLE">
+      <value>
+        <package name="java" withSubpackages="true" static="false" />
+        <emptyLine />
+        <package name="javax" withSubpackages="true" static="false" />
+        <emptyLine />
+        <package name="" withSubpackages="true" static="false" />
+        <emptyLine />
+        <package name="org.springframework" withSubpackages="true" static="false" />
+        <emptyLine />
+        <package name="" withSubpackages="true" static="true" />
+      </value>
+    </option>
+    <option name="RIGHT_MARGIN" value="90" />
+    <option name="ENABLE_JAVADOC_FORMATTING" value="false" />
+    <GroovyCodeStyleSettings>
+      <option name="CLASS_COUNT_TO_USE_IMPORT_ON_DEMAND" value="500" />
+      <option name="NAMES_COUNT_TO_USE_IMPORT_ON_DEMAND" value="500" />
+      <option name="IMPORT_LAYOUT_TABLE">
+        <value>
+          <emptyLine />
+          <package name="javax" withSubpackages="true" static="false" />
+          <package name="java" withSubpackages="true" static="false" />
+          <emptyLine />
+          <package name="" withSubpackages="true" static="false" />
+          <emptyLine />
+          <package name="org.springframework" withSubpackages="true" static="false" />
+          <emptyLine />
+          <package name="" withSubpackages="true" static="true" />
+        </value>
+      </option>
+    </GroovyCodeStyleSettings>
+    <JavaCodeStyleSettings>
+      <option name="CLASS_NAMES_IN_JAVADOC" value="3" />
+      <option name="INSERT_INNER_CLASS_IMPORTS" value="true" />
+      <option name="CLASS_COUNT_TO_USE_IMPORT_ON_DEMAND" value="500" />
+      <option name="NAMES_COUNT_TO_USE_IMPORT_ON_DEMAND" value="500" />
+      <option name="PACKAGES_TO_USE_IMPORT_ON_DEMAND">
+        <value />
+      </option>
+      <option name="IMPORT_LAYOUT_TABLE">
+        <value>
+          <package name="java" withSubpackages="true" static="false" />
+          <emptyLine />
+          <package name="javax" withSubpackages="true" static="false" />
+          <emptyLine />
+          <package name="" withSubpackages="true" static="false" />
+          <emptyLine />
+          <package name="org.springframework" withSubpackages="true" static="false" />
+          <emptyLine />
+          <package name="" withSubpackages="true" static="true" />
+        </value>
+      </option>
+      <option name="ENABLE_JAVADOC_FORMATTING" value="false" />
+      <option name="JD_ALIGN_PARAM_COMMENTS" value="false" />
+      <option name="JD_ALIGN_EXCEPTION_COMMENTS" value="false" />
+      <option name="JD_KEEP_INVALID_TAGS" value="false" />
+      <option name="JD_KEEP_EMPTY_LINES" value="false" />
+    </JavaCodeStyleSettings>
+    <JetCodeStyleSettings>
+      <option name="PACKAGES_TO_USE_STAR_IMPORTS">
+        <value>
+          <package name="java.util" alias="false" withSubpackages="false" />
+          <package name="kotlinx.android.synthetic" alias="false" withSubpackages="false" />
+        </value>
+      </option>
+      <option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="20" />
+      <option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="20" />
+    </JetCodeStyleSettings>
+    <editorconfig>
+      <option name="ENABLED" value="false" />
+    </editorconfig>
+    <codeStyleSettings language="Groovy">
+      <indentOptions>
+        <option name="USE_TAB_CHARACTER" value="true" />
+      </indentOptions>
+    </codeStyleSettings>
+    <codeStyleSettings language="JAVA">
+      <option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="1" />
+      <option name="BLANK_LINES_AROUND_FIELD" value="1" />
+      <option name="BLANK_LINES_AROUND_FIELD_IN_INTERFACE" value="1" />
+      <option name="ELSE_ON_NEW_LINE" value="true" />
+      <option name="CATCH_ON_NEW_LINE" value="true" />
+      <option name="FINALLY_ON_NEW_LINE" value="true" />
+      <option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
+      <option name="SPACE_WITHIN_ARRAY_INITIALIZER_BRACES" value="true" />
+      <option name="SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE" value="true" />
+      <option name="BINARY_OPERATION_SIGN_ON_NEXT_LINE" value="true" />
+      <option name="KEEP_SIMPLE_CLASSES_IN_ONE_LINE" value="true" />
+      <option name="KEEP_MULTIPLE_EXPRESSIONS_IN_ONE_LINE" value="true" />
+      <indentOptions>
+        <option name="USE_TAB_CHARACTER" value="true" />
+      </indentOptions>
+    </codeStyleSettings>
+    <codeStyleSettings language="JSON">
+      <indentOptions>
+        <option name="TAB_SIZE" value="2" />
+      </indentOptions>
+    </codeStyleSettings>
+    <codeStyleSettings language="XML">
+      <indentOptions>
+        <option name="USE_TAB_CHARACTER" value="true" />
+      </indentOptions>
+    </codeStyleSettings>
+    <codeStyleSettings language="kotlin">
+      <indentOptions>
+        <option name="USE_TAB_CHARACTER" value="true" />
+      </indentOptions>
+    </codeStyleSettings>
+  </code_scheme>
+</component>
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
new file mode 100644
index 000000000000..79ee123c2b23
--- /dev/null
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -0,0 +1,5 @@
+<component name="ProjectCodeStyleConfiguration">
+  <state>
+    <option name="USE_PER_PROJECT_SETTINGS" value="true" />
+  </state>
+</component>
\ No newline at end of file
diff --git a/.idea/copyright/java.xml b/.idea/copyright/java.xml
new file mode 100644
index 000000000000..f48ffaf6b6e1
--- /dev/null
+++ b/.idea/copyright/java.xml
@@ -0,0 +1,6 @@
+<component name="CopyrightManager">
+  <copyright>
+    <option name="notice" value="Copyright 2012-&amp;#36;today.year the original author or authors.&#10;&#10;Licensed under the Apache License, Version 2.0 (the &quot;License&quot;);&#10;you may not use this file except in compliance with the License.&#10;You may obtain a copy of the License at&#10;&#10;     https://www.apache.org/licenses/LICENSE-2.0&#10;&#10;Unless required by applicable law or agreed to in writing, software&#10;distributed under the License is distributed on an &quot;AS IS&quot; BASIS,&#10;WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.&#10;See the License for the specific language governing permissions and&#10;limitations under the License." />
+    <option name="myName" value="java" />
+  </copyright>
+</component>
\ No newline at end of file
diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml
new file mode 100644
index 000000000000..d278876c98f1
--- /dev/null
+++ b/.idea/copyright/profiles_settings.xml
@@ -0,0 +1,7 @@
+<component name="CopyrightManager">
+  <settings>
+    <module2copyright>
+      <element module="java" copyright="java" />
+    </module2copyright>
+  </settings>
+</component>
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 000000000000..df1bf12fd2db
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,17 @@
+<component name="InspectionProjectProfileManager">
+  <profile version="1.0">
+    <option name="myName" value="Project Default" />
+    <inspection_tool class="LombokGetterMayBeUsed" enabled="false" level="WARNING" enabled_by_default="false" />
+    <inspection_tool class="NullableProblems" enabled="false" level="WARNING" enabled_by_default="false">
+      <option name="REPORT_NULLABLE_METHOD_OVERRIDES_NOTNULL" value="true" />
+      <option name="REPORT_NOT_ANNOTATED_METHOD_OVERRIDES_NOTNULL" value="true" />
+      <option name="REPORT_NOTNULL_PARAMETER_OVERRIDES_NULLABLE" value="true" />
+      <option name="REPORT_NOT_ANNOTATED_PARAMETER_OVERRIDES_NOTNULL" value="true" />
+      <option name="REPORT_NOT_ANNOTATED_GETTER" value="true" />
+      <option name="REPORT_NOT_ANNOTATED_SETTER_PARAMETER" value="true" />
+      <option name="REPORT_ANNOTATION_NOT_PROPAGATED_TO_OVERRIDERS" value="true" />
+      <option name="REPORT_NULLS_PASSED_TO_NON_ANNOTATED_METHOD" value="true" />
+    </inspection_tool>
+    <inspection_tool class="UnqualifiedFieldAccess" enabled="true" level="ERROR" enabled_by_default="true" editorAttributes="ERRORS_ATTRIBUTES" />
+  </profile>
+</component>
diff --git a/.idea/scopes/java.xml b/.idea/scopes/java.xml
new file mode 100644
index 000000000000..a4576baff901
--- /dev/null
+++ b/.idea/scopes/java.xml
@@ -0,0 +1,3 @@
+<component name="DependencyValidationManager">
+  <scope name="java" pattern="file:*.java&amp;&amp;!file:*package-info.java" />
+</component>
\ No newline at end of file
diff --git a/.sdkmanrc b/.sdkmanrc
index 93afdeb201db..6dbcaa9337a9 100644
--- a/.sdkmanrc
+++ b/.sdkmanrc
@@ -1,3 +1,3 @@
 # Enable auto-env through the sdkman_auto_env config
 # Add key=value pairs of SDKs to use below
-java=17.0.7-librca
+java=17.0.9-librca
diff --git a/README.adoc b/README.adoc
index b0e635135a51..b603722e4e25 100755
--- a/README.adoc
+++ b/README.adoc
@@ -1,4 +1,4 @@
-= Spring Boot image:https://ci.spring.io/api/v1/teams/spring-boot/pipelines/spring-boot-3.1.x/jobs/build/badge["Build Status", link="https://ci.spring.io/teams/spring-boot/pipelines/spring-boot-3.1.x?groups=Build"] image:https://badges.gitter.im/Join Chat.svg["Chat",link="https://gitter.im/spring-projects/spring-boot?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge"] image:https://img.shields.io/badge/Revved%20up%20by-Gradle%20Enterprise-06A0CE?logo=Gradle&labelColor=02303A["Revved up by Gradle Enterprise", link="https://ge.spring.io/scans?&search.rootProjectNames=Spring%20Boot%20Build&search.rootProjectNames=spring-boot-build"]
+= Spring Boot image:https://ci.spring.io/api/v1/teams/spring-boot/pipelines/spring-boot-3.2.x/jobs/build/badge["Build Status", link="https://ci.spring.io/teams/spring-boot/pipelines/spring-boot-3.2.x?groups=Build"] image:https://badges.gitter.im/Join Chat.svg["Chat",link="https://gitter.im/spring-projects/spring-boot?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge"] image:https://img.shields.io/badge/Revved%20up%20by-Gradle%20Enterprise-06A0CE?logo=Gradle&labelColor=02303A["Revved up by Gradle Enterprise", link="https://ge.spring.io/scans?&search.rootProjectNames=Spring%20Boot%20Build&search.rootProjectNames=spring-boot-build"]
 :docs: https://docs.spring.io/spring-boot/docs/current-SNAPSHOT/reference
 :github: https://github.com/spring-projects/spring-boot
 
diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle
index 2968c3e14e79..4970e29102db 100644
--- a/buildSrc/build.gradle
+++ b/buildSrc/build.gradle
@@ -10,49 +10,56 @@ repositories {
 	gradlePluginPortal()
 }
 
-new File(new File("$projectDir").parentFile, "gradle.properties").withInputStream {
+sourceCompatibility = 17
+targetCompatibility = 17
+
+def versions = [:]
+new File(projectDir.parentFile, "gradle.properties").withInputStream {
 	def properties = new Properties()
 	properties.load(it)
-	ext.set("kotlinVersion", properties["kotlinVersion"])
-	ext.set("springFrameworkVersion", properties["springFrameworkVersion"])
-	if (properties["springFrameworkVersion"].contains("-")) {
-		repositories {
-			maven { url "https://repo.spring.io/milestone" }
-			maven { url "https://repo.spring.io/snapshot" }
-		}
+	["assertj", "commonsCodec", "hamcrest", "jackson", "junitJupiter",
+		"kotlin", "maven"].each {
+		versions[it] = properties[it + "Version"]
+	}
+}
+versions["springFramework"] = "6.0.12"
+ext.set("versions", versions)
+if (versions.springFramework.contains("-")) {
+	repositories {
+		maven { url "https://repo.spring.io/milestone" }
+		maven { url "https://repo.spring.io/snapshot" }
 	}
 }
-
-sourceCompatibility = 17
-targetCompatibility = 17
 
 dependencies {
 	checkstyle "io.spring.javaformat:spring-javaformat-checkstyle:${javaFormatVersion}"
 
-	implementation(platform("org.springframework:spring-framework-bom:${springFrameworkVersion}"))
+	implementation(platform("org.springframework:spring-framework-bom:${versions.springFramework}"))
 	implementation("com.diffplug.gradle:goomph:3.37.2")
-	implementation("com.fasterxml.jackson.core:jackson-databind:2.11.4")
+	implementation("com.fasterxml.jackson.core:jackson-databind:${versions.jackson}")
 	implementation("com.gradle:gradle-enterprise-gradle-plugin:3.12.1")
 	implementation("com.tngtech.archunit:archunit:1.0.0")
-	implementation("commons-codec:commons-codec:1.13")
+	implementation("commons-codec:commons-codec:${versions.commonsCodec}")
+	implementation("de.undercouch.download:de.undercouch.download.gradle.plugin:5.5.0")
 	implementation("io.spring.javaformat:spring-javaformat-gradle-plugin:${javaFormatVersion}")
-	implementation("org.apache.maven:maven-embedder:3.6.3")
+	implementation("org.apache.maven:maven-embedder:${versions.maven}")
 	implementation("org.asciidoctor:asciidoctor-gradle-jvm:3.3.2")
-	implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}")
-	implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable:${kotlinVersion}")
+	implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}")
+	implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable:${versions.kotlin}")
 	implementation("org.springframework:spring-context")
 	implementation("org.springframework:spring-core")
 	implementation("org.springframework:spring-web")
 
-	testImplementation("org.assertj:assertj-core:3.11.1")
-	testImplementation("org.apache.logging.log4j:log4j-core:2.17.1")
-	testImplementation("org.junit.jupiter:junit-jupiter:5.6.0")
+	testImplementation("org.assertj:assertj-core:${versions.assertj}")
+	testImplementation("org.hamcrest:hamcrest:${versions.hamcrest}")
+	testImplementation("org.junit.jupiter:junit-jupiter:${versions.junitJupiter}")
+	testImplementation("org.springframework:spring-test")
 
 	testRuntimeOnly("org.junit.platform:junit-platform-launcher")
 }
 
 checkstyle {
-	toolVersion = 8.11
+	toolVersion = "10.12.4"
 }
 
 gradlePlugin {
@@ -62,8 +69,8 @@ gradlePlugin {
 			implementationClass = "org.springframework.boot.build.processors.AnnotationProcessorPlugin"
 		}
 		architecturePlugin {
-		 	id = "org.springframework.boot.architecture"
-		 	implementationClass = "org.springframework.boot.build.architecture.ArchitecturePlugin"
+			id = "org.springframework.boot.architecture"
+			implementationClass = "org.springframework.boot.build.architecture.ArchitecturePlugin"
 		}
 		autoConfigurationPlugin {
 			id = "org.springframework.boot.auto-configuration"
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/AsciidoctorConventions.java b/buildSrc/src/main/java/org/springframework/boot/build/AsciidoctorConventions.java
index 537ff277f739..339db1c3f766 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/AsciidoctorConventions.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/AsciidoctorConventions.java
@@ -134,7 +134,7 @@ private void configureForkOptions(AbstractAsciidoctorTask asciidoctorTask) {
 
 	private String determineGitHubTag(Project project) {
 		String version = "v" + project.getVersion();
-		return (version.endsWith("-SNAPSHOT")) ? "3.1.x" : version;
+		return (version.endsWith("-SNAPSHOT")) ? "main" : version;
 	}
 
 	private void configureOptions(AbstractAsciidoctorTask asciidoctorTask) {
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/JavaConventions.java b/buildSrc/src/main/java/org/springframework/boot/build/JavaConventions.java
index 08f7e18aa999..6f13a5fbbac2 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/JavaConventions.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/JavaConventions.java
@@ -179,7 +179,7 @@ private void configureTestConventions(Project project) {
 
 	private void configureTestRetries(Test test) {
 		TestRetryExtension testRetry = test.getExtensions().getByType(TestRetryExtension.class);
-		testRetry.getFailOnPassedAfterRetry().set(true);
+		testRetry.getFailOnPassedAfterRetry().set(false);
 		testRetry.getMaxRetries().set(isCi() ? 3 : 0);
 	}
 
@@ -239,7 +239,7 @@ private void configureSpringJavaFormat(Project project) {
 		project.getTasks().withType(Format.class, (Format) -> Format.setEncoding("UTF-8"));
 		project.getPlugins().apply(CheckstylePlugin.class);
 		CheckstyleExtension checkstyle = project.getExtensions().getByType(CheckstyleExtension.class);
-		checkstyle.setToolVersion("8.45.1");
+		checkstyle.setToolVersion("10.12.4");
 		checkstyle.getConfigDirectory().set(project.getRootProject().file("src/checkstyle"));
 		String version = SpringJavaFormatPlugin.class.getPackage().getImplementationVersion();
 		DependencySet checkstyleDependencies = project.getConfigurations().getByName("checkstyle").getDependencies();
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/BomExtension.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/BomExtension.java
index 57a16796cac0..5291f7190992 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/bom/BomExtension.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/BomExtension.java
@@ -113,7 +113,8 @@ public void library(String name, String version, Action<LibraryHandler> action)
 		LibraryHandler libraryHandler = objects.newInstance(LibraryHandler.class, (version != null) ? version : "");
 		action.execute(libraryHandler);
 		LibraryVersion libraryVersion = new LibraryVersion(DependencyVersion.parse(libraryHandler.version));
-		addLibrary(new Library(name, libraryVersion, libraryHandler.groups, libraryHandler.prohibitedVersions));
+		addLibrary(new Library(name, libraryHandler.calendarName, libraryVersion, libraryHandler.groups,
+				libraryHandler.prohibitedVersions, libraryHandler.considerSnapshots));
 	}
 
 	public void effectiveBomArtifact() {
@@ -213,8 +214,12 @@ public static class LibraryHandler {
 
 		private final List<ProhibitedVersion> prohibitedVersions = new ArrayList<>();
 
+		private boolean considerSnapshots = false;
+
 		private String version;
 
+		private String calendarName;
+
 		@Inject
 		public LibraryHandler(String version) {
 			this.version = version;
@@ -224,6 +229,14 @@ public void version(String version) {
 			this.version = version;
 		}
 
+		public void considerSnapshots() {
+			this.considerSnapshots = true;
+		}
+
+		public void setCalendarName(String calendarName) {
+			this.calendarName = calendarName;
+		}
+
 		public void group(String id, Action<GroupHandler> action) {
 			GroupHandler groupHandler = new GroupHandler(id);
 			action.execute(groupHandler);
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/BomPlugin.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/BomPlugin.java
index d6db769e4604..a18d10481d80 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/bom/BomPlugin.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/BomPlugin.java
@@ -62,7 +62,8 @@ public void apply(Project project) {
 		createApiEnforcedConfiguration(project);
 		BomExtension bom = project.getExtensions()
 			.create("bom", BomExtension.class, project.getDependencies(), project);
-		project.getTasks().create("bomrCheck", CheckBom.class, bom);
+		CheckBom checkBom = project.getTasks().create("bomrCheck", CheckBom.class, bom);
+		project.getTasks().named("check").configure((check) -> check.dependsOn(checkBom));
 		project.getTasks().create("bomrUpgrade", UpgradeBom.class, bom);
 		project.getTasks().create("moveToSnapshots", MoveToSnapshots.class, bom);
 		new PublishingCustomizer(project, bom).customize();
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/CheckBom.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/CheckBom.java
index 3aeaf0161b5c..7c2089e25969 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/bom/CheckBom.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/CheckBom.java
@@ -16,18 +16,25 @@
 
 package org.springframework.boot.build.bom;
 
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Set;
 import java.util.TreeSet;
 import java.util.stream.Collectors;
 
 import javax.inject.Inject;
 
+import org.apache.maven.artifact.versioning.ArtifactVersion;
+import org.apache.maven.artifact.versioning.DefaultArtifactVersion;
+import org.apache.maven.artifact.versioning.Restriction;
+import org.apache.maven.artifact.versioning.VersionRange;
 import org.gradle.api.DefaultTask;
-import org.gradle.api.InvalidUserDataException;
+import org.gradle.api.GradleException;
 import org.gradle.api.tasks.TaskAction;
 
 import org.springframework.boot.build.bom.Library.Group;
 import org.springframework.boot.build.bom.Library.Module;
+import org.springframework.boot.build.bom.Library.ProhibitedVersion;
 import org.springframework.boot.build.bom.bomr.version.DependencyVersion;
 
 /**
@@ -46,18 +53,41 @@ public CheckBom(BomExtension bom) {
 
 	@TaskAction
 	void checkBom() {
+		List<String> errors = new ArrayList<>();
 		for (Library library : this.bom.getLibraries()) {
-			for (Group group : library.getGroups()) {
-				for (Module module : group.getModules()) {
-					if (!module.getExclusions().isEmpty()) {
-						checkExclusions(group.getId(), module, library.getVersion().getVersion());
-					}
+			checkLibrary(library, errors);
+		}
+		if (!errors.isEmpty()) {
+			System.out.println();
+			errors.forEach(System.out::println);
+			System.out.println();
+			throw new GradleException("Bom check failed. See previous output for details.");
+		}
+	}
+
+	private void checkLibrary(Library library, List<String> errors) {
+		List<String> libraryErrors = new ArrayList<>();
+		checkExclusions(library, libraryErrors);
+		checkProhibitedVersions(library, libraryErrors);
+		if (!libraryErrors.isEmpty()) {
+			errors.add(library.getName());
+			for (String libraryError : libraryErrors) {
+				errors.add("    - " + libraryError);
+			}
+		}
+	}
+
+	private void checkExclusions(Library library, List<String> errors) {
+		for (Group group : library.getGroups()) {
+			for (Module module : group.getModules()) {
+				if (!module.getExclusions().isEmpty()) {
+					checkExclusions(group.getId(), module, library.getVersion().getVersion(), errors);
 				}
 			}
 		}
 	}
 
-	private void checkExclusions(String groupId, Module module, DependencyVersion version) {
+	private void checkExclusions(String groupId, Module module, DependencyVersion version, List<String> errors) {
 		Set<String> resolved = getProject().getConfigurations()
 			.detachedConfiguration(
 					getProject().getDependencies().create(groupId + ":" + module.getName() + ":" + version))
@@ -87,8 +117,34 @@ private void checkExclusions(String groupId, Module module, DependencyVersion ve
 		}
 		exclusions.removeAll(resolved);
 		if (!unused.isEmpty()) {
-			throw new InvalidUserDataException(
-					"Unnecessary exclusions on " + groupId + ":" + module.getName() + ": " + exclusions);
+			errors.add("Unnecessary exclusions on " + groupId + ":" + module.getName() + ": " + exclusions);
+		}
+	}
+
+	private void checkProhibitedVersions(Library library, List<String> errors) {
+		ArtifactVersion currentVersion = new DefaultArtifactVersion(library.getVersion().getVersion().toString());
+		for (ProhibitedVersion prohibited : library.getProhibitedVersions()) {
+			if (prohibited.isProhibited(library.getVersion().getVersion().toString())) {
+				errors.add("Current version " + currentVersion + " is prohibited");
+			}
+			else {
+				VersionRange versionRange = prohibited.getRange();
+				if (versionRange != null) {
+					for (Restriction restriction : versionRange.getRestrictions()) {
+						ArtifactVersion upperBound = restriction.getUpperBound();
+						if (upperBound == null) {
+							return;
+						}
+						int comparison = currentVersion.compareTo(upperBound);
+						if ((restriction.isUpperBoundInclusive() && comparison <= 0)
+								|| ((!restriction.isUpperBoundInclusive()) && comparison < 0)) {
+							return;
+						}
+					}
+					errors.add("Version range " + versionRange + " is ineffective as the current version, "
+							+ currentVersion + ", is greater than its upper bound");
+				}
+			}
 		}
 	}
 
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/Library.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/Library.java
index b51cd42dd52e..a2d6f4517cb9 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/bom/Library.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/Library.java
@@ -20,6 +20,7 @@
 import java.util.List;
 import java.util.Locale;
 
+import org.apache.maven.artifact.versioning.DefaultArtifactVersion;
 import org.apache.maven.artifact.versioning.VersionRange;
 
 import org.springframework.boot.build.bom.bomr.version.DependencyVersion;
@@ -34,6 +35,8 @@ public class Library {
 
 	private final String name;
 
+	private final String calendarName;
+
 	private final LibraryVersion version;
 
 	private final List<Group> groups;
@@ -42,28 +45,39 @@ public class Library {
 
 	private final List<ProhibitedVersion> prohibitedVersions;
 
+	private final boolean considerSnapshots;
+
 	/**
 	 * Create a new {@code Library} with the given {@code name}, {@code version}, and
 	 * {@code groups}.
 	 * @param name name of the library
+	 * @param calendarName name of the library as it appears in the Spring Calendar. May
+	 * be {@code null} in which case the {@code name} is used.
 	 * @param version version of the library
 	 * @param groups groups in the library
 	 * @param prohibitedVersions version of the library that are prohibited
+	 * @param considerSnapshots whether to consider snapshots
 	 */
-	public Library(String name, LibraryVersion version, List<Group> groups,
-			List<ProhibitedVersion> prohibitedVersions) {
+	public Library(String name, String calendarName, LibraryVersion version, List<Group> groups,
+			List<ProhibitedVersion> prohibitedVersions, boolean considerSnapshots) {
 		this.name = name;
+		this.calendarName = (calendarName != null) ? calendarName : name;
 		this.version = version;
 		this.groups = groups;
 		this.versionProperty = "Spring Boot".equals(name) ? null
 				: name.toLowerCase(Locale.ENGLISH).replace(' ', '-') + ".version";
 		this.prohibitedVersions = prohibitedVersions;
+		this.considerSnapshots = considerSnapshots;
 	}
 
 	public String getName() {
 		return this.name;
 	}
 
+	public String getCalendarName() {
+		return this.calendarName;
+	}
+
 	public LibraryVersion getVersion() {
 		return this.version;
 	}
@@ -80,6 +94,10 @@ public List<ProhibitedVersion> getProhibitedVersions() {
 		return this.prohibitedVersions;
 	}
 
+	public boolean isConsiderSnapshots() {
+		return this.considerSnapshots;
+	}
+
 	/**
 	 * A version or range of versions that are prohibited from being used in a bom.
 	 */
@@ -124,6 +142,16 @@ public String getReason() {
 			return this.reason;
 		}
 
+		public boolean isProhibited(String candidate) {
+			boolean result = false;
+			result = result
+					|| (this.range != null && this.range.containsVersion(new DefaultArtifactVersion(candidate)));
+			result = result || this.startsWith.stream().anyMatch(candidate::startsWith);
+			result = result || this.endsWith.stream().anyMatch(candidate::endsWith);
+			result = result || this.contains.stream().anyMatch(candidate::contains);
+			return result;
+		}
+
 	}
 
 	public static class LibraryVersion {
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/UpgradePolicy.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/UpgradePolicy.java
index c74bee8223fe..e9d72393965f 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/bom/UpgradePolicy.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/UpgradePolicy.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -28,24 +28,19 @@
 public enum UpgradePolicy implements BiPredicate<DependencyVersion, DependencyVersion> {
 
 	/**
-	 * All versions more recent than the current version will be suggested as possible
-	 * upgrades.
+	 * Any version.
 	 */
-	ANY((candidate, current) -> current.compareTo(candidate) < 0),
+	ANY((candidate, current) -> true),
 
 	/**
-	 * New minor versions of the current major version will be suggested as possible
-	 * upgrades. For example, if the current version is 1.2.3, all 1.x.y versions after
-	 * 1.2.3 will be suggested. 2.x versions will not be offered.
+	 * Minor versions of the current major version.
 	 */
-	SAME_MAJOR_VERSION(DependencyVersion::isSameMajorAndNewerThan),
+	SAME_MAJOR_VERSION((candidate, current) -> candidate.isSameMajor(current)),
 
 	/**
-	 * New patch versions of the current minor version will be offered as possible
-	 * upgrades. For example, if the current version is 1.2.3, all 1.2.x versions after
-	 * 1.2.3 will be suggested. 1.x versions will not be offered.
+	 * Patch versions of the current minor version.
 	 */
-	SAME_MINOR_VERSION(DependencyVersion::isSameMinorAndNewerThan);
+	SAME_MINOR_VERSION((candidate, current) -> candidate.isSameMinor(current));
 
 	private final BiPredicate<DependencyVersion, DependencyVersion> delegate;
 
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/MoveToSnapshots.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/MoveToSnapshots.java
index 322ecd5011a7..b47545839378 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/MoveToSnapshots.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/MoveToSnapshots.java
@@ -17,12 +17,23 @@
 package org.springframework.boot.build.bom.bomr;
 
 import java.net.URI;
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.function.BiPredicate;
 
 import javax.inject.Inject;
 
 import org.gradle.api.Task;
+import org.gradle.api.tasks.TaskAction;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import org.springframework.boot.build.bom.BomExtension;
+import org.springframework.boot.build.bom.Library;
+import org.springframework.boot.build.bom.bomr.ReleaseSchedule.Release;
+import org.springframework.boot.build.bom.bomr.github.Milestone;
+import org.springframework.boot.build.bom.bomr.version.DependencyVersion;
 
 /**
  * A {@link Task} to move to snapshot dependencies.
@@ -31,14 +42,22 @@
  */
 public abstract class MoveToSnapshots extends UpgradeDependencies {
 
+	private static final Logger log = LoggerFactory.getLogger(MoveToSnapshots.class);
+
 	private final URI REPOSITORY_URI = URI.create("https://repo.spring.io/snapshot/");
 
 	@Inject
 	public MoveToSnapshots(BomExtension bom) {
-		super(bom);
+		super(bom, true);
 		getRepositoryUris().add(this.REPOSITORY_URI);
 	}
 
+	@Override
+	@TaskAction
+	void upgradeDependencies() {
+		super.upgradeDependencies();
+	}
+
 	@Override
 	protected String issueTitle(Upgrade upgrade) {
 		String snapshotVersion = upgrade.getVersion().toString();
@@ -57,4 +76,33 @@ private String releaseVersion(Upgrade upgrade) {
 		return snapshotVersion.substring(0, snapshotVersion.length() - "-SNAPSHOT".length());
 	}
 
+	@Override
+	protected boolean eligible(Library library) {
+		return library.isConsiderSnapshots() && super.eligible(library);
+	}
+
+	@Override
+	protected List<BiPredicate<Library, DependencyVersion>> determineUpdatePredicates(Milestone milestone) {
+		ReleaseSchedule releaseSchedule = new ReleaseSchedule();
+		Map<String, List<Release>> releases = releaseSchedule.releasesBetween(OffsetDateTime.now(),
+				milestone.getDueOn());
+		List<BiPredicate<Library, DependencyVersion>> predicates = super.determineUpdatePredicates(milestone);
+		predicates.add((library, candidate) -> {
+			List<Release> releasesForLibrary = releases.get(library.getCalendarName());
+			if (releasesForLibrary != null) {
+				for (Release release : releasesForLibrary) {
+					if (candidate.isSnapshotFor(release.getVersion())) {
+						return true;
+					}
+				}
+			}
+			if (log.isInfoEnabled()) {
+				log.info("Ignoring " + candidate + ". No release of " + library.getName() + " scheduled before "
+						+ milestone.getDueOn());
+			}
+			return false;
+		});
+		return predicates;
+	}
+
 }
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/MultithreadedLibraryUpdateResolver.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/MultithreadedLibraryUpdateResolver.java
index 69239b006ee3..18a5490b631b 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/MultithreadedLibraryUpdateResolver.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/MultithreadedLibraryUpdateResolver.java
@@ -16,36 +16,39 @@
 
 package org.springframework.boot.build.bom.bomr;
 
-import java.time.Duration;
-import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
+import java.util.stream.Stream;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import org.springframework.boot.build.bom.Library;
-import org.springframework.boot.build.bom.UpgradePolicy;
 
 /**
- * Uses multiple threads to find library updates.
+ * {@link LibraryUpdateResolver} decorator that uses multiple threads to find library
+ * updates.
  *
  * @author Moritz Halbritter
+ * @author Andy Wilkinson
  */
-class MultithreadedLibraryUpdateResolver extends StandardLibraryUpdateResolver {
+class MultithreadedLibraryUpdateResolver implements LibraryUpdateResolver {
 
 	private static final Logger LOGGER = LoggerFactory.getLogger(MultithreadedLibraryUpdateResolver.class);
 
 	private final int threads;
 
-	MultithreadedLibraryUpdateResolver(VersionResolver versionResolver, UpgradePolicy upgradePolicy, int threads) {
-		super(versionResolver, upgradePolicy);
+	private final LibraryUpdateResolver delegate;
+
+	MultithreadedLibraryUpdateResolver(int threads, LibraryUpdateResolver delegate) {
 		this.threads = threads;
+		this.delegate = delegate;
 	}
 
 	@Override
@@ -54,34 +57,28 @@ public List<LibraryWithVersionOptions> findLibraryUpdates(Collection<Library> li
 		LOGGER.info("Looking for updates using {} threads", this.threads);
 		ExecutorService executorService = Executors.newFixedThreadPool(this.threads);
 		try {
-			List<Future<LibraryWithVersionOptions>> jobs = new ArrayList<>();
-			for (Library library : librariesToUpgrade) {
-				if (isLibraryExcluded(library)) {
-					continue;
-				}
-				jobs.add(executorService.submit(() -> {
-					LOGGER.info("Looking for updates for {}", library.getName());
-					long start = System.nanoTime();
-					List<VersionOption> versionOptions = getVersionOptions(library, librariesByName);
-					LOGGER.info("Found {} updates for {}, took {}", versionOptions.size(), library.getName(),
-							Duration.ofNanos(System.nanoTime() - start));
-					return new LibraryWithVersionOptions(library, versionOptions);
-				}));
-			}
-			List<LibraryWithVersionOptions> result = new ArrayList<>();
-			for (Future<LibraryWithVersionOptions> job : jobs) {
-				try {
-					result.add(job.get());
-				}
-				catch (InterruptedException | ExecutionException ex) {
-					throw new RuntimeException(ex);
-				}
-			}
-			return result;
+			return librariesToUpgrade.stream()
+				.map((library) -> executorService.submit(
+						() -> this.delegate.findLibraryUpdates(Collections.singletonList(library), librariesByName)))
+				.flatMap(this::getResult)
+				.toList();
 		}
 		finally {
 			executorService.shutdownNow();
 		}
 	}
 
+	private Stream<LibraryWithVersionOptions> getResult(Future<List<LibraryWithVersionOptions>> job) {
+		try {
+			return job.get().stream();
+		}
+		catch (InterruptedException ex) {
+			Thread.currentThread().interrupt();
+			throw new RuntimeException(ex);
+		}
+		catch (ExecutionException ex) {
+			throw new RuntimeException(ex);
+		}
+	}
+
 }
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/ReleaseSchedule.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/ReleaseSchedule.java
new file mode 100644
index 000000000000..c32518b46246
--- /dev/null
+++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/ReleaseSchedule.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright 2023-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.build.bom.bomr;
+
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.springframework.boot.build.bom.bomr.version.DependencyVersion;
+import org.springframework.http.ResponseEntity;
+import org.springframework.util.LinkedCaseInsensitiveMap;
+import org.springframework.web.client.RestOperations;
+import org.springframework.web.client.RestTemplate;
+
+/**
+ * Release schedule for Spring projects, retrieved from
+ * <a href="https://calendar.spring.io">https://calendar.spring.io</a>.
+ *
+ * @author Andy Wilkinson
+ */
+class ReleaseSchedule {
+
+	private static final Pattern LIBRARY_AND_VERSION = Pattern.compile("([A-Za-z0-9 ]+) ([0-9A-Za-z.-]+)");
+
+	private final RestOperations rest;
+
+	ReleaseSchedule() {
+		this(new RestTemplate());
+	}
+
+	ReleaseSchedule(RestOperations rest) {
+		this.rest = rest;
+	}
+
+	@SuppressWarnings({ "unchecked", "rawtypes" })
+	Map<String, List<Release>> releasesBetween(OffsetDateTime start, OffsetDateTime end) {
+		ResponseEntity<List> response = this.rest
+			.getForEntity("https://calendar.spring.io/releases?start=" + start + "&end=" + end, List.class);
+		List<Map<String, String>> body = response.getBody();
+		Map<String, List<Release>> releasesByLibrary = new LinkedCaseInsensitiveMap<>();
+		body.stream()
+			.map(this::asRelease)
+			.filter(Objects::nonNull)
+			.forEach((release) -> releasesByLibrary.computeIfAbsent(release.getLibraryName(), (l) -> new ArrayList<>())
+				.add(release));
+		return releasesByLibrary;
+	}
+
+	private Release asRelease(Map<String, String> entry) {
+		LocalDate due = LocalDate.parse(entry.get("start"));
+		String title = entry.get("title");
+		Matcher matcher = LIBRARY_AND_VERSION.matcher(title);
+		if (!matcher.matches()) {
+			return null;
+		}
+		String library = matcher.group(1);
+		String version = matcher.group(2);
+		return new Release(library, DependencyVersion.parse(version), due);
+	}
+
+	static class Release {
+
+		private final String libraryName;
+
+		private final DependencyVersion version;
+
+		private final LocalDate dueOn;
+
+		Release(String libraryName, DependencyVersion version, LocalDate dueOn) {
+			this.libraryName = libraryName;
+			this.version = version;
+			this.dueOn = dueOn;
+		}
+
+		String getLibraryName() {
+			return this.libraryName;
+		}
+
+		DependencyVersion getVersion() {
+			return this.version;
+		}
+
+		LocalDate getDueOn() {
+			return this.dueOn;
+		}
+
+	}
+
+}
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/StandardLibraryUpdateResolver.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/StandardLibraryUpdateResolver.java
index bcfb1406c5f5..5bbdd90a62f6 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/StandardLibraryUpdateResolver.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/StandardLibraryUpdateResolver.java
@@ -19,22 +19,18 @@
 import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.SortedSet;
-import java.util.stream.Collectors;
+import java.util.function.BiPredicate;
 
-import org.apache.maven.artifact.versioning.DefaultArtifactVersion;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import org.springframework.boot.build.bom.Library;
 import org.springframework.boot.build.bom.Library.Group;
 import org.springframework.boot.build.bom.Library.Module;
-import org.springframework.boot.build.bom.Library.ProhibitedVersion;
-import org.springframework.boot.build.bom.UpgradePolicy;
 import org.springframework.boot.build.bom.bomr.version.DependencyVersion;
 
 /**
@@ -48,11 +44,13 @@ class StandardLibraryUpdateResolver implements LibraryUpdateResolver {
 
 	private final VersionResolver versionResolver;
 
-	private final UpgradePolicy upgradePolicy;
+	private final BiPredicate<Library, DependencyVersion> predicate;
 
-	StandardLibraryUpdateResolver(VersionResolver versionResolver, UpgradePolicy upgradePolicy) {
+	StandardLibraryUpdateResolver(VersionResolver versionResolver,
+			List<BiPredicate<Library, DependencyVersion>> predicates) {
 		this.versionResolver = versionResolver;
-		this.upgradePolicy = upgradePolicy;
+		this.predicate = (library, dependencyVersion) -> predicates.stream()
+			.allMatch((predicate) -> predicate.test(library, dependencyVersion));
 	}
 
 	@Override
@@ -83,60 +81,27 @@ protected List<VersionOption> getVersionOptions(Library library, Map<String, Lib
 
 	private List<VersionOption> determineResolvedVersionOptions(Library library) {
 		Map<String, SortedSet<DependencyVersion>> moduleVersions = new LinkedHashMap<>();
-		DependencyVersion libraryVersion = library.getVersion().getVersion();
 		for (Group group : library.getGroups()) {
 			for (Module module : group.getModules()) {
 				moduleVersions.put(group.getId() + ":" + module.getName(),
-						getLaterVersionsForModule(group.getId(), module.getName(), libraryVersion));
+						getLaterVersionsForModule(group.getId(), module.getName(), library));
 			}
 			for (String bom : group.getBoms()) {
-				moduleVersions.put(group.getId() + ":" + bom,
-						getLaterVersionsForModule(group.getId(), bom, libraryVersion));
+				moduleVersions.put(group.getId() + ":" + bom, getLaterVersionsForModule(group.getId(), bom, library));
 			}
 			for (String plugin : group.getPlugins()) {
 				moduleVersions.put(group.getId() + ":" + plugin,
-						getLaterVersionsForModule(group.getId(), plugin, libraryVersion));
+						getLaterVersionsForModule(group.getId(), plugin, library));
 			}
 		}
-		List<DependencyVersion> allVersions = moduleVersions.values()
+		return moduleVersions.values()
 			.stream()
 			.flatMap(SortedSet::stream)
 			.distinct()
-			.filter((dependencyVersion) -> isPermitted(dependencyVersion, library.getProhibitedVersions()))
-			.toList();
-		if (allVersions.isEmpty()) {
-			return Collections.emptyList();
-		}
-		return allVersions.stream()
-			.map((version) -> new VersionOption.ResolvedVersionOption(version,
+			.filter((dependencyVersion) -> this.predicate.test(library, dependencyVersion))
+			.map((version) -> (VersionOption) new VersionOption.ResolvedVersionOption(version,
 					getMissingModules(moduleVersions, version)))
-			.collect(Collectors.toList());
-	}
-
-	private boolean isPermitted(DependencyVersion dependencyVersion, List<ProhibitedVersion> prohibitedVersions) {
-		for (ProhibitedVersion prohibitedVersion : prohibitedVersions) {
-			String dependencyVersionToString = dependencyVersion.toString();
-			if (prohibitedVersion.getRange() != null && prohibitedVersion.getRange()
-				.containsVersion(new DefaultArtifactVersion(dependencyVersionToString))) {
-				return false;
-			}
-			for (String startsWith : prohibitedVersion.getStartsWith()) {
-				if (dependencyVersionToString.startsWith(startsWith)) {
-					return false;
-				}
-			}
-			for (String endsWith : prohibitedVersion.getEndsWith()) {
-				if (dependencyVersionToString.endsWith(endsWith)) {
-					return false;
-				}
-			}
-			for (String contains : prohibitedVersion.getContains()) {
-				if (dependencyVersionToString.contains(contains)) {
-					return false;
-				}
-			}
-		}
-		return true;
+			.toList();
 	}
 
 	private List<String> getMissingModules(Map<String, SortedSet<DependencyVersion>> moduleVersions,
@@ -150,11 +115,8 @@ private List<String> getMissingModules(Map<String, SortedSet<DependencyVersion>>
 		return missingModules;
 	}
 
-	private SortedSet<DependencyVersion> getLaterVersionsForModule(String groupId, String artifactId,
-			DependencyVersion currentVersion) {
-		SortedSet<DependencyVersion> versions = this.versionResolver.resolveVersions(groupId, artifactId);
-		versions.removeIf((candidate) -> !this.upgradePolicy.test(candidate, currentVersion));
-		return versions;
+	private SortedSet<DependencyVersion> getLaterVersionsForModule(String groupId, String artifactId, Library library) {
+		return this.versionResolver.resolveVersions(groupId, artifactId);
 	}
 
 }
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/UpgradeDependencies.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/UpgradeDependencies.java
index ae2625c0e4a5..81511606b710 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/UpgradeDependencies.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/UpgradeDependencies.java
@@ -27,6 +27,7 @@
 import java.util.List;
 import java.util.Properties;
 import java.util.Set;
+import java.util.function.BiPredicate;
 import java.util.function.Predicate;
 import java.util.regex.Pattern;
 
@@ -49,6 +50,7 @@
 import org.springframework.boot.build.bom.bomr.github.GitHubRepository;
 import org.springframework.boot.build.bom.bomr.github.Issue;
 import org.springframework.boot.build.bom.bomr.github.Milestone;
+import org.springframework.boot.build.bom.bomr.version.DependencyVersion;
 import org.springframework.util.StringUtils;
 
 /**
@@ -61,10 +63,17 @@ public abstract class UpgradeDependencies extends DefaultTask {
 
 	private final BomExtension bom;
 
+	private final boolean movingToSnapshots;
+
 	@Inject
 	public UpgradeDependencies(BomExtension bom) {
+		this(bom, false);
+	}
+
+	protected UpgradeDependencies(BomExtension bom, boolean movingToSnapshots) {
 		this.bom = bom;
 		getThreads().convention(2);
+		this.movingToSnapshots = movingToSnapshots;
 	}
 
 	@Input
@@ -90,7 +99,7 @@ void upgradeDependencies() {
 				this.bom.getUpgrade().getGitHub().getRepository());
 		List<String> issueLabels = verifyLabels(repository);
 		Milestone milestone = determineMilestone(repository);
-		List<Upgrade> upgrades = resolveUpgrades();
+		List<Upgrade> upgrades = resolveUpgrades(milestone);
 		applyUpgrades(repository, issueLabels, milestone, upgrades);
 	}
 
@@ -100,45 +109,31 @@ private void applyUpgrades(GitHubRepository repository, List<String> issueLabels
 		Path gradleProperties = new File(getProject().getRootProject().getProjectDir(), "gradle.properties").toPath();
 		UpgradeApplicator upgradeApplicator = new UpgradeApplicator(buildFile, gradleProperties);
 		List<Issue> existingUpgradeIssues = repository.findIssues(issueLabels, milestone);
+		System.out.println("Applying upgrades...");
+		System.out.println("");
 		for (Upgrade upgrade : upgrades) {
+			System.out.println(upgrade.getLibrary().getName() + " " + upgrade.getVersion());
 			String title = issueTitle(upgrade);
 			Issue existingUpgradeIssue = findExistingUpgradeIssue(existingUpgradeIssues, upgrade);
-			if (existingUpgradeIssue != null) {
-				if (existingUpgradeIssue.getState() == Issue.State.CLOSED) {
-					System.out.println(title + " (supersedes #" + existingUpgradeIssue.getNumber() + " "
-							+ existingUpgradeIssue.getTitle() + ")");
-				}
-				else {
-					System.out.println(title + " (completes existing upgrade)");
-				}
-			}
-			else {
-				System.out.println(title);
-			}
 			try {
 				Path modified = upgradeApplicator.apply(upgrade);
-				int issueNumber;
-				if (existingUpgradeIssue != null && existingUpgradeIssue.getState() == Issue.State.OPEN) {
-					issueNumber = existingUpgradeIssue.getNumber();
-				}
-				else {
-					issueNumber = repository.openIssue(title,
-							(existingUpgradeIssue != null) ? "Supersedes #" + existingUpgradeIssue.getNumber() : "",
-							issueLabels, milestone);
-					if (existingUpgradeIssue != null && existingUpgradeIssue.getState() == Issue.State.CLOSED) {
-						existingUpgradeIssue.label(Arrays.asList("type: task", "status: superseded"));
-					}
+				int issueNumber = getOrOpenUpgradeIssue(repository, issueLabels, milestone, title,
+						existingUpgradeIssue);
+				if (existingUpgradeIssue != null && existingUpgradeIssue.getState() == Issue.State.CLOSED) {
+					existingUpgradeIssue.label(Arrays.asList("type: task", "status: superseded"));
 				}
+				System.out.println("   Issue: " + issueNumber + " - " + title
+						+ getExistingUpgradeIssueMessageDetails(existingUpgradeIssue));
 				if (new ProcessBuilder().command("git", "add", modified.toFile().getAbsolutePath())
 					.start()
 					.waitFor() != 0) {
 					throw new IllegalStateException("git add failed");
 				}
-				if (new ProcessBuilder().command("git", "commit", "-m", commitMessage(upgrade, issueNumber))
-					.start()
-					.waitFor() != 0) {
+				String commitMessage = commitMessage(upgrade, issueNumber);
+				if (new ProcessBuilder().command("git", "commit", "-m", commitMessage).start().waitFor() != 0) {
 					throw new IllegalStateException("git commit failed");
 				}
+				System.out.println("  Commit: " + commitMessage.substring(0, commitMessage.indexOf('\n')));
 			}
 			catch (IOException ex) {
 				throw new TaskExecutionException(this, ex);
@@ -149,6 +144,25 @@ private void applyUpgrades(GitHubRepository repository, List<String> issueLabels
 		}
 	}
 
+	private int getOrOpenUpgradeIssue(GitHubRepository repository, List<String> issueLabels, Milestone milestone,
+			String title, Issue existingUpgradeIssue) {
+		if (existingUpgradeIssue != null && existingUpgradeIssue.getState() == Issue.State.OPEN) {
+			return existingUpgradeIssue.getNumber();
+		}
+		String body = (existingUpgradeIssue != null) ? "Supersedes #" + existingUpgradeIssue.getNumber() : "";
+		return repository.openIssue(title, body, issueLabels, milestone);
+	}
+
+	private String getExistingUpgradeIssueMessageDetails(Issue existingUpgradeIssue) {
+		if (existingUpgradeIssue == null) {
+			return "";
+		}
+		if (existingUpgradeIssue.getState() != Issue.State.CLOSED) {
+			return " (completes existing upgrade)";
+		}
+		return " (supersedes #" + existingUpgradeIssue.getNumber() + " " + existingUpgradeIssue.getTitle() + ")";
+	}
+
 	private List<String> verifyLabels(GitHubRepository repository) {
 		Set<String> availableLabels = repository.getLabels();
 		List<String> issueLabels = this.bom.getUpgrade().getGitHub().getIssueLabels();
@@ -188,9 +202,12 @@ private Milestone determineMilestone(GitHubRepository repository) {
 	private Issue findExistingUpgradeIssue(List<Issue> existingUpgradeIssues, Upgrade upgrade) {
 		String toMatch = "Upgrade to " + upgrade.getLibrary().getName();
 		for (Issue existingUpgradeIssue : existingUpgradeIssues) {
-			if (existingUpgradeIssue.getTitle()
-				.substring(0, existingUpgradeIssue.getTitle().lastIndexOf(' '))
-				.equals(toMatch)) {
+			String title = existingUpgradeIssue.getTitle();
+			int lastSpaceIndex = title.lastIndexOf(' ');
+			if (lastSpaceIndex > -1) {
+				title = title.substring(0, lastSpaceIndex);
+			}
+			if (title.equals(toMatch)) {
 				return existingUpgradeIssue;
 			}
 		}
@@ -198,29 +215,54 @@ private Issue findExistingUpgradeIssue(List<Issue> existingUpgradeIssues, Upgrad
 	}
 
 	@SuppressWarnings("deprecation")
-	private List<Upgrade> resolveUpgrades() {
+	private List<Upgrade> resolveUpgrades(Milestone milestone) {
 		List<Upgrade> upgrades = new InteractiveUpgradeResolver(getServices().get(UserInputHandler.class),
-				new MultithreadedLibraryUpdateResolver(new MavenMetadataVersionResolver(getRepositoryUris().get()),
-						this.bom.getUpgrade().getPolicy(), getThreads().get()))
-			.resolveUpgrades(matchingLibraries(getLibraries().getOrNull()), this.bom.getLibraries());
+				new MultithreadedLibraryUpdateResolver(getThreads().get(),
+						new StandardLibraryUpdateResolver(new MavenMetadataVersionResolver(getRepositoryUris().get()),
+								determineUpdatePredicates(milestone))))
+			.resolveUpgrades(matchingLibraries(), this.bom.getLibraries());
 		return upgrades;
 	}
 
-	private List<Library> matchingLibraries(String pattern) {
-		if (pattern == null) {
-			return this.bom.getLibraries();
-		}
-		Predicate<String> libraryPredicate = Pattern.compile(pattern).asPredicate();
-		List<Library> matchingLibraries = this.bom.getLibraries()
+	protected List<BiPredicate<Library, DependencyVersion>> determineUpdatePredicates(Milestone milestone) {
+		List<BiPredicate<Library, DependencyVersion>> updatePredicates = new ArrayList<>();
+		updatePredicates.add(this::compilesWithUpgradePolicy);
+		updatePredicates.add(this::isAnUpgrade);
+		updatePredicates.add(this::isNotProhibited);
+		return updatePredicates;
+	}
+
+	private boolean compilesWithUpgradePolicy(Library library, DependencyVersion candidate) {
+		return this.bom.getUpgrade().getPolicy().test(candidate, library.getVersion().getVersion());
+	}
+
+	private boolean isAnUpgrade(Library library, DependencyVersion candidate) {
+		return library.getVersion().getVersion().isUpgrade(candidate, this.movingToSnapshots);
+	}
+
+	private boolean isNotProhibited(Library library, DependencyVersion candidate) {
+		return !library.getProhibitedVersions()
 			.stream()
-			.filter((library) -> libraryPredicate.test(library.getName()))
-			.toList();
+			.anyMatch((prohibited) -> prohibited.isProhibited(candidate.toString()));
+	}
+
+	private List<Library> matchingLibraries() {
+		List<Library> matchingLibraries = this.bom.getLibraries().stream().filter(this::eligible).toList();
 		if (matchingLibraries.isEmpty()) {
-			throw new InvalidUserDataException("No libraries matched '" + pattern + "'");
+			throw new InvalidUserDataException("No libraries to upgrade");
 		}
 		return matchingLibraries;
 	}
 
+	protected boolean eligible(Library library) {
+		String pattern = getLibraries().getOrNull();
+		if (pattern == null) {
+			return true;
+		}
+		Predicate<String> libraryPredicate = Pattern.compile(pattern).asPredicate();
+		return libraryPredicate.test(library.getName());
+	}
+
 	protected abstract String issueTitle(Upgrade upgrade);
 
 	protected abstract String commitMessage(Upgrade upgrade, int issueNumber);
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/github/Milestone.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/github/Milestone.java
index f50dd28c48dd..38d3f1dd5e27 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/github/Milestone.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/github/Milestone.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,6 +16,8 @@
 
 package org.springframework.boot.build.bom.bomr.github;
 
+import java.time.OffsetDateTime;
+
 /**
  * A milestone in a {@link GitHubRepository GitHub repository}.
  *
@@ -27,9 +29,12 @@ public class Milestone {
 
 	private final int number;
 
-	Milestone(String name, int number) {
+	private final OffsetDateTime dueOn;
+
+	Milestone(String name, int number, OffsetDateTime dueOn) {
 		this.name = name;
 		this.number = number;
+		this.dueOn = dueOn;
 	}
 
 	/**
@@ -48,6 +53,10 @@ public int getNumber() {
 		return this.number;
 	}
 
+	public OffsetDateTime getDueOn() {
+		return this.dueOn;
+	}
+
 	@Override
 	public String toString() {
 		return this.name + " (" + this.number + ")";
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/github/StandardGitHubRepository.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/github/StandardGitHubRepository.java
index 28027e2c8852..2a5fb9ba527f 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/github/StandardGitHubRepository.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/github/StandardGitHubRepository.java
@@ -17,6 +17,7 @@
 package org.springframework.boot.build.bom.bomr.github;
 
 import java.time.Duration;
+import java.time.OffsetDateTime;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -75,8 +76,9 @@ public Set<String> getLabels() {
 
 	@Override
 	public List<Milestone> getMilestones() {
-		return get("milestones?per_page=100",
-				(milestone) -> new Milestone((String) milestone.get("title"), (Integer) milestone.get("number")));
+		return get("milestones?per_page=100", (milestone) -> new Milestone((String) milestone.get("title"),
+				(Integer) milestone.get("number"),
+				(milestone.get("due_on") != null) ? OffsetDateTime.parse((String) milestone.get("due_on")) : null));
 	}
 
 	@Override
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/AbstractDependencyVersion.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/AbstractDependencyVersion.java
index 1a0ef1ce979d..4d17ceefc81f 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/AbstractDependencyVersion.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/AbstractDependencyVersion.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -38,6 +38,14 @@ public int compareTo(DependencyVersion other) {
 		return this.comparableVersion.compareTo(otherComparable);
 	}
 
+	@Override
+	public boolean isUpgrade(DependencyVersion candidate, boolean movingToSnapshots) {
+		ComparableVersion comparableCandidate = (candidate instanceof AbstractDependencyVersion)
+				? ((AbstractDependencyVersion) candidate).comparableVersion
+				: new ComparableVersion(candidate.toString());
+		return comparableCandidate.compareTo(this.comparableVersion) > 0;
+	}
+
 	@Override
 	public boolean equals(Object obj) {
 		if (this == obj) {
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/ArtifactVersionDependencyVersion.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/ArtifactVersionDependencyVersion.java
index 59eacedd3b11..61e1d761e201 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/ArtifactVersionDependencyVersion.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/ArtifactVersionDependencyVersion.java
@@ -23,6 +23,8 @@
 import org.apache.maven.artifact.versioning.ComparableVersion;
 import org.apache.maven.artifact.versioning.DefaultArtifactVersion;
 
+import org.springframework.util.StringUtils;
+
 /**
  * A {@link DependencyVersion} backed by an {@link ArtifactVersion}.
  *
@@ -33,47 +35,91 @@ class ArtifactVersionDependencyVersion extends AbstractDependencyVersion {
 	private final ArtifactVersion artifactVersion;
 
 	protected ArtifactVersionDependencyVersion(ArtifactVersion artifactVersion) {
-		super(new ComparableVersion(artifactVersion.toString()));
+		super(new ComparableVersion(toNormalizedString(artifactVersion)));
 		this.artifactVersion = artifactVersion;
 	}
 
+	private static String toNormalizedString(ArtifactVersion artifactVersion) {
+		String versionString = artifactVersion.toString();
+		if (versionString.endsWith(".RELEASE")) {
+			return versionString.substring(0, versionString.length() - 8);
+		}
+		if (versionString.endsWith(".BUILD-SNAPSHOT")) {
+			return versionString.substring(0, versionString.length() - 15) + "-SNAPSHOT";
+		}
+		return versionString;
+	}
+
 	protected ArtifactVersionDependencyVersion(ArtifactVersion artifactVersion, ComparableVersion comparableVersion) {
 		super(comparableVersion);
 		this.artifactVersion = artifactVersion;
 	}
 
 	@Override
-	public boolean isNewerThan(DependencyVersion other) {
+	public boolean isSameMajor(DependencyVersion other) {
 		if (other instanceof ReleaseTrainDependencyVersion) {
 			return false;
 		}
-		return compareTo(other) > 0;
+		return extractArtifactVersionDependencyVersion(other).map(this::isSameMajor).orElse(true);
+	}
+
+	private boolean isSameMajor(ArtifactVersionDependencyVersion other) {
+		return this.artifactVersion.getMajorVersion() == other.artifactVersion.getMajorVersion();
 	}
 
 	@Override
-	public boolean isSameMajorAndNewerThan(DependencyVersion other) {
+	public boolean isSameMinor(DependencyVersion other) {
 		if (other instanceof ReleaseTrainDependencyVersion) {
 			return false;
 		}
-		return extractArtifactVersionDependencyVersion(other).map(this::isSameMajorAndNewerThan).orElse(true);
+		return extractArtifactVersionDependencyVersion(other).map(this::isSameMinor).orElse(true);
 	}
 
-	private boolean isSameMajorAndNewerThan(ArtifactVersionDependencyVersion other) {
-		return this.artifactVersion.getMajorVersion() == other.artifactVersion.getMajorVersion() && isNewerThan(other);
+	private boolean isSameMinor(ArtifactVersionDependencyVersion other) {
+		return isSameMajor(other) && this.artifactVersion.getMinorVersion() == other.artifactVersion.getMinorVersion();
 	}
 
 	@Override
-	public boolean isSameMinorAndNewerThan(DependencyVersion other) {
-		if (other instanceof ReleaseTrainDependencyVersion) {
+	public boolean isUpgrade(DependencyVersion candidate, boolean movingToSnapshots) {
+		if (!(candidate instanceof ArtifactVersionDependencyVersion)) {
 			return false;
 		}
-		return extractArtifactVersionDependencyVersion(other).map(this::isSameMinorAndNewerThan).orElse(true);
+		ArtifactVersion other = ((ArtifactVersionDependencyVersion) candidate).artifactVersion;
+		if (this.artifactVersion.equals(other)) {
+			return false;
+		}
+		if (sameMajorMinorIncremental(other)) {
+			if (!StringUtils.hasLength(this.artifactVersion.getQualifier())
+					|| "RELEASE".equals(this.artifactVersion.getQualifier())) {
+				return false;
+			}
+			if (isSnapshot()) {
+				return true;
+			}
+			else if (((ArtifactVersionDependencyVersion) candidate).isSnapshot()) {
+				return movingToSnapshots;
+			}
+		}
+		return super.isUpgrade(candidate, movingToSnapshots);
+	}
+
+	private boolean sameMajorMinorIncremental(ArtifactVersion other) {
+		return this.artifactVersion.getMajorVersion() == other.getMajorVersion()
+				&& this.artifactVersion.getMinorVersion() == other.getMinorVersion()
+				&& this.artifactVersion.getIncrementalVersion() == other.getIncrementalVersion();
 	}
 
-	private boolean isSameMinorAndNewerThan(ArtifactVersionDependencyVersion other) {
-		return this.artifactVersion.getMajorVersion() == other.artifactVersion.getMajorVersion()
-				&& this.artifactVersion.getMinorVersion() == other.artifactVersion.getMinorVersion()
-				&& isNewerThan(other);
+	private boolean isSnapshot() {
+		return "SNAPSHOT".equals(this.artifactVersion.getQualifier())
+				|| "BUILD".equals(this.artifactVersion.getQualifier());
+	}
+
+	@Override
+	public boolean isSnapshotFor(DependencyVersion candidate) {
+		if (!isSnapshot() || !(candidate instanceof ArtifactVersionDependencyVersion)) {
+			return false;
+		}
+		return sameMajorMinorIncremental(((ArtifactVersionDependencyVersion) candidate).artifactVersion);
 	}
 
 	@Override
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/CalendarVersionDependencyVersion.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/CalendarVersionDependencyVersion.java
index a237ac3af4c4..475e2149f90b 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/CalendarVersionDependencyVersion.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/CalendarVersionDependencyVersion.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -41,14 +41,6 @@ protected CalendarVersionDependencyVersion(ArtifactVersion artifactVersion, Comp
 		super(artifactVersion, comparableVersion);
 	}
 
-	@Override
-	public boolean isNewerThan(DependencyVersion other) {
-		if (other instanceof ReleaseTrainDependencyVersion) {
-			return true;
-		}
-		return super.isNewerThan(other);
-	}
-
 	static CalendarVersionDependencyVersion parse(String version) {
 		if (!CALENDAR_VERSION_PATTERN.matcher(version).matches()) {
 			return null;
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/DependencyVersion.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/DependencyVersion.java
index dde4ff0dbb0a..f4b9b897a1ba 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/DependencyVersion.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/DependencyVersion.java
@@ -28,29 +28,38 @@
 public interface DependencyVersion extends Comparable<DependencyVersion> {
 
 	/**
-	 * Returns whether this version is newer than the given {@code other} version.
-	 * @param other version to test
-	 * @return {@code true} if this version is newer, otherwise {@code false}
+	 * Returns whether this version has the same major and minor versions as the
+	 * {@code other} version.
+	 * @param other the version to test
+	 * @return {@code true} if this version has the same major and minor, otherwise
+	 * {@code false}
 	 */
-	boolean isNewerThan(DependencyVersion other);
+	boolean isSameMinor(DependencyVersion other);
 
 	/**
-	 * Returns whether this version has the same major versions as the {@code other}
-	 * version while also being newer.
-	 * @param other version to test
-	 * @return {@code true} if this version has the same major and is newer, otherwise
-	 * {@code false}
+	 * Returns whether this version has the same major version as the {@code other}
+	 * version.
+	 * @param other the version to test
+	 * @return {@code true} if this version has the same major, otherwise {@code false}
 	 */
-	boolean isSameMajorAndNewerThan(DependencyVersion other);
+	boolean isSameMajor(DependencyVersion other);
 
 	/**
-	 * Returns whether this version has the same major and minor versions as the
-	 * {@code other} version while also being newer.
-	 * @param other version to test
-	 * @return {@code true} if this version has the same major and minor and is newer,
-	 * otherwise {@code false}
+	 * Returns whether the given {@code candidate} is an upgrade of this version.
+	 * @param candidate the version to consider
+	 * @param movingToSnapshots whether the upgrade is to be considered as part of moving
+	 * to snaphots
+	 * @return {@code true} if the candidate is an upgrade, otherwise false
+	 */
+	boolean isUpgrade(DependencyVersion candidate, boolean movingToSnapshots);
+
+	/**
+	 * Returns whether this version is a snapshot for the given {@code candidate}.
+	 * @param candidate the version to consider
+	 * @return {@code true} if this version is a snapshot for the candidate, otherwise
+	 * false
 	 */
-	boolean isSameMinorAndNewerThan(DependencyVersion other);
+	boolean isSnapshotFor(DependencyVersion candidate);
 
 	static DependencyVersion parse(String version) {
 		List<Function<String, DependencyVersion>> parsers = Arrays.asList(CalendarVersionDependencyVersion::parse,
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/ReleaseTrainDependencyVersion.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/ReleaseTrainDependencyVersion.java
index 838ba33e8b98..c9c79bcdc69b 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/ReleaseTrainDependencyVersion.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/ReleaseTrainDependencyVersion.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -28,7 +28,8 @@
  */
 final class ReleaseTrainDependencyVersion implements DependencyVersion {
 
-	private static final Pattern VERSION_PATTERN = Pattern.compile("([A-Z][a-z]+)-([A-Z]+)([0-9]*)");
+	private static final Pattern VERSION_PATTERN = Pattern
+		.compile("([A-Z][a-z]+)-((BUILD-SNAPSHOT)|([A-Z-]+)([0-9]*))");
 
 	private final String releaseTrain;
 
@@ -62,30 +63,56 @@ public int compareTo(DependencyVersion other) {
 	}
 
 	@Override
-	public boolean isNewerThan(DependencyVersion other) {
-		if (other instanceof CalendarVersionDependencyVersion) {
-			return false;
+	public boolean isUpgrade(DependencyVersion candidate, boolean movingToSnapshots) {
+		if (!(candidate instanceof ReleaseTrainDependencyVersion)) {
+			return true;
 		}
-		if (!(other instanceof ReleaseTrainDependencyVersion otherReleaseTrain)) {
+		ReleaseTrainDependencyVersion candidateReleaseTrain = (ReleaseTrainDependencyVersion) candidate;
+		int comparison = this.releaseTrain.compareTo(candidateReleaseTrain.releaseTrain);
+		if (comparison != 0) {
+			return comparison < 0;
+		}
+		if (movingToSnapshots && !isSnapshot() && candidateReleaseTrain.isSnapshot()) {
 			return true;
 		}
-		return otherReleaseTrain.compareTo(this) < 0;
+		comparison = this.type.compareTo(candidateReleaseTrain.type);
+		if (comparison != 0) {
+			return comparison < 0;
+		}
+		return Integer.compare(this.version, candidateReleaseTrain.version) < 0;
+	}
+
+	private boolean isSnapshot() {
+		return "BUILD-SNAPSHOT".equals(this.type);
+	}
+
+	@Override
+	public boolean isSnapshotFor(DependencyVersion candidate) {
+		if (!isSnapshot() || !(candidate instanceof ReleaseTrainDependencyVersion)) {
+			return false;
+		}
+		ReleaseTrainDependencyVersion candidateReleaseTrain = (ReleaseTrainDependencyVersion) candidate;
+		return this.releaseTrain.equals(candidateReleaseTrain.releaseTrain);
 	}
 
 	@Override
-	public boolean isSameMajorAndNewerThan(DependencyVersion other) {
-		return isNewerThan(other);
+	public boolean isSameMajor(DependencyVersion other) {
+		return isSameReleaseTrain(other);
 	}
 
 	@Override
-	public boolean isSameMinorAndNewerThan(DependencyVersion other) {
+	public boolean isSameMinor(DependencyVersion other) {
+		return isSameReleaseTrain(other);
+	}
+
+	private boolean isSameReleaseTrain(DependencyVersion other) {
 		if (other instanceof CalendarVersionDependencyVersion) {
 			return false;
 		}
-		if (!(other instanceof ReleaseTrainDependencyVersion otherReleaseTrain)) {
-			return true;
+		if (other instanceof ReleaseTrainDependencyVersion otherReleaseTrain) {
+			return otherReleaseTrain.releaseTrain.equals(this.releaseTrain);
 		}
-		return otherReleaseTrain.releaseTrain.equals(this.releaseTrain) && isNewerThan(other);
+		return true;
 	}
 
 	@Override
@@ -121,8 +148,9 @@ static ReleaseTrainDependencyVersion parse(String input) {
 		if (!matcher.matches()) {
 			return null;
 		}
-		return new ReleaseTrainDependencyVersion(matcher.group(1), matcher.group(2),
-				(StringUtils.hasLength(matcher.group(3))) ? Integer.parseInt(matcher.group(3)) : 0, input);
+		return new ReleaseTrainDependencyVersion(matcher.group(1),
+				StringUtils.hasLength(matcher.group(3)) ? matcher.group(3) : matcher.group(4),
+				(StringUtils.hasLength(matcher.group(5))) ? Integer.parseInt(matcher.group(5)) : 0, input);
 	}
 
 }
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/UnstructuredDependencyVersion.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/UnstructuredDependencyVersion.java
index a39d8a3843fa..5799225958bf 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/UnstructuredDependencyVersion.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/UnstructuredDependencyVersion.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -34,23 +34,23 @@ private UnstructuredDependencyVersion(String version) {
 	}
 
 	@Override
-	public boolean isNewerThan(DependencyVersion other) {
-		return compareTo(other) > 0;
+	public boolean isSameMajor(DependencyVersion other) {
+		return true;
 	}
 
 	@Override
-	public boolean isSameMajorAndNewerThan(DependencyVersion other) {
-		return compareTo(other) > 0;
+	public boolean isSameMinor(DependencyVersion other) {
+		return true;
 	}
 
 	@Override
-	public boolean isSameMinorAndNewerThan(DependencyVersion other) {
-		return compareTo(other) > 0;
+	public String toString() {
+		return this.version;
 	}
 
 	@Override
-	public String toString() {
-		return this.version;
+	public boolean isSnapshotFor(DependencyVersion candidate) {
+		return false;
 	}
 
 	static UnstructuredDependencyVersion parse(String version) {
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/classpath/CheckClasspathForUnnecessaryExclusions.java b/buildSrc/src/main/java/org/springframework/boot/build/classpath/CheckClasspathForUnnecessaryExclusions.java
index 268ace77791e..511898299957 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/classpath/CheckClasspathForUnnecessaryExclusions.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/classpath/CheckClasspathForUnnecessaryExclusions.java
@@ -38,6 +38,8 @@
 import org.gradle.api.artifacts.component.ModuleComponentIdentifier;
 import org.gradle.api.artifacts.dsl.DependencyHandler;
 import org.gradle.api.artifacts.result.ResolvedArtifactResult;
+import org.gradle.api.file.FileCollection;
+import org.gradle.api.tasks.Classpath;
 import org.gradle.api.tasks.Input;
 import org.gradle.api.tasks.TaskAction;
 
@@ -61,6 +63,8 @@ public class CheckClasspathForUnnecessaryExclusions extends DefaultTask {
 
 	private final ConfigurationContainer configurations;
 
+	private Configuration classpath;
+
 	@Inject
 	public CheckClasspathForUnnecessaryExclusions(DependencyHandler dependencyHandler,
 			ConfigurationContainer configurations) {
@@ -72,11 +76,17 @@ public CheckClasspathForUnnecessaryExclusions(DependencyHandler dependencyHandle
 	}
 
 	public void setClasspath(Configuration classpath) {
+		this.classpath = classpath;
 		this.exclusionsByDependencyId.clear();
 		this.dependencyById.clear();
 		classpath.getAllDependencies().all(this::processDependency);
 	}
 
+	@Classpath
+	public FileCollection getClasspath() {
+		return this.classpath;
+	}
+
 	private void processDependency(Dependency dependency) {
 		if (dependency instanceof ModuleDependency moduleDependency) {
 			processDependency(moduleDependency);
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/context/properties/CheckAdditionalSpringConfigurationMetadata.java b/buildSrc/src/main/java/org/springframework/boot/build/context/properties/CheckAdditionalSpringConfigurationMetadata.java
index d45b1d4e9299..cc7bdfd87b20 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/context/properties/CheckAdditionalSpringConfigurationMetadata.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/context/properties/CheckAdditionalSpringConfigurationMetadata.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -93,7 +93,7 @@ private Report createReport() throws IOException, JsonParseException, JsonMappin
 
 	@SuppressWarnings("unchecked")
 	private void check(String key, Map<String, Object> json, Analysis analysis) {
-		List<Map<String, Object>> groups = (List<Map<String, Object>>) json.get(key);
+		List<Map<String, Object>> groups = (List<Map<String, Object>>) json.getOrDefault(key, Collections.emptyList());
 		List<String> names = groups.stream().map((group) -> (String) group.get("name")).toList();
 		List<String> sortedNames = sortedCopy(names);
 		for (int i = 0; i < names.size(); i++) {
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java b/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java
index d7c8710e2cb4..6245d5b9da5e 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java
@@ -78,8 +78,10 @@ void documentConfigurationProperties() throws IOException {
 		snippets.add("application-properties.security", "Security Properties", this::securityPrefixes);
 		snippets.add("application-properties.rsocket", "RSocket Properties", this::rsocketPrefixes);
 		snippets.add("application-properties.actuator", "Actuator Properties", this::actuatorPrefixes);
-		snippets.add("application-properties.docker-compose", "Docker Compose Properties", this::dockerComposePrefixes);
 		snippets.add("application-properties.devtools", "Devtools Properties", this::devtoolsPrefixes);
+		snippets.add("application-properties.docker-compose", "Docker Compose Properties", this::dockerComposePrefixes);
+		snippets.add("application-properties.testcontainers", "Testcontainers Properties",
+				this::testcontainersPrefixes);
 		snippets.add("application-properties.testing", "Testing Properties", this::testingPrefixes);
 		snippets.writeTo(this.outputDir.toPath());
 	}
@@ -106,6 +108,7 @@ private void corePrefixes(Config config) {
 		config.accept("spring.reactor");
 		config.accept("spring.ssl");
 		config.accept("spring.task");
+		config.accept("spring.threads");
 		config.accept("spring.mandatory-file-encoding");
 		config.accept("info");
 		config.accept("spring.output.ansi.enabled");
@@ -170,6 +173,7 @@ private void integrationPrefixes(Config prefix) {
 		prefix.accept("spring.integration");
 		prefix.accept("spring.jms");
 		prefix.accept("spring.kafka");
+		prefix.accept("spring.pulsar");
 		prefix.accept("spring.rabbitmq");
 		prefix.accept("spring.hazelcast");
 		prefix.accept("spring.webservices");
@@ -222,7 +226,11 @@ private void devtoolsPrefixes(Config prefix) {
 	}
 
 	private void testingPrefixes(Config prefix) {
-		prefix.accept("spring.test");
+		prefix.accept("spring.test.");
+	}
+
+	private void testcontainersPrefixes(Config prefix) {
+		prefix.accept("spring.testcontainers.");
 	}
 
 }
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/docs/ApplicationRunner.java b/buildSrc/src/main/java/org/springframework/boot/build/docs/ApplicationRunner.java
index 0f95d55d3d67..785be772544b 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/docs/ApplicationRunner.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/docs/ApplicationRunner.java
@@ -105,8 +105,8 @@ public Property<String> getApplicationJar() {
 	}
 
 	public void normalizeTomcatPort() {
-		this.normalizations.put("(Tomcat started on port\\(s\\): )[\\d]+( \\(http\\))", "$18080$2");
-		this.normalizations.put("(Tomcat initialized with port\\(s\\): )[\\d]+( \\(http\\))", "$18080$2");
+		this.normalizations.put("(Tomcat started on port )[\\d]+( \\(http\\))", "$18080$2");
+		this.normalizations.put("(Tomcat initialized with port )[\\d]+( \\(http\\))", "$18080$2");
 	}
 
 	public void normalizeLiveReloadPort() {
diff --git a/buildSrc/src/main/java/org/springframework/boot/build/mavenplugin/DocumentPluginGoals.java b/buildSrc/src/main/java/org/springframework/boot/build/mavenplugin/DocumentPluginGoals.java
index 0419f33496ed..f5256fc17c80 100644
--- a/buildSrc/src/main/java/org/springframework/boot/build/mavenplugin/DocumentPluginGoals.java
+++ b/buildSrc/src/main/java/org/springframework/boot/build/mavenplugin/DocumentPluginGoals.java
@@ -21,9 +21,11 @@
 import java.io.IOException;
 import java.io.PrintWriter;
 import java.util.List;
+import java.util.Map;
 
 import org.gradle.api.DefaultTask;
 import org.gradle.api.Task;
+import org.gradle.api.tasks.Input;
 import org.gradle.api.tasks.InputFile;
 import org.gradle.api.tasks.OutputDirectory;
 import org.gradle.api.tasks.TaskAction;
@@ -45,6 +47,8 @@ public class DocumentPluginGoals extends DefaultTask {
 
 	private File outputDir;
 
+	private Map<String, String> goalSections;
+
 	@OutputDirectory
 	public File getOutputDir() {
 		return this.outputDir;
@@ -54,6 +58,15 @@ public void setOutputDir(File outputDir) {
 		this.outputDir = outputDir;
 	}
 
+	@Input
+	public Map<String, String> getGoalSections() {
+		return this.goalSections;
+	}
+
+	public void setGoalSections(Map<String, String> goalSections) {
+		this.goalSections = goalSections;
+	}
+
 	@InputFile
 	public File getPluginXml() {
 		return this.pluginXml;
@@ -79,7 +92,7 @@ private void writeOverview(Plugin plugin) throws IOException {
 			writer.println("| Goal | Description");
 			writer.println();
 			for (Mojo mojo : plugin.getMojos()) {
-				writer.printf("| <<goals-%s,%s:%s>>%n", mojo.getGoal(), plugin.getGoalPrefix(), mojo.getGoal());
+				writer.printf("| <<%s,%s:%s>>%n", goalSectionId(mojo), plugin.getGoalPrefix(), mojo.getGoal());
 				writer.printf("| %s%n", mojo.getDescription());
 				writer.println();
 			}
@@ -89,7 +102,7 @@ private void writeOverview(Plugin plugin) throws IOException {
 
 	private void documentMojo(Plugin plugin, Mojo mojo) throws IOException {
 		try (PrintWriter writer = new PrintWriter(new FileWriter(new File(this.outputDir, mojo.getGoal() + ".adoc")))) {
-			String sectionId = "goals-" + mojo.getGoal();
+			String sectionId = goalSectionId(mojo);
 			writer.println();
 			writer.println();
 			writer.printf("[[%s]]%n", sectionId);
@@ -99,12 +112,11 @@ private void documentMojo(Plugin plugin, Mojo mojo) throws IOException {
 			writer.println(mojo.getDescription());
 			List<Parameter> parameters = mojo.getParameters().stream().filter(Parameter::isEditable).toList();
 			List<Parameter> requiredParameters = parameters.stream().filter(Parameter::isRequired).toList();
-			String parametersSectionId = sectionId + "-parameters";
-			String detailsSectionId = parametersSectionId + "-details";
+			String detailsSectionId = sectionId + ".parameter-details";
 			if (!requiredParameters.isEmpty()) {
 				writer.println();
 				writer.println();
-				writer.printf("[[%s-required]]%n", parametersSectionId);
+				writer.printf("[[%s.required-parameters]]%n", sectionId);
 				writer.println("== Required parameters");
 				writeParametersTable(writer, detailsSectionId, requiredParameters);
 			}
@@ -114,7 +126,7 @@ private void documentMojo(Plugin plugin, Mojo mojo) throws IOException {
 			if (!optionalParameters.isEmpty()) {
 				writer.println();
 				writer.println();
-				writer.printf("[[%s-optional]]%n", parametersSectionId);
+				writer.printf("[[%s.optional-parameters]]%n", sectionId);
 				writer.println("== Optional parameters");
 				writeParametersTable(writer, detailsSectionId, optionalParameters);
 			}
@@ -126,6 +138,15 @@ private void documentMojo(Plugin plugin, Mojo mojo) throws IOException {
 		}
 	}
 
+	private String goalSectionId(Mojo mojo) {
+		String goalSection = this.goalSections.get(mojo.getGoal());
+		if (goalSection == null) {
+			throw new IllegalStateException("Goal '" + mojo.getGoal() + "' has not be assigned to a section");
+		}
+		String sectionId = goalSection + "." + mojo.getGoal() + "-goal";
+		return sectionId;
+	}
+
 	private void writeParametersTable(PrintWriter writer, String detailsSectionId, List<Parameter> parameters) {
 		writer.println("[cols=\"3,2,3\"]");
 		writer.println("|===");
@@ -133,7 +154,7 @@ private void writeParametersTable(PrintWriter writer, String detailsSectionId, L
 		writer.println();
 		for (Parameter parameter : parameters) {
 			String name = parameter.getName();
-			writer.printf("| <<%s-%s,%s>>%n", detailsSectionId, name, name);
+			writer.printf("| <<%s.%s,%s>>%n", detailsSectionId, parameterId(name), name);
 			writer.printf("| `%s`%n", typeNameToJavadocLink(shortTypeName(parameter.getType()), parameter.getType()));
 			String defaultValue = parameter.getDefaultValue();
 			if (defaultValue != null) {
@@ -152,7 +173,7 @@ private void writeParameterDetails(PrintWriter writer, List<Parameter> parameter
 			String name = parameter.getName();
 			writer.println();
 			writer.println();
-			writer.printf("[[%s-%s]]%n", sectionId, name);
+			writer.printf("[[%s.%s]]%n", sectionId, parameterId(name));
 			writer.printf("=== `%s`%n", name);
 			writer.println(parameter.getDescription());
 			writer.println();
@@ -168,6 +189,20 @@ private void writeParameterDetails(PrintWriter writer, List<Parameter> parameter
 		}
 	}
 
+	private String parameterId(String name) {
+		StringBuilder id = new StringBuilder(name.length() + 4);
+		for (char c : name.toCharArray()) {
+			if (Character.isLowerCase(c)) {
+				id.append(c);
+			}
+			else {
+				id.append("-");
+				id.append(Character.toLowerCase(c));
+			}
+		}
+		return id.toString();
+	}
+
 	private void writeDetail(PrintWriter writer, String name, String value) {
 		writer.printf("| %s%n", name);
 		writer.printf("| `%s`%n", value);
diff --git a/buildSrc/src/test/java/org/springframework/boot/build/ConventionsPluginTests.java b/buildSrc/src/test/java/org/springframework/boot/build/ConventionsPluginTests.java
index 6f7c1d0f76ee..5d60470ee066 100644
--- a/buildSrc/src/test/java/org/springframework/boot/build/ConventionsPluginTests.java
+++ b/buildSrc/src/test/java/org/springframework/boot/build/ConventionsPluginTests.java
@@ -187,7 +187,7 @@ void testRetryIsConfiguredWithThreeRetriesOnCI() throws IOException {
 		}
 		assertThat(runGradle(Collections.singletonMap("CI", "true"), "retryConfig", "--stacktrace").getOutput())
 			.contains("maxRetries: 3")
-			.contains("failOnPassedAfterRetry: true");
+			.contains("failOnPassedAfterRetry: false");
 	}
 
 	@Test
@@ -209,7 +209,7 @@ void testRetryIsConfiguredWithZeroRetriesLocally() throws IOException {
 		}
 		assertThat(runGradle(Collections.singletonMap("CI", "local"), "retryConfig", "--stacktrace").getOutput())
 			.contains("maxRetries: 0")
-			.contains("failOnPassedAfterRetry: true");
+			.contains("failOnPassedAfterRetry: false");
 	}
 
 	private BuildResult runGradle(String... args) {
diff --git a/buildSrc/src/test/java/org/springframework/boot/build/architecture/ArchitectureCheckTests.java b/buildSrc/src/test/java/org/springframework/boot/build/architecture/ArchitectureCheckTests.java
index d9c70b2b031a..1294d6192975 100644
--- a/buildSrc/src/test/java/org/springframework/boot/build/architecture/ArchitectureCheckTests.java
+++ b/buildSrc/src/test/java/org/springframework/boot/build/architecture/ArchitectureCheckTests.java
@@ -46,7 +46,7 @@ class ArchitectureCheckTests {
 	void whenPackagesAreTangledTaskFailsAndWritesAReport() throws Exception {
 		prepareTask("tangled", (architectureCheck) -> {
 			assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture);
-			assertThat(failureReport(architectureCheck).length()).isGreaterThan(0);
+			assertThat(failureReport(architectureCheck)).isNotEmpty();
 		});
 	}
 
@@ -54,7 +54,7 @@ void whenPackagesAreTangledTaskFailsAndWritesAReport() throws Exception {
 	void whenPackagesAreNotTangledTaskSucceedsAndWritesAnEmptyReport() throws Exception {
 		prepareTask("untangled", (architectureCheck) -> {
 			architectureCheck.checkArchitecture();
-			assertThat(failureReport(architectureCheck).length()).isZero();
+			assertThat(failureReport(architectureCheck)).isEmpty();
 		});
 	}
 
@@ -66,7 +66,7 @@ File failureReport(ArchitectureCheck architectureCheck) {
 	void whenBeanPostProcessorBeanMethodIsNotStaticTaskFailsAndWritesAReport() throws Exception {
 		prepareTask("bpp/nonstatic", (architectureCheck) -> {
 			assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture);
-			assertThat(failureReport(architectureCheck).length()).isGreaterThan(0);
+			assertThat(failureReport(architectureCheck)).isNotEmpty();
 		});
 	}
 
@@ -74,7 +74,7 @@ void whenBeanPostProcessorBeanMethodIsNotStaticTaskFailsAndWritesAReport() throw
 	void whenBeanPostProcessorBeanMethodIsStaticAndHasUnsafeParametersTaskFailsAndWritesAReport() throws Exception {
 		prepareTask("bpp/unsafeparameters", (architectureCheck) -> {
 			assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture);
-			assertThat(failureReport(architectureCheck).length()).isGreaterThan(0);
+			assertThat(failureReport(architectureCheck)).isNotEmpty();
 		});
 	}
 
@@ -83,7 +83,7 @@ void whenBeanPostProcessorBeanMethodIsStaticAndHasSafeParametersTaskSucceedsAndW
 			throws Exception {
 		prepareTask("bpp/safeparameters", (architectureCheck) -> {
 			architectureCheck.checkArchitecture();
-			assertThat(failureReport(architectureCheck).length()).isZero();
+			assertThat(failureReport(architectureCheck)).isEmpty();
 		});
 	}
 
@@ -92,7 +92,7 @@ void whenBeanPostProcessorBeanMethodIsStaticAndHasNoParametersTaskSucceedsAndWri
 			throws Exception {
 		prepareTask("bpp/noparameters", (architectureCheck) -> {
 			architectureCheck.checkArchitecture();
-			assertThat(failureReport(architectureCheck).length()).isZero();
+			assertThat(failureReport(architectureCheck)).isEmpty();
 		});
 	}
 
@@ -100,7 +100,7 @@ void whenBeanPostProcessorBeanMethodIsStaticAndHasNoParametersTaskSucceedsAndWri
 	void whenBeanFactoryPostProcessorBeanMethodIsNotStaticTaskFailsAndWritesAReport() throws Exception {
 		prepareTask("bfpp/nonstatic", (architectureCheck) -> {
 			assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture);
-			assertThat(failureReport(architectureCheck).length()).isGreaterThan(0);
+			assertThat(failureReport(architectureCheck)).isNotEmpty();
 		});
 	}
 
@@ -108,7 +108,7 @@ void whenBeanFactoryPostProcessorBeanMethodIsNotStaticTaskFailsAndWritesAReport(
 	void whenBeanFactoryPostProcessorBeanMethodIsStaticAndHasParametersTaskFailsAndWritesAReport() throws Exception {
 		prepareTask("bfpp/parameters", (architectureCheck) -> {
 			assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture);
-			assertThat(failureReport(architectureCheck).length()).isGreaterThan(0);
+			assertThat(failureReport(architectureCheck)).isNotEmpty();
 		});
 	}
 
@@ -117,7 +117,7 @@ void whenBeanFactoryPostProcessorBeanMethodIsStaticAndHasNoParametersTaskSucceed
 			throws Exception {
 		prepareTask("bfpp/noparameters", (architectureCheck) -> {
 			architectureCheck.checkArchitecture();
-			assertThat(failureReport(architectureCheck).length()).isZero();
+			assertThat(failureReport(architectureCheck)).isEmpty();
 		});
 	}
 
diff --git a/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/ReleaseScheduleTests.java b/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/ReleaseScheduleTests.java
new file mode 100644
index 000000000000..bbf78521403c
--- /dev/null
+++ b/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/ReleaseScheduleTests.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2023-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.build.bom.bomr;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.build.bom.bomr.ReleaseSchedule.Release;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.web.client.RestTemplate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+/**
+ * Tests for {@link ReleaseSchedule}.
+ *
+ * @author Andy Wilkinson
+ */
+class ReleaseScheduleTests {
+
+	private final RestTemplate rest = new RestTemplate();
+
+	private final ReleaseSchedule releaseSchedule = new ReleaseSchedule(this.rest);
+
+	private final MockRestServiceServer server = MockRestServiceServer.bindTo(this.rest).build();
+
+	@Test
+	void releasesBetween() {
+		this.server
+			.expect(requestTo("https://calendar.spring.io/releases?start=2023-09-01T00:00Z&end=2023-09-21T23:59Z"))
+			.andRespond(withSuccess(new ClassPathResource("releases.json"), MediaType.APPLICATION_JSON));
+		Map<String, List<Release>> releases = this.releaseSchedule
+			.releasesBetween(OffsetDateTime.parse("2023-09-01T00:00Z"), OffsetDateTime.parse("2023-09-21T23:59Z"));
+		assertThat(releases).hasSize(23);
+		assertThat(releases.get("Spring Framework")).hasSize(3);
+		assertThat(releases.get("Spring Boot")).hasSize(4);
+		assertThat(releases.get("Spring Modulith")).hasSize(1);
+		assertThat(releases.get("spring graphql")).hasSize(3);
+	}
+
+}
diff --git a/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/UpgradeApplicatorTests.java b/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/UpgradeApplicatorTests.java
index 6c96ab74ef08..af184a2be532 100644
--- a/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/UpgradeApplicatorTests.java
+++ b/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/UpgradeApplicatorTests.java
@@ -51,9 +51,9 @@ void whenUpgradeIsAppliedToLibraryWithVersionThenBomIsUpdated() throws IOExcepti
 		String originalContents = Files.readString(bom.toPath());
 		File gradleProperties = new File(this.temp, "gradle.properties");
 		FileCopyUtils.copy(new File("src/test/resources/gradle.properties"), gradleProperties);
-		new UpgradeApplicator(bom.toPath(), gradleProperties.toPath()).apply(
-				new Upgrade(new Library("ActiveMQ", new LibraryVersion(DependencyVersion.parse("5.15.11")), null, null),
-						DependencyVersion.parse("5.16")));
+		new UpgradeApplicator(bom.toPath(), gradleProperties.toPath())
+			.apply(new Upgrade(new Library("ActiveMQ", null, new LibraryVersion(DependencyVersion.parse("5.15.11")),
+					null, null, false), DependencyVersion.parse("5.16")));
 		String bomContents = Files.readString(bom.toPath());
 		assertThat(bomContents).hasSize(originalContents.length() - 3);
 	}
@@ -64,9 +64,9 @@ void whenUpgradeIsAppliedToLibraryWithVersionPropertyThenGradlePropertiesIsUpdat
 		FileCopyUtils.copy(new File("src/test/resources/bom.gradle"), bom);
 		File gradleProperties = new File(this.temp, "gradle.properties");
 		FileCopyUtils.copy(new File("src/test/resources/gradle.properties"), gradleProperties);
-		new UpgradeApplicator(bom.toPath(), gradleProperties.toPath())
-			.apply(new Upgrade(new Library("Kotlin", new LibraryVersion(DependencyVersion.parse("1.3.70")), null, null),
-					DependencyVersion.parse("1.4")));
+		new UpgradeApplicator(bom.toPath(), gradleProperties.toPath()).apply(new Upgrade(
+				new Library("Kotlin", null, new LibraryVersion(DependencyVersion.parse("1.3.70")), null, null, false),
+				DependencyVersion.parse("1.4")));
 		Properties properties = new Properties();
 		try (InputStream in = new FileInputStream(gradleProperties)) {
 			properties.load(in);
diff --git a/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/ArtifactVersionDependencyVersionTests.java b/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/ArtifactVersionDependencyVersionTests.java
index d0091b3fb1de..a50e6ba016fe 100644
--- a/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/ArtifactVersionDependencyVersionTests.java
+++ b/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/ArtifactVersionDependencyVersionTests.java
@@ -38,83 +38,93 @@ void parseWhenVersionIsAMavenVersionShouldReturnAVersion() {
 	}
 
 	@Test
-	void isNewerThanWhenInputIsOlderMajorShouldReturnTrue() {
-		assertThat(version("2.1.2").isNewerThan(version("1.9.0"))).isTrue();
+	void isSameMajorWhenSameMajorAndMinorShouldReturnTrue() {
+		assertThat(version("1.10.2").isSameMajor(version("1.10.0"))).isTrue();
 	}
 
 	@Test
-	void isNewerThanWhenInputIsOlderMinorShouldReturnTrue() {
-		assertThat(version("2.1.2").isNewerThan(version("2.0.2"))).isTrue();
+	void isSameMajorWhenSameMajorShouldReturnTrue() {
+		assertThat(version("1.10.2").isSameMajor(version("1.9.0"))).isTrue();
 	}
 
 	@Test
-	void isNewerThanWhenInputIsOlderPatchShouldReturnTrue() {
-		assertThat(version("2.1.2").isNewerThan(version("2.1.1"))).isTrue();
+	void isSameMajorWhenDifferentMajorShouldReturnFalse() {
+		assertThat(version("2.0.2").isSameMajor(version("1.9.0"))).isFalse();
 	}
 
 	@Test
-	void isNewerThanWhenInputIsNewerMajorShouldReturnFalse() {
-		assertThat(version("2.1.2").isNewerThan(version("3.2.1"))).isFalse();
+	void isSameMinorWhenSameMinorShouldReturnTrue() {
+		assertThat(version("1.10.2").isSameMinor(version("1.10.1"))).isTrue();
 	}
 
 	@Test
-	void isSameMajorAndNewerThanWhenMinorIsOlderShouldReturnTrue() {
-		assertThat(version("1.10.2").isSameMajorAndNewerThan(version("1.9.0"))).isTrue();
+	void isSameMinorWhenDifferentMinorShouldReturnFalse() {
+		assertThat(version("1.10.2").isSameMinor(version("1.9.1"))).isFalse();
 	}
 
 	@Test
-	void isSameMajorAndNewerThanWhenMajorIsOlderShouldReturnFalse() {
-		assertThat(version("2.0.2").isSameMajorAndNewerThan(version("1.9.0"))).isFalse();
+	void isSnapshotForWhenSnapshotForReleaseShouldReturnTrue() {
+		assertThat(version("1.10.2-SNAPSHOT").isSnapshotFor(version("1.10.2"))).isTrue();
 	}
 
 	@Test
-	void isSameMajorAndNewerThanWhenPatchIsNewerShouldReturnTrue() {
-		assertThat(version("2.1.2").isSameMajorAndNewerThan(version("2.1.1"))).isTrue();
+	void isSnapshotForWhenBuildSnapshotForReleaseShouldReturnTrue() {
+		assertThat(version("1.10.2.BUILD-SNAPSHOT").isSnapshotFor(version("1.10.2.RELEASE"))).isTrue();
 	}
 
 	@Test
-	void isSameMajorAndNewerThanWhenMinorIsNewerShouldReturnFalse() {
-		assertThat(version("2.1.2").isSameMajorAndNewerThan(version("2.2.1"))).isFalse();
+	void isSnapshotForWhenSnapshotForReleaseCandidateShouldReturnTrue() {
+		assertThat(version("1.10.2-SNAPSHOT").isSnapshotFor(version("1.10.2-RC2"))).isTrue();
 	}
 
 	@Test
-	void isSameMajorAndNewerThanWhenMajorIsNewerShouldReturnFalse() {
-		assertThat(version("2.1.2").isSameMajorAndNewerThan(version("3.0.1"))).isFalse();
+	void isSnapshotForWhenBuildSnapshotForReleaseCandidateShouldReturnTrue() {
+		assertThat(version("1.10.2.BUILD-SNAPSHOT").isSnapshotFor(version("1.10.2.RC2"))).isTrue();
 	}
 
 	@Test
-	void isSameMinorAndNewerThanWhenPatchIsOlderShouldReturnTrue() {
-		assertThat(version("1.10.2").isSameMinorAndNewerThan(version("1.10.1"))).isTrue();
+	void isSnapshotForWhenSnapshotForMilestoneShouldReturnTrue() {
+		assertThat(version("1.10.2-SNAPSHOT").isSnapshotFor(version("1.10.2-M1"))).isTrue();
 	}
 
 	@Test
-	void isSameMinorAndNewerThanWhenMinorIsOlderShouldReturnFalse() {
-		assertThat(version("2.1.2").isSameMinorAndNewerThan(version("2.0.1"))).isFalse();
+	void isSnapshotForWhenBuildSnapshotForMilestoneShouldReturnTrue() {
+		assertThat(version("1.10.2.BUILD-SNAPSHOT").isSnapshotFor(version("1.10.2.M1"))).isTrue();
 	}
 
 	@Test
-	void isSameMinorAndNewerThanWhenVersionsAreTheSameShouldReturnFalse() {
-		assertThat(version("2.1.2").isSameMinorAndNewerThan(version("2.1.2"))).isFalse();
+	void isSnapshotForWhenSnapshotForDifferentReleaseShouldReturnFalse() {
+		assertThat(version("1.10.1-SNAPSHOT").isSnapshotFor(version("1.10.2"))).isFalse();
 	}
 
 	@Test
-	void isSameMinorAndNewerThanWhenSameSnapshotsShouldReturnFalse() {
-		assertThat(version("3.1.2-SNAPSHOT").isSameMinorAndNewerThan(version("3.1.2-SNAPSHOT"))).isFalse();
+	void isSnapshotForWhenBuildSnapshotForDifferentReleaseShouldReturnTrue() {
+		assertThat(version("1.10.1.BUILD-SNAPSHOT").isSnapshotFor(version("1.10.2.RELEASE"))).isFalse();
 	}
 
 	@Test
-	void isSameMinorAndNewerThanWhenPatchIsNewerShouldReturnFalse() {
-		assertThat(version("2.1.2").isSameMinorAndNewerThan(version("2.1.3"))).isFalse();
+	void isSnapshotForWhenSnapshotForDifferentReleaseCandidateShouldReturnTrue() {
+		assertThat(version("1.10.1-SNAPSHOT").isSnapshotFor(version("1.10.2-RC2"))).isFalse();
 	}
 
 	@Test
-	void isSameMinorAndNewerThanWhenMinorIsNewerShouldReturnFalse() {
-		assertThat(version("2.1.2").isSameMinorAndNewerThan(version("2.0.1"))).isFalse();
+	void isSnapshotForWhenBuildSnapshotForDifferentReleaseCandidateShouldReturnTrue() {
+		assertThat(version("1.10.1.BUILD-SNAPSHOT").isSnapshotFor(version("1.10.2.RC2"))).isFalse();
 	}
 
 	@Test
-	void isSameMinorAndNewerThanWhenMajorIsNewerShouldReturnFalse() {
-		assertThat(version("3.1.2").isSameMinorAndNewerThan(version("2.0.1"))).isFalse();
+	void isSnapshotForWhenSnapshotForDifferentMilestoneShouldReturnTrue() {
+		assertThat(version("1.10.1-SNAPSHOT").isSnapshotFor(version("1.10.2-M1"))).isFalse();
+	}
+
+	@Test
+	void isSnapshotForWhenBuildSnapshotForDifferentMilestoneShouldReturnTrue() {
+		assertThat(version("1.10.1.BUILD-SNAPSHOT").isSnapshotFor(version("1.10.2.M1"))).isFalse();
+	}
+
+	@Test
+	void isSnapshotForWhenNotSnapshotShouldReturnFalse() {
+		assertThat(version("1.10.1-M1").isSnapshotFor(version("1.10.1"))).isFalse();
 	}
 
 	private ArtifactVersionDependencyVersion version(String version) {
diff --git a/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/CalendarVersionDependencyVersionTests.java b/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/CalendarVersionDependencyVersionTests.java
index 279016c7af2a..08ebb7dbf98f 100644
--- a/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/CalendarVersionDependencyVersionTests.java
+++ b/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/CalendarVersionDependencyVersionTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -38,93 +38,38 @@ void parseWhenVersionIsACalendarVersionShouldReturnAVersion() {
 	}
 
 	@Test
-	void isNewerThanWhenInputIsEarlierYearShouldReturnTrue() {
-		assertThat(version("2020.1.2").isNewerThan(version("2019.9.0"))).isTrue();
+	void isSameMajorWhenSameMajorAndMinorShouldReturnTrue() {
+		assertThat(version("2020.0.0").isSameMajor(version("2020.0.1"))).isTrue();
 	}
 
 	@Test
-	void isNewerThanWhenInputIsOlderMinorShouldReturnTrue() {
-		assertThat(version("2020.1.2").isNewerThan(version("2020.0.2"))).isTrue();
+	void isSameMajorWhenSameMajorShouldReturnTrue() {
+		assertThat(version("2020.0.0").isSameMajor(version("2020.1.0"))).isTrue();
 	}
 
 	@Test
-	void isNewerThanWhenInputIsOlderMicroShouldReturnTrue() {
-		assertThat(version("2020.1.2").isNewerThan(version("2020.1.1"))).isTrue();
+	void isSameMajorWhenDifferentMajorShouldReturnFalse() {
+		assertThat(version("2020.0.0").isSameMajor(version("2021.0.0"))).isFalse();
 	}
 
 	@Test
-	void isNewerThanWhenInputIsLaterYearShouldReturnFalse() {
-		assertThat(version("2020.1.2").isNewerThan(version("2021.2.1"))).isFalse();
+	void isSameMinorWhenSameMinorShouldReturnTrue() {
+		assertThat(version("2020.0.0").isSameMinor(version("2020.0.1"))).isTrue();
 	}
 
 	@Test
-	void isSameMajorAndNewerThanWhenMinorIsOlderShouldReturnTrue() {
-		assertThat(version("2020.10.2").isSameMajorAndNewerThan(version("2020.9.0"))).isTrue();
-	}
-
-	@Test
-	void isSameMajorAndNewerThanWhenMajorIsOlderShouldReturnFalse() {
-		assertThat(version("2020.0.2").isSameMajorAndNewerThan(version("2019.9.0"))).isFalse();
-	}
-
-	@Test
-	void isSameMajorAndNewerThanWhenMicroIsNewerShouldReturnTrue() {
-		assertThat(version("2020.1.2").isSameMajorAndNewerThan(version("2020.1.1"))).isTrue();
-	}
-
-	@Test
-	void isSameMajorAndNewerThanWhenMinorIsNewerShouldReturnFalse() {
-		assertThat(version("2020.1.2").isSameMajorAndNewerThan(version("2020.2.1"))).isFalse();
-	}
-
-	@Test
-	void isSameMajorAndNewerThanWhenMajorIsNewerShouldReturnFalse() {
-		assertThat(version("2019.1.2").isSameMajorAndNewerThan(version("2020.0.1"))).isFalse();
-	}
-
-	@Test
-	void isSameMinorAndNewerThanWhenMicroIsOlderShouldReturnTrue() {
-		assertThat(version("2020.10.2").isSameMinorAndNewerThan(version("2020.10.1"))).isTrue();
-	}
-
-	@Test
-	void isSameMinorAndNewerThanWhenMinorIsOlderShouldReturnFalse() {
-		assertThat(version("2020.1.2").isSameMinorAndNewerThan(version("2020.0.1"))).isFalse();
-	}
-
-	@Test
-	void isSameMinorAndNewerThanWhenVersionsAreTheSameShouldReturnFalse() {
-		assertThat(version("2020.1.2").isSameMinorAndNewerThan(version("2020.1.2"))).isFalse();
-	}
-
-	@Test
-	void isSameMinorAndNewerThanWhenMicroIsNewerShouldReturnFalse() {
-		assertThat(version("2020.1.2").isSameMinorAndNewerThan(version("2020.1.3"))).isFalse();
-	}
-
-	@Test
-	void isSameMinorAndNewerThanWhenMinorIsNewerShouldReturnFalse() {
-		assertThat(version("2020.1.2").isSameMinorAndNewerThan(version("2020.0.1"))).isFalse();
-	}
-
-	@Test
-	void isSameMinorAndNewerThanWhenMajorIsNewerShouldReturnFalse() {
-		assertThat(version("2020.1.2").isSameMinorAndNewerThan(version("2019.0.1"))).isFalse();
-	}
-
-	@Test
-	void calendarVersionIsNewerThanReleaseTrainVersion() {
-		assertThat(version("2020.0.0").isNewerThan(releaseTrainVersion("Aluminium-RELEASE"))).isTrue();
+	void isSameMinorWhenDifferentMinorShouldReturnFalse() {
+		assertThat(version("2020.0.0").isSameMinor(version("2020.1.0"))).isFalse();
 	}
 
 	@Test
 	void calendarVersionIsNotSameMajorAsReleaseTrainVersion() {
-		assertThat(version("2020.0.0").isSameMajorAndNewerThan(releaseTrainVersion("Aluminium-RELEASE"))).isFalse();
+		assertThat(version("2020.0.0").isSameMajor(releaseTrainVersion("Aluminium-RELEASE"))).isFalse();
 	}
 
 	@Test
 	void calendarVersionIsNotSameMinorAsReleaseTrainVersion() {
-		assertThat(version("2020.0.0").isSameMinorAndNewerThan(releaseTrainVersion("Aluminium-RELEASE"))).isFalse();
+		assertThat(version("2020.0.0").isSameMinor(releaseTrainVersion("Aluminium-RELEASE"))).isFalse();
 	}
 
 	private ReleaseTrainDependencyVersion releaseTrainVersion(String version) {
diff --git a/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/DependencyVersionTests.java b/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/DependencyVersionTests.java
index 64e5f7b2a328..06dc3e6fb3d2 100644
--- a/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/DependencyVersionTests.java
+++ b/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/DependencyVersionTests.java
@@ -68,9 +68,4 @@ void parseWhenCalendarVersionWithModifierShouldReturnArtifactVersionDependencyVe
 		assertThat(DependencyVersion.parse("2020.0.0-M1")).isInstanceOf(CalendarVersionDependencyVersion.class);
 	}
 
-	@Test
-	void calendarVersionShouldBeNewerThanAReleaseCalendarVersion() {
-
-	}
-
 }
diff --git a/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/DependencyVersionUpgradeTests.java b/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/DependencyVersionUpgradeTests.java
new file mode 100644
index 000000000000..404ae42fe89f
--- /dev/null
+++ b/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/DependencyVersionUpgradeTests.java
@@ -0,0 +1,304 @@
+/*
+ * Copyright 2023-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.build.bom.bomr.version;
+
+import java.lang.annotation.Annotation;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Repeatable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.lang.reflect.Method;
+import java.util.function.Function;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.ArgumentsProvider;
+import org.junit.jupiter.params.provider.ArgumentsSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link DependencyVersion#isUpgrade} of {@link DependencyVersion}
+ * implementations.
+ *
+ * @author Andy Wilkinson
+ */
+class DependencyVersionUpgradeTests {
+
+	@ParameterizedTest
+	@ArtifactVersion(current = "1.2.3", candidate = "1.2.3")
+	@ArtifactVersion(current = "1.2.3.RELEASE", candidate = "1.2.3.RELEASE")
+	@CalendarVersion(current = "2023.0.0", candidate = "2023.0.0")
+	@ReleaseTrain(current = "Kay-RELEASE", candidate = "Kay-RELEASE")
+	void isUpgradeWhenSameVersionShouldReturnFalse(DependencyVersion current, DependencyVersion candidate) {
+		assertThat(current.isUpgrade(candidate, false)).isFalse();
+	}
+
+	@ParameterizedTest
+	@ArtifactVersion(current = "1.2.3-SNAPSHOT", candidate = "1.2.3-SNAPSHOT")
+	@ArtifactVersion(current = "1.2.3.BUILD-SNAPSHOT", candidate = "1.2.3.BUILD-SNAPSHOT")
+	@CalendarVersion(current = "2023.0.0-SNAPSHOT", candidate = "2023.0.0-SNAPSHOT")
+	@ReleaseTrain(current = "Kay-BUILD-SNAPSHOT", candidate = "Kay-BUILD-SNAPSHOT")
+	void isUpgradeWhenSameSnapshotVersionShouldReturnFalse(DependencyVersion current, DependencyVersion candidate) {
+		assertThat(current.isUpgrade(candidate, false)).isFalse();
+	}
+
+	@ParameterizedTest
+	@ArtifactVersion(current = "1.2.3-SNAPSHOT", candidate = "1.2.3-SNAPSHOT")
+	@ArtifactVersion(current = "1.2.3.BUILD-SNAPSHOT", candidate = "1.2.3.BUILD-SNAPSHOT")
+	@CalendarVersion(current = "2023.0.0-SNAPSHOT", candidate = "2023.0.0-SNAPSHOT")
+	@ReleaseTrain(current = "Kay-BUILD-SNAPSHOT", candidate = "Kay-BUILD-SNAPSHOT")
+	void isUpgradeWhenSameSnapshotVersionAndMovingToSnapshotsShouldReturnFalse(DependencyVersion current,
+			DependencyVersion candidate) {
+		assertThat(current.isUpgrade(candidate, true)).isFalse();
+	}
+
+	@ParameterizedTest
+	@ArtifactVersion(current = "1.2.3", candidate = "1.2.4")
+	@ArtifactVersion(current = "1.2.3.RELEASE", candidate = "1.2.4.RELEASE")
+	@CalendarVersion(current = "2023.0.0", candidate = "2023.0.1")
+	@ReleaseTrain(current = "Kay-RELEASE", candidate = "Kay-SR1")
+	void isUpgradeWhenLaterPatchReleaseShouldReturnTrue(DependencyVersion current, DependencyVersion candidate) {
+		assertThat(current.isUpgrade(candidate, false)).isTrue();
+	}
+
+	@ParameterizedTest
+	@ArtifactVersion(current = "1.2.3", candidate = "1.2.4-SNAPSHOT")
+	@ArtifactVersion(current = "1.2.3.RELEASE", candidate = "1.2.4.BUILD-SNAPSHOT")
+	@CalendarVersion(current = "2023.0.0", candidate = "2023.0.1-SNAPSHOT")
+	void isUpgradeWhenSnapshotOfLaterPatchReleaseShouldReturnTrue(DependencyVersion current,
+			DependencyVersion candidate) {
+		assertThat(current.isUpgrade(candidate, false)).isTrue();
+	}
+
+	@ParameterizedTest
+	@ArtifactVersion(current = "1.2.3", candidate = "1.2.4-SNAPSHOT")
+	@ArtifactVersion(current = "1.2.3.RELEASE", candidate = "1.2.4.BUILD-SNAPSHOT")
+	@CalendarVersion(current = "2023.0.0", candidate = "2023.0.1-SNAPSHOT")
+	@ReleaseTrain(current = "Kay-RELEASE", candidate = "Kay-BUILD-SNAPSHOT")
+	void isUpgradeWhenSnapshotOfLaterPatchReleaseAndMovingToSnapshotsShouldReturnTrue(DependencyVersion current,
+			DependencyVersion candidate) {
+		assertThat(current.isUpgrade(candidate, true)).isTrue();
+	}
+
+	@ParameterizedTest
+	@ArtifactVersion(current = "1.2.3", candidate = "1.2.3-SNAPSHOT")
+	@ArtifactVersion(current = "1.2.3.RELEASE", candidate = "1.2.3.BUILD-SNAPSHOT")
+	@CalendarVersion(current = "2023.0.0", candidate = "2023.0.0-SNAPSHOT")
+	@ReleaseTrain(current = "Kay-RELEASE", candidate = "Kay-BUILD-SNAPSHOT")
+	void isUpgradeWhenSnapshotOfSameVersionShouldReturnFalse(DependencyVersion current, DependencyVersion candidate) {
+		assertThat(current.isUpgrade(candidate, false)).isFalse();
+	}
+
+	@ParameterizedTest
+	@ArtifactVersion(current = "1.2.3-SNAPSHOT", candidate = "1.2.3-M2")
+	@ArtifactVersion(current = "1.2.3.BUILD-SNAPSHOT", candidate = "1.2.3.M2")
+	@CalendarVersion(current = "2023.0.0-SNAPSHOT", candidate = "2023.0.0-M2")
+	@ReleaseTrain(current = "Kay-BUILD-SNAPSHOT", candidate = "Kay-M2")
+	void isUpgradeWhenSnapshotToMilestoneShouldReturnTrue(DependencyVersion current, DependencyVersion candidate) {
+		assertThat(current.isUpgrade(candidate, false)).isTrue();
+	}
+
+	@ParameterizedTest
+	@ArtifactVersion(current = "1.2.3-SNAPSHOT", candidate = "1.2.3-RC1")
+	@ArtifactVersion(current = "1.2.3.BUILD-SNAPSHOT", candidate = "1.2.3.RC1")
+	@CalendarVersion(current = "2023.0.0-SNAPSHOT", candidate = "2023.0.0-RC1")
+	@ReleaseTrain(current = "Kay-BUILD-SNAPSHOT", candidate = "Kay-RC1")
+	void isUpgradeWhenSnapshotToReleaseCandidateShouldReturnTrue(DependencyVersion current,
+			DependencyVersion candidate) {
+		assertThat(current.isUpgrade(candidate, false)).isTrue();
+	}
+
+	@ParameterizedTest
+	@ArtifactVersion(current = "1.2.3-SNAPSHOT", candidate = "1.2.3")
+	@ArtifactVersion(current = "1.2.3.BUILD-SNAPSHOT", candidate = "1.2.3.RELEASE")
+	@CalendarVersion(current = "2023.0.0-SNAPSHOT", candidate = "2023.0.0")
+	@ReleaseTrain(current = "Kay-BUILD-SNAPSHOT", candidate = "Kay-RELEASE")
+	void isUpgradeWhenSnapshotToReleaseShouldReturnTrue(DependencyVersion current, DependencyVersion candidate) {
+		assertThat(current.isUpgrade(candidate, false)).isTrue();
+	}
+
+	@ParameterizedTest
+	@ArtifactVersion(current = "1.2.3-M1", candidate = "1.2.3-SNAPSHOT")
+	@ArtifactVersion(current = "1.2.3.M1", candidate = "1.2.3.BUILD-SNAPSHOT")
+	@CalendarVersion(current = "2023.0.0-M1", candidate = "2023.0.0-SNAPSHOT")
+	@ReleaseTrain(current = "Kay-M1", candidate = "Kay-BUILD-SNAPSHOT")
+	void isUpgradeWhenMilestoneToSnapshotShouldReturnFalse(DependencyVersion current, DependencyVersion candidate) {
+		assertThat(current.isUpgrade(candidate, false)).isFalse();
+	}
+
+	@ParameterizedTest
+	@ArtifactVersion(current = "1.2.3-RC1", candidate = "1.2.3-SNAPSHOT")
+	@ArtifactVersion(current = "1.2.3.RC1", candidate = "1.2.3.BUILD-SNAPSHOT")
+	@CalendarVersion(current = "2023.0.0-RC1", candidate = "2023.0.0-SNAPSHOT")
+	@ReleaseTrain(current = "Kay-RC1", candidate = "Kay-BUILD-SNAPSHOT")
+	void isUpgradeWhenReleaseCandidateToSnapshotShouldReturnFalse(DependencyVersion current,
+			DependencyVersion candidate) {
+		assertThat(current.isUpgrade(candidate, false)).isFalse();
+	}
+
+	@ParameterizedTest
+	@ArtifactVersion(current = "1.2.3", candidate = "1.2.3-SNAPSHOT")
+	@ArtifactVersion(current = "1.2.3.RELEASE", candidate = "1.2.3.BUILD-SNAPSHOT")
+	@CalendarVersion(current = "2023.0.0", candidate = "2023.0.0-SNAPSHOT")
+	@ReleaseTrain(current = "Kay-RELEASE", candidate = "Kay-BUILD-SNAPSHOT")
+	void isUpgradeWhenReleaseToSnapshotShouldReturnFalse(DependencyVersion current, DependencyVersion candidate) {
+		assertThat(current.isUpgrade(candidate, false)).isFalse();
+	}
+
+	@ParameterizedTest
+	@ArtifactVersion(current = "1.2.3-M1", candidate = "1.2.3-SNAPSHOT")
+	@ArtifactVersion(current = "1.2.3.M1", candidate = "1.2.3.BUILD-SNAPSHOT")
+	@CalendarVersion(current = "2023.0.0-M1", candidate = "2023.0.0-SNAPSHOT")
+	@ReleaseTrain(current = "Kay-M1", candidate = "Kay-BUILD-SNAPSHOT")
+	void isUpgradeWhenMilestoneToSnapshotAndMovingToSnapshotsShouldReturnTrue(DependencyVersion current,
+			DependencyVersion candidate) {
+		assertThat(current.isUpgrade(candidate, true)).isTrue();
+	}
+
+	@ParameterizedTest
+	@ArtifactVersion(current = "1.2.3-RC1", candidate = "1.2.3-SNAPSHOT")
+	@ArtifactVersion(current = "1.2.3.RC1", candidate = "1.2.3.BUILD-SNAPSHOT")
+	@CalendarVersion(current = "2023.0.0-RC1", candidate = "2023.0.0-SNAPSHOT")
+	@ReleaseTrain(current = "Kay-RC1", candidate = "Kay-BUILD-SNAPSHOT")
+	void isUpgradeWhenReleaseCandidateToSnapshotAndMovingToSnapshotsShouldReturnTrue(DependencyVersion current,
+			DependencyVersion candidate) {
+		assertThat(current.isUpgrade(candidate, true)).isTrue();
+	}
+
+	@ParameterizedTest
+	@ArtifactVersion(current = "1.2.3", candidate = "1.2.3-SNAPSHOT")
+	@ArtifactVersion(current = "1.2.3.RELEASE", candidate = "1.2.3.BUILD-SNAPSHOT")
+	@CalendarVersion(current = "2023.0.0", candidate = "2023.0.0-SNAPSHOT")
+	void isUpgradeWhenReleaseToSnapshotAndMovingToSnapshotsShouldReturnFalse(DependencyVersion current,
+			DependencyVersion candidate) {
+		assertThat(current.isUpgrade(candidate, true)).isFalse();
+	}
+
+	@ParameterizedTest
+	@ReleaseTrain(current = "Kay-RELEASE", candidate = "Kay-BUILD-SNAPSHOT")
+	void isUpgradeWhenReleaseTrainToSnapshotAndMovingToSnapshotsShouldReturnTrue(DependencyVersion current,
+			DependencyVersion candidate) {
+		assertThat(current.isUpgrade(candidate, true)).isTrue();
+	}
+
+	@Repeatable(ArtifactVersions.class)
+	@Target(ElementType.METHOD)
+	@Retention(RetentionPolicy.RUNTIME)
+	@ArgumentsSource(InputProvider.class)
+	@interface ArtifactVersion {
+
+		String current();
+
+		String candidate();
+
+	}
+
+	@Target(ElementType.METHOD)
+	@Retention(RetentionPolicy.RUNTIME)
+	@interface ArtifactVersions {
+
+		ArtifactVersion[] value();
+
+	}
+
+	@Target(ElementType.METHOD)
+	@Retention(RetentionPolicy.RUNTIME)
+	@ArgumentsSource(InputProvider.class)
+	@interface ReleaseTrain {
+
+		String current();
+
+		String candidate();
+
+	}
+
+	@Target(ElementType.METHOD)
+	@Retention(RetentionPolicy.RUNTIME)
+	@ArgumentsSource(InputProvider.class)
+	@interface CalendarVersion {
+
+		String current();
+
+		String candidate();
+
+	}
+
+	static class InputProvider implements ArgumentsProvider {
+
+		@Override
+		public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
+			Method testMethod = context.getRequiredTestMethod();
+			Stream<Arguments> artifactVersions = artifactVersions(testMethod)
+				.map((artifactVersion) -> Arguments.of(VersionType.ARTIFACT_VERSION.parse(artifactVersion.current()),
+						VersionType.ARTIFACT_VERSION.parse(artifactVersion.candidate())));
+			Stream<Arguments> releaseTrains = releaseTrains(testMethod)
+				.map((releaseTrain) -> Arguments.of(VersionType.RELEASE_TRAIN.parse(releaseTrain.current()),
+						VersionType.RELEASE_TRAIN.parse(releaseTrain.candidate())));
+			Stream<Arguments> calendarVersions = calendarVersions(testMethod)
+				.map((calendarVersion) -> Arguments.of(VersionType.CALENDAR_VERSION.parse(calendarVersion.current()),
+						VersionType.CALENDAR_VERSION.parse(calendarVersion.candidate())));
+			return Stream.concat(Stream.concat(artifactVersions, releaseTrains), calendarVersions);
+		}
+
+		private Stream<ArtifactVersion> artifactVersions(Method testMethod) {
+			ArtifactVersions artifactVersions = testMethod.getAnnotation(ArtifactVersions.class);
+			if (artifactVersions != null) {
+				return Stream.of(artifactVersions.value());
+			}
+			return versions(testMethod, ArtifactVersion.class);
+		}
+
+		private Stream<ReleaseTrain> releaseTrains(Method testMethod) {
+			return versions(testMethod, ReleaseTrain.class);
+		}
+
+		private Stream<CalendarVersion> calendarVersions(Method testMethod) {
+			return versions(testMethod, CalendarVersion.class);
+		}
+
+		private <T extends Annotation> Stream<T> versions(Method testMethod, Class<T> type) {
+			T annotation = testMethod.getAnnotation(type);
+			return (annotation != null) ? Stream.of(annotation) : Stream.empty();
+		}
+
+	}
+
+	enum VersionType {
+
+		ARTIFACT_VERSION(ArtifactVersionDependencyVersion::parse),
+
+		CALENDAR_VERSION(CalendarVersionDependencyVersion::parse),
+
+		RELEASE_TRAIN(ReleaseTrainDependencyVersion::parse);
+
+		private final Function<String, DependencyVersion> parser;
+
+		VersionType(Function<String, DependencyVersion> parser) {
+			this.parser = parser;
+		}
+
+		DependencyVersion parse(String version) {
+			return this.parser.apply(version);
+		}
+
+	}
+
+}
diff --git a/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/MultipleComponentsDependencyVersionTests.java b/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/MultipleComponentsDependencyVersionTests.java
index 8602a5982a39..ad8b814cd487 100644
--- a/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/MultipleComponentsDependencyVersionTests.java
+++ b/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/MultipleComponentsDependencyVersionTests.java
@@ -29,43 +29,23 @@
 class MultipleComponentsDependencyVersionTests {
 
 	@Test
-	void isNewerThanOnVersionWithNumericQualifierWhenInputHasNoQualifierShouldReturnTrue() {
-		assertThat(version("2.9.9.20190806").isNewerThan(DependencyVersion.parse("2.9.9"))).isTrue();
+	void isSameMajorOfFiveComponentVersionWithSameMajorShouldReturnTrue() {
+		assertThat(version("21.4.0.0.1").isSameMajor(version("21.1.0.0"))).isTrue();
 	}
 
 	@Test
-	void isNewerThanOnVersionWithNumericQualifierWhenInputHasOlderQualifierShouldReturnTrue() {
-		assertThat(version("2.9.9.20190806").isNewerThan(version("2.9.9.20190805"))).isTrue();
+	void isSameMajorOfFiveComponentVersionWithDifferentMajorShouldReturnFalse() {
+		assertThat(version("21.4.0.0.1").isSameMajor(version("22.1.0.0"))).isFalse();
 	}
 
 	@Test
-	void isNewerThanOnVersionWithNumericQualifierWhenInputHasNewerQualifierShouldReturnFalse() {
-		assertThat(version("2.9.9.20190806").isNewerThan(version("2.9.9.20190807"))).isFalse();
+	void isSameMinorOfFiveComponentVersionWithSameMinorShouldReturnTrue() {
+		assertThat(version("21.4.0.0.1").isSameMinor(version("21.4.0.0"))).isTrue();
 	}
 
 	@Test
-	void isNewerThanOnVersionWithNumericQualifierWhenInputHasSameQualifierShouldReturnFalse() {
-		assertThat(version("2.9.9.20190806").isNewerThan(version("2.9.9.20190806"))).isFalse();
-	}
-
-	@Test
-	void isNewerThanWorksWith5Components() {
-		assertThat(version("21.4.0.0.1").isNewerThan(version("21.1.0.0"))).isTrue();
-	}
-
-	@Test
-	void isNewerThanWorksWith5ComponentsAndLastComponentIsConsidered() {
-		assertThat(version("21.1.0.0.1").isNewerThan(version("21.1.0.0"))).isTrue();
-	}
-
-	@Test
-	void isSameMajorAndNewerThanWorksWith5Components() {
-		assertThat(version("21.4.0.0.1").isSameMajorAndNewerThan(version("21.1.0.0"))).isTrue();
-	}
-
-	@Test
-	void isSameMinorAndNewerThanWorksWith5Components() {
-		assertThat(version("21.4.0.0.1").isSameMinorAndNewerThan(version("21.1.0.0"))).isFalse();
+	void isSameMinorOfFiveComponentVersionWithDifferentMinorShouldReturnFalse() {
+		assertThat(version("21.4.0.0.1").isSameMinor(version("21.5.0.0"))).isFalse();
 	}
 
 	private MultipleComponentsDependencyVersion version(String version) {
diff --git a/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/ReleaseTrainDependencyVersionTests.java b/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/ReleaseTrainDependencyVersionTests.java
index f7cdde0fcd5c..d9c4541c9157 100644
--- a/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/ReleaseTrainDependencyVersionTests.java
+++ b/buildSrc/src/test/java/org/springframework/boot/build/bom/bomr/version/ReleaseTrainDependencyVersionTests.java
@@ -38,81 +38,73 @@ void parsingOfAReleaseTrainVersionReturnsVersion() {
 	}
 
 	@Test
-	void isNewerThanWhenReleaseTrainIsNewerShouldReturnTrue() {
-		assertThat(version("Lovelace-RELEASE").isNewerThan(version("Kay-SR5"))).isTrue();
+	void isSameMajorWhenReleaseTrainIsDifferentShouldReturnFalse() {
+		assertThat(version("Lovelace-RELEASE").isSameMajor(version("Kay-SR5"))).isFalse();
 	}
 
 	@Test
-	void isNewerThanWhenVersionIsNewerShouldReturnTrue() {
-		assertThat(version("Kay-SR10").isNewerThan(version("Kay-SR5"))).isTrue();
+	void isSameMajorWhenReleaseTrainIsTheSameShouldReturnTrue() {
+		assertThat(version("Lovelace-RELEASE").isSameMajor(version("Lovelace-SR5"))).isTrue();
 	}
 
 	@Test
-	void isNewerThanWhenVersionIsOlderShouldReturnFalse() {
-		assertThat(version("Kay-RELEASE").isNewerThan(version("Kay-SR5"))).isFalse();
+	void isSameMinorWhenReleaseTrainIsDifferentShouldReturnFalse() {
+		assertThat(version("Lovelace-RELEASE").isSameMajor(version("Kay-SR5"))).isFalse();
 	}
 
 	@Test
-	void isNewerThanWhenReleaseTrainIsOlderShouldReturnFalse() {
-		assertThat(version("Ingalls-RELEASE").isNewerThan(version("Kay-SR5"))).isFalse();
+	void isSameMinorWhenReleaseTrainIsTheSameShouldReturnTrue() {
+		assertThat(version("Lovelace-RELEASE").isSameMajor(version("Lovelace-SR5"))).isTrue();
 	}
 
 	@Test
-	void isSameMajorAndNewerWhenWhenReleaseTrainIsNewerShouldReturnTrue() {
-		assertThat(version("Lovelace-RELEASE").isSameMajorAndNewerThan(version("Kay-SR5"))).isTrue();
-	}
-
-	@Test
-	void isSameMajorAndNewerThanWhenReleaseTrainIsOlderShouldReturnFalse() {
-		assertThat(version("Ingalls-RELEASE").isSameMajorAndNewerThan(version("Kay-SR5"))).isFalse();
+	void releaseTrainVersionIsNotSameMajorAsCalendarTrainVersion() {
+		assertThat(version("Kay-SR6").isSameMajor(calendarVersion("2020.0.0"))).isFalse();
 	}
 
 	@Test
-	void isSameMajorAndNewerThanWhenVersionIsNewerShouldReturnTrue() {
-		assertThat(version("Kay-SR6").isSameMajorAndNewerThan(version("Kay-SR5"))).isTrue();
+	void releaseTrainVersionIsNotSameMinorAsCalendarVersion() {
+		assertThat(version("Kay-SR6").isSameMinor(calendarVersion("2020.0.0"))).isFalse();
 	}
 
 	@Test
-	void isSameMinorAndNewerThanWhenReleaseTrainIsNewerShouldReturnFalse() {
-		assertThat(version("Lovelace-RELEASE").isSameMinorAndNewerThan(version("Kay-SR5"))).isFalse();
+	void isSnapshotForWhenSnapshotForServiceReleaseShouldReturnTrue() {
+		assertThat(version("Kay-BUILD-SNAPSHOT").isSnapshotFor(version("Kay-SR2"))).isTrue();
 	}
 
 	@Test
-	void isSameMinorAndNewerThanWhenReleaseTrainIsTheSameAndVersionIsNewerShouldReturnTrue() {
-		assertThat(version("Kay-SR6").isSameMinorAndNewerThan(version("Kay-SR5"))).isTrue();
+	void isSnapshotForWhenSnapshotForReleaseShouldReturnTrue() {
+		assertThat(version("Kay-BUILD-SNAPSHOT").isSnapshotFor(version("Kay-RELEASE"))).isTrue();
 	}
 
 	@Test
-	void isSameMinorAndNewerThanWhenReleaseTrainAndVersionAreTheSameShouldReturnFalse() {
-		assertThat(version("Kay-SR6").isSameMinorAndNewerThan(version("Kay-SR6"))).isFalse();
+	void isSnapshotForWhenSnapshotForReleaseCandidateShouldReturnTrue() {
+		assertThat(version("Kay-BUILD-SNAPSHOT").isSnapshotFor(version("Kay-RC1"))).isTrue();
 	}
 
 	@Test
-	void isSameMinorAndNewerThanWhenReleaseTrainIsTheSameAndVersionIsOlderShouldReturnFalse() {
-		assertThat(version("Kay-SR6").isSameMinorAndNewerThan(version("Kay-SR7"))).isFalse();
+	void isSnapshotForWhenSnapshotForMilestoneShouldReturnTrue() {
+		assertThat(version("Kay-BUILD-SNAPSHOT").isSnapshotFor(version("Kay-M2"))).isTrue();
 	}
 
 	@Test
-	void releaseTrainVersionIsNotNewerThanCalendarVersion() {
-		assertThat(version("Kay-SR6").isNewerThan(calendarVersion("2020.0.0"))).isFalse();
+	void isSnapshotForWhenSnapshotForDifferentReleaseShouldReturnFalse() {
+		assertThat(version("Kay-BUILD-SNAPSHOT").isSnapshotFor(version("Lovelace-RELEASE"))).isFalse();
 	}
 
 	@Test
-	void releaseTrainVersionIsNotSameMajorAsCalendarTrainVersion() {
-		assertThat(version("Kay-SR6").isSameMajorAndNewerThan(calendarVersion("2020.0.0"))).isFalse();
+	void isSnapshotForWhenSnapshotForDifferentReleaseCandidateShouldReturnTrue() {
+		assertThat(version("Kay-BUILD-SNAPSHOT").isSnapshotFor(version("Lovelace-RC2"))).isFalse();
 	}
 
 	@Test
-	void releaseTrainVersionIsNotSameMinorAsCalendarVersion() {
-		assertThat(version("Kay-SR6").isSameMinorAndNewerThan(calendarVersion("2020.0.0"))).isFalse();
+	void isSnapshotForWhenSnapshotForDifferentMilestoneShouldReturnTrue() {
+		assertThat(version("Kay-BUILD-SNAPSHOT").isSnapshotFor(version("Lovelace-M1"))).isFalse();
 	}
 
 	@Test
-	void whenComparedWithADifferentDependencyVersionTypeThenTheResultsAreNonZero() {
-		DependencyVersion dysprosium = ReleaseTrainDependencyVersion.parse("Dysprosium-SR16");
-		DependencyVersion twentyTwenty = ArtifactVersionDependencyVersion.parse("2020.0.0");
-		assertThat(dysprosium).isLessThan(twentyTwenty);
-		assertThat(twentyTwenty).isGreaterThan(dysprosium);
+	void isSnapshotForWhenNotSnapshotShouldReturnFalse() {
+		assertThat(version("Kay-M1").isSnapshotFor(version("Kay-RELEASE"))).isFalse();
 	}
 
 	private static ReleaseTrainDependencyVersion version(String input) {
diff --git a/buildSrc/src/test/java/org/springframework/boot/build/mavenplugin/PluginXmlParserTests.java b/buildSrc/src/test/java/org/springframework/boot/build/mavenplugin/PluginXmlParserTests.java
index 14755846dffe..fba1d1c5f8a9 100644
--- a/buildSrc/src/test/java/org/springframework/boot/build/mavenplugin/PluginXmlParserTests.java
+++ b/buildSrc/src/test/java/org/springframework/boot/build/mavenplugin/PluginXmlParserTests.java
@@ -24,7 +24,7 @@
 import org.springframework.boot.build.mavenplugin.PluginXmlParser.Plugin;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
 
 /**
  * Tests for {@link PluginXmlParser}.
@@ -49,9 +49,9 @@ void parseExistingDescriptorReturnPluginDescriptor() {
 
 	@Test
 	void parseNonExistingFileThrowException() {
-		assertThatThrownBy(() -> this.parser.parse(new File("src/test/resources/nonexistent.xml")))
-			.isInstanceOf(RuntimeException.class)
-			.hasCauseInstanceOf(FileNotFoundException.class);
+		assertThatExceptionOfType(RuntimeException.class)
+			.isThrownBy(() -> this.parser.parse(new File("src/test/resources/nonexistent.xml")))
+			.withCauseInstanceOf(FileNotFoundException.class);
 	}
 
 }
diff --git a/buildSrc/src/test/resources/releases.json b/buildSrc/src/test/resources/releases.json
new file mode 100644
index 000000000000..3c5be29801d4
--- /dev/null
+++ b/buildSrc/src/test/resources/releases.json
@@ -0,0 +1,272 @@
+[
+    {
+        "allDay": true,
+        "start": "2023-09-22",
+        "title": "Spring Modulith 1.0.1",
+        "url": "https://github.com/spring-projects/spring-modulith/milestone/15"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-22",
+        "title": "Spring Modulith 1.1 M1",
+        "url": "https://github.com/spring-projects/spring-modulith/milestone/16"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-12",
+        "title": "Reactor 2020.0.36",
+        "url": "https://github.com/reactor/reactor/milestone/51"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-12",
+        "title": "Reactor 2022.0.11",
+        "url": "https://github.com/reactor/reactor/milestone/52"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-12",
+        "title": "Reactor 2023.0.0-M3",
+        "url": "https://github.com/reactor/reactor/milestone/53"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-12",
+        "title": "Reactor Core 3.4.33",
+        "url": "https://github.com/reactor/reactor-core/milestone/158"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-12",
+        "title": "Reactor Core 3.5.10",
+        "url": "https://github.com/reactor/reactor-core/milestone/159"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-12",
+        "title": "Reactor Core 3.6.0-M3",
+        "url": "https://github.com/reactor/reactor-core/milestone/160"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-13",
+        "title": "Sts4 4.20.0.RELEASE",
+        "url": "https://github.com/spring-projects/sts4/milestone/66"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-20",
+        "title": "Spring Batch 5.1.0-M3",
+        "url": "https://github.com/spring-projects/spring-batch/milestone/150"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-19",
+        "title": "Spring Integration 6.2.0-M3",
+        "url": "https://github.com/spring-projects/spring-integration/milestone/306"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-19",
+        "title": "Spring Integration 5.5.19",
+        "url": "https://github.com/spring-projects/spring-integration/milestone/309"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-19",
+        "title": "Spring Integration 6.1.3",
+        "url": "https://github.com/spring-projects/spring-integration/milestone/310"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-15",
+        "title": "Spring Data Release 2023.1.0-M3",
+        "url": "https://github.com/spring-projects/spring-data-release/milestone/30"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-15",
+        "title": "Spring Data Release 2021.2.16",
+        "url": "https://github.com/spring-projects/spring-data-release/milestone/39"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-15",
+        "title": "Spring Data Release 2022.0.10",
+        "url": "https://github.com/spring-projects/spring-data-release/milestone/40"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-15",
+        "title": "Spring Data Release 2023.0.4",
+        "url": "https://github.com/spring-projects/spring-data-release/milestone/41"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-19",
+        "title": "Spring Graphql 1.0.5",
+        "url": "https://github.com/spring-projects/spring-graphql/milestone/27"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-19",
+        "title": "Spring Graphql 1.1.6",
+        "url": "https://github.com/spring-projects/spring-graphql/milestone/33"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-19",
+        "title": "Spring Graphql 1.2.3",
+        "url": "https://github.com/spring-projects/spring-graphql/milestone/34"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-19",
+        "title": "Spring Authorization Server 1.2.0-M1",
+        "url": "https://github.com/spring-projects/spring-authorization-server/milestone/34"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-18",
+        "title": "Spring Kafka 3.1.0-M1",
+        "url": "https://github.com/spring-projects/spring-kafka/milestone/225"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-14",
+        "title": "Spring Cloud Dataflow 2.11.0",
+        "url": "https://github.com/spring-cloud/spring-cloud-dataflow/milestone/159"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-11",
+        "title": "Micrometer 1.9.15",
+        "url": "https://github.com/micrometer-metrics/micrometer/milestone/217"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-11",
+        "title": "Micrometer 1.10.11",
+        "url": "https://github.com/micrometer-metrics/micrometer/milestone/218"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-11",
+        "title": "Micrometer 1.11.4",
+        "url": "https://github.com/micrometer-metrics/micrometer/milestone/219"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-11",
+        "title": "Micrometer 1.12.0-M3",
+        "url": "https://github.com/micrometer-metrics/micrometer/milestone/220"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-11",
+        "title": "Tracing 1.0.10",
+        "url": "https://github.com/micrometer-metrics/tracing/milestone/33"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-11",
+        "title": "Tracing 1.1.5",
+        "url": "https://github.com/micrometer-metrics/tracing/milestone/34"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-26",
+        "title": "Spring Cloud Release 2023.0.0-M2",
+        "url": "https://github.com/spring-cloud/spring-cloud-release/milestone/134"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-11",
+        "title": "Context Propagation 1.0.6",
+        "url": "https://github.com/micrometer-metrics/context-propagation/milestone/19"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-14",
+        "title": "Spring Ldap 3.2.0-M3",
+        "url": "https://github.com/spring-projects/spring-ldap/milestone/63"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-21",
+        "title": "Spring Boot 3.2.0-M3",
+        "url": "https://github.com/spring-projects/spring-boot/milestone/306"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-21",
+        "title": "Spring Boot 2.7.16",
+        "url": "https://github.com/spring-projects/spring-boot/milestone/315"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-21",
+        "title": "Spring Boot 3.0.11",
+        "url": "https://github.com/spring-projects/spring-boot/milestone/316"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-21",
+        "title": "Spring Boot 3.1.4",
+        "url": "https://github.com/spring-projects/spring-boot/milestone/317"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-14",
+        "title": "Spring Cloud Deployer 2.9.0",
+        "url": "https://github.com/spring-cloud/spring-cloud-deployer/milestone/116"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-12",
+        "title": "Reactor Kafka 1.3.21",
+        "url": "https://github.com/reactor/reactor-kafka/milestone/38"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-18",
+        "title": "Spring Security 6.2.0-M3",
+        "url": "https://github.com/spring-projects/spring-security/milestone/308"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-22",
+        "title": "Stream Applications 4.0.0",
+        "url": "https://github.com/spring-cloud/stream-applications/milestone/7"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-12",
+        "title": "Reactor Netty 1.1.11",
+        "url": "https://github.com/reactor/reactor-netty/milestone/153"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-12",
+        "title": "Reactor Netty 1.0.36",
+        "url": "https://github.com/reactor/reactor-netty/milestone/154"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-14",
+        "title": "Spring Framework 6.0.12",
+        "url": "https://github.com/spring-projects/spring-framework/milestone/331"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-14",
+        "title": "Spring Framework 5.3.30",
+        "url": "https://github.com/spring-projects/spring-framework/milestone/332"
+    },
+    {
+        "allDay": true,
+        "start": "2023-09-14",
+        "title": "Spring Framework 6.1.0-RC1",
+        "url": "https://github.com/spring-projects/spring-framework/milestone/333"
+    }
+]
\ No newline at end of file
diff --git a/ci/README.adoc b/ci/README.adoc
index 4601e84e0b13..5ef82f3ab88d 100644
--- a/ci/README.adoc
+++ b/ci/README.adoc
@@ -11,7 +11,7 @@ The pipeline can be deployed using the following command:
 
 [source]
 ----
-$ fly -t spring-boot set-pipeline -p spring-boot-3.1.x -c ci/pipeline.yml -l ci/parameters.yml
+$ fly -t spring-boot set-pipeline -p spring-boot-3.2.x -c ci/pipeline.yml -l ci/parameters.yml
 ----
 
 NOTE: This assumes that you have credhub integration configured with the appropriate
diff --git a/ci/config/release-scripts.yml b/ci/config/release-scripts.yml
new file mode 100644
index 000000000000..207ae802c063
--- /dev/null
+++ b/ci/config/release-scripts.yml
@@ -0,0 +1,11 @@
+spring:
+  main:
+    banner-mode: off
+sonatype:
+  exclude:
+    - "build-info.json"
+    - "org/springframework/boot/spring-boot-docs/.*"
+sdkman:
+  artifact: "org.springframework.boot:spring-boot-cli:*:zip:bin"
+  broadcast-url: "https://github.com/spring-projects/spring-boot/releases/v%s"
+  candidate: "springboot"
diff --git a/ci/images/build-release-scripts.sh b/ci/images/build-release-scripts.sh
deleted file mode 100755
index 7ba7b4745710..000000000000
--- a/ci/images/build-release-scripts.sh
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/bin/bash
-set -ex
-
-pushd /release-scripts
-	./mvnw clean install
-popd
-cp /release-scripts/target/spring-boot-release-scripts.jar .
\ No newline at end of file
diff --git a/ci/images/ci-image-jdk20/Dockerfile b/ci/images/ci-image-jdk20/Dockerfile
deleted file mode 100644
index bf31873a2bc3..000000000000
--- a/ci/images/ci-image-jdk20/Dockerfile
+++ /dev/null
@@ -1,11 +0,0 @@
-FROM ubuntu:jammy-20230624
-
-ADD setup.sh /setup.sh
-ADD get-jdk-url.sh /get-jdk-url.sh
-ADD get-docker-url.sh /get-docker-url.sh
-ADD get-docker-compose-url.sh /get-docker-compose-url.sh
-RUN ./setup.sh java17 java20
-
-ENV JAVA_HOME /opt/openjdk
-ENV PATH $JAVA_HOME/bin:$PATH
-ADD docker-lib.sh /docker-lib.sh
diff --git a/ci/images/ci-image-jdk21/Dockerfile b/ci/images/ci-image-jdk21/Dockerfile
new file mode 100644
index 000000000000..a56960bf6dd6
--- /dev/null
+++ b/ci/images/ci-image-jdk21/Dockerfile
@@ -0,0 +1,14 @@
+FROM ubuntu:jammy-20231004
+
+ADD setup.sh /setup.sh
+ADD get-jdk-url.sh /get-jdk-url.sh
+ADD get-docker-url.sh /get-docker-url.sh
+ADD get-docker-compose-url.sh /get-docker-compose-url.sh
+RUN ./setup.sh java17 java21
+
+ENV LANG en_US.UTF-8
+ENV LANGUAGE en_US:en
+ENV LC_ALL en_US.UTF-8
+ENV JAVA_HOME /opt/openjdk
+ENV PATH $JAVA_HOME/bin:$PATH
+ADD docker-lib.sh /docker-lib.sh
diff --git a/ci/images/ci-image/Dockerfile b/ci/images/ci-image/Dockerfile
index 4028f53d971e..45b125643abc 100644
--- a/ci/images/ci-image/Dockerfile
+++ b/ci/images/ci-image/Dockerfile
@@ -1,4 +1,4 @@
-FROM ubuntu:jammy-20230624
+FROM ubuntu:jammy-20231004
 
 ADD setup.sh /setup.sh
 ADD get-jdk-url.sh /get-jdk-url.sh
@@ -6,10 +6,9 @@ ADD get-docker-url.sh /get-docker-url.sh
 ADD get-docker-compose-url.sh /get-docker-compose-url.sh
 RUN ./setup.sh java17
 
+ENV LANG en_US.UTF-8
+ENV LANGUAGE en_US:en
+ENV LC_ALL en_US.UTF-8
 ENV JAVA_HOME /opt/openjdk
 ENV PATH $JAVA_HOME/bin:$PATH
 ADD docker-lib.sh /docker-lib.sh
-
-ADD build-release-scripts.sh /build-release-scripts.sh
-ADD releasescripts /release-scripts
-RUN ./build-release-scripts.sh
diff --git a/ci/images/get-docker-url.sh b/ci/images/get-docker-url.sh
index c6d980229afd..965d738b72cb 100755
--- a/ci/images/get-docker-url.sh
+++ b/ci/images/get-docker-url.sh
@@ -1,5 +1,5 @@
 #!/bin/bash
 set -e
 
-version="24.0.4"
+version="24.0.7"
 echo "https://download.docker.com/linux/static/stable/x86_64/docker-$version.tgz";
diff --git a/ci/images/get-jdk-url.sh b/ci/images/get-jdk-url.sh
index ec2505e5b13d..db11e34f6094 100755
--- a/ci/images/get-jdk-url.sh
+++ b/ci/images/get-jdk-url.sh
@@ -3,10 +3,10 @@ set -e
 
 case "$1" in
 	java17)
-		 echo "https://github.com/bell-sw/Liberica/releases/download/17.0.7+7/bellsoft-jdk17.0.7+7-linux-amd64.tar.gz"
+		 echo "https://github.com/bell-sw/Liberica/releases/download/17.0.9+11/bellsoft-jdk17.0.9+11-linux-amd64.tar.gz"
 	;;
-	java20)
-		 echo "https://github.com/bell-sw/Liberica/releases/download/20.0.1+10/bellsoft-jdk20.0.1+10-linux-amd64.tar.gz"
+	java21)
+		 echo "https://github.com/bell-sw/Liberica/releases/download/21.0.1+12/bellsoft-jdk21.0.1+12-linux-amd64.tar.gz"
 	;;
 	*)
 		echo $"Unknown java version"
diff --git a/ci/images/releasescripts/.gitignore b/ci/images/releasescripts/.gitignore
deleted file mode 100644
index a2a3040aa86d..000000000000
--- a/ci/images/releasescripts/.gitignore
+++ /dev/null
@@ -1,31 +0,0 @@
-HELP.md
-target/
-!.mvn/wrapper/maven-wrapper.jar
-!**/src/main/**
-!**/src/test/**
-
-### STS ###
-.apt_generated
-.classpath
-.factorypath
-.project
-.settings
-.springBeans
-.sts4-cache
-
-### IntelliJ IDEA ###
-.idea
-*.iws
-*.iml
-*.ipr
-
-### NetBeans ###
-/nbproject/private/
-/nbbuild/
-/dist/
-/nbdist/
-/.nb-gradle/
-build/
-
-### VS Code ###
-.vscode/
diff --git a/ci/images/releasescripts/.mvn/wrapper/MavenWrapperDownloader.java b/ci/images/releasescripts/.mvn/wrapper/MavenWrapperDownloader.java
deleted file mode 100644
index da7c33969591..000000000000
--- a/ci/images/releasescripts/.mvn/wrapper/MavenWrapperDownloader.java
+++ /dev/null
@@ -1,118 +0,0 @@
-/*
-Licensed to the Apache Software Foundation (ASF) under one
-or more contributor license agreements.  See the NOTICE file
-distributed with this work for additional information
-regarding copyright ownership.  The ASF licenses this file
-to you under the Apache License, Version 2.0 (the
-"License"); you may not use this file except in compliance
-with the License.  You may obtain a copy of the License at
-
-  https://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing,
-software distributed under the License is distributed on an
-"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-KIND, either express or implied.  See the License for the
-specific language governing permissions and limitations
-under the License.
-*/
-
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.net.URL;
-import java.nio.channels.Channels;
-import java.nio.channels.ReadableByteChannel;
-import java.util.Properties;
-
-public class MavenWrapperDownloader {
-
-	/**
-	 * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided.
-	 */
-	private static final String DEFAULT_DOWNLOAD_URL =
-			"https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.4.2/maven-wrapper-0.4.2.jar";
-
-	/**
-	 * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to
-	 * use instead of the default one.
-	 */
-	private static final String MAVEN_WRAPPER_PROPERTIES_PATH =
-			".mvn/wrapper/maven-wrapper.properties";
-
-	/**
-	 * Path where the maven-wrapper.jar will be saved to.
-	 */
-	private static final String MAVEN_WRAPPER_JAR_PATH =
-			".mvn/wrapper/maven-wrapper.jar";
-
-	/**
-	 * Name of the property which should be used to override the default download url for the wrapper.
-	 */
-	private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl";
-
-	public static void main(String args[]) {
-		System.out.println("- Downloader started");
-		File baseDirectory = new File(args[0]);
-		System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath());
-
-		// If the maven-wrapper.properties exists, read it and check if it contains a custom
-		// wrapperUrl parameter.
-		File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH);
-		String url = DEFAULT_DOWNLOAD_URL;
-		if (mavenWrapperPropertyFile.exists()) {
-			FileInputStream mavenWrapperPropertyFileInputStream = null;
-			try {
-				mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile);
-				Properties mavenWrapperProperties = new Properties();
-				mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream);
-				url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url);
-			}
-			catch (IOException e) {
-				System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'");
-			}
-			finally {
-				try {
-					if (mavenWrapperPropertyFileInputStream != null) {
-						mavenWrapperPropertyFileInputStream.close();
-					}
-				}
-				catch (IOException e) {
-					// Ignore ...
-				}
-			}
-		}
-		System.out.println("- Downloading from: : " + url);
-
-		File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH);
-		if (!outputFile.getParentFile().exists()) {
-			if (!outputFile.getParentFile().mkdirs()) {
-				System.out.println(
-						"- ERROR creating output direcrory '" + outputFile.getParentFile().getAbsolutePath() + "'");
-			}
-		}
-		System.out.println("- Downloading to: " + outputFile.getAbsolutePath());
-		try {
-			downloadFileFromURL(url, outputFile);
-			System.out.println("Done");
-			System.exit(0);
-		}
-		catch (Throwable e) {
-			System.out.println("- Error downloading");
-			e.printStackTrace();
-			System.exit(1);
-		}
-	}
-
-	private static void downloadFileFromURL(String urlString, File destination) throws Exception {
-		URL website = new URL(urlString);
-		ReadableByteChannel rbc;
-		rbc = Channels.newChannel(website.openStream());
-		FileOutputStream fos = new FileOutputStream(destination);
-		fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE);
-		fos.close();
-		rbc.close();
-	}
-
-}
diff --git a/ci/images/releasescripts/.mvn/wrapper/maven-wrapper.jar b/ci/images/releasescripts/.mvn/wrapper/maven-wrapper.jar
deleted file mode 100644
index 01e67997377a..000000000000
Binary files a/ci/images/releasescripts/.mvn/wrapper/maven-wrapper.jar and /dev/null differ
diff --git a/ci/images/releasescripts/.mvn/wrapper/maven-wrapper.properties b/ci/images/releasescripts/.mvn/wrapper/maven-wrapper.properties
deleted file mode 100644
index f5374227f968..000000000000
--- a/ci/images/releasescripts/.mvn/wrapper/maven-wrapper.properties
+++ /dev/null
@@ -1,17 +0,0 @@
-#
-# Copyright 2012-2019 the original author or authors.
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-#      https://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-#
-
-distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.0/apache-maven-3.6.0-bin.zip
diff --git a/ci/images/releasescripts/mvnw b/ci/images/releasescripts/mvnw
deleted file mode 100755
index 8b9da3b8b600..000000000000
--- a/ci/images/releasescripts/mvnw
+++ /dev/null
@@ -1,286 +0,0 @@
-#!/bin/sh
-# ----------------------------------------------------------------------------
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements.  See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership.  The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License.  You may obtain a copy of the License at
-#
-#    https://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied.  See the License for the
-# specific language governing permissions and limitations
-# under the License.
-# ----------------------------------------------------------------------------
-
-# ----------------------------------------------------------------------------
-# Maven2 Start Up Batch script
-#
-# Required ENV vars:
-# ------------------
-#   JAVA_HOME - location of a JDK home dir
-#
-# Optional ENV vars
-# -----------------
-#   M2_HOME - location of maven2's installed home dir
-#   MAVEN_OPTS - parameters passed to the Java VM when running Maven
-#     e.g. to debug Maven itself, use
-#       set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
-#   MAVEN_SKIP_RC - flag to disable loading of mavenrc files
-# ----------------------------------------------------------------------------
-
-if [ -z "$MAVEN_SKIP_RC" ] ; then
-
-  if [ -f /etc/mavenrc ] ; then
-    . /etc/mavenrc
-  fi
-
-  if [ -f "$HOME/.mavenrc" ] ; then
-    . "$HOME/.mavenrc"
-  fi
-
-fi
-
-# OS specific support.  $var _must_ be set to either true or false.
-cygwin=false;
-darwin=false;
-mingw=false
-case "`uname`" in
-  CYGWIN*) cygwin=true ;;
-  MINGW*) mingw=true;;
-  Darwin*) darwin=true
-    # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home
-    # See https://developer.apple.com/library/mac/qa/qa1170/_index.html
-    if [ -z "$JAVA_HOME" ]; then
-      if [ -x "/usr/libexec/java_home" ]; then
-        export JAVA_HOME="`/usr/libexec/java_home`"
-      else
-        export JAVA_HOME="/Library/Java/Home"
-      fi
-    fi
-    ;;
-esac
-
-if [ -z "$JAVA_HOME" ] ; then
-  if [ -r /etc/gentoo-release ] ; then
-    JAVA_HOME=`java-config --jre-home`
-  fi
-fi
-
-if [ -z "$M2_HOME" ] ; then
-  ## resolve links - $0 may be a link to maven's home
-  PRG="$0"
-
-  # need this for relative symlinks
-  while [ -h "$PRG" ] ; do
-    ls=`ls -ld "$PRG"`
-    link=`expr "$ls" : '.*-> \(.*\)$'`
-    if expr "$link" : '/.*' > /dev/null; then
-      PRG="$link"
-    else
-      PRG="`dirname "$PRG"`/$link"
-    fi
-  done
-
-  saveddir=`pwd`
-
-  M2_HOME=`dirname "$PRG"`/..
-
-  # make it fully qualified
-  M2_HOME=`cd "$M2_HOME" && pwd`
-
-  cd "$saveddir"
-  # echo Using m2 at $M2_HOME
-fi
-
-# For Cygwin, ensure paths are in UNIX format before anything is touched
-if $cygwin ; then
-  [ -n "$M2_HOME" ] &&
-    M2_HOME=`cygpath --unix "$M2_HOME"`
-  [ -n "$JAVA_HOME" ] &&
-    JAVA_HOME=`cygpath --unix "$JAVA_HOME"`
-  [ -n "$CLASSPATH" ] &&
-    CLASSPATH=`cygpath --path --unix "$CLASSPATH"`
-fi
-
-# For Mingw, ensure paths are in UNIX format before anything is touched
-if $mingw ; then
-  [ -n "$M2_HOME" ] &&
-    M2_HOME="`(cd "$M2_HOME"; pwd)`"
-  [ -n "$JAVA_HOME" ] &&
-    JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`"
-  # TODO classpath?
-fi
-
-if [ -z "$JAVA_HOME" ]; then
-  javaExecutable="`which javac`"
-  if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then
-    # readlink(1) is not available as standard on Solaris 10.
-    readLink=`which readlink`
-    if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then
-      if $darwin ; then
-        javaHome="`dirname \"$javaExecutable\"`"
-        javaExecutable="`cd \"$javaHome\" && pwd -P`/javac"
-      else
-        javaExecutable="`readlink -f \"$javaExecutable\"`"
-      fi
-      javaHome="`dirname \"$javaExecutable\"`"
-      javaHome=`expr "$javaHome" : '\(.*\)/bin'`
-      JAVA_HOME="$javaHome"
-      export JAVA_HOME
-    fi
-  fi
-fi
-
-if [ -z "$JAVACMD" ] ; then
-  if [ -n "$JAVA_HOME"  ] ; then
-    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
-      # IBM's JDK on AIX uses strange locations for the executables
-      JAVACMD="$JAVA_HOME/jre/sh/java"
-    else
-      JAVACMD="$JAVA_HOME/bin/java"
-    fi
-  else
-    JAVACMD="`which java`"
-  fi
-fi
-
-if [ ! -x "$JAVACMD" ] ; then
-  echo "Error: JAVA_HOME is not defined correctly." >&2
-  echo "  We cannot execute $JAVACMD" >&2
-  exit 1
-fi
-
-if [ -z "$JAVA_HOME" ] ; then
-  echo "Warning: JAVA_HOME environment variable is not set."
-fi
-
-CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher
-
-# traverses directory structure from process work directory to filesystem root
-# first directory with .mvn subdirectory is considered project base directory
-find_maven_basedir() {
-
-  if [ -z "$1" ]
-  then
-    echo "Path not specified to find_maven_basedir"
-    return 1
-  fi
-
-  basedir="$1"
-  wdir="$1"
-  while [ "$wdir" != '/' ] ; do
-    if [ -d "$wdir"/.mvn ] ; then
-      basedir=$wdir
-      break
-    fi
-    # workaround for JBEAP-8937 (on Solaris 10/Sparc)
-    if [ -d "${wdir}" ]; then
-      wdir=`cd "$wdir/.."; pwd`
-    fi
-    # end of workaround
-  done
-  echo "${basedir}"
-}
-
-# concatenates all lines of a file
-concat_lines() {
-  if [ -f "$1" ]; then
-    echo "$(tr -s '\n' ' ' < "$1")"
-  fi
-}
-
-BASE_DIR=`find_maven_basedir "$(pwd)"`
-if [ -z "$BASE_DIR" ]; then
-  exit 1;
-fi
-
-##########################################################################################
-# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
-# This allows using the maven wrapper in projects that prohibit checking in binary data.
-##########################################################################################
-if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then
-    if [ "$MVNW_VERBOSE" = true ]; then
-      echo "Found .mvn/wrapper/maven-wrapper.jar"
-    fi
-else
-    if [ "$MVNW_VERBOSE" = true ]; then
-      echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..."
-    fi
-    jarUrl="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.4.2/maven-wrapper-0.4.2.jar"
-    while IFS="=" read key value; do
-      case "$key" in (wrapperUrl) jarUrl="$value"; break ;;
-      esac
-    done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties"
-    if [ "$MVNW_VERBOSE" = true ]; then
-      echo "Downloading from: $jarUrl"
-    fi
-    wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar"
-
-    if command -v wget > /dev/null; then
-        if [ "$MVNW_VERBOSE" = true ]; then
-          echo "Found wget ... using wget"
-        fi
-        wget "$jarUrl" -O "$wrapperJarPath"
-    elif command -v curl > /dev/null; then
-        if [ "$MVNW_VERBOSE" = true ]; then
-          echo "Found curl ... using curl"
-        fi
-        curl -o "$wrapperJarPath" "$jarUrl"
-    else
-        if [ "$MVNW_VERBOSE" = true ]; then
-          echo "Falling back to using Java to download"
-        fi
-        javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java"
-        if [ -e "$javaClass" ]; then
-            if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
-                if [ "$MVNW_VERBOSE" = true ]; then
-                  echo " - Compiling MavenWrapperDownloader.java ..."
-                fi
-                # Compiling the Java class
-                ("$JAVA_HOME/bin/javac" "$javaClass")
-            fi
-            if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
-                # Running the downloader
-                if [ "$MVNW_VERBOSE" = true ]; then
-                  echo " - Running MavenWrapperDownloader.java ..."
-                fi
-                ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR")
-            fi
-        fi
-    fi
-fi
-##########################################################################################
-# End of extension
-##########################################################################################
-
-export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}
-if [ "$MVNW_VERBOSE" = true ]; then
-  echo $MAVEN_PROJECTBASEDIR
-fi
-MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS"
-
-# For Cygwin, switch paths to Windows format before running java
-if $cygwin; then
-  [ -n "$M2_HOME" ] &&
-    M2_HOME=`cygpath --path --windows "$M2_HOME"`
-  [ -n "$JAVA_HOME" ] &&
-    JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"`
-  [ -n "$CLASSPATH" ] &&
-    CLASSPATH=`cygpath --path --windows "$CLASSPATH"`
-  [ -n "$MAVEN_PROJECTBASEDIR" ] &&
-    MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"`
-fi
-
-WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
-
-exec "$JAVACMD" \
-  $MAVEN_OPTS \
-  -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \
-  "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \
-  ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@"
diff --git a/ci/images/releasescripts/mvnw.cmd b/ci/images/releasescripts/mvnw.cmd
deleted file mode 100644
index fef5a8f7f988..000000000000
--- a/ci/images/releasescripts/mvnw.cmd
+++ /dev/null
@@ -1,161 +0,0 @@
-@REM ----------------------------------------------------------------------------
-@REM Licensed to the Apache Software Foundation (ASF) under one
-@REM or more contributor license agreements.  See the NOTICE file
-@REM distributed with this work for additional information
-@REM regarding copyright ownership.  The ASF licenses this file
-@REM to you under the Apache License, Version 2.0 (the
-@REM "License"); you may not use this file except in compliance
-@REM with the License.  You may obtain a copy of the License at
-@REM
-@REM    https://www.apache.org/licenses/LICENSE-2.0
-@REM
-@REM Unless required by applicable law or agreed to in writing,
-@REM software distributed under the License is distributed on an
-@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-@REM KIND, either express or implied.  See the License for the
-@REM specific language governing permissions and limitations
-@REM under the License.
-@REM ----------------------------------------------------------------------------
-
-@REM ----------------------------------------------------------------------------
-@REM Maven2 Start Up Batch script
-@REM
-@REM Required ENV vars:
-@REM JAVA_HOME - location of a JDK home dir
-@REM
-@REM Optional ENV vars
-@REM M2_HOME - location of maven2's installed home dir
-@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands
-@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a key stroke before ending
-@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven
-@REM     e.g. to debug Maven itself, use
-@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
-@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files
-@REM ----------------------------------------------------------------------------
-
-@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
-@echo off
-@REM set title of command window
-title %0
-@REM enable echoing my setting MAVEN_BATCH_ECHO to 'on'
-@if "%MAVEN_BATCH_ECHO%" == "on"  echo %MAVEN_BATCH_ECHO%
-
-@REM set %HOME% to equivalent of $HOME
-if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%")
-
-@REM Execute a user defined script before this one
-if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre
-@REM check for pre script, once with legacy .bat ending and once with .cmd ending
-if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat"
-if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd"
-:skipRcPre
-
-@setlocal
-
-set ERROR_CODE=0
-
-@REM To isolate internal variables from possible post scripts, we use another setlocal
-@setlocal
-
-@REM ==== START VALIDATION ====
-if not "%JAVA_HOME%" == "" goto OkJHome
-
-echo.
-echo Error: JAVA_HOME not found in your environment. >&2
-echo Please set the JAVA_HOME variable in your environment to match the >&2
-echo location of your Java installation. >&2
-echo.
-goto error
-
-:OkJHome
-if exist "%JAVA_HOME%\bin\java.exe" goto init
-
-echo.
-echo Error: JAVA_HOME is set to an invalid directory. >&2
-echo JAVA_HOME = "%JAVA_HOME%" >&2
-echo Please set the JAVA_HOME variable in your environment to match the >&2
-echo location of your Java installation. >&2
-echo.
-goto error
-
-@REM ==== END VALIDATION ====
-
-:init
-
-@REM Find the project base dir, i.e. the directory that contains the folder ".mvn".
-@REM Fallback to current working directory if not found.
-
-set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR%
-IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir
-
-set EXEC_DIR=%CD%
-set WDIR=%EXEC_DIR%
-:findBaseDir
-IF EXIST "%WDIR%"\.mvn goto baseDirFound
-cd ..
-IF "%WDIR%"=="%CD%" goto baseDirNotFound
-set WDIR=%CD%
-goto findBaseDir
-
-:baseDirFound
-set MAVEN_PROJECTBASEDIR=%WDIR%
-cd "%EXEC_DIR%"
-goto endDetectBaseDir
-
-:baseDirNotFound
-set MAVEN_PROJECTBASEDIR=%EXEC_DIR%
-cd "%EXEC_DIR%"
-
-:endDetectBaseDir
-
-IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig
-
-@setlocal EnableExtensions EnableDelayedExpansion
-for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a
-@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS%
-
-:endReadAdditionalConfig
-
-SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe"
-set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"
-set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
-
-set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.4.2/maven-wrapper-0.4.2.jar"
-FOR /F "tokens=1,2 delims==" %%A IN (%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties) DO (
-	IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B 
-)
-
-@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
-@REM This allows using the maven wrapper in projects that prohibit checking in binary data.
-if exist %WRAPPER_JAR% (
-    echo Found %WRAPPER_JAR%
-) else (
-    echo Couldn't find %WRAPPER_JAR%, downloading it ...
-	echo Downloading from: %DOWNLOAD_URL%
-    powershell -Command "(New-Object Net.WebClient).DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"
-    echo Finished downloading %WRAPPER_JAR%
-)
-@REM End of extension
-
-%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %*
-if ERRORLEVEL 1 goto error
-goto end
-
-:error
-set ERROR_CODE=1
-
-:end
-@endlocal & set ERROR_CODE=%ERROR_CODE%
-
-if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost
-@REM check for post script, once with legacy .bat ending and once with .cmd ending
-if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat"
-if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd"
-:skipRcPost
-
-@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on'
-if "%MAVEN_BATCH_PAUSE%" == "on" pause
-
-if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE%
-
-exit /B %ERROR_CODE%
diff --git a/ci/images/releasescripts/pom.xml b/ci/images/releasescripts/pom.xml
deleted file mode 100644
index 48b4efa69b34..000000000000
--- a/ci/images/releasescripts/pom.xml
+++ /dev/null
@@ -1,92 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project xmlns="http://maven.apache.org/POM/4.0.0"
-	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
-	<modelVersion>4.0.0</modelVersion>
-	<parent>
-		<groupId>org.springframework.boot</groupId>
-		<artifactId>spring-boot-starter-parent</artifactId>
-		<version>2.2.4.RELEASE</version>
-		<relativePath /> <!-- lookup parent from repository -->
-	</parent>
-	<groupId>io.spring.concourse.releasescripts</groupId>
-	<artifactId>release-scripts</artifactId>
-	<version>0.0.1-SNAPSHOT</version>
-	<name>releasescripts</name>
-	<description>Utility that can be used when releasing Java projects</description>
-	<properties>
-		<java.version>1.8</java.version>
-		<spring-javaformat.version>0.0.26</spring-javaformat.version>
-	</properties>
-	<dependencies>
-		<dependency>
-			<groupId>org.bouncycastle</groupId>
-			<artifactId>bcpg-jdk18on</artifactId>
-			<version>1.71</version>
-		</dependency>
-		<dependency>
-			<groupId>org.springframework.boot</groupId>
-			<artifactId>spring-boot-starter</artifactId>
-		</dependency>
-		<dependency>
-			<groupId>org.springframework</groupId>
-			<artifactId>spring-web</artifactId>
-		</dependency>
-		<dependency>
-			<groupId>com.fasterxml.jackson.core</groupId>
-			<artifactId>jackson-databind</artifactId>
-		</dependency>
-		<dependency>
-			<groupId>com.fasterxml.jackson.datatype</groupId>
-			<artifactId>jackson-datatype-jdk8</artifactId>
-		</dependency>
-		<dependency>
-			<groupId>com.fasterxml.jackson.datatype</groupId>
-			<artifactId>jackson-datatype-jsr310</artifactId>
-		</dependency>
-		<dependency>
-			<groupId>com.fasterxml.jackson.module</groupId>
-			<artifactId>jackson-module-parameter-names</artifactId>
-		</dependency>
-		<dependency>
-			<groupId>org.awaitility</groupId>
-			<artifactId>awaitility</artifactId>
-		</dependency>
-		<dependency>
-			<groupId>org.springframework.boot</groupId>
-			<artifactId>spring-boot-starter-test</artifactId>
-			<scope>test</scope>
-			<exclusions>
-				<exclusion>
-					<groupId>org.junit.vintage</groupId>
-					<artifactId>junit-vintage-engine</artifactId>
-				</exclusion>
-			</exclusions>
-		</dependency>
-	</dependencies>
-	<build>
-		<finalName>spring-boot-release-scripts</finalName>
-		<plugins>
-			<plugin>
-				<groupId>org.springframework.boot</groupId>
-				<artifactId>spring-boot-maven-plugin</artifactId>
-			</plugin>
-			<plugin>
-				<groupId>io.spring.javaformat</groupId>
-				<artifactId>spring-javaformat-maven-plugin</artifactId>
-				<version>${spring-javaformat.version}</version>
-				<executions>
-					<execution>
-						<phase>validate</phase>
-						<configuration>
-							<skip>${disable.checks}</skip>
-						</configuration>
-						<goals>
-							<goal>validate</goal>
-						</goals>
-					</execution>
-				</executions>
-			</plugin>
-		</plugins>
-	</build>
-</project>
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/Application.java b/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/Application.java
deleted file mode 100644
index 9a4c7cb9d46d..000000000000
--- a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/Application.java
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
-* Copyright 2012-2020 the original author or authors.
-*
-* Licensed under the Apache License, Version 2.0 (the "License");
-* you may not use this file except in compliance with the License.
-* You may obtain a copy of the License at
-*
-*      https://www.apache.org/licenses/LICENSE-2.0
-*
-* Unless required by applicable law or agreed to in writing, software
-* distributed under the License is distributed on an "AS IS" BASIS,
-* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-* See the License for the specific language governing permissions and
-* limitations under the License.
-*/
-
-package io.spring.concourse.releasescripts;
-
-import org.springframework.boot.SpringApplication;
-import org.springframework.boot.autoconfigure.SpringBootApplication;
-import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
-
-@SpringBootApplication
-@ConfigurationPropertiesScan
-public class Application {
-
-	public static void main(String[] args) {
-		SpringApplication.run(Application.class, args);
-	}
-
-}
diff --git a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/ReleaseInfo.java b/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/ReleaseInfo.java
deleted file mode 100644
index 34fec8172cbf..000000000000
--- a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/ReleaseInfo.java
+++ /dev/null
@@ -1,82 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts;
-
-import io.spring.concourse.releasescripts.artifactory.payload.BuildInfoResponse;
-
-import org.springframework.boot.context.properties.PropertyMapper;
-import org.springframework.util.StringUtils;
-
-/**
- * Properties corresponding to the release.
- *
- * @author Madhura Bhave
- */
-public class ReleaseInfo {
-
-	private String buildName;
-
-	private String buildNumber;
-
-	private String groupId;
-
-	private String version;
-
-	public static ReleaseInfo from(BuildInfoResponse.BuildInfo buildInfo) {
-		ReleaseInfo info = new ReleaseInfo();
-		PropertyMapper propertyMapper = PropertyMapper.get();
-		propertyMapper.from(buildInfo.getName()).to(info::setBuildName);
-		propertyMapper.from(buildInfo.getNumber()).to(info::setBuildNumber);
-		String[] moduleInfo = StringUtils.delimitedListToStringArray(buildInfo.getModules()[0].getId(), ":");
-		propertyMapper.from(moduleInfo[0]).to(info::setGroupId);
-		propertyMapper.from(moduleInfo[2]).to(info::setVersion);
-		return info;
-	}
-
-	public String getBuildName() {
-		return this.buildName;
-	}
-
-	public void setBuildName(String buildName) {
-		this.buildName = buildName;
-	}
-
-	public String getBuildNumber() {
-		return this.buildNumber;
-	}
-
-	public void setBuildNumber(String buildNumber) {
-		this.buildNumber = buildNumber;
-	}
-
-	public String getGroupId() {
-		return this.groupId;
-	}
-
-	public void setGroupId(String groupId) {
-		this.groupId = groupId;
-	}
-
-	public String getVersion() {
-		return this.version;
-	}
-
-	public void setVersion(String version) {
-		this.version = version;
-	}
-
-}
diff --git a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/ReleaseProperties.java b/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/ReleaseProperties.java
deleted file mode 100644
index 4973fedc2777..000000000000
--- a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/ReleaseProperties.java
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts;
-
-import org.springframework.boot.context.properties.ConfigurationProperties;
-
-/**
- * {@link ConfigurationProperties @ConfigurationProperties} corresponding to the release.
- *
- * @author Madhura Bhave
- */
-@ConfigurationProperties(prefix = "release")
-public class ReleaseProperties {
-
-	private String buildName;
-
-	private String buildNumber;
-
-	private String groupId;
-
-	private String version;
-
-	public String getBuildName() {
-		return this.buildName;
-	}
-
-	public void setBuildName(String buildName) {
-		this.buildName = buildName;
-	}
-
-	public String getBuildNumber() {
-		return this.buildNumber;
-	}
-
-	public void setBuildNumber(String buildNumber) {
-		this.buildNumber = buildNumber;
-	}
-
-	public String getGroupId() {
-		return this.groupId;
-	}
-
-	public void setGroupId(String groupId) {
-		this.groupId = groupId;
-	}
-
-	public String getVersion() {
-		return this.version;
-	}
-
-	public void setVersion(String version) {
-		this.version = version;
-	}
-
-}
diff --git a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/ReleaseType.java b/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/ReleaseType.java
deleted file mode 100644
index f60295897746..000000000000
--- a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/ReleaseType.java
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts;
-
-/**
- * Release type.
- *
- * @author Madhura Bhave
- */
-public enum ReleaseType {
-
-	MILESTONE("M", "libs-milestone-local"),
-
-	RELEASE_CANDIDATE("RC", "libs-milestone-local"),
-
-	RELEASE("RELEASE", "libs-release-local");
-
-	private final String identifier;
-
-	private final String repo;
-
-	ReleaseType(String identifier, String repo) {
-		this.identifier = identifier;
-		this.repo = repo;
-	}
-
-	public static ReleaseType from(String releaseType) {
-		for (ReleaseType type : ReleaseType.values()) {
-			if (type.identifier.equals(releaseType)) {
-				return type;
-			}
-		}
-		throw new IllegalArgumentException("Invalid release type");
-	}
-
-	public String getRepo() {
-		return this.repo;
-	}
-
-}
diff --git a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/artifactory/ArtifactoryProperties.java b/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/artifactory/ArtifactoryProperties.java
deleted file mode 100644
index 952271167377..000000000000
--- a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/artifactory/ArtifactoryProperties.java
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts.artifactory;
-
-import org.springframework.boot.context.properties.ConfigurationProperties;
-
-/**
- * {@link ConfigurationProperties @ConfigurationProperties} for an Artifactory server.
- *
- * @author Madhura Bhave
- */
-@ConfigurationProperties(prefix = "artifactory")
-public class ArtifactoryProperties {
-
-	private String username;
-
-	private String password;
-
-	public String getUsername() {
-		return this.username;
-	}
-
-	public void setUsername(String username) {
-		this.username = username;
-	}
-
-	public String getPassword() {
-		return this.password;
-	}
-
-	public void setPassword(String password) {
-		this.password = password;
-	}
-
-}
diff --git a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/artifactory/ArtifactoryService.java b/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/artifactory/ArtifactoryService.java
deleted file mode 100644
index 2bb2cd7603ed..000000000000
--- a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/artifactory/ArtifactoryService.java
+++ /dev/null
@@ -1,119 +0,0 @@
-/*
- * Copyright 2012-2021 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts.artifactory;
-
-import java.net.URI;
-
-import io.spring.concourse.releasescripts.ReleaseInfo;
-import io.spring.concourse.releasescripts.artifactory.payload.BuildInfoResponse;
-import io.spring.concourse.releasescripts.artifactory.payload.BuildInfoResponse.Status;
-import io.spring.concourse.releasescripts.artifactory.payload.PromotionRequest;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import org.springframework.boot.web.client.RestTemplateBuilder;
-import org.springframework.http.MediaType;
-import org.springframework.http.RequestEntity;
-import org.springframework.http.ResponseEntity;
-import org.springframework.stereotype.Component;
-import org.springframework.util.StringUtils;
-import org.springframework.web.client.HttpClientErrorException;
-import org.springframework.web.client.RestTemplate;
-
-/**
- * Central class for interacting with Artifactory's REST API.
- *
- * @author Madhura Bhave
- */
-@Component
-public class ArtifactoryService {
-
-	private static final Logger logger = LoggerFactory.getLogger(ArtifactoryService.class);
-
-	private static final String ARTIFACTORY_URL = "https://repo.spring.io";
-
-	private static final String PROMOTION_URL = ARTIFACTORY_URL + "/api/build/promote/";
-
-	private static final String BUILD_INFO_URL = ARTIFACTORY_URL + "/api/build/";
-
-	private static final String STAGING_REPO = "libs-staging-local";
-
-	private final RestTemplate restTemplate;
-
-	public ArtifactoryService(RestTemplateBuilder builder, ArtifactoryProperties artifactoryProperties) {
-		String username = artifactoryProperties.getUsername();
-		String password = artifactoryProperties.getPassword();
-		if (StringUtils.hasLength(username)) {
-			builder = builder.basicAuthentication(username, password);
-		}
-		this.restTemplate = builder.build();
-	}
-
-	/**
-	 * Move artifacts to a target repository in Artifactory.
-	 * @param targetRepo the targetRepo
-	 * @param releaseInfo the release information
-	 */
-	public void promote(String targetRepo, ReleaseInfo releaseInfo) {
-		PromotionRequest request = getPromotionRequest(targetRepo);
-		String buildName = releaseInfo.getBuildName();
-		String buildNumber = releaseInfo.getBuildNumber();
-		logger.info("Promoting " + buildName + "/" + buildNumber + " to " + request.getTargetRepo());
-		RequestEntity<PromotionRequest> requestEntity = RequestEntity
-				.post(URI.create(PROMOTION_URL + buildName + "/" + buildNumber)).contentType(MediaType.APPLICATION_JSON)
-				.body(request);
-		try {
-			this.restTemplate.exchange(requestEntity, String.class);
-			logger.debug("Promotion complete");
-		}
-		catch (HttpClientErrorException ex) {
-			boolean isAlreadyPromoted = isAlreadyPromoted(buildName, buildNumber, request.getTargetRepo());
-			if (isAlreadyPromoted) {
-				logger.info("Already promoted.");
-			}
-			else {
-				logger.info("Promotion failed.");
-				throw ex;
-			}
-		}
-	}
-
-	private boolean isAlreadyPromoted(String buildName, String buildNumber, String targetRepo) {
-		try {
-			logger.debug("Checking if already promoted");
-			ResponseEntity<BuildInfoResponse> entity = this.restTemplate
-					.getForEntity(BUILD_INFO_URL + buildName + "/" + buildNumber, BuildInfoResponse.class);
-			Status[] statuses = entity.getBody().getBuildInfo().getStatuses();
-			BuildInfoResponse.Status status = (statuses != null) ? statuses[0] : null;
-			if (status == null) {
-				logger.debug("Returned no status object");
-				return false;
-			}
-			logger.debug("Returned repository " + status.getRepository() + " expecting " + targetRepo);
-			return status.getRepository().equals(targetRepo);
-		}
-		catch (HttpClientErrorException ex) {
-			logger.debug("Client error, assuming not promoted");
-			return false;
-		}
-	}
-
-	private PromotionRequest getPromotionRequest(String targetRepo) {
-		return new PromotionRequest("staged", STAGING_REPO, targetRepo);
-	}
-
-}
diff --git a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/artifactory/DistributionTimeoutException.java b/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/artifactory/DistributionTimeoutException.java
deleted file mode 100644
index c5ba1812b4fe..000000000000
--- a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/artifactory/DistributionTimeoutException.java
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts.artifactory;
-
-/**
- * Runtime exception if artifact distribution to Bintray fails.
- *
- * @author Madhura Bhave
- */
-public class DistributionTimeoutException extends RuntimeException {
-
-	private String message;
-
-	DistributionTimeoutException(String message) {
-		super(message);
-		this.message = message;
-	}
-
-	@Override
-	public String getMessage() {
-		return this.message;
-	}
-
-}
diff --git a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/artifactory/payload/BuildInfoResponse.java b/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/artifactory/payload/BuildInfoResponse.java
deleted file mode 100644
index 149d2c56fd5d..000000000000
--- a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/artifactory/payload/BuildInfoResponse.java
+++ /dev/null
@@ -1,166 +0,0 @@
-/*
- * Copyright 2012-2020 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts.artifactory.payload;
-
-import java.util.Arrays;
-import java.util.Set;
-import java.util.function.Predicate;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-
-/**
- * Represents the response from Artifactory's buildInfo endpoint.
- *
- * @author Madhura Bhave
- */
-public class BuildInfoResponse {
-
-	private BuildInfo buildInfo;
-
-	public BuildInfo getBuildInfo() {
-		return this.buildInfo;
-	}
-
-	public void setBuildInfo(BuildInfo buildInfo) {
-		this.buildInfo = buildInfo;
-	}
-
-	public static class BuildInfo {
-
-		private String name;
-
-		private String number;
-
-		private String version;
-
-		private Status[] statuses;
-
-		private Module[] modules;
-
-		public Status[] getStatuses() {
-			return this.statuses;
-		}
-
-		public void setStatuses(Status[] statuses) {
-			this.statuses = statuses;
-		}
-
-		public String getName() {
-			return this.name;
-		}
-
-		public void setName(String name) {
-			this.name = name;
-		}
-
-		public String getNumber() {
-			return this.number;
-		}
-
-		public void setNumber(String number) {
-			this.number = number;
-		}
-
-		public Module[] getModules() {
-			return this.modules;
-		}
-
-		public void setModules(Module[] modules) {
-			this.modules = modules;
-		}
-
-		public String getVersion() {
-			return this.version;
-		}
-
-		public void setVersion(String version) {
-			this.version = version;
-
-		}
-
-		public Set<String> getArtifactDigests(Predicate<Artifact> predicate) {
-			return Arrays.stream(this.modules).flatMap((module) -> {
-				Artifact[] artifacts = module.getArtifacts();
-				return (artifacts != null) ? Arrays.stream(artifacts) : Stream.empty();
-			}).filter(predicate).map(Artifact::getSha256).collect(Collectors.toSet());
-		}
-
-	}
-
-	public static class Status {
-
-		private String repository;
-
-		public String getRepository() {
-			return this.repository;
-		}
-
-		public void setRepository(String repository) {
-			this.repository = repository;
-		}
-
-	}
-
-	public static class Module {
-
-		private String id;
-
-		private Artifact[] artifacts;
-
-		public String getId() {
-			return this.id;
-		}
-
-		public void setId(String id) {
-			this.id = id;
-		}
-
-		public Artifact[] getArtifacts() {
-			return this.artifacts;
-		}
-
-		public void setArtifacts(Artifact[] artifacts) {
-			this.artifacts = artifacts;
-		}
-
-	}
-
-	public static class Artifact {
-
-		private String name;
-
-		private String sha256;
-
-		public String getName() {
-			return this.name;
-		}
-
-		public void setName(String name) {
-			this.name = name;
-		}
-
-		public String getSha256() {
-			return this.sha256;
-		}
-
-		public void setSha256(String sha256) {
-			this.sha256 = sha256;
-		}
-
-	}
-
-}
diff --git a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/artifactory/payload/DistributionRequest.java b/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/artifactory/payload/DistributionRequest.java
deleted file mode 100644
index 241f6a6600aa..000000000000
--- a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/artifactory/payload/DistributionRequest.java
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts.artifactory.payload;
-
-/**
- * Represents a request to distribute artifacts from Artifactory to Bintray.
- *
- * @author Madhura Bhave
- */
-public class DistributionRequest {
-
-	private final String[] sourceRepos;
-
-	private final String targetRepo = "spring-distributions";
-
-	private final String async = "true";
-
-	public DistributionRequest(String[] sourceRepos) {
-		this.sourceRepos = sourceRepos;
-	}
-
-	public String[] getSourceRepos() {
-		return sourceRepos;
-	}
-
-	public String getTargetRepo() {
-		return targetRepo;
-	}
-
-	public String getAsync() {
-		return async;
-	}
-
-}
diff --git a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/artifactory/payload/PromotionRequest.java b/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/artifactory/payload/PromotionRequest.java
deleted file mode 100644
index cf9974c53123..000000000000
--- a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/artifactory/payload/PromotionRequest.java
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts.artifactory.payload;
-
-/**
- * Represents a request to promote artifacts from a sourceRepo to a targetRepo.
- *
- * @author Madhura Bhave
- */
-public class PromotionRequest {
-
-	private final String status;
-
-	private final String sourceRepo;
-
-	private final String targetRepo;
-
-	public PromotionRequest(String status, String sourceRepo, String targetRepo) {
-		this.status = status;
-		this.sourceRepo = sourceRepo;
-		this.targetRepo = targetRepo;
-	}
-
-	public String getTargetRepo() {
-		return this.targetRepo;
-	}
-
-	public String getSourceRepo() {
-		return this.sourceRepo;
-	}
-
-	public String getStatus() {
-		return this.status;
-	}
-
-}
diff --git a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/command/Command.java b/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/command/Command.java
deleted file mode 100644
index e8541ae12848..000000000000
--- a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/command/Command.java
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts.command;
-
-import org.springframework.boot.ApplicationArguments;
-import org.springframework.util.ClassUtils;
-
-/**
- * @author Madhura Bhave
- */
-public interface Command {
-
-	default String getName() {
-		String name = ClassUtils.getShortName(getClass());
-		int lastDot = name.lastIndexOf(".");
-		if (lastDot != -1) {
-			name = name.substring(lastDot + 1, name.length());
-		}
-		if (name.endsWith("Command")) {
-			name = name.substring(0, name.length() - "Command".length());
-		}
-		return name.toLowerCase();
-	}
-
-	void run(ApplicationArguments args) throws Exception;
-
-}
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/command/CommandProcessor.java b/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/command/CommandProcessor.java
deleted file mode 100644
index 50a0b2132a7e..000000000000
--- a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/command/CommandProcessor.java
+++ /dev/null
@@ -1,58 +0,0 @@
-/*
- * Copyright 2012-2020 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts.command;
-
-import java.util.Collections;
-import java.util.List;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import org.springframework.boot.ApplicationArguments;
-import org.springframework.boot.ApplicationRunner;
-import org.springframework.stereotype.Component;
-import org.springframework.util.Assert;
-
-/**
- * {@link ApplicationRunner} to delegate incoming requests to commands.
- *
- * @author Madhura Bhave
- */
-@Component
-public class CommandProcessor implements ApplicationRunner {
-
-	private static final Logger logger = LoggerFactory.getLogger(CommandProcessor.class);
-
-	private final List<Command> commands;
-
-	public CommandProcessor(List<Command> commands) {
-		this.commands = Collections.unmodifiableList(commands);
-	}
-
-	@Override
-	public void run(ApplicationArguments args) throws Exception {
-		logger.debug("Running command processor");
-		List<String> nonOptionArgs = args.getNonOptionArgs();
-		Assert.state(!nonOptionArgs.isEmpty(), "No command argument specified");
-		String request = nonOptionArgs.get(0);
-		Command command = this.commands.stream().filter((candidate) -> candidate.getName().equals(request)).findFirst()
-				.orElseThrow(() -> new IllegalStateException("Unknown command '" + request + "'"));
-		logger.debug("Found command " + command.getClass().getName());
-		command.run(args);
-	}
-
-}
diff --git a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/command/PromoteCommand.java b/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/command/PromoteCommand.java
deleted file mode 100644
index dc8f80ceba62..000000000000
--- a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/command/PromoteCommand.java
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- * Copyright 2012-2020 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts.command;
-
-import java.io.File;
-import java.nio.file.Files;
-import java.util.List;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-import io.spring.concourse.releasescripts.ReleaseInfo;
-import io.spring.concourse.releasescripts.ReleaseType;
-import io.spring.concourse.releasescripts.artifactory.ArtifactoryService;
-import io.spring.concourse.releasescripts.artifactory.payload.BuildInfoResponse;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import org.springframework.boot.ApplicationArguments;
-import org.springframework.stereotype.Component;
-import org.springframework.util.Assert;
-
-/**
- * Command used to move the build artifacts to a target repository in Artifactory.
- *
- * @author Madhura Bhave
- */
-@Component
-public class PromoteCommand implements Command {
-
-	private static final Logger logger = LoggerFactory.getLogger(PromoteCommand.class);
-
-	private final ArtifactoryService service;
-
-	private final ObjectMapper objectMapper;
-
-	public PromoteCommand(ArtifactoryService service, ObjectMapper objectMapper) {
-		this.service = service;
-		this.objectMapper = objectMapper;
-	}
-
-	@Override
-	public void run(ApplicationArguments args) throws Exception {
-		logger.debug("Running 'promote' command");
-		List<String> nonOptionArgs = args.getNonOptionArgs();
-		Assert.state(!nonOptionArgs.isEmpty(), "No command argument specified");
-		Assert.state(nonOptionArgs.size() == 3, "Release type or build info location not specified");
-		String releaseType = nonOptionArgs.get(1);
-		ReleaseType type = ReleaseType.from(releaseType);
-		String buildInfoLocation = nonOptionArgs.get(2);
-		byte[] content = Files.readAllBytes(new File(buildInfoLocation).toPath());
-		BuildInfoResponse buildInfoResponse = this.objectMapper.readValue(new String(content), BuildInfoResponse.class);
-		ReleaseInfo releaseInfo = ReleaseInfo.from(buildInfoResponse.getBuildInfo());
-		this.service.promote(type.getRepo(), releaseInfo);
-	}
-
-}
diff --git a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/command/PublishToCentralCommand.java b/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/command/PublishToCentralCommand.java
deleted file mode 100644
index 2dccc1876e48..000000000000
--- a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/command/PublishToCentralCommand.java
+++ /dev/null
@@ -1,80 +0,0 @@
-/*
- * Copyright 2012-2021 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts.command;
-
-import java.io.File;
-import java.nio.file.Files;
-import java.util.List;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-import io.spring.concourse.releasescripts.ReleaseInfo;
-import io.spring.concourse.releasescripts.ReleaseType;
-import io.spring.concourse.releasescripts.artifactory.payload.BuildInfoResponse;
-import io.spring.concourse.releasescripts.artifactory.payload.BuildInfoResponse.BuildInfo;
-import io.spring.concourse.releasescripts.sonatype.SonatypeService;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import org.springframework.boot.ApplicationArguments;
-import org.springframework.stereotype.Component;
-import org.springframework.util.Assert;
-
-/**
- * Command used to publish a release to Maven Central.
- *
- * @author Andy Wilkinson
- */
-@Component
-public class PublishToCentralCommand implements Command {
-
-	private static final Logger logger = LoggerFactory.getLogger(PublishToCentralCommand.class);
-
-	private final SonatypeService sonatype;
-
-	private final ObjectMapper objectMapper;
-
-	public PublishToCentralCommand(SonatypeService sonatype, ObjectMapper objectMapper) {
-		this.sonatype = sonatype;
-		this.objectMapper = objectMapper;
-	}
-
-	@Override
-	public String getName() {
-		return "publishToCentral";
-	}
-
-	@Override
-	public void run(ApplicationArguments args) throws Exception {
-		List<String> nonOptionArgs = args.getNonOptionArgs();
-		Assert.state(nonOptionArgs.size() == 4,
-				"Release type, build info location, or artifacts location not specified");
-		String releaseType = nonOptionArgs.get(1);
-		ReleaseType type = ReleaseType.from(releaseType);
-		if (!ReleaseType.RELEASE.equals(type)) {
-			return;
-		}
-		String buildInfoLocation = nonOptionArgs.get(2);
-		logger.debug("Loading build-info from " + buildInfoLocation);
-		byte[] content = Files.readAllBytes(new File(buildInfoLocation).toPath());
-		BuildInfoResponse buildInfoResponse = this.objectMapper.readValue(content, BuildInfoResponse.class);
-		BuildInfo buildInfo = buildInfoResponse.getBuildInfo();
-		ReleaseInfo releaseInfo = ReleaseInfo.from(buildInfo);
-		String artifactsLocation = nonOptionArgs.get(3);
-		this.sonatype.publish(releaseInfo, new File(artifactsLocation).toPath());
-	}
-
-}
diff --git a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/command/PublishToSdkmanCommand.java b/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/command/PublishToSdkmanCommand.java
deleted file mode 100644
index 1938610dc0cd..000000000000
--- a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/command/PublishToSdkmanCommand.java
+++ /dev/null
@@ -1,72 +0,0 @@
-/*
- * Copyright 2012-2020 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts.command;
-
-import java.util.List;
-
-import io.spring.concourse.releasescripts.ReleaseType;
-import io.spring.concourse.releasescripts.sdkman.SdkmanService;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import org.springframework.boot.ApplicationArguments;
-import org.springframework.stereotype.Component;
-import org.springframework.util.Assert;
-
-/**
- * Command used to publish to SDKMAN.
- *
- * @author Madhura Bhave
- */
-@Component
-public class PublishToSdkmanCommand implements Command {
-
-	private static final Logger logger = LoggerFactory.getLogger(PublishToSdkmanCommand.class);
-
-	private static final String PUBLISH_TO_SDKMAN_COMMAND = "publishToSdkman";
-
-	private final SdkmanService service;
-
-	public PublishToSdkmanCommand(SdkmanService service) {
-		this.service = service;
-	}
-
-	@Override
-	public String getName() {
-		return PUBLISH_TO_SDKMAN_COMMAND;
-	}
-
-	@Override
-	public void run(ApplicationArguments args) throws Exception {
-		logger.debug("Running 'push to SDKMAN' command");
-		List<String> nonOptionArgs = args.getNonOptionArgs();
-		Assert.state(!nonOptionArgs.isEmpty(), "No command argument specified");
-		Assert.state(nonOptionArgs.size() >= 3, "Release type or version not specified");
-		String releaseType = nonOptionArgs.get(1);
-		ReleaseType type = ReleaseType.from(releaseType);
-		if (!ReleaseType.RELEASE.equals(type)) {
-			return;
-		}
-		String version = nonOptionArgs.get(2);
-		boolean makeDefault = false;
-		if (nonOptionArgs.size() == 4) {
-			makeDefault = Boolean.parseBoolean(nonOptionArgs.get(3));
-		}
-		this.service.publish(version, makeDefault);
-	}
-
-}
diff --git a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/sdkman/SdkmanProperties.java b/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/sdkman/SdkmanProperties.java
deleted file mode 100644
index 575d3cf1b703..000000000000
--- a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/sdkman/SdkmanProperties.java
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright 2012-2020 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts.sdkman;
-
-import org.springframework.boot.context.properties.ConfigurationProperties;
-
-/**
- * {@link ConfigurationProperties @ConfigurationProperties} for SDKMAN.
- *
- * @author Madhura Bhave
- */
-@ConfigurationProperties(prefix = "sdkman")
-public class SdkmanProperties {
-
-	private String consumerKey;
-
-	private String consumerToken;
-
-	public String getConsumerKey() {
-		return this.consumerKey;
-	}
-
-	public void setConsumerKey(String consumerKey) {
-		this.consumerKey = consumerKey;
-	}
-
-	public String getConsumerToken() {
-		return this.consumerToken;
-	}
-
-	public void setConsumerToken(String consumerToken) {
-		this.consumerToken = consumerToken;
-	}
-
-}
diff --git a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/sdkman/SdkmanService.java b/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/sdkman/SdkmanService.java
deleted file mode 100644
index dc87b9285170..000000000000
--- a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/sdkman/SdkmanService.java
+++ /dev/null
@@ -1,158 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts.sdkman;
-
-import java.net.URI;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import org.springframework.boot.web.client.RestTemplateBuilder;
-import org.springframework.http.MediaType;
-import org.springframework.http.RequestEntity;
-import org.springframework.stereotype.Component;
-import org.springframework.web.client.RestTemplate;
-
-/**
- * Central class for interacting with SDKMAN's API.
- *
- * @author Madhura Bhave
- * @author Moritz Halbritter
- */
-@Component
-public class SdkmanService {
-
-	private static final Logger logger = LoggerFactory.getLogger(SdkmanService.class);
-
-	private static final String SDKMAN_URL = "https://vendors.sdkman.io/";
-
-	private static final String DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/org/springframework/boot/spring-boot-cli/"
-			+ "%s/spring-boot-cli-%s-bin.zip";
-
-	private static final String CHANGELOG_URL = "https://github.com/spring-projects/spring-boot/releases/tag/v%s";
-
-	private static final String SPRING_BOOT = "springboot";
-
-	private final RestTemplate restTemplate;
-
-	private final SdkmanProperties properties;
-
-	private final String CONSUMER_KEY_HEADER = "Consumer-Key";
-
-	private final String CONSUMER_TOKEN_HEADER = "Consumer-Token";
-
-	public SdkmanService(RestTemplateBuilder builder, SdkmanProperties properties) {
-		this.restTemplate = builder.build();
-		this.properties = properties;
-	}
-
-	public void publish(String version, boolean makeDefault) {
-		release(version);
-		if (makeDefault) {
-			makeDefault(version);
-		}
-		broadcast(version);
-	}
-
-	private void broadcast(String version) {
-		BroadcastRequest broadcastRequest = new BroadcastRequest(version, String.format(CHANGELOG_URL, version));
-		RequestEntity<BroadcastRequest> broadcastEntity = RequestEntity.post(URI.create(SDKMAN_URL + "announce/struct"))
-				.header(CONSUMER_KEY_HEADER, this.properties.getConsumerKey())
-				.header(CONSUMER_TOKEN_HEADER, this.properties.getConsumerToken())
-				.contentType(MediaType.APPLICATION_JSON).body(broadcastRequest);
-		this.restTemplate.exchange(broadcastEntity, String.class);
-		logger.debug("Broadcast complete");
-	}
-
-	private void makeDefault(String version) {
-		logger.debug("Making this version the default");
-		Request request = new Request(version);
-		RequestEntity<Request> requestEntity = RequestEntity.put(URI.create(SDKMAN_URL + "default"))
-				.header(CONSUMER_KEY_HEADER, this.properties.getConsumerKey())
-				.header(CONSUMER_TOKEN_HEADER, this.properties.getConsumerToken())
-				.contentType(MediaType.APPLICATION_JSON).body(request);
-		this.restTemplate.exchange(requestEntity, String.class);
-		logger.debug("Make default complete");
-	}
-
-	private void release(String version) {
-		ReleaseRequest releaseRequest = new ReleaseRequest(version, String.format(DOWNLOAD_URL, version, version));
-		RequestEntity<ReleaseRequest> releaseEntity = RequestEntity.post(URI.create(SDKMAN_URL + "release"))
-				.header(CONSUMER_KEY_HEADER, this.properties.getConsumerKey())
-				.header(CONSUMER_TOKEN_HEADER, this.properties.getConsumerToken())
-				.contentType(MediaType.APPLICATION_JSON).body(releaseRequest);
-		this.restTemplate.exchange(releaseEntity, String.class);
-		logger.debug("Release complete");
-	}
-
-	static class Request {
-
-		private final String candidate = SPRING_BOOT;
-
-		private final String version;
-
-		Request(String version) {
-			this.version = version;
-		}
-
-		public String getCandidate() {
-			return this.candidate;
-		}
-
-		public String getVersion() {
-			return this.version;
-		}
-
-	}
-
-	static class ReleaseRequest extends Request {
-
-		private final String url;
-
-		ReleaseRequest(String version, String url) {
-			super(version);
-			this.url = url;
-		}
-
-		public String getUrl() {
-			return this.url;
-		}
-
-	}
-
-	static class BroadcastRequest extends Request {
-
-		private final String hashtag = SPRING_BOOT;
-
-		private final String url;
-
-		BroadcastRequest(String version, String url) {
-			super(version);
-			this.url = url;
-		}
-
-		public String getHashtag() {
-			return this.hashtag;
-		}
-
-		public String getUrl() {
-			return url;
-		}
-
-	}
-
-}
diff --git a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/sonatype/ArtifactCollector.java b/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/sonatype/ArtifactCollector.java
deleted file mode 100644
index d1d466f5b783..000000000000
--- a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/sonatype/ArtifactCollector.java
+++ /dev/null
@@ -1,64 +0,0 @@
-/*
- * Copyright 2012-2021 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts.sonatype;
-
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.Collection;
-import java.util.List;
-import java.util.function.Predicate;
-import java.util.regex.Pattern;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-
-import org.springframework.core.io.PathResource;
-
-/**
- * Collects artifacts to be deployed.
- *
- * @author Andy Wilkinson
- */
-class ArtifactCollector {
-
-	private final Predicate<Path> excludeFilter;
-
-	ArtifactCollector(List<String> exclude) {
-		this.excludeFilter = excludeFilter(exclude);
-	}
-
-	private Predicate<Path> excludeFilter(List<String> exclude) {
-		Predicate<String> patternFilter = exclude.stream().map(Pattern::compile).map(Pattern::asPredicate)
-				.reduce((path) -> false, Predicate::or).negate();
-		return (path) -> patternFilter.test(path.toString());
-	}
-
-	Collection<DeployableArtifact> collectArtifacts(Path root) {
-		try (Stream<Path> artifacts = Files.walk(root)) {
-			return artifacts.filter(Files::isRegularFile).filter(this.excludeFilter)
-					.map((artifact) -> deployableArtifact(artifact, root)).toList();
-		}
-		catch (IOException ex) {
-			throw new RuntimeException("Could not read artifacts from '" + root + "'");
-		}
-	}
-
-	private DeployableArtifact deployableArtifact(Path artifact, Path root) {
-		return new DeployableArtifact(new PathResource(artifact), root.relativize(artifact).toString());
-	}
-
-}
diff --git a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/sonatype/DeployableArtifact.java b/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/sonatype/DeployableArtifact.java
deleted file mode 100644
index 45bfa9c3d242..000000000000
--- a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/sonatype/DeployableArtifact.java
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * Copyright 2012-2021 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts.sonatype;
-
-import org.springframework.core.io.Resource;
-
-/**
- * An artifact that can be deployed.
- *
- * @author Andy Wilkinson
- */
-class DeployableArtifact {
-
-	private final Resource resource;
-
-	private final String path;
-
-	DeployableArtifact(Resource resource, String path) {
-		this.resource = resource;
-		this.path = path;
-	}
-
-	Resource getResource() {
-		return this.resource;
-	}
-
-	String getPath() {
-		return this.path;
-	}
-
-}
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/sonatype/SonatypeProperties.java b/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/sonatype/SonatypeProperties.java
deleted file mode 100644
index 4f9d0a4c5409..000000000000
--- a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/sonatype/SonatypeProperties.java
+++ /dev/null
@@ -1,123 +0,0 @@
-/*
- * Copyright 2012-2021 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts.sonatype;
-
-import java.time.Duration;
-import java.util.ArrayList;
-import java.util.List;
-
-import com.fasterxml.jackson.annotation.JsonProperty;
-
-import org.springframework.boot.context.properties.ConfigurationProperties;
-
-/**
- * {@link ConfigurationProperties @ConfigurationProperties} for Sonatype.
- *
- * @author Madhura Bhave
- */
-@ConfigurationProperties(prefix = "sonatype")
-public class SonatypeProperties {
-
-	@JsonProperty("username")
-	private String userToken;
-
-	@JsonProperty("password")
-	private String passwordToken;
-
-	/**
-	 * URL of the Nexus instance used to publish releases.
-	 */
-	private String url;
-
-	/**
-	 * ID of the staging profile used to publish releases.
-	 */
-	private String stagingProfileId;
-
-	/**
-	 * Time between requests made to determine if the closing of a staging repository has
-	 * completed.
-	 */
-	private Duration pollingInterval = Duration.ofSeconds(15);
-
-	/**
-	 * Number of threads used to upload artifacts to the staging repository.
-	 */
-	private int uploadThreads = 8;
-
-	/**
-	 * Regular expression patterns of artifacts to exclude
-	 */
-	private List<String> exclude = new ArrayList<>();
-
-	public String getUserToken() {
-		return this.userToken;
-	}
-
-	public void setUserToken(String userToken) {
-		this.userToken = userToken;
-	}
-
-	public String getPasswordToken() {
-		return this.passwordToken;
-	}
-
-	public void setPasswordToken(String passwordToken) {
-		this.passwordToken = passwordToken;
-	}
-
-	public String getUrl() {
-		return this.url;
-	}
-
-	public void setUrl(String url) {
-		this.url = url;
-	}
-
-	public String getStagingProfileId() {
-		return this.stagingProfileId;
-	}
-
-	public void setStagingProfileId(String stagingProfileId) {
-		this.stagingProfileId = stagingProfileId;
-	}
-
-	public Duration getPollingInterval() {
-		return this.pollingInterval;
-	}
-
-	public void setPollingInterval(Duration pollingInterval) {
-		this.pollingInterval = pollingInterval;
-	}
-
-	public int getUploadThreads() {
-		return this.uploadThreads;
-	}
-
-	public void setUploadThreads(int uploadThreads) {
-		this.uploadThreads = uploadThreads;
-	}
-
-	public List<String> getExclude() {
-		return this.exclude;
-	}
-
-	public void setExclude(List<String> exclude) {
-		this.exclude = exclude;
-	}
-
-}
diff --git a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/sonatype/SonatypeService.java b/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/sonatype/SonatypeService.java
deleted file mode 100644
index ed5703662116..000000000000
--- a/ci/images/releasescripts/src/main/java/io/spring/concourse/releasescripts/sonatype/SonatypeService.java
+++ /dev/null
@@ -1,306 +0,0 @@
-/*
- * Copyright 2012-2021 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts.sonatype;
-
-import java.nio.file.Path;
-import java.time.Duration;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-
-import com.fasterxml.jackson.annotation.JsonCreator;
-import com.fasterxml.jackson.annotation.JsonCreator.Mode;
-import com.fasterxml.jackson.annotation.JsonProperty;
-import io.spring.concourse.releasescripts.ReleaseInfo;
-import org.apache.logging.log4j.util.Strings;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import org.springframework.boot.web.client.RestTemplateBuilder;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
-import org.springframework.stereotype.Component;
-import org.springframework.util.StringUtils;
-import org.springframework.web.client.HttpClientErrorException;
-import org.springframework.web.client.RestTemplate;
-
-/**
- * Central class for interacting with Sonatype.
- *
- * @author Madhura Bhave
- * @author Andy Wilkinson
- */
-@Component
-public class SonatypeService {
-
-	private static final Logger logger = LoggerFactory.getLogger(SonatypeService.class);
-
-	private static final String NEXUS_REPOSITORY_PATH = "/service/local/repositories/releases/content/org/springframework/boot/spring-boot/";
-
-	private static final String NEXUS_STAGING_PATH = "/service/local/staging/";
-
-	private final ArtifactCollector artifactCollector;
-
-	private final RestTemplate restTemplate;
-
-	private final String stagingProfileId;
-
-	private final Duration pollingInterval;
-
-	private final int threads;
-
-	public SonatypeService(RestTemplateBuilder builder, SonatypeProperties sonatypeProperties) {
-		String username = sonatypeProperties.getUserToken();
-		String password = sonatypeProperties.getPasswordToken();
-		if (StringUtils.hasLength(username)) {
-			builder = builder.basicAuthentication(username, password);
-		}
-		this.restTemplate = builder.rootUri(sonatypeProperties.getUrl()).build();
-		this.stagingProfileId = sonatypeProperties.getStagingProfileId();
-		this.pollingInterval = sonatypeProperties.getPollingInterval();
-		this.threads = sonatypeProperties.getUploadThreads();
-
-		this.artifactCollector = new ArtifactCollector(sonatypeProperties.getExclude());
-	}
-
-	/**
-	 * Publishes the release by creating a staging repository and deploying to it the
-	 * artifacts at the given {@code artifactsRoot}. The repository is then closed and,
-	 * upon successfully closure, it is released.
-	 * @param releaseInfo the release information
-	 * @param artifactsRoot the root directory of the artifacts to stage
-	 */
-	public void publish(ReleaseInfo releaseInfo, Path artifactsRoot) {
-		if (artifactsPublished(releaseInfo)) {
-			return;
-		}
-		logger.info("Creating staging repository");
-		String buildId = releaseInfo.getBuildNumber();
-		String repositoryId = createStagingRepository(buildId);
-		Collection<DeployableArtifact> artifacts = this.artifactCollector.collectArtifacts(artifactsRoot);
-		logger.info("Staging repository {} created. Deploying {} artifacts", repositoryId, artifacts.size());
-		deploy(artifacts, repositoryId);
-		logger.info("Deploy complete. Closing staging repository");
-		close(repositoryId);
-		logger.info("Staging repository closed");
-		release(repositoryId, buildId);
-		logger.info("Staging repository released");
-	}
-
-	private boolean artifactsPublished(ReleaseInfo releaseInfo) {
-		try {
-			ResponseEntity<?> entity = this.restTemplate
-					.getForEntity(String.format(NEXUS_REPOSITORY_PATH + "%s/spring-boot-%s.jar.sha1",
-							releaseInfo.getVersion(), releaseInfo.getVersion()), byte[].class);
-			if (HttpStatus.OK.equals(entity.getStatusCode())) {
-				logger.info("Already published to Sonatype.");
-				return true;
-			}
-		}
-		catch (HttpClientErrorException ex) {
-
-		}
-		return false;
-	}
-
-	private String createStagingRepository(String buildId) {
-		Map<String, Object> body = new HashMap<>();
-		body.put("data", Collections.singletonMap("description", buildId));
-		PromoteResponse response = this.restTemplate.postForObject(
-				String.format(NEXUS_STAGING_PATH + "profiles/%s/start", this.stagingProfileId), body,
-				PromoteResponse.class);
-		String repositoryId = response.data.stagedRepositoryId;
-		return repositoryId;
-	}
-
-	private void deploy(Collection<DeployableArtifact> artifacts, String repositoryId) {
-		ExecutorService executor = Executors.newFixedThreadPool(this.threads);
-		try {
-			CompletableFuture.allOf(artifacts.stream()
-					.map((artifact) -> CompletableFuture.runAsync(() -> deploy(artifact, repositoryId), executor))
-					.toArray(CompletableFuture[]::new)).get(60, TimeUnit.MINUTES);
-		}
-		catch (InterruptedException ex) {
-			Thread.currentThread().interrupt();
-			throw new RuntimeException("Interrupted during artifact deploy");
-		}
-		catch (ExecutionException ex) {
-			throw new RuntimeException("Deploy failed", ex);
-		}
-		catch (TimeoutException ex) {
-			throw new RuntimeException("Deploy timed out", ex);
-		}
-		finally {
-			executor.shutdown();
-		}
-	}
-
-	private void deploy(DeployableArtifact deployableArtifact, String repositoryId) {
-		try {
-			this.restTemplate.put(
-					NEXUS_STAGING_PATH + "deployByRepositoryId/" + repositoryId + "/" + deployableArtifact.getPath(),
-					deployableArtifact.getResource());
-			logger.info("Deloyed {}", deployableArtifact.getPath());
-		}
-		catch (HttpClientErrorException ex) {
-			logger.error("Failed to deploy {}. Error response: {}", deployableArtifact.getPath(),
-					ex.getResponseBodyAsString());
-			throw ex;
-		}
-	}
-
-	private void close(String stagedRepositoryId) {
-		Map<String, Object> body = new HashMap<>();
-		body.put("data", Collections.singletonMap("stagedRepositoryId", stagedRepositoryId));
-		this.restTemplate.postForEntity(String.format(NEXUS_STAGING_PATH + "profiles/%s/finish", this.stagingProfileId),
-				body, Void.class);
-		logger.info("Close requested. Awaiting result");
-		while (true) {
-			StagingRepository repository = this.restTemplate
-					.getForObject(NEXUS_STAGING_PATH + "repository/" + stagedRepositoryId, StagingRepository.class);
-			if (!repository.transitioning) {
-				if ("open".equals(repository.type)) {
-					logFailures(stagedRepositoryId);
-					throw new RuntimeException("Close failed");
-				}
-				return;
-			}
-			try {
-				Thread.sleep(this.pollingInterval.toMillis());
-			}
-			catch (InterruptedException ex) {
-				Thread.currentThread().interrupt();
-				throw new RuntimeException("Interrupted while waiting for staging repository to close", ex);
-			}
-		}
-	}
-
-	private void logFailures(String stagedRepositoryId) {
-		try {
-			StagingRepositoryActivity[] activities = this.restTemplate.getForObject(
-					NEXUS_STAGING_PATH + "repository/" + stagedRepositoryId + "/activity",
-					StagingRepositoryActivity[].class);
-			List<String> failureMessages = Stream.of(activities).flatMap((activity) -> activity.events.stream())
-					.filter((event) -> event.severity > 0).flatMap((event) -> event.properties.stream())
-					.filter((property) -> "failureMessage".equals(property.name))
-					.map((property) -> "    " + property.value).toList();
-			if (failureMessages.isEmpty()) {
-				logger.error("Close failed for unknown reasons");
-			}
-			logger.error("Close failed:\n{}", Strings.join(failureMessages, '\n'));
-		}
-		catch (Exception ex) {
-			logger.error("Failed to determine causes of close failure", ex);
-		}
-	}
-
-	private void release(String stagedRepositoryId, String buildId) {
-		Map<String, Object> data = new HashMap<>();
-		data.put("stagedRepositoryIds", Arrays.asList(stagedRepositoryId));
-		data.put("description", "Releasing " + buildId);
-		data.put("autoDropAfterRelease", true);
-		Map<String, Object> body = Collections.singletonMap("data", data);
-		this.restTemplate.postForEntity(NEXUS_STAGING_PATH + "bulk/promote", body, Void.class);
-	}
-
-	private static final class PromoteResponse {
-
-		private final Data data;
-
-		@JsonCreator(mode = Mode.PROPERTIES)
-		private PromoteResponse(@JsonProperty("data") Data data) {
-			this.data = data;
-		}
-
-		private static final class Data {
-
-			private final String stagedRepositoryId;
-
-			@JsonCreator(mode = Mode.PROPERTIES)
-			Data(@JsonProperty("stagedRepositoryId") String stagedRepositoryId) {
-				this.stagedRepositoryId = stagedRepositoryId;
-			}
-
-		}
-
-	}
-
-	private static final class StagingRepository {
-
-		private final String type;
-
-		private final boolean transitioning;
-
-		private StagingRepository(String type, boolean transitioning) {
-			this.type = type;
-			this.transitioning = transitioning;
-		}
-
-	}
-
-	private static final class StagingRepositoryActivity {
-
-		private final List<Event> events;
-
-		@JsonCreator
-		private StagingRepositoryActivity(@JsonProperty("events") List<Event> events) {
-			this.events = events;
-		}
-
-		private static class Event {
-
-			private final List<Property> properties;
-
-			private final int severity;
-
-			@JsonCreator
-			public Event(@JsonProperty("name") String name, @JsonProperty("properties") List<Property> properties,
-					@JsonProperty("severity") int severity) {
-				this.properties = properties;
-				this.severity = severity;
-			}
-
-			private static class Property {
-
-				private final String name;
-
-				private final String value;
-
-				@JsonCreator
-				private Property(@JsonProperty("name") String name, @JsonProperty("value") String value) {
-					this.name = name;
-					this.value = value;
-				}
-
-			}
-
-		}
-
-	}
-
-}
diff --git a/ci/images/releasescripts/src/main/resources/application.properties b/ci/images/releasescripts/src/main/resources/application.properties
deleted file mode 100644
index 56dfa61a1909..000000000000
--- a/ci/images/releasescripts/src/main/resources/application.properties
+++ /dev/null
@@ -1,4 +0,0 @@
-spring.main.banner-mode=off
-sonatype.exclude[0]=build-info\\.json
-sonatype.exclude[1]=org/springframework/boot/spring-boot-docs/.*
-logging.level.io.spring.concourse=DEBUG
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/java/io/spring/concourse/releasescripts/artifactory/ArtifactoryServiceTests.java b/ci/images/releasescripts/src/test/java/io/spring/concourse/releasescripts/artifactory/ArtifactoryServiceTests.java
deleted file mode 100644
index 70abda3c3b6e..000000000000
--- a/ci/images/releasescripts/src/test/java/io/spring/concourse/releasescripts/artifactory/ArtifactoryServiceTests.java
+++ /dev/null
@@ -1,141 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts.artifactory;
-
-import java.util.Base64;
-
-import io.spring.concourse.releasescripts.ReleaseInfo;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.context.properties.EnableConfigurationProperties;
-import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
-import org.springframework.core.io.ClassPathResource;
-import org.springframework.http.HttpMethod;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
-import org.springframework.test.web.client.MockRestServiceServer;
-import org.springframework.test.web.client.response.DefaultResponseCreator;
-import org.springframework.web.client.HttpClientErrorException;
-
-import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
-import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
-import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
-import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
-import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
-import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
-import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
-
-/**
- * Tests for {@link ArtifactoryService}.
- *
- * @author Madhura Bhave
- */
-@RestClientTest(ArtifactoryService.class)
-@EnableConfigurationProperties(ArtifactoryProperties.class)
-class ArtifactoryServiceTests {
-
-	@Autowired
-	private ArtifactoryService service;
-
-	@Autowired
-	private ArtifactoryProperties properties;
-
-	@Autowired
-	private MockRestServiceServer server;
-
-	@AfterEach
-	void tearDown() {
-		this.server.reset();
-	}
-
-	@Test
-	void promoteWhenSuccessful() {
-		this.server.expect(requestTo("https://repo.spring.io/api/build/promote/example-build/example-build-1"))
-				.andExpect(method(HttpMethod.POST))
-				.andExpect(content().json(
-						"{\"status\": \"staged\", \"sourceRepo\": \"libs-staging-local\", \"targetRepo\": \"libs-milestone-local\"}"))
-				.andExpect(
-						header("Authorization",
-								"Basic " + Base64.getEncoder()
-										.encodeToString(String.format("%s:%s", this.properties.getUsername(),
-												this.properties.getPassword()).getBytes())))
-				.andExpect(header("Content-Type", MediaType.APPLICATION_JSON.toString())).andRespond(withSuccess());
-		this.service.promote("libs-milestone-local", getReleaseInfo());
-		this.server.verify();
-	}
-
-	@Test
-	void promoteWhenArtifactsAlreadyPromoted() {
-		this.server.expect(requestTo("https://repo.spring.io/api/build/promote/example-build/example-build-1"))
-				.andRespond(withStatus(HttpStatus.CONFLICT));
-		this.server.expect(requestTo("https://repo.spring.io/api/build/example-build/example-build-1"))
-				.andRespond(withJsonFrom("build-info-response.json"));
-		this.service.promote("libs-release-local", getReleaseInfo());
-		this.server.verify();
-	}
-
-	@Test
-	void promoteWhenCheckForArtifactsAlreadyPromotedFails() {
-		this.server.expect(requestTo("https://repo.spring.io/api/build/promote/example-build/example-build-1"))
-				.andRespond(withStatus(HttpStatus.CONFLICT));
-		this.server.expect(requestTo("https://repo.spring.io/api/build/example-build/example-build-1"))
-				.andRespond(withStatus(HttpStatus.FORBIDDEN));
-		assertThatExceptionOfType(HttpClientErrorException.class)
-				.isThrownBy(() -> this.service.promote("libs-release-local", getReleaseInfo()));
-		this.server.verify();
-	}
-
-	@Test
-	void promoteWhenCheckForArtifactsAlreadyPromotedReturnsNoStatus() {
-		this.server.expect(requestTo("https://repo.spring.io/api/build/promote/example-build/example-build-1"))
-				.andRespond(withStatus(HttpStatus.CONFLICT));
-		this.server.expect(requestTo("https://repo.spring.io/api/build/example-build/example-build-1"))
-				.andRespond(withJsonFrom("no-status-build-info-response.json"));
-		assertThatExceptionOfType(HttpClientErrorException.class)
-				.isThrownBy(() -> this.service.promote("libs-milestone-local", getReleaseInfo()));
-		this.server.verify();
-	}
-
-	@Test
-	void promoteWhenPromotionFails() {
-		this.server.expect(requestTo("https://repo.spring.io/api/build/promote/example-build/example-build-1"))
-				.andRespond(withStatus(HttpStatus.CONFLICT));
-		this.server.expect(requestTo("https://repo.spring.io/api/build/example-build/example-build-1"))
-				.andRespond(withJsonFrom("staged-build-info-response.json"));
-		assertThatExceptionOfType(HttpClientErrorException.class)
-				.isThrownBy(() -> this.service.promote("libs-release-local", getReleaseInfo()));
-		this.server.verify();
-	}
-
-	private ReleaseInfo getReleaseInfo() {
-		ReleaseInfo releaseInfo = new ReleaseInfo();
-		releaseInfo.setBuildName("example-build");
-		releaseInfo.setBuildNumber("example-build-1");
-		return releaseInfo;
-	}
-
-	private DefaultResponseCreator withJsonFrom(String path) {
-		return withSuccess(getClassPathResource(path), MediaType.APPLICATION_JSON);
-	}
-
-	private ClassPathResource getClassPathResource(String path) {
-		return new ClassPathResource(path, getClass());
-	}
-
-}
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/java/io/spring/concourse/releasescripts/sdkman/SdkmanServiceTests.java b/ci/images/releasescripts/src/test/java/io/spring/concourse/releasescripts/sdkman/SdkmanServiceTests.java
deleted file mode 100644
index 3a81ecfd3f29..000000000000
--- a/ci/images/releasescripts/src/test/java/io/spring/concourse/releasescripts/sdkman/SdkmanServiceTests.java
+++ /dev/null
@@ -1,88 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts.sdkman;
-
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.context.properties.EnableConfigurationProperties;
-import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
-import org.springframework.http.HttpMethod;
-import org.springframework.http.MediaType;
-import org.springframework.test.web.client.MockRestServiceServer;
-
-import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
-import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
-import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
-import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
-import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
-
-/**
- * Tests for {@link SdkmanService}.
- *
- * @author Madhura Bhave
- */
-@EnableConfigurationProperties(SdkmanProperties.class)
-@RestClientTest(SdkmanService.class)
-class SdkmanServiceTests {
-
-	@Autowired
-	private SdkmanService service;
-
-	@Autowired
-	private MockRestServiceServer server;
-
-	@AfterEach
-	void tearDown() {
-		this.server.reset();
-	}
-
-	@Test
-	void publishWhenMakeDefaultTrue() {
-		setupExpectation("https://vendors.sdkman.io/release",
-				"{\"candidate\": \"springboot\", \"version\": \"1.2.3\", \"url\": \"https://repo.maven.apache.org/maven2/org/springframework/boot/spring-boot-cli/1.2.3/spring-boot-cli-1.2.3-bin.zip\"}");
-		setupExpectation("https://vendors.sdkman.io/default", "{\"candidate\": \"springboot\", \"version\": \"1.2.3\"}",
-				HttpMethod.PUT);
-		setupExpectation("https://vendors.sdkman.io/announce/struct",
-				"{\"candidate\": \"springboot\", \"version\": \"1.2.3\", \"hashtag\": \"springboot\", \"url\": \"https://github.com/spring-projects/spring-boot/releases/tag/v1.2.3\"}");
-		this.service.publish("1.2.3", true);
-		this.server.verify();
-	}
-
-	@Test
-	void publishWhenMakeDefaultFalse() {
-		setupExpectation("https://vendors.sdkman.io/release",
-				"{\"candidate\": \"springboot\", \"version\": \"1.2.3\", \"url\": \"https://repo.maven.apache.org/maven2/org/springframework/boot/spring-boot-cli/1.2.3/spring-boot-cli-1.2.3-bin.zip\"}");
-		setupExpectation("https://vendors.sdkman.io/announce/struct",
-				"{\"candidate\": \"springboot\", \"version\": \"1.2.3\", \"hashtag\": \"springboot\", \"url\": \"https://github.com/spring-projects/spring-boot/releases/tag/v1.2.3\"}");
-		this.service.publish("1.2.3", false);
-		this.server.verify();
-	}
-
-	private void setupExpectation(String url, String body) {
-		setupExpectation(url, body, HttpMethod.POST);
-	}
-
-	private void setupExpectation(String url, String body, HttpMethod method) {
-		this.server.expect(requestTo(url)).andExpect(method(method)).andExpect(content().json(body))
-				.andExpect(header("Consumer-Key", "sdkman-consumer-key"))
-				.andExpect(header("Consumer-Token", "sdkman-consumer-token"))
-				.andExpect(header("Content-Type", MediaType.APPLICATION_JSON.toString())).andRespond(withSuccess());
-	}
-
-}
diff --git a/ci/images/releasescripts/src/test/java/io/spring/concourse/releasescripts/sonatype/SonatypeServiceTests.java b/ci/images/releasescripts/src/test/java/io/spring/concourse/releasescripts/sonatype/SonatypeServiceTests.java
deleted file mode 100644
index 6c9133c7c677..000000000000
--- a/ci/images/releasescripts/src/test/java/io/spring/concourse/releasescripts/sonatype/SonatypeServiceTests.java
+++ /dev/null
@@ -1,236 +0,0 @@
-/*
- * Copyright 2012-2021 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.spring.concourse.releasescripts.sonatype;
-
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.Set;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-
-import io.spring.concourse.releasescripts.ReleaseInfo;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.context.properties.EnableConfigurationProperties;
-import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
-import org.springframework.core.io.FileSystemResource;
-import org.springframework.http.HttpMethod;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
-import org.springframework.http.client.ClientHttpRequest;
-import org.springframework.test.web.client.ExpectedCount;
-import org.springframework.test.web.client.MockRestServiceServer;
-import org.springframework.test.web.client.RequestMatcher;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
-import static org.hamcrest.Matchers.equalTo;
-import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
-import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath;
-import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
-import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
-import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
-import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
-
-/**
- * Tests for {@link SonatypeService}.
- *
- * @author Madhura Bhave
- */
-@RestClientTest(components = SonatypeService.class, properties = "sonatype.url=https://nexus.example.org")
-@EnableConfigurationProperties(SonatypeProperties.class)
-class SonatypeServiceTests {
-
-	@Autowired
-	private SonatypeService service;
-
-	@Autowired
-	private MockRestServiceServer server;
-
-	@AfterEach
-	void tearDown() {
-		this.server.reset();
-	}
-
-	@Test
-	void publishWhenAlreadyPublishedShouldNotPublish() {
-		this.server.expect(requestTo(String.format(
-				"/service/local/repositories/releases/content/org/springframework/boot/spring-boot/%s/spring-boot-%s.jar.sha1",
-				"1.1.0.RELEASE", "1.1.0.RELEASE"))).andExpect(method(HttpMethod.GET))
-				.andRespond(withSuccess().body("ce8d8b6838ecceb68962b9150b18682f4237ccf71".getBytes()));
-		Path artifactsRoot = new File("src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo")
-				.toPath();
-		this.service.publish(getReleaseInfo(), artifactsRoot);
-		this.server.verify();
-	}
-
-	@Test
-	void publishWithSuccessfulClose() throws IOException {
-		this.server.expect(requestTo(String.format(
-				"/service/local/repositories/releases/content/org/springframework/boot/spring-boot/%s/spring-boot-%s.jar.sha1",
-				"1.1.0.RELEASE", "1.1.0.RELEASE"))).andExpect(method(HttpMethod.GET))
-				.andRespond(withStatus(HttpStatus.NOT_FOUND));
-		this.server.expect(requestTo("/service/local/staging/profiles/1a2b3c4d/start"))
-				.andExpect(method(HttpMethod.POST)).andExpect(header("Content-Type", "application/json"))
-				.andExpect(header("Accept", "application/json, application/*+json"))
-				.andExpect(jsonPath("$.data.description").value("example-build-1"))
-				.andRespond(withStatus(HttpStatus.CREATED).contentType(MediaType.APPLICATION_JSON).body(
-						"{\"data\":{\"stagedRepositoryId\":\"example-6789\", \"description\":\"example-build\"}}"));
-		Path artifactsRoot = new File("src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo")
-				.toPath();
-		try (Stream<Path> artifacts = Files.walk(artifactsRoot)) {
-			Set<RequestMatcher> uploads = artifacts.filter(Files::isRegularFile)
-					.map((artifact) -> artifactsRoot.relativize(artifact))
-					.filter((artifact) -> !artifact.startsWith("build-info.json"))
-					.map((artifact) -> requestTo(
-							"/service/local/staging/deployByRepositoryId/example-6789/" + artifact.toString()))
-					.collect(Collectors.toCollection(HashSet::new));
-			AnyOfRequestMatcher uploadRequestsMatcher = anyOf(uploads);
-			assertThat(uploadRequestsMatcher.candidates).hasSize(150);
-			this.server.expect(ExpectedCount.times(150), uploadRequestsMatcher).andExpect(method(HttpMethod.PUT))
-					.andRespond(withSuccess());
-			this.server.expect(requestTo("/service/local/staging/profiles/1a2b3c4d/finish"))
-					.andExpect(method(HttpMethod.POST)).andExpect(header("Content-Type", "application/json"))
-					.andExpect(header("Accept", "application/json, application/*+json"))
-					.andRespond(withStatus(HttpStatus.CREATED));
-			this.server.expect(ExpectedCount.times(2), requestTo("/service/local/staging/repository/example-6789"))
-					.andExpect(method(HttpMethod.GET))
-					.andExpect(header("Accept", "application/json, application/*+json"))
-					.andRespond(withSuccess().contentType(MediaType.APPLICATION_JSON)
-							.body("{\"type\":\"open\", \"transitioning\":true}"));
-			this.server.expect(requestTo("/service/local/staging/repository/example-6789"))
-					.andExpect(method(HttpMethod.GET))
-					.andExpect(header("Accept", "application/json, application/*+json"))
-					.andRespond(withSuccess().contentType(MediaType.APPLICATION_JSON)
-							.body("{\"type\":\"closed\", \"transitioning\":false}"));
-			this.server.expect(requestTo("/service/local/staging/bulk/promote")).andExpect(method(HttpMethod.POST))
-					.andExpect(header("Content-Type", "application/json"))
-					.andExpect(header("Accept", "application/json, application/*+json"))
-					.andExpect(jsonPath("$.data.description").value("Releasing example-build-1"))
-					.andExpect(jsonPath("$.data.autoDropAfterRelease").value(true))
-					.andExpect(jsonPath("$.data.stagedRepositoryIds").value(equalTo(Arrays.asList("example-6789"))))
-					.andRespond(withSuccess());
-			this.service.publish(getReleaseInfo(), artifactsRoot);
-			this.server.verify();
-			assertThat(uploadRequestsMatcher.candidates).hasSize(0);
-		}
-	}
-
-	@Test
-	void publishWithCloseFailureDueToRuleViolations() throws IOException {
-		this.server.expect(requestTo(String.format(
-				"/service/local/repositories/releases/content/org/springframework/boot/spring-boot/%s/spring-boot-%s.jar.sha1",
-				"1.1.0.RELEASE", "1.1.0.RELEASE"))).andExpect(method(HttpMethod.GET))
-				.andRespond(withStatus(HttpStatus.NOT_FOUND));
-		this.server.expect(requestTo("/service/local/staging/profiles/1a2b3c4d/start"))
-				.andExpect(method(HttpMethod.POST)).andExpect(header("Content-Type", "application/json"))
-				.andExpect(header("Accept", "application/json, application/*+json"))
-				.andExpect(jsonPath("$.data.description").value("example-build-1"))
-				.andRespond(withStatus(HttpStatus.CREATED).contentType(MediaType.APPLICATION_JSON).body(
-						"{\"data\":{\"stagedRepositoryId\":\"example-6789\", \"description\":\"example-build\"}}"));
-		Path artifactsRoot = new File("src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo")
-				.toPath();
-		try (Stream<Path> artifacts = Files.walk(artifactsRoot)) {
-			Set<RequestMatcher> uploads = artifacts.filter(Files::isRegularFile)
-					.map((artifact) -> artifactsRoot.relativize(artifact))
-					.filter((artifact) -> !"build-info.json".equals(artifact.toString()))
-					.map((artifact) -> requestTo(
-							"/service/local/staging/deployByRepositoryId/example-6789/" + artifact.toString()))
-					.collect(Collectors.toCollection(HashSet::new));
-			AnyOfRequestMatcher uploadRequestsMatcher = anyOf(uploads);
-			assertThat(uploadRequestsMatcher.candidates).hasSize(150);
-			this.server.expect(ExpectedCount.times(150), uploadRequestsMatcher).andExpect(method(HttpMethod.PUT))
-					.andRespond(withSuccess());
-			this.server.expect(requestTo("/service/local/staging/profiles/1a2b3c4d/finish"))
-					.andExpect(method(HttpMethod.POST)).andExpect(header("Content-Type", "application/json"))
-					.andExpect(header("Accept", "application/json, application/*+json"))
-					.andRespond(withStatus(HttpStatus.CREATED));
-			this.server.expect(ExpectedCount.times(2), requestTo("/service/local/staging/repository/example-6789"))
-					.andExpect(method(HttpMethod.GET))
-					.andExpect(header("Accept", "application/json, application/*+json"))
-					.andRespond(withSuccess().contentType(MediaType.APPLICATION_JSON)
-							.body("{\"type\":\"open\", \"transitioning\":true}"));
-			this.server.expect(requestTo("/service/local/staging/repository/example-6789"))
-					.andExpect(method(HttpMethod.GET))
-					.andExpect(header("Accept", "application/json, application/*+json"))
-					.andRespond(withSuccess().contentType(MediaType.APPLICATION_JSON)
-							.body("{\"type\":\"open\", \"transitioning\":false}"));
-			this.server.expect(requestTo("/service/local/staging/repository/example-6789/activity"))
-					.andExpect(method(HttpMethod.GET))
-					.andExpect(header("Accept", "application/json, application/*+json"))
-					.andRespond(withSuccess().contentType(MediaType.APPLICATION_JSON).body(new FileSystemResource(
-							new File("src/test/resources/io/spring/concourse/releasescripts/sonatype/activity.json"))));
-			assertThatExceptionOfType(RuntimeException.class)
-					.isThrownBy(() -> this.service.publish(getReleaseInfo(), artifactsRoot))
-					.withMessage("Close failed");
-			this.server.verify();
-			assertThat(uploadRequestsMatcher.candidates).hasSize(0);
-		}
-	}
-
-	private ReleaseInfo getReleaseInfo() {
-		ReleaseInfo releaseInfo = new ReleaseInfo();
-		releaseInfo.setBuildName("example-build");
-		releaseInfo.setBuildNumber("example-build-1");
-		releaseInfo.setVersion("1.1.0.RELEASE");
-		releaseInfo.setGroupId("example");
-		return releaseInfo;
-	}
-
-	private AnyOfRequestMatcher anyOf(Set<RequestMatcher> candidates) {
-		return new AnyOfRequestMatcher(candidates);
-	}
-
-	private static class AnyOfRequestMatcher implements RequestMatcher {
-
-		private final Object monitor = new Object();
-
-		private final Set<RequestMatcher> candidates;
-
-		private AnyOfRequestMatcher(Set<RequestMatcher> candidates) {
-			this.candidates = candidates;
-		}
-
-		@Override
-		public void match(ClientHttpRequest request) throws IOException, AssertionError {
-			synchronized (this.monitor) {
-				Iterator<RequestMatcher> iterator = this.candidates.iterator();
-				while (iterator.hasNext()) {
-					try {
-						iterator.next().match(request);
-						iterator.remove();
-						return;
-					}
-					catch (AssertionError ex) {
-						// Continue
-					}
-				}
-				throw new AssertionError(
-						"No matching request matcher was found for request to '" + request.getURI() + "'");
-			}
-		}
-
-	}
-
-}
diff --git a/ci/images/releasescripts/src/test/resources/application.yml b/ci/images/releasescripts/src/test/resources/application.yml
deleted file mode 100644
index 9c2cbc9d5b25..000000000000
--- a/ci/images/releasescripts/src/test/resources/application.yml
+++ /dev/null
@@ -1,16 +0,0 @@
-artifactory:
-  username: user
-  password: password
-bintray:
-  username: bintray-user
-  api-key: bintray-api-key
-  repo: test
-  subject: jars
-sonatype:
-  user-token: sonatype-user
-  password-token: sonatype-password
-  polling-interval: 1s
-  staging-profile-id: 1a2b3c4d
-sdkman:
-  consumer-key: sdkman-consumer-key
-  consumer-token: sdkman-consumer-token
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/artifactory/build-info-response.json b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/artifactory/build-info-response.json
deleted file mode 100644
index bfe0d3be186b..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/artifactory/build-info-response.json
+++ /dev/null
@@ -1,35 +0,0 @@
-{
-  "buildInfo": {
-    "version": "1.0.1",
-    "name": "example",
-    "number": "example-build-1",
-    "started": "2019-09-10T12:18:05.430+0000",
-    "durationMillis": 0,
-    "artifactoryPrincipal": "user",
-    "url": "https://my-ci.com",
-    "modules": [
-      {
-        "id": "org.example.demo:demo:2.2.0",
-        "artifacts": [
-          {
-            "type": "jar",
-            "sha1": "ayyyya9151a22cb3145538e523dbbaaaaaaaa",
-            "sha256": "aaaaaaaaa85f5c5093721f3ed0edda8ff8290yyyyyyyyyy",
-            "md5": "aaaaaacddea1724b0b69d8yyyyyyy",
-            "name": "demo-2.2.0.jar"
-          }
-        ]
-      }
-    ],
-    "statuses": [
-      {
-        "status": "staged",
-        "repository": "libs-release-local",
-        "timestamp": "2019-09-10T12:42:24.716+0000",
-        "user": "user",
-        "timestampDate": 1568119344716
-      }
-    ]
-  },
-  "uri": "https://my-artifactory-repo.com/api/build/example/example-build-1"
-}
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/artifactory/filtered-build-info-response.json b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/artifactory/filtered-build-info-response.json
deleted file mode 100644
index 9f9935114de5..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/artifactory/filtered-build-info-response.json
+++ /dev/null
@@ -1,59 +0,0 @@
-{
-  "buildInfo": {
-    "version": "1.0.1",
-    "name": "example",
-    "number": "example-build-1",
-    "started": "2019-09-10T12:18:05.430+0000",
-    "durationMillis": 0,
-    "artifactoryPrincipal": "user",
-    "url": "https://my-ci.com",
-    "modules": [
-      {
-        "id": "org.example.demo:demo:2.2.0",
-        "artifacts": [
-          {
-            "type": "jar",
-            "sha1": "ayyyya9151a22cb3145538e523dbbaaaaaaaa",
-            "sha256": "aaaaaaaaa85f5c5093721f3ed0edda8ff8290yyyyyyyyyy",
-            "md5": "aaaaaacddea1724b0b69d8yyyyyyy",
-            "name": "demo-2.2.0.jar"
-          }
-        ]
-      },
-      {
-        "id": "org.example.demo:demo:2.2.0:zip",
-        "artifacts": [
-          {
-            "type": "zip",
-            "sha1": "ayyyya9151a22cb3145538e523dbbaaaaaaab",
-            "sha256": "aaaaaaaaa85f5c5093721f3ed0edda8ff8290yyyyyyyyyz",
-            "md5": "aaaaaacddea1724b0b69d8yyyyyyz",
-            "name": "demo-2.2.0.zip"
-          }
-        ]
-      },
-      {
-        "id": "org.example.demo:demo:2.2.0:doc",
-        "artifacts": [
-          {
-            "type": "jar",
-            "sha1": "ayyyya9151a22cb3145538e523dbbaaaaaaba",
-            "sha256": "aaaaaaaaa85f5c5093721f3ed0edda8ff8290yyyyyyyyzy",
-            "md5": "aaaaaacddea1724b0b69d8yyyyyzy",
-            "name": "demo-2.2.0.doc"
-          }
-        ]
-      }
-    ],
-    "statuses": [
-      {
-        "status": "staged",
-        "repository": "libs-release-local",
-        "timestamp": "2019-09-10T12:42:24.716+0000",
-        "user": "user",
-        "timestampDate": 1568119344716
-      }
-    ]
-  },
-  "uri": "https://my-artifactory-repo.com/api/build/example/example-build-1"
-}
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/artifactory/no-status-build-info-response.json b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/artifactory/no-status-build-info-response.json
deleted file mode 100644
index 6183ca80018c..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/artifactory/no-status-build-info-response.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
-  "buildInfo": {
-    "version": "1.0.1",
-    "name": "example",
-    "number": "example-build-1",
-    "started": "2019-09-10T12:18:05.430+0000",
-    "durationMillis": 0,
-    "artifactoryPrincipal": "user",
-    "url": "https://my-ci.com",
-    "modules": [
-      {
-        "id": "org.example.demo:demo:2.2.0",
-        "artifacts": [
-          {
-            "type": "jar",
-            "sha1": "ayyyya9151a22cb3145538e523dbbaaaaaaaa",
-            "sha256": "aaaaaaaaa85f5c5093721f3ed0edda8ff8290yyyyyyyyyy",
-            "md5": "aaaaaacddea1724b0b69d8yyyyyyy",
-            "name": "demo-2.2.0.jar"
-          }
-        ]
-      }
-    ]
-  },
-  "uri": "https://my-artifactory-repo.com/api/build/example/example-build-1"
-}
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/artifactory/staged-build-info-response.json b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/artifactory/staged-build-info-response.json
deleted file mode 100644
index 33be7a0ffe23..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/artifactory/staged-build-info-response.json
+++ /dev/null
@@ -1,35 +0,0 @@
-{
-  "buildInfo": {
-    "version": "1.0.1",
-    "name": "example",
-    "number": "example-build-1",
-    "started": "2019-09-10T12:18:05.430+0000",
-    "durationMillis": 0,
-    "artifactoryPrincipal": "user",
-    "url": "https://my-ci.com",
-    "modules": [
-      {
-        "id": "org.example.demo:demo:2.2.0",
-        "artifacts": [
-          {
-            "type": "jar",
-            "sha1": "ayyyya9151a22cb3145538e523dbbaaaaaaaa",
-            "sha256": "aaaaaaaaa85f5c5093721f3ed0edda8ff8290yyyyyyyyyy",
-            "md5": "aaaaaacddea1724b0b69d8yyyyyyy",
-            "name": "demo-2.2.0.jar"
-          }
-        ]
-      }
-    ],
-    "statuses": [
-      {
-        "status": "staged",
-        "repository": "libs-staging-local",
-        "timestamp": "2019-09-10T12:42:24.716+0000",
-        "user": "user",
-        "timestampDate": 1568119344716
-      }
-    ]
-  },
-  "uri": "https://my-artifactory-repo.com/api/build/example/example-build-1"
-}
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/activity.json b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/activity.json
deleted file mode 100644
index 92e0e141371d..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/activity.json
+++ /dev/null
@@ -1,362 +0,0 @@
-[
-    {
-        "events": [
-            {
-                "name": "repositoryCreated",
-                "properties": [
-                    {
-                        "name": "id",
-                        "value": "orgspringframework-7161"
-                    },
-                    {
-                        "name": "user",
-                        "value": "user"
-                    },
-                    {
-                        "name": "ip",
-                        "value": "127.0.0.1"
-                    }
-                ],
-                "severity": 0,
-                "timestamp": "2021-02-08T14:31:13.523Z"
-            }
-        ],
-        "name": "open",
-        "started": "2021-02-08T14:31:00.662Z",
-        "stopped": "2021-02-08T14:31:14.855Z"
-    },
-    {
-        "events": [
-            {
-                "name": "rulesEvaluate",
-                "properties": [
-                    {
-                        "name": "id",
-                        "value": "5e9e8e6f8d20a3"
-                    },
-                    {
-                        "name": "rule",
-                        "value": "no-traversal-paths-in-archive-file"
-                    },
-                    {
-                        "name": "rule",
-                        "value": "profile-target-matching-staging"
-                    },
-                    {
-                        "name": "rule",
-                        "value": "sbom-report"
-                    },
-                    {
-                        "name": "rule",
-                        "value": "checksum-staging"
-                    },
-                    {
-                        "name": "rule",
-                        "value": "javadoc-staging"
-                    },
-                    {
-                        "name": "rule",
-                        "value": "pom-staging"
-                    },
-                    {
-                        "name": "rule",
-                        "value": "signature-staging"
-                    },
-                    {
-                        "name": "rule",
-                        "value": "sources-staging"
-                    }
-                ],
-                "severity": 0,
-                "timestamp": "2021-02-08T14:31:37.327Z"
-            },
-            {
-                "name": "ruleEvaluate",
-                "properties": [
-                    {
-                        "name": "typeId",
-                        "value": "no-traversal-paths-in-archive-file"
-                    }
-                ],
-                "severity": 0,
-                "timestamp": "2021-02-08T14:31:41.254Z"
-            },
-            {
-                "name": "rulePassed",
-                "properties": [
-                    {
-                        "name": "typeId",
-                        "value": "no-traversal-paths-in-archive-file"
-                    }
-                ],
-                "severity": 0,
-                "timestamp": "2021-02-08T14:31:47.498Z"
-            },
-            {
-                "name": "ruleEvaluate",
-                "properties": [
-                    {
-                        "name": "typeId",
-                        "value": "javadoc-staging"
-                    }
-                ],
-                "severity": 0,
-                "timestamp": "2021-02-08T14:31:53.438Z"
-            },
-            {
-                "name": "rulePassed",
-                "properties": [
-                    {
-                        "name": "typeId",
-                        "value": "javadoc-staging"
-                    }
-                ],
-                "severity": 0,
-                "timestamp": "2021-02-08T14:31:54.623Z"
-            },
-            {
-                "name": "ruleEvaluate",
-                "properties": [
-                    {
-                        "name": "typeId",
-                        "value": "pom-staging"
-                    }
-                ],
-                "severity": 0,
-                "timestamp": "2021-02-08T14:31:58.091Z"
-            },
-            {
-                "name": "ruleFailed",
-                "properties": [
-                    {
-                        "name": "typeId",
-                        "value": "pom-staging"
-                    },
-                    {
-                        "name": "failureMessage",
-                        "value": "Invalid POM: /org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom: Project name missing, Project description missing, Project URL missing, License information missing, SCM URL missing, Developer information missing"
-                    },
-                    {
-                        "name": "failureMessage",
-                        "value": "Invalid POM: /org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom: Project name missing, Project description missing, Project URL missing, License information missing, SCM URL missing, Developer information missing"
-                    },
-                    {
-                        "name": "failureMessage",
-                        "value": "Invalid POM: /org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom: Project name missing, Project description missing, Project URL missing, License information missing, SCM URL missing, Developer information missing"
-                    }
-                ],
-                "severity": 1,
-                "timestamp": "2021-02-08T14:31:59.403Z"
-            },
-            {
-                "name": "ruleEvaluate",
-                "properties": [
-                    {
-                        "name": "typeId",
-                        "value": "profile-target-matching-staging"
-                    }
-                ],
-                "severity": 0,
-                "timestamp": "2021-02-08T14:32:05.322Z"
-            },
-            {
-                "name": "rulePassed",
-                "properties": [
-                    {
-                        "name": "typeId",
-                        "value": "profile-target-matching-staging"
-                    }
-                ],
-                "severity": 0,
-                "timestamp": "2021-02-08T14:32:06.492Z"
-            },
-            {
-                "name": "ruleEvaluate",
-                "properties": [
-                    {
-                        "name": "typeId",
-                        "value": "checksum-staging"
-                    }
-                ],
-                "severity": 0,
-                "timestamp": "2021-02-08T14:32:12.415Z"
-            },
-            {
-                "name": "rulePassed",
-                "properties": [
-                    {
-                        "name": "typeId",
-                        "value": "checksum-staging"
-                    }
-                ],
-                "severity": 0,
-                "timestamp": "2021-02-08T14:32:13.568Z"
-            },
-            {
-                "name": "ruleEvaluate",
-                "properties": [
-                    {
-                        "name": "typeId",
-                        "value": "signature-staging"
-                    }
-                ],
-                "severity": 0,
-                "timestamp": "2021-02-08T14:32:18.288Z"
-            },
-            {
-                "name": "ruleFailed",
-                "properties": [
-                    {
-                        "name": "typeId",
-                        "value": "signature-staging"
-                    },
-                    {
-                        "name": "failureMessage",
-                        "value": "Missing Signature: '/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.asc' does not exist for 'module-one-1.0.0-javadoc.jar'."
-                    },
-                    {
-                        "name": "failureMessage",
-                        "value": "Missing Signature: '/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.asc' does not exist for 'module-one-1.0.0.jar'."
-                    },
-                    {
-                        "name": "failureMessage",
-                        "value": "Missing Signature: '/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.asc' does not exist for 'module-one-1.0.0-sources.jar'."
-                    },
-                    {
-                        "name": "failureMessage",
-                        "value": "Missing Signature: '/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.asc' does not exist for 'module-one-1.0.0.module'."
-                    },
-                    {
-                        "name": "failureMessage",
-                        "value": "Missing Signature: '/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.asc' does not exist for 'module-one-1.0.0.pom'."
-                    },
-                    {
-                        "name": "failureMessage",
-                        "value": "Missing Signature: '/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.asc' does not exist for 'module-two-1.0.0.module'."
-                    },
-                    {
-                        "name": "failureMessage",
-                        "value": "Missing Signature: '/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.asc' does not exist for 'module-two-1.0.0.pom'."
-                    },
-                    {
-                        "name": "failureMessage",
-                        "value": "Missing Signature: '/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.asc' does not exist for 'module-two-1.0.0-sources.jar'."
-                    },
-                    {
-                        "name": "failureMessage",
-                        "value": "Missing Signature: '/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.asc' does not exist for 'module-two-1.0.0.jar'."
-                    },
-                    {
-                        "name": "failureMessage",
-                        "value": "Missing Signature: '/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.asc' does not exist for 'module-two-1.0.0-javadoc.jar'."
-                    },
-                    {
-                        "name": "failureMessage",
-                        "value": "Missing Signature: '/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.asc' does not exist for 'module-three-1.0.0.module'."
-                    },
-                    {
-                        "name": "failureMessage",
-                        "value": "Missing Signature: '/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.asc' does not exist for 'module-three-1.0.0-javadoc.jar'."
-                    },
-                    {
-                        "name": "failureMessage",
-                        "value": "Missing Signature: '/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.asc' does not exist for 'module-three-1.0.0-sources.jar'."
-                    },
-                    {
-                        "name": "failureMessage",
-                        "value": "Missing Signature: '/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.asc' does not exist for 'module-three-1.0.0.jar'."
-                    },
-                    {
-                        "name": "failureMessage",
-                        "value": "Missing Signature: '/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.asc' does not exist for 'module-three-1.0.0.pom'."
-                    }
-                ],
-                "severity": 1,
-                "timestamp": "2021-02-08T14:32:19.443Z"
-            },
-            {
-                "name": "ruleEvaluate",
-                "properties": [
-                    {
-                        "name": "typeId",
-                        "value": "sources-staging"
-                    }
-                ],
-                "severity": 0,
-                "timestamp": "2021-02-08T14:32:24.175Z"
-            },
-            {
-                "name": "rulePassed",
-                "properties": [
-                    {
-                        "name": "typeId",
-                        "value": "sources-staging"
-                    }
-                ],
-                "severity": 0,
-                "timestamp": "2021-02-08T14:32:28.940Z"
-            },
-            {
-                "name": "ruleEvaluate",
-                "properties": [
-                    {
-                        "name": "typeId",
-                        "value": "sbom-report"
-                    }
-                ],
-                "severity": 0,
-                "timestamp": "2021-02-08T14:32:34.906Z"
-            },
-            {
-                "name": "rulePassed",
-                "properties": [
-                    {
-                        "name": "typeId",
-                        "value": "sbom-report"
-                    },
-                    {
-                        "name": "successMessage",
-                        "value": "Successfully requested SBOM report"
-                    }
-                ],
-                "severity": 0,
-                "timestamp": "2021-02-08T14:32:36.520Z"
-            },
-            {
-                "name": "rulesFailed",
-                "properties": [
-                    {
-                        "name": "id",
-                        "value": "5e9e8e6f8d20a3"
-                    },
-                    {
-                        "name": "failureCount",
-                        "value": "2"
-                    }
-                ],
-                "severity": 1,
-                "timestamp": "2021-02-08T14:32:42.068Z"
-            },
-            {
-                "name": "repositoryCloseFailed",
-                "properties": [
-                    {
-                        "name": "id",
-                        "value": "orgspringframework-7161"
-                    },
-                    {
-                        "name": "cause",
-                        "value": "com.sonatype.nexus.staging.StagingRulesFailedException: One or more rules have failed"
-                    }
-                ],
-                "severity": 1,
-                "timestamp": "2021-02-08T14:32:43.218Z"
-            }
-        ],
-        "name": "close",
-        "started": "2021-02-08T14:31:34.943Z",
-        "startedByIpAddress": "127.0.0.1",
-        "startedByUserId": "user",
-        "stopped": "2021-02-08T14:32:47.138Z"
-    }
-]
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/build-info.json b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/build-info.json
deleted file mode 100644
index bfe0d3be186b..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/build-info.json
+++ /dev/null
@@ -1,35 +0,0 @@
-{
-  "buildInfo": {
-    "version": "1.0.1",
-    "name": "example",
-    "number": "example-build-1",
-    "started": "2019-09-10T12:18:05.430+0000",
-    "durationMillis": 0,
-    "artifactoryPrincipal": "user",
-    "url": "https://my-ci.com",
-    "modules": [
-      {
-        "id": "org.example.demo:demo:2.2.0",
-        "artifacts": [
-          {
-            "type": "jar",
-            "sha1": "ayyyya9151a22cb3145538e523dbbaaaaaaaa",
-            "sha256": "aaaaaaaaa85f5c5093721f3ed0edda8ff8290yyyyyyyyyy",
-            "md5": "aaaaaacddea1724b0b69d8yyyyyyy",
-            "name": "demo-2.2.0.jar"
-          }
-        ]
-      }
-    ],
-    "statuses": [
-      {
-        "status": "staged",
-        "repository": "libs-release-local",
-        "timestamp": "2019-09-10T12:42:24.716+0000",
-        "user": "user",
-        "timestampDate": 1568119344716
-      }
-    ]
-  },
-  "uri": "https://my-artifactory-repo.com/api/build/example/example-build-1"
-}
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar
deleted file mode 100644
index a142d391c0af..000000000000
Binary files a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar and /dev/null differ
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.asc
deleted file mode 100644
index 85d6a7cb1e5a..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT3qtgf8CeDvxzi7lPghlrZtmYTTjaic4FZsGRWTPky3H24i4wSRDhG0L5sj4uPK
-eLLlITr5a9j26UCas9HRSthiC8+EgIMAhSN0X482SQhUZHAW67ErIvaHlwL+ixMD
-0T5pmsW8PKN3lV1TFMhNYSEC2GRG/4GF+3yQA8LR+BgeEu/E5nmysIH8vuQMkOD6
-3pKA8VKNBml591j6UTqxoHtPX+rThaziz3Hy3+ekf5iWslllTTGPd2SWqTvnj2Ae
-GvRzsbli+FEM0Aj/v8jUQnQzOz891QSvWR+fMfCqZimiJMc+GBzJ9umbcyQsB5tY
-e26mAoYd9KEpGXMKN4biHbJZNp1GGw==
-=x/MY
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.md5 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.md5
deleted file mode 100644
index 95fa4af1641f..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.md5
+++ /dev/null
@@ -1 +0,0 @@
-e84da489be91de821c95d41b8f0e0a0a
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.md5.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.md5.asc
deleted file mode 100644
index 1cbf6ccffa07..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.md5.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT1PSwf+InTdlh+BVVy3RFszEL5vN7waSPYMImXhgd5EZ1d4Lxd6EeOwtNgWNKpG
-E+Ps/Kw0jZDvoD49WJlcUjzDHBNHcE7C/L3GAWHV6WwklhtQaJ4EegsynWdSXz6k
-fqJY6r58aGKGjpKPutRWAjvfcdC170+ZRsc2oi9xrAgHCpvXzTjq4+O9Ah0t5jwW
-jcZ/Xubcw4vjsw774OucHbtwGsvRN5SDJ3IONOH8WCwhUP5vEEKvA6MYX0KGoTdS
-3wTCyZTzU3qtTWxcbTCpiJIWbYwRR7TzLB/uydWHlAMzuz6coIiBpYsGiO6wkmfg
-W+QvcE7wyW2jtb22pCImLyObyZ21VA==
-=VjDv
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.sha1 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.sha1
deleted file mode 100644
index 2a2834e1ab41..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.sha1
+++ /dev/null
@@ -1 +0,0 @@
-8992b17455ce660da9c5fe47226b7ded9e872637
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.sha1.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.sha1.asc
deleted file mode 100644
index 4cd212d6e1e9..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.sha1.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT2HQgf+MTUEnwzXK4zi76VI7C5cchGan26lIA2Ebq4vtYGKDqNSISOAAdWs9+nT
-U6ZA6OIFo5qdeD6F/45s91IoDbxbhMDMFEsSijKASqiuZN5TZM1U2h2kWFAl/sEl
-EI1RTygn+xDw/ah4V3/duuMFC+jRgvJ/LgemIF4KBvECWaTQKNu0fu5d4dPXMpp+
-jrxMEZPQZsivpOvklzV8O7wAkf/ZQhJdcB2m8uOfSPlJ91a4EEtXF9/GzzkXUi1P
-bzt4NsmOag3227B3mO1Bc6yZdDBNu8wQ9apiJVCpqsxB9Dz0PCL4dHNa1u9g6Xo6
-ElRgneV4HZp+LB125VoNabKuNH00bw==
-=2yDl
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.sha256 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.sha256
deleted file mode 100644
index 4f27f01046dd..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.sha256
+++ /dev/null
@@ -1 +0,0 @@
-10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.sha256.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.sha256.asc
deleted file mode 100644
index e29629cbec07..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.sha256.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT0ScQf7Bip+quFq1CzDCTDxUhdTTOIpQcCfMKo1Jegpa2Hlm63XuK+6zVI9u6S+
-dBXPdYeneMOqMPQUAaw3M06ZqohaDOt8Ti1EiQFGXCdOvbonTA52Lrd4EEZxwNnK
-BdPuIh/8qCfozm5KbZe1bFyGVRAdNyf27KvzHgfBTirLtI+3MiOdL4bvNZbWRPfh
-J84Ko+Ena8jPFgyz6nJv2Q2U/V3dCooLJAXs2vEG6owwk5J9zvSysWpHaJbXas5v
-KXO9TOBBjf3+vxb1WVQa8ZYUU3+FIFes0RFVgOWghJXIooOcWrwOV2Q8z9qWXwoK
-mMZ2oLS+z/7clXibK45KeRUeCX5DvQ==
-=5oO1
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.sha512 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.sha512
deleted file mode 100644
index eb02a04a89d2..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.sha512
+++ /dev/null
@@ -1 +0,0 @@
-2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.sha512.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.sha512.asc
deleted file mode 100644
index f35b726baff6..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.sha512.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT3d+AgAwQvlwnKQLLuiZADGL+I922/YG317N2Re1EjC6WlMRZUKXH54fckRTyPm
-4ZLyxVHy8LlUD2Q10g69opb7HRd/tV0miBJhn5OU1wIM3hqTgxNp9EFckK4md45k
-osnhQJNDsFToxJL8zPP+KRs/aWPZs+FrRcH6k26lwLl2gTfyBDsaU11HFRVEN9yi
-X41obVyKiVNlc9efSSvlLtRBSVt0VhAFhck+3t61H6D9H09QxaDGAqmduDua3Tg3
-t5eqURuDfv3TfSztYgK3JBmG/6gVMsZodCgyC+8rhDDs6vSoDG30apx5Leg2rPbj
-xuk2wi/WNzc94IgY9tVS3tAfT2k6yQ==
-=6+Cv
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar
deleted file mode 100644
index a142d391c0af..000000000000
Binary files a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar and /dev/null differ
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.asc
deleted file mode 100644
index 85d6a7cb1e5a..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT3qtgf8CeDvxzi7lPghlrZtmYTTjaic4FZsGRWTPky3H24i4wSRDhG0L5sj4uPK
-eLLlITr5a9j26UCas9HRSthiC8+EgIMAhSN0X482SQhUZHAW67ErIvaHlwL+ixMD
-0T5pmsW8PKN3lV1TFMhNYSEC2GRG/4GF+3yQA8LR+BgeEu/E5nmysIH8vuQMkOD6
-3pKA8VKNBml591j6UTqxoHtPX+rThaziz3Hy3+ekf5iWslllTTGPd2SWqTvnj2Ae
-GvRzsbli+FEM0Aj/v8jUQnQzOz891QSvWR+fMfCqZimiJMc+GBzJ9umbcyQsB5tY
-e26mAoYd9KEpGXMKN4biHbJZNp1GGw==
-=x/MY
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.md5 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.md5
deleted file mode 100644
index 95fa4af1641f..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.md5
+++ /dev/null
@@ -1 +0,0 @@
-e84da489be91de821c95d41b8f0e0a0a
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.md5.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.md5.asc
deleted file mode 100644
index 1cbf6ccffa07..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.md5.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT1PSwf+InTdlh+BVVy3RFszEL5vN7waSPYMImXhgd5EZ1d4Lxd6EeOwtNgWNKpG
-E+Ps/Kw0jZDvoD49WJlcUjzDHBNHcE7C/L3GAWHV6WwklhtQaJ4EegsynWdSXz6k
-fqJY6r58aGKGjpKPutRWAjvfcdC170+ZRsc2oi9xrAgHCpvXzTjq4+O9Ah0t5jwW
-jcZ/Xubcw4vjsw774OucHbtwGsvRN5SDJ3IONOH8WCwhUP5vEEKvA6MYX0KGoTdS
-3wTCyZTzU3qtTWxcbTCpiJIWbYwRR7TzLB/uydWHlAMzuz6coIiBpYsGiO6wkmfg
-W+QvcE7wyW2jtb22pCImLyObyZ21VA==
-=VjDv
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.sha1 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.sha1
deleted file mode 100644
index 2a2834e1ab41..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.sha1
+++ /dev/null
@@ -1 +0,0 @@
-8992b17455ce660da9c5fe47226b7ded9e872637
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.sha1.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.sha1.asc
deleted file mode 100644
index 4cd212d6e1e9..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.sha1.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT2HQgf+MTUEnwzXK4zi76VI7C5cchGan26lIA2Ebq4vtYGKDqNSISOAAdWs9+nT
-U6ZA6OIFo5qdeD6F/45s91IoDbxbhMDMFEsSijKASqiuZN5TZM1U2h2kWFAl/sEl
-EI1RTygn+xDw/ah4V3/duuMFC+jRgvJ/LgemIF4KBvECWaTQKNu0fu5d4dPXMpp+
-jrxMEZPQZsivpOvklzV8O7wAkf/ZQhJdcB2m8uOfSPlJ91a4EEtXF9/GzzkXUi1P
-bzt4NsmOag3227B3mO1Bc6yZdDBNu8wQ9apiJVCpqsxB9Dz0PCL4dHNa1u9g6Xo6
-ElRgneV4HZp+LB125VoNabKuNH00bw==
-=2yDl
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.sha256 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.sha256
deleted file mode 100644
index 4f27f01046dd..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.sha256
+++ /dev/null
@@ -1 +0,0 @@
-10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.sha256.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.sha256.asc
deleted file mode 100644
index e29629cbec07..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.sha256.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT0ScQf7Bip+quFq1CzDCTDxUhdTTOIpQcCfMKo1Jegpa2Hlm63XuK+6zVI9u6S+
-dBXPdYeneMOqMPQUAaw3M06ZqohaDOt8Ti1EiQFGXCdOvbonTA52Lrd4EEZxwNnK
-BdPuIh/8qCfozm5KbZe1bFyGVRAdNyf27KvzHgfBTirLtI+3MiOdL4bvNZbWRPfh
-J84Ko+Ena8jPFgyz6nJv2Q2U/V3dCooLJAXs2vEG6owwk5J9zvSysWpHaJbXas5v
-KXO9TOBBjf3+vxb1WVQa8ZYUU3+FIFes0RFVgOWghJXIooOcWrwOV2Q8z9qWXwoK
-mMZ2oLS+z/7clXibK45KeRUeCX5DvQ==
-=5oO1
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.sha512 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.sha512
deleted file mode 100644
index eb02a04a89d2..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.sha512
+++ /dev/null
@@ -1 +0,0 @@
-2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.sha512.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.sha512.asc
deleted file mode 100644
index f35b726baff6..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.sha512.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT3d+AgAwQvlwnKQLLuiZADGL+I922/YG317N2Re1EjC6WlMRZUKXH54fckRTyPm
-4ZLyxVHy8LlUD2Q10g69opb7HRd/tV0miBJhn5OU1wIM3hqTgxNp9EFckK4md45k
-osnhQJNDsFToxJL8zPP+KRs/aWPZs+FrRcH6k26lwLl2gTfyBDsaU11HFRVEN9yi
-X41obVyKiVNlc9efSSvlLtRBSVt0VhAFhck+3t61H6D9H09QxaDGAqmduDua3Tg3
-t5eqURuDfv3TfSztYgK3JBmG/6gVMsZodCgyC+8rhDDs6vSoDG30apx5Leg2rPbj
-xuk2wi/WNzc94IgY9tVS3tAfT2k6yQ==
-=6+Cv
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar
deleted file mode 100644
index a142d391c0af..000000000000
Binary files a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar and /dev/null differ
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.asc
deleted file mode 100644
index 85d6a7cb1e5a..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT3qtgf8CeDvxzi7lPghlrZtmYTTjaic4FZsGRWTPky3H24i4wSRDhG0L5sj4uPK
-eLLlITr5a9j26UCas9HRSthiC8+EgIMAhSN0X482SQhUZHAW67ErIvaHlwL+ixMD
-0T5pmsW8PKN3lV1TFMhNYSEC2GRG/4GF+3yQA8LR+BgeEu/E5nmysIH8vuQMkOD6
-3pKA8VKNBml591j6UTqxoHtPX+rThaziz3Hy3+ekf5iWslllTTGPd2SWqTvnj2Ae
-GvRzsbli+FEM0Aj/v8jUQnQzOz891QSvWR+fMfCqZimiJMc+GBzJ9umbcyQsB5tY
-e26mAoYd9KEpGXMKN4biHbJZNp1GGw==
-=x/MY
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.md5 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.md5
deleted file mode 100644
index 95fa4af1641f..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.md5
+++ /dev/null
@@ -1 +0,0 @@
-e84da489be91de821c95d41b8f0e0a0a
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.md5.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.md5.asc
deleted file mode 100644
index 1cbf6ccffa07..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.md5.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT1PSwf+InTdlh+BVVy3RFszEL5vN7waSPYMImXhgd5EZ1d4Lxd6EeOwtNgWNKpG
-E+Ps/Kw0jZDvoD49WJlcUjzDHBNHcE7C/L3GAWHV6WwklhtQaJ4EegsynWdSXz6k
-fqJY6r58aGKGjpKPutRWAjvfcdC170+ZRsc2oi9xrAgHCpvXzTjq4+O9Ah0t5jwW
-jcZ/Xubcw4vjsw774OucHbtwGsvRN5SDJ3IONOH8WCwhUP5vEEKvA6MYX0KGoTdS
-3wTCyZTzU3qtTWxcbTCpiJIWbYwRR7TzLB/uydWHlAMzuz6coIiBpYsGiO6wkmfg
-W+QvcE7wyW2jtb22pCImLyObyZ21VA==
-=VjDv
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.sha1 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.sha1
deleted file mode 100644
index 2a2834e1ab41..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.sha1
+++ /dev/null
@@ -1 +0,0 @@
-8992b17455ce660da9c5fe47226b7ded9e872637
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.sha1.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.sha1.asc
deleted file mode 100644
index 4cd212d6e1e9..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.sha1.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT2HQgf+MTUEnwzXK4zi76VI7C5cchGan26lIA2Ebq4vtYGKDqNSISOAAdWs9+nT
-U6ZA6OIFo5qdeD6F/45s91IoDbxbhMDMFEsSijKASqiuZN5TZM1U2h2kWFAl/sEl
-EI1RTygn+xDw/ah4V3/duuMFC+jRgvJ/LgemIF4KBvECWaTQKNu0fu5d4dPXMpp+
-jrxMEZPQZsivpOvklzV8O7wAkf/ZQhJdcB2m8uOfSPlJ91a4EEtXF9/GzzkXUi1P
-bzt4NsmOag3227B3mO1Bc6yZdDBNu8wQ9apiJVCpqsxB9Dz0PCL4dHNa1u9g6Xo6
-ElRgneV4HZp+LB125VoNabKuNH00bw==
-=2yDl
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.sha256 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.sha256
deleted file mode 100644
index 4f27f01046dd..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.sha256
+++ /dev/null
@@ -1 +0,0 @@
-10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.sha256.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.sha256.asc
deleted file mode 100644
index e29629cbec07..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.sha256.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT0ScQf7Bip+quFq1CzDCTDxUhdTTOIpQcCfMKo1Jegpa2Hlm63XuK+6zVI9u6S+
-dBXPdYeneMOqMPQUAaw3M06ZqohaDOt8Ti1EiQFGXCdOvbonTA52Lrd4EEZxwNnK
-BdPuIh/8qCfozm5KbZe1bFyGVRAdNyf27KvzHgfBTirLtI+3MiOdL4bvNZbWRPfh
-J84Ko+Ena8jPFgyz6nJv2Q2U/V3dCooLJAXs2vEG6owwk5J9zvSysWpHaJbXas5v
-KXO9TOBBjf3+vxb1WVQa8ZYUU3+FIFes0RFVgOWghJXIooOcWrwOV2Q8z9qWXwoK
-mMZ2oLS+z/7clXibK45KeRUeCX5DvQ==
-=5oO1
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.sha512 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.sha512
deleted file mode 100644
index eb02a04a89d2..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.sha512
+++ /dev/null
@@ -1 +0,0 @@
-2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.sha512.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.sha512.asc
deleted file mode 100644
index f35b726baff6..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.sha512.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT3d+AgAwQvlwnKQLLuiZADGL+I922/YG317N2Re1EjC6WlMRZUKXH54fckRTyPm
-4ZLyxVHy8LlUD2Q10g69opb7HRd/tV0miBJhn5OU1wIM3hqTgxNp9EFckK4md45k
-osnhQJNDsFToxJL8zPP+KRs/aWPZs+FrRcH6k26lwLl2gTfyBDsaU11HFRVEN9yi
-X41obVyKiVNlc9efSSvlLtRBSVt0VhAFhck+3t61H6D9H09QxaDGAqmduDua3Tg3
-t5eqURuDfv3TfSztYgK3JBmG/6gVMsZodCgyC+8rhDDs6vSoDG30apx5Leg2rPbj
-xuk2wi/WNzc94IgY9tVS3tAfT2k6yQ==
-=6+Cv
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module
deleted file mode 100644
index 6a92fa0a1afb..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module
+++ /dev/null
@@ -1,101 +0,0 @@
-{
-  "formatVersion": "1.1",
-  "component": {
-    "group": "org.springframework.example",
-    "module": "module-one",
-    "version": "1.0.0",
-    "attributes": {
-      "org.gradle.status": "release"
-    }
-  },
-  "createdBy": {
-    "gradle": {
-      "version": "6.5.1",
-      "buildId": "mvqepqsdqjcahjl7cii6b6ucoe"
-    }
-  },
-  "variants": [
-    {
-      "name": "apiElements",
-      "attributes": {
-        "org.gradle.category": "library",
-        "org.gradle.dependency.bundling": "external",
-        "org.gradle.jvm.version": 8,
-        "org.gradle.libraryelements": "jar",
-        "org.gradle.usage": "java-api"
-      },
-      "files": [
-        {
-          "name": "module-one-1.0.0.jar",
-          "url": "module-one-1.0.0.jar",
-          "size": 261,
-          "sha512": "2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00",
-          "sha256": "10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385",
-          "sha1": "8992b17455ce660da9c5fe47226b7ded9e872637",
-          "md5": "e84da489be91de821c95d41b8f0e0a0a"
-        }
-      ]
-    },
-    {
-      "name": "runtimeElements",
-      "attributes": {
-        "org.gradle.category": "library",
-        "org.gradle.dependency.bundling": "external",
-        "org.gradle.jvm.version": 8,
-        "org.gradle.libraryelements": "jar",
-        "org.gradle.usage": "java-runtime"
-      },
-      "files": [
-        {
-          "name": "module-one-1.0.0.jar",
-          "url": "module-one-1.0.0.jar",
-          "size": 261,
-          "sha512": "2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00",
-          "sha256": "10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385",
-          "sha1": "8992b17455ce660da9c5fe47226b7ded9e872637",
-          "md5": "e84da489be91de821c95d41b8f0e0a0a"
-        }
-      ]
-    },
-    {
-      "name": "javadocElements",
-      "attributes": {
-        "org.gradle.category": "documentation",
-        "org.gradle.dependency.bundling": "external",
-        "org.gradle.docstype": "javadoc",
-        "org.gradle.usage": "java-runtime"
-      },
-      "files": [
-        {
-          "name": "module-one-1.0.0-javadoc.jar",
-          "url": "module-one-1.0.0-javadoc.jar",
-          "size": 261,
-          "sha512": "2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00",
-          "sha256": "10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385",
-          "sha1": "8992b17455ce660da9c5fe47226b7ded9e872637",
-          "md5": "e84da489be91de821c95d41b8f0e0a0a"
-        }
-      ]
-    },
-    {
-      "name": "sourcesElements",
-      "attributes": {
-        "org.gradle.category": "documentation",
-        "org.gradle.dependency.bundling": "external",
-        "org.gradle.docstype": "sources",
-        "org.gradle.usage": "java-runtime"
-      },
-      "files": [
-        {
-          "name": "module-one-1.0.0-sources.jar",
-          "url": "module-one-1.0.0-sources.jar",
-          "size": 261,
-          "sha512": "2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00",
-          "sha256": "10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385",
-          "sha1": "8992b17455ce660da9c5fe47226b7ded9e872637",
-          "md5": "e84da489be91de821c95d41b8f0e0a0a"
-        }
-      ]
-    }
-  ]
-}
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.asc
deleted file mode 100644
index 54d7598382da..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT1HBQf/fCBHR+fpZjkcgonkAVWcGvRx5kRHlsCISs64XMw90++DTawoKxr9/TvY
-fltQlq/xaf+2O2Xzh9HIymtZBeKp7a4fWQ2AHf/ygkGyIKvy8h+mu3MGDdmHZeA4
-fn9FGjaE0a/wYJmCEHJ1qJ4GaNq47gzRTu76jzZNafnNRlq1rlyVu2txnlks6xDr
-oE8EnRT86Y67Ku8YArjkhZSHhf/tzSSwdTAgBinh6eba5tW5ueRXfsheqgtpJMov
-hiDIVxuAlJoHy2cQ8L9+8geg0OSXLwQ9BXrBsDCLvrDauU735/Hv/NGrWE95kemw
-Ay9jCXhXFWKkzCw2ps3QHTTpTK4aVw==
-=1QME
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.md5 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.md5
deleted file mode 100644
index 7da5a1024ebb..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.md5
+++ /dev/null
@@ -1 +0,0 @@
-b5b2aedb082633674ef9308a2ac21934
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.md5.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.md5.asc
deleted file mode 100644
index f525064e6acc..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.md5.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT1SLQgApB6OWW9cgtaofOu3HwgsVxaxLYPsDf057m2O5pI6uV5Ikyt97B1txjTB
-9EXcy4gsfu7rxwgRHIEPCQkQKhhZioscT1SPFN0yopCwsJEvxrJE018ojyaIem/L
-KVcbtiBVMj3GZCbS0DHpwZNx2u7yblyBqUGhCMKLkYqVL7nUHJKtECECs5jbJnb9
-xXGFe0xlZ/IbkHv5QXyStgUYCah7ayWQDvjN7UJrpJL1lmTD0rjWLilkeKsVu3/k
-11cZb5YdOmrL9a+8ql1jXPkma3HPjoIPRC5LB2BnloduwEPsiiLGG7Cs8UFEJNjQ
-m5w+l4dDd03y5ioaW8fI/meAKpBm4g==
-=gwLM
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.sha1 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.sha1
deleted file mode 100644
index f4d48063e987..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.sha1
+++ /dev/null
@@ -1 +0,0 @@
-b7cb6c2ce7fbb98b8eb502c3ef8fcab0dd4880fc
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.sha1.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.sha1.asc
deleted file mode 100644
index 0c9e8cb57007..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.sha1.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT2y5AgAlI4H5hwDIgVmXtRq/ri7kxEJnC9L9FOv8aE9YasHAruaU1YR5m17Jncl
-4guJHc+gSd3BiSx1rsI6PNxLACabw4Vy56eCRpmiFWeIkoCETBUk8AN25Q/1tzgw
-hHmIRgOkF9PzSBWDTUNsyx/7E9P2QSiJOkMAGGuMKGDpYTR9zmaluzwfY+BI/VoW
-BbZpdzt02OGQosWmA7DlwkXUwip6iBjga79suUFIsyH0hmRW2q/nCeJ04ttzXUog
-NTNkpEwMYpZAzQXE7ks7WJJlAPkVYPWy/j5YCV7xTFb9I/56ux+/wRUaGU5fumSR
-lr3PNoYNToC/4GLX6Kc2OH0e1LXNTQ==
-=s02D
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.sha256 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.sha256
deleted file mode 100644
index cdc919db3db6..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.sha256
+++ /dev/null
@@ -1 +0,0 @@
-4ef7e1ba5fda72b0168c9aab4746ec6ee57fb73020c49fe2f49251187aaab074
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.sha256.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.sha256.asc
deleted file mode 100644
index 94ab1b8db014..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.sha256.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT1/vwgAhUTLKjxmry4W3cVdfX/D/vxDTLAp5OxwJy36CZmJwsVuN9TLjPo4tRqq
-woiopR2oSTaJqld2pe98WlIeDJJRe4ta1Uwvg7k4Sf6YaZXm01Wufk4a835sFUwY
-BTWmnFYX0+dp5mLyXZmZjrAr5Q2bowRuqZd2DAYiNY/E5MH2T7OAJE2hCOHUpCaB
-JVeP7HcbaGYR3NX/mLq0t8+xjTPXQk/OHijuusuLQxfLZvZiaikDoOHUD6l0dlRw
-xcLTghG5+jd1q7noKAbUVgoEOshstfomCHZpPMj11c7KIuG1+3wRMdm+F67lkcJ5
-eDW2fmF+6LYr+WlEi33rDIyTk3GhlQ==
-=mHUe
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.sha512 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.sha512
deleted file mode 100644
index 76ab9ddba9cd..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.sha512
+++ /dev/null
@@ -1 +0,0 @@
-29b1bc06a150e4764826e35e2d2541933b0583ce823f5b00c02effad9f37f02f0d2eef1c81214d69eaf74220e1f77332c5e6a91eb413a3022b5a8a1d7914c4c3
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.sha512.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.sha512.asc
deleted file mode 100644
index 8041ff51f1f1..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.sha512.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT0QLAf/ffTpTfH4IebklGJIKZC8ZjRt4CgwpR431qNeWkY25cHmWFj48x2u9dmS
-ZpxN572d3PPjcMigT/9wM05omiU+4DHxGgHq/Xj6GXN1DNaENcu7uoye96thjKPv
-jz98tPIRMC9hYr3m/K1CJ3+ZG0++7JorCZRpodH/MhklRWXOvNszs81VWtgvMnpd
-h9r0PuoaYBl6bIl19o7E3JJU6dKgwfre4b+a1RSYI+A8bmJOKMgHytAKi+804r0P
-4R2WuQT4q+dSmkMtgp65vJ9giv/xuFrd1bT4n+qcDkwE8pTcWvsB4w1RkDOKs4fK
-/ta5xBQ1hiKAd6nJffke1b0MBrZOrA==
-=ZMpE
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom
deleted file mode 100644
index d618d6e120e5..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom
+++ /dev/null
@@ -1,49 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
-    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
-  <!-- This module was also published with a richer model, Gradle metadata,  -->
-  <!-- which should be used instead. Do not delete the following line which  -->
-  <!-- is to indicate to Gradle or any Gradle module metadata file consumer  -->
-  <!-- that they should prefer consuming it instead. -->
-  <!-- do_not_remove: published-with-gradle-metadata -->
-  <modelVersion>4.0.0</modelVersion>
-  <groupId>org.springframework.example</groupId>
-  <artifactId>module-one</artifactId>
-  <version>1.0.0</version>
-  <name>module-one</name>
-  <description>Example module</description>
-  <url>https://spring.io/projects/spring-boot</url>
-  <organization>
-    <name>Spring</name>
-    <url>https://spring.io</url>
-  </organization>
-  <licenses>
-    <license>
-      <name>Apache License, Version 2.0</name>
-      <url>https://www.apache.org/licenses/LICENSE-2.0</url>
-    </license>
-  </licenses>
-  <developers>
-    <developer>
-      <name>Spring</name>
-      <email>ask@spring.io</email>
-      <organization>Spring</organization>
-      <organizationUrl>https://www.spring.io</organizationUrl>
-    </developer>
-  </developers>
-  <scm>
-    <connection>
-      scm:git:git://github.com/spring-projects/spring-boot.git
-    </connection>
-    <developerConnection>
-      scm:git:ssh://git@github.com/spring-projects/spring-boot.git
-    </developerConnection>
-    <url>https://github.com/spring-projects/spring-boot</url>
-  </scm>
-  <issueManagement>
-    <system>GitHub</system>
-    <url>
-      https://github.com/spring-projects/spring-boot/issues
-    </url>
-  </issueManagement>
-</project>
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.asc
deleted file mode 100644
index eba13f46b597..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT04rwgAwJHic8GGHFZ+UAJYLW/OxJOVyd0ebx4yT5zAyTjyvxnrlKmKZ6GP/NhZ
-htJQnZez85lUKA0TsMvl/6H2iEhKOns6HgqY3PLFkKNRKOq601phtD9HCkxDibWB
-UDT01I0q2xNOljD03lhfytefnSnZ96AaySol2v5DBIZsOKWGir0/8KJCpEQJHjCF
-TwNk8lNF3moGlO4zUfoBbkSZ+J0J8Bq5QI3nIAWFYxHcrZ2YGsAZd48kux8x2V3C
-c6QsYEonmztqxop76a7K8Gv+MDmo/u/vqM8z5C63/WpOoDtRG+F5vtPkhCrR6M5f
-ygubQUy5TL+dWdHE8zgA2O9hZuoHEg==
-=bkxG
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.md5 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.md5
deleted file mode 100644
index d82ed4d3a5d3..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.md5
+++ /dev/null
@@ -1 +0,0 @@
-48776112e8bc3ca36b6392b0e9d6d619
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.md5.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.md5.asc
deleted file mode 100644
index 78c3a0a5f668..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.md5.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT0XEAf+O9a/29MIWBtj1oLxIT1LLdzTU68qt5+qW+58SNQmMxu0MaESW4GZOc3p
-mTV0EJyxUkCLJyoqOY4/GhqBAm33mMZSY8BQtvUZPYxpbJwBo+pE8YfnH3n1v20P
-4pS4oJKekXAhTqShpx5oFjCK4J3chaz+Xc8Ldm1DXakCRc1bc/YYZ+87sy2z+PXk
-PmN3KPcc/XjH4GPjmVUR8vR1TGUjUMQGvbAdrgkjFyaCGNvyreuHLsAFWrFFbIOn
-/mB++enkXhmjWbiyvmvWQvtU0QFA4sRGYww0Lup1GRQ+00IqHF1QRMskqujAwmok
-+TuB3Zc9WuAERPre+Qr1DEevClNwAQ==
-=3beu
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.sha1 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.sha1
deleted file mode 100644
index 28dc2dadd344..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.sha1
+++ /dev/null
@@ -1 +0,0 @@
-a5cc75e17f8eccfc4ac30bfbb09f42d5f34ecbb1
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.sha1.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.sha1.asc
deleted file mode 100644
index 9d1d2ea54f3b..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.sha1.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT2aVAf+MQhSBr1jzcQE5mo1bMOXa4owaRr+dRir5Q4Gr7Fz4NuGoQEIzoL7XP5r
-0zIjebzworxCaw+JNyaIxniHNBeK3sPHTLeW8bCrdJLkhE9RtGdEHLyPYXwPuFin
-xVw3VQHWiA0uPM+JaekgdPDtK5wGFQ/AK3pc6vR108oT0kV4zQEqgRnvLqV9Q5zZ
-UPHBi5kypu1BmCW4upYL1dmjASWPn9Q8cNpHcX/NJPNJ9zW0yxAAtq4wLfh7PQml
-3EaHEYllsf8v1vMv00+zZNhc6O4BBP1qrRiaYHDAJhJjn6ctV9GFhJ2Ttxh/NmSy
-H679tlC2PeRjGMi8bOHBshcikn5KUw==
-=4aJI
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.sha256 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.sha256
deleted file mode 100644
index 8d1625bf07c2..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.sha256
+++ /dev/null
@@ -1 +0,0 @@
-3b31f2abf79368001e2fab02997446ac62db714b9db9cb78c4c542aa962485dc
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.sha256.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.sha256.asc
deleted file mode 100644
index e572b776de94..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.sha256.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT0nDQgAlfchq7/W/wubx3IR3tQs0tKiix3nZIc97zuH6sR8+r+CJe78wbmSE9Oo
-/z96wfzeZYNIKh2v+dBLHF7OfcPGBE7tiX07jfCa6KzjjY3hFBhW+muMP/aBRb+4
-itSs6F3lkZOPW2+hpSdFQ6U8Rm81cAlZv7Zk2XswwTQkJo8GcNL1w/5wAVpNK0yG
-VinZr8YRMFs6OYQxLqGSypDLAmv9rOaJ7aCdaKnQwYES65kC7tbe0SRZGQoDe8n4
-XLzpvC8rM9MXZDEN4qI+ZAANOJNVsXUmDZLDSe4ak48u/cTOokY8I6bR2k/XOhbu
-L+D4W7oKAE9HmzlTMusosyjNOBQAmQ==
-=Wjji
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.sha512 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.sha512
deleted file mode 100644
index 6edd0b3e98f8..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.sha512
+++ /dev/null
@@ -1 +0,0 @@
-05bd8fd394a15b9dcc1bfaece0a63b0fdc2c3625a7e0aa5230fd3b5b75a8f8934a0af550b44437aa1486909058e84703e63fdec6f637d639d565b55bdaf1fa6c
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.sha512.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.sha512.asc
deleted file mode 100644
index 896fc8f31e59..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.sha512.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT19rwf/a6sZxSDNTxN72VvsrKsHq+wMes5UUcQ+L7e5QLjaCTx2ayW2FdHMBaNi
-IDBBE9kxnxa/S6G6nSRARUjXowsEYZGUNLLvUjNZ4Z3g2R9XyGPaz3Ky9yWpRm36
-E0lFqf8aaCLpzwV2z7cfeVNYsd2gnHakphK/UiZzXFz+GYzqby/0m5Kk8Zs7rK6V
-/ji0bYWUi8t1jli8MfTHQtM8EUHG0nXRfEKilyoYkO3UsTEh/UN1VRpJ5DgcRC8L
-Zbd2zPnV15MPUzZvz3kkycUulQdhOqTDjUod9P/WoASwjDuKCG2/kquwOvnoHXJ9
-9Ju+ca0s9y0jbotIygYxJXZVev3EiA==
-=oWIp
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar
deleted file mode 100644
index a142d391c0af..000000000000
Binary files a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar and /dev/null differ
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.asc
deleted file mode 100644
index 85d6a7cb1e5a..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT3qtgf8CeDvxzi7lPghlrZtmYTTjaic4FZsGRWTPky3H24i4wSRDhG0L5sj4uPK
-eLLlITr5a9j26UCas9HRSthiC8+EgIMAhSN0X482SQhUZHAW67ErIvaHlwL+ixMD
-0T5pmsW8PKN3lV1TFMhNYSEC2GRG/4GF+3yQA8LR+BgeEu/E5nmysIH8vuQMkOD6
-3pKA8VKNBml591j6UTqxoHtPX+rThaziz3Hy3+ekf5iWslllTTGPd2SWqTvnj2Ae
-GvRzsbli+FEM0Aj/v8jUQnQzOz891QSvWR+fMfCqZimiJMc+GBzJ9umbcyQsB5tY
-e26mAoYd9KEpGXMKN4biHbJZNp1GGw==
-=x/MY
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.md5 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.md5
deleted file mode 100644
index 95fa4af1641f..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.md5
+++ /dev/null
@@ -1 +0,0 @@
-e84da489be91de821c95d41b8f0e0a0a
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.md5.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.md5.asc
deleted file mode 100644
index 1cbf6ccffa07..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.md5.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT1PSwf+InTdlh+BVVy3RFszEL5vN7waSPYMImXhgd5EZ1d4Lxd6EeOwtNgWNKpG
-E+Ps/Kw0jZDvoD49WJlcUjzDHBNHcE7C/L3GAWHV6WwklhtQaJ4EegsynWdSXz6k
-fqJY6r58aGKGjpKPutRWAjvfcdC170+ZRsc2oi9xrAgHCpvXzTjq4+O9Ah0t5jwW
-jcZ/Xubcw4vjsw774OucHbtwGsvRN5SDJ3IONOH8WCwhUP5vEEKvA6MYX0KGoTdS
-3wTCyZTzU3qtTWxcbTCpiJIWbYwRR7TzLB/uydWHlAMzuz6coIiBpYsGiO6wkmfg
-W+QvcE7wyW2jtb22pCImLyObyZ21VA==
-=VjDv
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.sha1 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.sha1
deleted file mode 100644
index 2a2834e1ab41..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.sha1
+++ /dev/null
@@ -1 +0,0 @@
-8992b17455ce660da9c5fe47226b7ded9e872637
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.sha1.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.sha1.asc
deleted file mode 100644
index 4cd212d6e1e9..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.sha1.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT2HQgf+MTUEnwzXK4zi76VI7C5cchGan26lIA2Ebq4vtYGKDqNSISOAAdWs9+nT
-U6ZA6OIFo5qdeD6F/45s91IoDbxbhMDMFEsSijKASqiuZN5TZM1U2h2kWFAl/sEl
-EI1RTygn+xDw/ah4V3/duuMFC+jRgvJ/LgemIF4KBvECWaTQKNu0fu5d4dPXMpp+
-jrxMEZPQZsivpOvklzV8O7wAkf/ZQhJdcB2m8uOfSPlJ91a4EEtXF9/GzzkXUi1P
-bzt4NsmOag3227B3mO1Bc6yZdDBNu8wQ9apiJVCpqsxB9Dz0PCL4dHNa1u9g6Xo6
-ElRgneV4HZp+LB125VoNabKuNH00bw==
-=2yDl
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.sha256 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.sha256
deleted file mode 100644
index 4f27f01046dd..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.sha256
+++ /dev/null
@@ -1 +0,0 @@
-10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.sha256.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.sha256.asc
deleted file mode 100644
index e29629cbec07..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.sha256.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT0ScQf7Bip+quFq1CzDCTDxUhdTTOIpQcCfMKo1Jegpa2Hlm63XuK+6zVI9u6S+
-dBXPdYeneMOqMPQUAaw3M06ZqohaDOt8Ti1EiQFGXCdOvbonTA52Lrd4EEZxwNnK
-BdPuIh/8qCfozm5KbZe1bFyGVRAdNyf27KvzHgfBTirLtI+3MiOdL4bvNZbWRPfh
-J84Ko+Ena8jPFgyz6nJv2Q2U/V3dCooLJAXs2vEG6owwk5J9zvSysWpHaJbXas5v
-KXO9TOBBjf3+vxb1WVQa8ZYUU3+FIFes0RFVgOWghJXIooOcWrwOV2Q8z9qWXwoK
-mMZ2oLS+z/7clXibK45KeRUeCX5DvQ==
-=5oO1
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.sha512 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.sha512
deleted file mode 100644
index eb02a04a89d2..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.sha512
+++ /dev/null
@@ -1 +0,0 @@
-2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.sha512.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.sha512.asc
deleted file mode 100644
index f35b726baff6..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.sha512.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT3d+AgAwQvlwnKQLLuiZADGL+I922/YG317N2Re1EjC6WlMRZUKXH54fckRTyPm
-4ZLyxVHy8LlUD2Q10g69opb7HRd/tV0miBJhn5OU1wIM3hqTgxNp9EFckK4md45k
-osnhQJNDsFToxJL8zPP+KRs/aWPZs+FrRcH6k26lwLl2gTfyBDsaU11HFRVEN9yi
-X41obVyKiVNlc9efSSvlLtRBSVt0VhAFhck+3t61H6D9H09QxaDGAqmduDua3Tg3
-t5eqURuDfv3TfSztYgK3JBmG/6gVMsZodCgyC+8rhDDs6vSoDG30apx5Leg2rPbj
-xuk2wi/WNzc94IgY9tVS3tAfT2k6yQ==
-=6+Cv
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar
deleted file mode 100644
index a142d391c0af..000000000000
Binary files a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar and /dev/null differ
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.asc
deleted file mode 100644
index 85d6a7cb1e5a..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT3qtgf8CeDvxzi7lPghlrZtmYTTjaic4FZsGRWTPky3H24i4wSRDhG0L5sj4uPK
-eLLlITr5a9j26UCas9HRSthiC8+EgIMAhSN0X482SQhUZHAW67ErIvaHlwL+ixMD
-0T5pmsW8PKN3lV1TFMhNYSEC2GRG/4GF+3yQA8LR+BgeEu/E5nmysIH8vuQMkOD6
-3pKA8VKNBml591j6UTqxoHtPX+rThaziz3Hy3+ekf5iWslllTTGPd2SWqTvnj2Ae
-GvRzsbli+FEM0Aj/v8jUQnQzOz891QSvWR+fMfCqZimiJMc+GBzJ9umbcyQsB5tY
-e26mAoYd9KEpGXMKN4biHbJZNp1GGw==
-=x/MY
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.md5 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.md5
deleted file mode 100644
index 95fa4af1641f..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.md5
+++ /dev/null
@@ -1 +0,0 @@
-e84da489be91de821c95d41b8f0e0a0a
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.md5.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.md5.asc
deleted file mode 100644
index 1cbf6ccffa07..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.md5.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT1PSwf+InTdlh+BVVy3RFszEL5vN7waSPYMImXhgd5EZ1d4Lxd6EeOwtNgWNKpG
-E+Ps/Kw0jZDvoD49WJlcUjzDHBNHcE7C/L3GAWHV6WwklhtQaJ4EegsynWdSXz6k
-fqJY6r58aGKGjpKPutRWAjvfcdC170+ZRsc2oi9xrAgHCpvXzTjq4+O9Ah0t5jwW
-jcZ/Xubcw4vjsw774OucHbtwGsvRN5SDJ3IONOH8WCwhUP5vEEKvA6MYX0KGoTdS
-3wTCyZTzU3qtTWxcbTCpiJIWbYwRR7TzLB/uydWHlAMzuz6coIiBpYsGiO6wkmfg
-W+QvcE7wyW2jtb22pCImLyObyZ21VA==
-=VjDv
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.sha1 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.sha1
deleted file mode 100644
index 2a2834e1ab41..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.sha1
+++ /dev/null
@@ -1 +0,0 @@
-8992b17455ce660da9c5fe47226b7ded9e872637
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.sha1.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.sha1.asc
deleted file mode 100644
index 4cd212d6e1e9..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.sha1.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT2HQgf+MTUEnwzXK4zi76VI7C5cchGan26lIA2Ebq4vtYGKDqNSISOAAdWs9+nT
-U6ZA6OIFo5qdeD6F/45s91IoDbxbhMDMFEsSijKASqiuZN5TZM1U2h2kWFAl/sEl
-EI1RTygn+xDw/ah4V3/duuMFC+jRgvJ/LgemIF4KBvECWaTQKNu0fu5d4dPXMpp+
-jrxMEZPQZsivpOvklzV8O7wAkf/ZQhJdcB2m8uOfSPlJ91a4EEtXF9/GzzkXUi1P
-bzt4NsmOag3227B3mO1Bc6yZdDBNu8wQ9apiJVCpqsxB9Dz0PCL4dHNa1u9g6Xo6
-ElRgneV4HZp+LB125VoNabKuNH00bw==
-=2yDl
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.sha256 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.sha256
deleted file mode 100644
index 4f27f01046dd..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.sha256
+++ /dev/null
@@ -1 +0,0 @@
-10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.sha256.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.sha256.asc
deleted file mode 100644
index e29629cbec07..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.sha256.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT0ScQf7Bip+quFq1CzDCTDxUhdTTOIpQcCfMKo1Jegpa2Hlm63XuK+6zVI9u6S+
-dBXPdYeneMOqMPQUAaw3M06ZqohaDOt8Ti1EiQFGXCdOvbonTA52Lrd4EEZxwNnK
-BdPuIh/8qCfozm5KbZe1bFyGVRAdNyf27KvzHgfBTirLtI+3MiOdL4bvNZbWRPfh
-J84Ko+Ena8jPFgyz6nJv2Q2U/V3dCooLJAXs2vEG6owwk5J9zvSysWpHaJbXas5v
-KXO9TOBBjf3+vxb1WVQa8ZYUU3+FIFes0RFVgOWghJXIooOcWrwOV2Q8z9qWXwoK
-mMZ2oLS+z/7clXibK45KeRUeCX5DvQ==
-=5oO1
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.sha512 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.sha512
deleted file mode 100644
index eb02a04a89d2..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.sha512
+++ /dev/null
@@ -1 +0,0 @@
-2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.sha512.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.sha512.asc
deleted file mode 100644
index f35b726baff6..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.sha512.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT3d+AgAwQvlwnKQLLuiZADGL+I922/YG317N2Re1EjC6WlMRZUKXH54fckRTyPm
-4ZLyxVHy8LlUD2Q10g69opb7HRd/tV0miBJhn5OU1wIM3hqTgxNp9EFckK4md45k
-osnhQJNDsFToxJL8zPP+KRs/aWPZs+FrRcH6k26lwLl2gTfyBDsaU11HFRVEN9yi
-X41obVyKiVNlc9efSSvlLtRBSVt0VhAFhck+3t61H6D9H09QxaDGAqmduDua3Tg3
-t5eqURuDfv3TfSztYgK3JBmG/6gVMsZodCgyC+8rhDDs6vSoDG30apx5Leg2rPbj
-xuk2wi/WNzc94IgY9tVS3tAfT2k6yQ==
-=6+Cv
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar
deleted file mode 100644
index a142d391c0af..000000000000
Binary files a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar and /dev/null differ
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.asc
deleted file mode 100644
index 85d6a7cb1e5a..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT3qtgf8CeDvxzi7lPghlrZtmYTTjaic4FZsGRWTPky3H24i4wSRDhG0L5sj4uPK
-eLLlITr5a9j26UCas9HRSthiC8+EgIMAhSN0X482SQhUZHAW67ErIvaHlwL+ixMD
-0T5pmsW8PKN3lV1TFMhNYSEC2GRG/4GF+3yQA8LR+BgeEu/E5nmysIH8vuQMkOD6
-3pKA8VKNBml591j6UTqxoHtPX+rThaziz3Hy3+ekf5iWslllTTGPd2SWqTvnj2Ae
-GvRzsbli+FEM0Aj/v8jUQnQzOz891QSvWR+fMfCqZimiJMc+GBzJ9umbcyQsB5tY
-e26mAoYd9KEpGXMKN4biHbJZNp1GGw==
-=x/MY
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.md5 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.md5
deleted file mode 100644
index 95fa4af1641f..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.md5
+++ /dev/null
@@ -1 +0,0 @@
-e84da489be91de821c95d41b8f0e0a0a
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.md5.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.md5.asc
deleted file mode 100644
index 1cbf6ccffa07..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.md5.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT1PSwf+InTdlh+BVVy3RFszEL5vN7waSPYMImXhgd5EZ1d4Lxd6EeOwtNgWNKpG
-E+Ps/Kw0jZDvoD49WJlcUjzDHBNHcE7C/L3GAWHV6WwklhtQaJ4EegsynWdSXz6k
-fqJY6r58aGKGjpKPutRWAjvfcdC170+ZRsc2oi9xrAgHCpvXzTjq4+O9Ah0t5jwW
-jcZ/Xubcw4vjsw774OucHbtwGsvRN5SDJ3IONOH8WCwhUP5vEEKvA6MYX0KGoTdS
-3wTCyZTzU3qtTWxcbTCpiJIWbYwRR7TzLB/uydWHlAMzuz6coIiBpYsGiO6wkmfg
-W+QvcE7wyW2jtb22pCImLyObyZ21VA==
-=VjDv
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.sha1 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.sha1
deleted file mode 100644
index 2a2834e1ab41..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.sha1
+++ /dev/null
@@ -1 +0,0 @@
-8992b17455ce660da9c5fe47226b7ded9e872637
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.sha1.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.sha1.asc
deleted file mode 100644
index 4cd212d6e1e9..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.sha1.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT2HQgf+MTUEnwzXK4zi76VI7C5cchGan26lIA2Ebq4vtYGKDqNSISOAAdWs9+nT
-U6ZA6OIFo5qdeD6F/45s91IoDbxbhMDMFEsSijKASqiuZN5TZM1U2h2kWFAl/sEl
-EI1RTygn+xDw/ah4V3/duuMFC+jRgvJ/LgemIF4KBvECWaTQKNu0fu5d4dPXMpp+
-jrxMEZPQZsivpOvklzV8O7wAkf/ZQhJdcB2m8uOfSPlJ91a4EEtXF9/GzzkXUi1P
-bzt4NsmOag3227B3mO1Bc6yZdDBNu8wQ9apiJVCpqsxB9Dz0PCL4dHNa1u9g6Xo6
-ElRgneV4HZp+LB125VoNabKuNH00bw==
-=2yDl
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.sha256 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.sha256
deleted file mode 100644
index 4f27f01046dd..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.sha256
+++ /dev/null
@@ -1 +0,0 @@
-10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.sha256.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.sha256.asc
deleted file mode 100644
index e29629cbec07..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.sha256.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT0ScQf7Bip+quFq1CzDCTDxUhdTTOIpQcCfMKo1Jegpa2Hlm63XuK+6zVI9u6S+
-dBXPdYeneMOqMPQUAaw3M06ZqohaDOt8Ti1EiQFGXCdOvbonTA52Lrd4EEZxwNnK
-BdPuIh/8qCfozm5KbZe1bFyGVRAdNyf27KvzHgfBTirLtI+3MiOdL4bvNZbWRPfh
-J84Ko+Ena8jPFgyz6nJv2Q2U/V3dCooLJAXs2vEG6owwk5J9zvSysWpHaJbXas5v
-KXO9TOBBjf3+vxb1WVQa8ZYUU3+FIFes0RFVgOWghJXIooOcWrwOV2Q8z9qWXwoK
-mMZ2oLS+z/7clXibK45KeRUeCX5DvQ==
-=5oO1
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.sha512 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.sha512
deleted file mode 100644
index eb02a04a89d2..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.sha512
+++ /dev/null
@@ -1 +0,0 @@
-2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.sha512.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.sha512.asc
deleted file mode 100644
index f35b726baff6..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.sha512.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT3d+AgAwQvlwnKQLLuiZADGL+I922/YG317N2Re1EjC6WlMRZUKXH54fckRTyPm
-4ZLyxVHy8LlUD2Q10g69opb7HRd/tV0miBJhn5OU1wIM3hqTgxNp9EFckK4md45k
-osnhQJNDsFToxJL8zPP+KRs/aWPZs+FrRcH6k26lwLl2gTfyBDsaU11HFRVEN9yi
-X41obVyKiVNlc9efSSvlLtRBSVt0VhAFhck+3t61H6D9H09QxaDGAqmduDua3Tg3
-t5eqURuDfv3TfSztYgK3JBmG/6gVMsZodCgyC+8rhDDs6vSoDG30apx5Leg2rPbj
-xuk2wi/WNzc94IgY9tVS3tAfT2k6yQ==
-=6+Cv
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module
deleted file mode 100644
index 8618f194c99c..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module
+++ /dev/null
@@ -1,101 +0,0 @@
-{
-  "formatVersion": "1.1",
-  "component": {
-    "group": "org.springframework.example",
-    "module": "module-three",
-    "version": "1.0.0",
-    "attributes": {
-      "org.gradle.status": "release"
-    }
-  },
-  "createdBy": {
-    "gradle": {
-      "version": "6.5.1",
-      "buildId": "mvqepqsdqjcahjl7cii6b6ucoe"
-    }
-  },
-  "variants": [
-    {
-      "name": "apiElements",
-      "attributes": {
-        "org.gradle.category": "library",
-        "org.gradle.dependency.bundling": "external",
-        "org.gradle.jvm.version": 8,
-        "org.gradle.libraryelements": "jar",
-        "org.gradle.usage": "java-api"
-      },
-      "files": [
-        {
-          "name": "module-three-1.0.0.jar",
-          "url": "module-three-1.0.0.jar",
-          "size": 261,
-          "sha512": "2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00",
-          "sha256": "10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385",
-          "sha1": "8992b17455ce660da9c5fe47226b7ded9e872637",
-          "md5": "e84da489be91de821c95d41b8f0e0a0a"
-        }
-      ]
-    },
-    {
-      "name": "runtimeElements",
-      "attributes": {
-        "org.gradle.category": "library",
-        "org.gradle.dependency.bundling": "external",
-        "org.gradle.jvm.version": 8,
-        "org.gradle.libraryelements": "jar",
-        "org.gradle.usage": "java-runtime"
-      },
-      "files": [
-        {
-          "name": "module-three-1.0.0.jar",
-          "url": "module-three-1.0.0.jar",
-          "size": 261,
-          "sha512": "2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00",
-          "sha256": "10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385",
-          "sha1": "8992b17455ce660da9c5fe47226b7ded9e872637",
-          "md5": "e84da489be91de821c95d41b8f0e0a0a"
-        }
-      ]
-    },
-    {
-      "name": "javadocElements",
-      "attributes": {
-        "org.gradle.category": "documentation",
-        "org.gradle.dependency.bundling": "external",
-        "org.gradle.docstype": "javadoc",
-        "org.gradle.usage": "java-runtime"
-      },
-      "files": [
-        {
-          "name": "module-three-1.0.0-javadoc.jar",
-          "url": "module-three-1.0.0-javadoc.jar",
-          "size": 261,
-          "sha512": "2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00",
-          "sha256": "10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385",
-          "sha1": "8992b17455ce660da9c5fe47226b7ded9e872637",
-          "md5": "e84da489be91de821c95d41b8f0e0a0a"
-        }
-      ]
-    },
-    {
-      "name": "sourcesElements",
-      "attributes": {
-        "org.gradle.category": "documentation",
-        "org.gradle.dependency.bundling": "external",
-        "org.gradle.docstype": "sources",
-        "org.gradle.usage": "java-runtime"
-      },
-      "files": [
-        {
-          "name": "module-three-1.0.0-sources.jar",
-          "url": "module-three-1.0.0-sources.jar",
-          "size": 261,
-          "sha512": "2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00",
-          "sha256": "10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385",
-          "sha1": "8992b17455ce660da9c5fe47226b7ded9e872637",
-          "md5": "e84da489be91de821c95d41b8f0e0a0a"
-        }
-      ]
-    }
-  ]
-}
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.asc
deleted file mode 100644
index f7112bc8e54a..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT3OcAf+OJv0t0rhNnJcF656mem5qv3fvcJKkPqyKF9I0TiP33W61/ntrGezdaDX
-tLde1MFRto3HS0/U0t6NqfMNTXYcQ5vH/qqnIRWP7Iv/t7f+mum6pOcYkxJhhXFT
-1pH0l4iqVQOBUiAJhOpUh0utLNWdZcEv+DdxgtFbFyaEDmg46Cpy9YtAH6XKEh5d
-ZZeiX/+XC+Ufx1bReDLHvFjUyQa/Lv8rEthX2eBmAXkoPwJG0LA9xF6X8leB0DI/
-9a1KiNcmRSSUarLpqV/hE6oQggGeMLVoJ+51klunRAfiXw6h2m9gRlnWikLjC+23
-/E2m+7Gb0Kc4izXIdHTqS2fYPMHsyw==
-=h486
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.md5 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.md5
deleted file mode 100644
index c5a7b486933a..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.md5
+++ /dev/null
@@ -1 +0,0 @@
-90fa60dcc829042dd1208c174752caeb
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.md5.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.md5.asc
deleted file mode 100644
index 83f03d877951..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.md5.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT0xjQgAwJcUWVwcl3PI7FhRUoPaPfqaoG3bUPwLePYuf++qPCNUDOmnq0aXtqbr
-Ul9SxQRDy9D7ygCWVCTVXRjg0HHZQT/ZYB7lhaDLxMEpV25q9acJAZ4qzbn8vRAG
-FqlqYaSlIDducapPUGWAOF/xwhf5k8tIGO5p0hY4wdU3b+0YU1w5DavYOetTZ26Z
-jwVagOj/6WFIHnu6PwXGkynqxui8dnsld23eamOZYsfR19weTNh0GT3ncl8y03eP
-Wy6CkFxzN0kvSdze0nAfO9dygpRxh7nNbz/uJhTFP4pDwz5iE8FBiUXZLkxTZ8YJ
-lvIKuS+JnUTEQcd00/nAL4cncMiJIQ==
-=lQP9
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.sha1 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.sha1
deleted file mode 100644
index 4b25265d7253..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.sha1
+++ /dev/null
@@ -1 +0,0 @@
-9090f5fcb4c79a89b3a46f2cb8071381e0787a03
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.sha1.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.sha1.asc
deleted file mode 100644
index cfa8f7e8880f..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.sha1.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT3urQf/W31jKnVjCIckj7XFbeucazmVr0K73LNpg0eQwqqz877KKmBDV8qn8b3o
-MTBDgUn/9LMJzUSWRFV+CkM0cgAG0s8vmzeymtH6RWv+ikHh/3Ky4sYxd9Pa3Ipo
-zeeIqyJk1dysfcLLsP1ml6ayh8VM/DK+DDc4CU9wrEGAUDeVIFiTw7DrMIB7PcdG
-ru7z6J/jcIA55RiJMDvuqhS+Obx/JUrmqDrrK8Npp9stRP+RpZpF1AKGgg1dfLo1
-bKw+KYuMhK7Kq7nIg9GqZvhr46oOKko5NF2l+GfVR14Gdb330/88t/IxwJvsUiCC
-sWQTrGJb062N5oHGtdoZ3mXLo7bnuw==
-=+snH
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.sha256 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.sha256
deleted file mode 100644
index 346a5ff6e495..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.sha256
+++ /dev/null
@@ -1 +0,0 @@
-2e8d3db6ece5719d7be27bcfdefa1f890da9a19f73390c7122797db1081d245b
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.sha256.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.sha256.asc
deleted file mode 100644
index 41d0ccfe35c6..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.sha256.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT14OggAlf9eyYFV3HRC7LoeM1Q9LrkYZUIDIUkukUxDxBTGPLf739qZtHgUl6lC
-yUCQqGswhuuwR8s7ht2MDMp8isjs1j7inpAQA3kYgHOCUMjYlIyhPdIxHtQ8WD+S
-CwW2nHtf7tXFrhKFecqolKyp+qZYWx1anMmbLggyaXWZmiIwhIHLxIogbyjVLdkD
-9qUAKCUpEvyNqogyYYtAjJERRzw9RN4lwnpm/uEkKtFQVoxui2VQr/DEbzooXu8A
-mqKkUBbgf9uxH5s+pUuUgbl+XZnPLGzJV6NcFe/jpsvEzHkUQzAsVnNnCWAPreY8
-RTfj2eGleFWESIiMFUAp6U0an5GoOQ==
-=T+Cv
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.sha512 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.sha512
deleted file mode 100644
index 1817ab28cb61..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.sha512
+++ /dev/null
@@ -1 +0,0 @@
-ac3e2a0dfb3b8ddaa79468f85698ff97e9b88e401849e2e733073b711a1673ba2e37be61d224fd69ec2e1c59ed0342e6b3757bc3931c2e297d24bcae9370fa3b
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.sha512.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.sha512.asc
deleted file mode 100644
index 1f6bfda96678..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.sha512.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT1HGQgAuINmQJ5vpFWWmXbIrEVf5+fTKq72R7gXdJ9XHYgQdSyKoeUUy3FElqfI
-55gyiLk1OMMy6Pd1HKi0bczOUOlz8K34uMXcT+ctm41Lp6243FfLm4iy1x/DWlHb
-IWksIG1TRf7g0b//OiBbbaesjnc5QK1rft6T4KiEPD9NtOi/8ON7vVu0S9oERUGO
-32Zwu/wGZeKztUoXVQ/zZk5UA9hYE/7C5bX3dRBS038luv7YZKe3313PfVj29vdx
-bsfRIcH/qIe//WL3OTTbaOvSgOs8qvJHPN8NmdH70GbZ2W9jTe7KrIlb8FBEaPEj
-BbLov9td9qxXlRxyBhLYRB7MN4rsKw==
-=qIiQ
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom
deleted file mode 100644
index badff025f688..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom
+++ /dev/null
@@ -1,49 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
-    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
-  <!-- This module was also published with a richer model, Gradle metadata,  -->
-  <!-- which should be used instead. Do not delete the following line which  -->
-  <!-- is to indicate to Gradle or any Gradle module metadata file consumer  -->
-  <!-- that they should prefer consuming it instead. -->
-  <!-- do_not_remove: published-with-gradle-metadata -->
-  <modelVersion>4.0.0</modelVersion>
-  <groupId>org.springframework.example</groupId>
-  <artifactId>module-three</artifactId>
-  <version>1.0.0</version>
-  <name>module-three</name>
-  <description>Example module</description>
-  <url>https://spring.io/projects/spring-boot</url>
-  <organization>
-    <name>Spring</name>
-    <url>https://spring.io</url>
-  </organization>
-  <licenses>
-    <license>
-      <name>Apache License, Version 2.0</name>
-      <url>https://www.apache.org/licenses/LICENSE-2.0</url>
-    </license>
-  </licenses>
-  <developers>
-    <developer>
-      <name>Spring</name>
-      <email>ask@spring.io</email>
-      <organization>Spring</organization>
-      <organizationUrl>https://www.spring.io</organizationUrl>
-    </developer>
-  </developers>
-  <scm>
-    <connection>
-      scm:git:git://github.com/spring-projects/spring-boot.git
-    </connection>
-    <developerConnection>
-      scm:git:ssh://git@github.com/spring-projects/spring-boot.git
-    </developerConnection>
-    <url>https://github.com/spring-projects/spring-boot</url>
-  </scm>
-  <issueManagement>
-    <system>GitHub</system>
-    <url>
-      https://github.com/spring-projects/spring-boot/issues
-    </url>
-  </issueManagement>
-</project>
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.asc
deleted file mode 100644
index eb100ce746aa..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT0T5QgAgXfcX/6hkGNWRp4xIbpC0P9wi21WBmlWeM1l1vPjlogcPB5fIQ15tnxL
-dyXVJjhdBXG70m5UkOtR5LbO+6Y7soEsocfuN/wdjNP/JUk2xW4HTj87F16r3EhV
-s1nrydd/nZxsIemTY1irOrCk4yEOWlAO91VOGFI4UoGGE6oeMiTFje6vbNidGT3Y
-RD1VrxbVasI38HHggQ+odrdp+rk8AwAUJq8g96KyRO5d+O6NQCf4cTe6S5+kJKG1
-ETQ0yASHiD5pzcpQiEQu+wclgAunVAzr5Ql/SnOcZEjUoVOLix7Ttcv5KcXjZhY9
-9VQyULZ1MzcrSEoRoOv8k8fT7swvLg==
-=KgwJ
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.md5 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.md5
deleted file mode 100644
index e156ed030237..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.md5
+++ /dev/null
@@ -1 +0,0 @@
-a819cb79241de23f94f3626ae83be12f
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.md5.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.md5.asc
deleted file mode 100644
index f55b2670d4e8..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.md5.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT2Umgf+K57ogLEAGx/40dFOV0yiGmvCXwywMCVbnMXCA85Ceti0TGFY6T0EaJXy
-wF7QQ0SW56svIxX/U58IVocWuaVRJA7tCZF1u9DCvafdYDJeM4iHHVu6GzM1Tng2
-JFYV4q5MtT712rCrcf7ZH3MntYawsGjBiF9IHWwvSNUyf53W7L4VSWcpv0tfPLra
-EeC7ztnnDXgi32FSpXvu27mDPbrQLibihUZBjoZ4uuRU2wB6HICJ90JjoYtK6JoC
-ToEZY4jFLkEmQ8dy0KUa5rhUDWJ+Bq+bYHhwMXl9HQUKZuqjvlmHCRHIsJgdU+Cl
-i5NPJkXhCZOs9tD3hf3NdeD9ef72kQ==
-=PraK
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.sha1 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.sha1
deleted file mode 100644
index 81ed28e231d7..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.sha1
+++ /dev/null
@@ -1 +0,0 @@
-e562eacb5f9cf14ccbb80c8be0396710fc6a546c
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.sha1.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.sha1.asc
deleted file mode 100644
index 53461b47a075..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.sha1.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT2j8QgAiciViDfKLAv2rYMyBJbyQjK29fpG78NMsKw+j3zWwJEPlPuZhIT0/KWQ
-3ipcYbtBoKYrKSG54uzPflGAQoostMYV+XtJ+0fjICsNDpKjfhuDWojaWkxnF1KD
-NcWSiapNO6iX0s62yaL/netVVsHsE5fVr//IG6WDTrJK2GEkOQAoca2W8ixI3G0s
-kTIJEmCMA9ZOUMKBwwtJ0NPEZPxe1N/R6SuGWGdkWlrqPRmA6lnY153zH3vJ6pqF
-RM1Phwpt46l3o20D5wOhqU8jvV7b5HUZ50sHV0sJOMUbwvFyrhIOzJLMixk1WJhR
-lnudbpWPssTJO3Fiv67b/iADaBaIbA==
-=4CYs
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.sha256 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.sha256
deleted file mode 100644
index 381f84d9d480..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.sha256
+++ /dev/null
@@ -1 +0,0 @@
-f043a098eee190d59e2b4b450c2de21dcb3b7fce224abefd89e0e5b44b939fa7
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.sha256.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.sha256.asc
deleted file mode 100644
index b9b35e5371e6..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.sha256.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT3Pwwf+IikjoDdjeMQfmERTN4Gjirx9+fler+Gr5JYC78OxLrB8uq0tn11wEJMQ
-ZDQe83OYjEkFQhPn6yQ5bc9edZTJztQJcGpVe7NkYffS4geo+ahuksaQWMF9opEc
-OqBB5fTWVt18qGFTI2F3CEDIo38muTgkzndFuzmcbVhAcknF5ybcVDIFpZNlnqe/
-xflD6lupXWXQC4nE4n2EVhNnmkiF9nKRJWwEJ1hoy1gwTxSYmnO/BLn8ob4tswnc
-gHnj4Yp7qoV/SnRnfNDHMPQMfzAwq1jucjjPOt3xkw17UIGBnrP1zAxeZFmarCTO
-YjJt5PUNwtqOVHlHzwgn3b3FGRJe3A==
-=AU99
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.sha512 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.sha512
deleted file mode 100644
index fb6a445480ea..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.sha512
+++ /dev/null
@@ -1 +0,0 @@
-67549e36f07c9f4b83cfa9f00fa647705128eabd03dec3df0066177e89940652fc7e13b8c0b60652c811e167c3ffaba0ab95a4f315a10f69572c12e4e944b6e6
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.sha512.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.sha512.asc
deleted file mode 100644
index 71232b747c6f..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.sha512.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT0bBgf+Nm9vooXNKE/z4cNeFqiHwLb+gMxvqlKnl/+03KbuFvlDUwdqSxfnHQZ3
-qfCBtIe6MN/0I5FCNRCcFxiCjCPDqSMAcvRPU5UOG2M2W0uvcqMwKO6KRGBLd65J
-MulDxoe7LrEk6KwNYfecxCtXBvLwzwQd3A10tcQOVl66neF/g+9jEc+MHJPa1xi5
-4pDOo3TQ4EpGfB8eF9Z+7YKc9hPYBFsm+n3P6SYcVAiRUiNBE8gCOvvZE9mOTQAo
-yC80AfDjXe/YBsd3a79hVW+ESQAKfK8S1RzO+c5GhIZCIJFdSJDOeww86O4U0n5g
-6hIXRNWUFUEueAvQ2dYHZujQSBxNig==
-=t5De
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar
deleted file mode 100644
index a142d391c0af..000000000000
Binary files a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar and /dev/null differ
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.asc
deleted file mode 100644
index 624356b34ae0..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1sACgkQmix6mORX
-xT1IkAf+N8ts9K88PC/dISZMWSrgRbSugd6VRG6pto6jsp9cJBzTzQ6C8psMkXcN
-qX0fnLBn4Zn1dovxRIrA8QeT/vxHMl1X0Foe6SUTMjK34Ofq4V1FVlhuJIi0YZrP
-L7B4cKTkv1ndwSVgE23zkynfaIPiPl1uZOwDmlpArokqnjSiUq9NndtKf87NwekW
-hbf7brgfZddeDj9xhAn5hz2pHUhx/uH9tOX3JlZgE+yATZsGm6Z9BSf4Lur0W85P
-hrJ+MfuYPzZ3n7okuaQdMT3QIqe3FO+dfGZKwakw2NWfgWP3LZsQ5rbyxBlyH7CG
-JA/VAIIqe99ftHcBFRGB6C9hMn4FcA==
-=prNt
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.md5 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.md5
deleted file mode 100644
index 95fa4af1641f..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.md5
+++ /dev/null
@@ -1 +0,0 @@
-e84da489be91de821c95d41b8f0e0a0a
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.md5.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.md5.asc
deleted file mode 100644
index 333ba4509b3f..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.md5.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1sACgkQmix6mORX
-xT3jzQf8DUis2+v7616JWRpUziEnpnvms7+PkgovWttcpbO0RlLr1s0Uno0D0jAt
-7KUGNN4//n8hKsojZ6ZI+7pzsk/0NathOQRNYcdb+AFf71T3yJhef1d5GmXHA7iV
-wA6AfrFTEQ7SaimZXEGGpFXzb3rPsVnryOEbTOXno3B7nNjZTUpjkW/APkvJueUk
-BIFCWH9rL1txRKWhKg8f6YT+l6HQFn+qu1Z3/MoqxCn6HvUxExA1mwNbzfvNaDTt
-l04jNVG6NqZyGhivuDnpyonnmwKySryKVvGrWn6b5SfgPPQYQJTWK3c7npSPjKpO
-ydzWOvISS55vBKLbB7g69g8ah3FHEw==
-=fcEr
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.sha1 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.sha1
deleted file mode 100644
index 2a2834e1ab41..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.sha1
+++ /dev/null
@@ -1 +0,0 @@
-8992b17455ce660da9c5fe47226b7ded9e872637
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.sha1.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.sha1.asc
deleted file mode 100644
index 66ae31b24c26..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.sha1.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1sACgkQmix6mORX
-xT0ovgf/aejEen2MEvJTF1Tjg24rK1jZAYqmgGi8H2b26h7ZEd/le2jWs6VpPmvv
-DX3pyaKFyOZXpU8SOkoPgi021VIt9LLWPlxMgcWlb6EWg8xw70PXISbUFy3IcxRi
-I14uAUXoFgIOT6jPt659kdXLNtYRsS3nQcBgJTIz6axHk2t5tD3TRf4xcLCyuVGv
-/obkTwpLr2jdPBxgTe+oDPjCnOyI6YeN0dKq4aiGBI/xECNpitbzmYQA2FQ+WvsG
-qq+1n/eAZAzAUWumxLna9ov1O0f6cY9d9hxWMTe2L4/a7B6KezF0CPnShFaC6pCV
-98aamE5QxBmSeQtmRdI75WFHJ1h0Vg==
-=0M4U
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.sha256 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.sha256
deleted file mode 100644
index 4f27f01046dd..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.sha256
+++ /dev/null
@@ -1 +0,0 @@
-10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.sha256.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.sha256.asc
deleted file mode 100644
index 825ed5de96dd..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.sha256.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1sACgkQmix6mORX
-xT104Qf+NG6IUBLo7eTPtPzWyNs9bShrJR759atbY9kBOrRfyoBM/hWrcr6pG4e5
-BmBPULkuBWLVM+Ra52B2S96848oiaLLEuiWlMudWRsWCxyVknjm9EKlMHA3VdQtu
-8grrPKS4mIwSvdzAEEeqR+mwtWXHlz+jc/R9NeQhNmmcNZv1nkyzCuoNCH/HMTl4
-/ei6enpYrYNnMrNz9TOMQ67sCtZEm6TaxlqS+9h/V9TEnq6+1qXEt4c+AsqQdMWH
-3BZREzXHFocQciSEXfL6m07pnNlnvCcjsM2SAqTeTqQupEmqFGkhL2blE1VMdplW
-fDCC/ee5JYVyyUXpzydSjCYwFbO3NQ==
-=gSEv
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.sha512 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.sha512
deleted file mode 100644
index eb02a04a89d2..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.sha512
+++ /dev/null
@@ -1 +0,0 @@
-2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.sha512.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.sha512.asc
deleted file mode 100644
index 7df8aa894e07..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.sha512.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1sACgkQmix6mORX
-xT0XdAf/feMRtW2BUz84x43aYLaERIEPx2TsqMUwVcov6sp5MWaSAJEX2vrscCRB
-K/7x4N3dxnrckc3sBF1hs+zrRwySYU1FyGVIxQxdeURnUWxCva0uOWj91jcUOIkA
-gpmOZZj51b4SkB6GZjtvN/Z3B4xzEPmTPfKFiBZPhuYW4HiC8FHM1JnlW6h2xoj6
-Bja//qoj9ccfRjiMnnI0iPgZNiyR8n8+EJGi0ykizxkiT6cWI84kZ+JQYooDHbCf
-NgPt2NjcGzd6SGrQW8/0td0N+xDRfLpTrbfTmlC5hikXmS0e79BV6W0eYcWcgDni
-r8WjbDmomHHFDhT8p1R+tGtd8p2txg==
-=BHvx
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar
deleted file mode 100644
index a142d391c0af..000000000000
Binary files a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar and /dev/null differ
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.asc
deleted file mode 100644
index 624356b34ae0..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1sACgkQmix6mORX
-xT1IkAf+N8ts9K88PC/dISZMWSrgRbSugd6VRG6pto6jsp9cJBzTzQ6C8psMkXcN
-qX0fnLBn4Zn1dovxRIrA8QeT/vxHMl1X0Foe6SUTMjK34Ofq4V1FVlhuJIi0YZrP
-L7B4cKTkv1ndwSVgE23zkynfaIPiPl1uZOwDmlpArokqnjSiUq9NndtKf87NwekW
-hbf7brgfZddeDj9xhAn5hz2pHUhx/uH9tOX3JlZgE+yATZsGm6Z9BSf4Lur0W85P
-hrJ+MfuYPzZ3n7okuaQdMT3QIqe3FO+dfGZKwakw2NWfgWP3LZsQ5rbyxBlyH7CG
-JA/VAIIqe99ftHcBFRGB6C9hMn4FcA==
-=prNt
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.md5 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.md5
deleted file mode 100644
index 95fa4af1641f..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.md5
+++ /dev/null
@@ -1 +0,0 @@
-e84da489be91de821c95d41b8f0e0a0a
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.md5.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.md5.asc
deleted file mode 100644
index 1cbf6ccffa07..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.md5.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT1PSwf+InTdlh+BVVy3RFszEL5vN7waSPYMImXhgd5EZ1d4Lxd6EeOwtNgWNKpG
-E+Ps/Kw0jZDvoD49WJlcUjzDHBNHcE7C/L3GAWHV6WwklhtQaJ4EegsynWdSXz6k
-fqJY6r58aGKGjpKPutRWAjvfcdC170+ZRsc2oi9xrAgHCpvXzTjq4+O9Ah0t5jwW
-jcZ/Xubcw4vjsw774OucHbtwGsvRN5SDJ3IONOH8WCwhUP5vEEKvA6MYX0KGoTdS
-3wTCyZTzU3qtTWxcbTCpiJIWbYwRR7TzLB/uydWHlAMzuz6coIiBpYsGiO6wkmfg
-W+QvcE7wyW2jtb22pCImLyObyZ21VA==
-=VjDv
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.sha1 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.sha1
deleted file mode 100644
index 2a2834e1ab41..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.sha1
+++ /dev/null
@@ -1 +0,0 @@
-8992b17455ce660da9c5fe47226b7ded9e872637
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.sha1.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.sha1.asc
deleted file mode 100644
index 4cd212d6e1e9..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.sha1.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT2HQgf+MTUEnwzXK4zi76VI7C5cchGan26lIA2Ebq4vtYGKDqNSISOAAdWs9+nT
-U6ZA6OIFo5qdeD6F/45s91IoDbxbhMDMFEsSijKASqiuZN5TZM1U2h2kWFAl/sEl
-EI1RTygn+xDw/ah4V3/duuMFC+jRgvJ/LgemIF4KBvECWaTQKNu0fu5d4dPXMpp+
-jrxMEZPQZsivpOvklzV8O7wAkf/ZQhJdcB2m8uOfSPlJ91a4EEtXF9/GzzkXUi1P
-bzt4NsmOag3227B3mO1Bc6yZdDBNu8wQ9apiJVCpqsxB9Dz0PCL4dHNa1u9g6Xo6
-ElRgneV4HZp+LB125VoNabKuNH00bw==
-=2yDl
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.sha256 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.sha256
deleted file mode 100644
index 4f27f01046dd..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.sha256
+++ /dev/null
@@ -1 +0,0 @@
-10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.sha256.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.sha256.asc
deleted file mode 100644
index 825ed5de96dd..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.sha256.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1sACgkQmix6mORX
-xT104Qf+NG6IUBLo7eTPtPzWyNs9bShrJR759atbY9kBOrRfyoBM/hWrcr6pG4e5
-BmBPULkuBWLVM+Ra52B2S96848oiaLLEuiWlMudWRsWCxyVknjm9EKlMHA3VdQtu
-8grrPKS4mIwSvdzAEEeqR+mwtWXHlz+jc/R9NeQhNmmcNZv1nkyzCuoNCH/HMTl4
-/ei6enpYrYNnMrNz9TOMQ67sCtZEm6TaxlqS+9h/V9TEnq6+1qXEt4c+AsqQdMWH
-3BZREzXHFocQciSEXfL6m07pnNlnvCcjsM2SAqTeTqQupEmqFGkhL2blE1VMdplW
-fDCC/ee5JYVyyUXpzydSjCYwFbO3NQ==
-=gSEv
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.sha512 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.sha512
deleted file mode 100644
index eb02a04a89d2..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.sha512
+++ /dev/null
@@ -1 +0,0 @@
-2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.sha512.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.sha512.asc
deleted file mode 100644
index f35b726baff6..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.sha512.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT3d+AgAwQvlwnKQLLuiZADGL+I922/YG317N2Re1EjC6WlMRZUKXH54fckRTyPm
-4ZLyxVHy8LlUD2Q10g69opb7HRd/tV0miBJhn5OU1wIM3hqTgxNp9EFckK4md45k
-osnhQJNDsFToxJL8zPP+KRs/aWPZs+FrRcH6k26lwLl2gTfyBDsaU11HFRVEN9yi
-X41obVyKiVNlc9efSSvlLtRBSVt0VhAFhck+3t61H6D9H09QxaDGAqmduDua3Tg3
-t5eqURuDfv3TfSztYgK3JBmG/6gVMsZodCgyC+8rhDDs6vSoDG30apx5Leg2rPbj
-xuk2wi/WNzc94IgY9tVS3tAfT2k6yQ==
-=6+Cv
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar
deleted file mode 100644
index a142d391c0af..000000000000
Binary files a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar and /dev/null differ
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.asc
deleted file mode 100644
index 624356b34ae0..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1sACgkQmix6mORX
-xT1IkAf+N8ts9K88PC/dISZMWSrgRbSugd6VRG6pto6jsp9cJBzTzQ6C8psMkXcN
-qX0fnLBn4Zn1dovxRIrA8QeT/vxHMl1X0Foe6SUTMjK34Ofq4V1FVlhuJIi0YZrP
-L7B4cKTkv1ndwSVgE23zkynfaIPiPl1uZOwDmlpArokqnjSiUq9NndtKf87NwekW
-hbf7brgfZddeDj9xhAn5hz2pHUhx/uH9tOX3JlZgE+yATZsGm6Z9BSf4Lur0W85P
-hrJ+MfuYPzZ3n7okuaQdMT3QIqe3FO+dfGZKwakw2NWfgWP3LZsQ5rbyxBlyH7CG
-JA/VAIIqe99ftHcBFRGB6C9hMn4FcA==
-=prNt
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.md5 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.md5
deleted file mode 100644
index 95fa4af1641f..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.md5
+++ /dev/null
@@ -1 +0,0 @@
-e84da489be91de821c95d41b8f0e0a0a
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.md5.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.md5.asc
deleted file mode 100644
index 333ba4509b3f..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.md5.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1sACgkQmix6mORX
-xT3jzQf8DUis2+v7616JWRpUziEnpnvms7+PkgovWttcpbO0RlLr1s0Uno0D0jAt
-7KUGNN4//n8hKsojZ6ZI+7pzsk/0NathOQRNYcdb+AFf71T3yJhef1d5GmXHA7iV
-wA6AfrFTEQ7SaimZXEGGpFXzb3rPsVnryOEbTOXno3B7nNjZTUpjkW/APkvJueUk
-BIFCWH9rL1txRKWhKg8f6YT+l6HQFn+qu1Z3/MoqxCn6HvUxExA1mwNbzfvNaDTt
-l04jNVG6NqZyGhivuDnpyonnmwKySryKVvGrWn6b5SfgPPQYQJTWK3c7npSPjKpO
-ydzWOvISS55vBKLbB7g69g8ah3FHEw==
-=fcEr
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.sha1 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.sha1
deleted file mode 100644
index 2a2834e1ab41..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.sha1
+++ /dev/null
@@ -1 +0,0 @@
-8992b17455ce660da9c5fe47226b7ded9e872637
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.sha1.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.sha1.asc
deleted file mode 100644
index 66ae31b24c26..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.sha1.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1sACgkQmix6mORX
-xT0ovgf/aejEen2MEvJTF1Tjg24rK1jZAYqmgGi8H2b26h7ZEd/le2jWs6VpPmvv
-DX3pyaKFyOZXpU8SOkoPgi021VIt9LLWPlxMgcWlb6EWg8xw70PXISbUFy3IcxRi
-I14uAUXoFgIOT6jPt659kdXLNtYRsS3nQcBgJTIz6axHk2t5tD3TRf4xcLCyuVGv
-/obkTwpLr2jdPBxgTe+oDPjCnOyI6YeN0dKq4aiGBI/xECNpitbzmYQA2FQ+WvsG
-qq+1n/eAZAzAUWumxLna9ov1O0f6cY9d9hxWMTe2L4/a7B6KezF0CPnShFaC6pCV
-98aamE5QxBmSeQtmRdI75WFHJ1h0Vg==
-=0M4U
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.sha256 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.sha256
deleted file mode 100644
index 4f27f01046dd..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.sha256
+++ /dev/null
@@ -1 +0,0 @@
-10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.sha256.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.sha256.asc
deleted file mode 100644
index 825ed5de96dd..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.sha256.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1sACgkQmix6mORX
-xT104Qf+NG6IUBLo7eTPtPzWyNs9bShrJR759atbY9kBOrRfyoBM/hWrcr6pG4e5
-BmBPULkuBWLVM+Ra52B2S96848oiaLLEuiWlMudWRsWCxyVknjm9EKlMHA3VdQtu
-8grrPKS4mIwSvdzAEEeqR+mwtWXHlz+jc/R9NeQhNmmcNZv1nkyzCuoNCH/HMTl4
-/ei6enpYrYNnMrNz9TOMQ67sCtZEm6TaxlqS+9h/V9TEnq6+1qXEt4c+AsqQdMWH
-3BZREzXHFocQciSEXfL6m07pnNlnvCcjsM2SAqTeTqQupEmqFGkhL2blE1VMdplW
-fDCC/ee5JYVyyUXpzydSjCYwFbO3NQ==
-=gSEv
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.sha512 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.sha512
deleted file mode 100644
index eb02a04a89d2..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.sha512
+++ /dev/null
@@ -1 +0,0 @@
-2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.sha512.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.sha512.asc
deleted file mode 100644
index f35b726baff6..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.sha512.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT3d+AgAwQvlwnKQLLuiZADGL+I922/YG317N2Re1EjC6WlMRZUKXH54fckRTyPm
-4ZLyxVHy8LlUD2Q10g69opb7HRd/tV0miBJhn5OU1wIM3hqTgxNp9EFckK4md45k
-osnhQJNDsFToxJL8zPP+KRs/aWPZs+FrRcH6k26lwLl2gTfyBDsaU11HFRVEN9yi
-X41obVyKiVNlc9efSSvlLtRBSVt0VhAFhck+3t61H6D9H09QxaDGAqmduDua3Tg3
-t5eqURuDfv3TfSztYgK3JBmG/6gVMsZodCgyC+8rhDDs6vSoDG30apx5Leg2rPbj
-xuk2wi/WNzc94IgY9tVS3tAfT2k6yQ==
-=6+Cv
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module
deleted file mode 100644
index 23e5ef1229ad..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module
+++ /dev/null
@@ -1,101 +0,0 @@
-{
-  "formatVersion": "1.1",
-  "component": {
-    "group": "org.springframework.example",
-    "module": "module-two",
-    "version": "1.0.0",
-    "attributes": {
-      "org.gradle.status": "release"
-    }
-  },
-  "createdBy": {
-    "gradle": {
-      "version": "6.5.1",
-      "buildId": "mvqepqsdqjcahjl7cii6b6ucoe"
-    }
-  },
-  "variants": [
-    {
-      "name": "apiElements",
-      "attributes": {
-        "org.gradle.category": "library",
-        "org.gradle.dependency.bundling": "external",
-        "org.gradle.jvm.version": 8,
-        "org.gradle.libraryelements": "jar",
-        "org.gradle.usage": "java-api"
-      },
-      "files": [
-        {
-          "name": "module-two-1.0.0.jar",
-          "url": "module-two-1.0.0.jar",
-          "size": 261,
-          "sha512": "2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00",
-          "sha256": "10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385",
-          "sha1": "8992b17455ce660da9c5fe47226b7ded9e872637",
-          "md5": "e84da489be91de821c95d41b8f0e0a0a"
-        }
-      ]
-    },
-    {
-      "name": "runtimeElements",
-      "attributes": {
-        "org.gradle.category": "library",
-        "org.gradle.dependency.bundling": "external",
-        "org.gradle.jvm.version": 8,
-        "org.gradle.libraryelements": "jar",
-        "org.gradle.usage": "java-runtime"
-      },
-      "files": [
-        {
-          "name": "module-two-1.0.0.jar",
-          "url": "module-two-1.0.0.jar",
-          "size": 261,
-          "sha512": "2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00",
-          "sha256": "10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385",
-          "sha1": "8992b17455ce660da9c5fe47226b7ded9e872637",
-          "md5": "e84da489be91de821c95d41b8f0e0a0a"
-        }
-      ]
-    },
-    {
-      "name": "javadocElements",
-      "attributes": {
-        "org.gradle.category": "documentation",
-        "org.gradle.dependency.bundling": "external",
-        "org.gradle.docstype": "javadoc",
-        "org.gradle.usage": "java-runtime"
-      },
-      "files": [
-        {
-          "name": "module-two-1.0.0-javadoc.jar",
-          "url": "module-two-1.0.0-javadoc.jar",
-          "size": 261,
-          "sha512": "2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00",
-          "sha256": "10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385",
-          "sha1": "8992b17455ce660da9c5fe47226b7ded9e872637",
-          "md5": "e84da489be91de821c95d41b8f0e0a0a"
-        }
-      ]
-    },
-    {
-      "name": "sourcesElements",
-      "attributes": {
-        "org.gradle.category": "documentation",
-        "org.gradle.dependency.bundling": "external",
-        "org.gradle.docstype": "sources",
-        "org.gradle.usage": "java-runtime"
-      },
-      "files": [
-        {
-          "name": "module-two-1.0.0-sources.jar",
-          "url": "module-two-1.0.0-sources.jar",
-          "size": 261,
-          "sha512": "2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00",
-          "sha256": "10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385",
-          "sha1": "8992b17455ce660da9c5fe47226b7ded9e872637",
-          "md5": "e84da489be91de821c95d41b8f0e0a0a"
-        }
-      ]
-    }
-  ]
-}
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.asc
deleted file mode 100644
index 3cf7219f0929..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1sACgkQmix6mORX
-xT2SwAf/SDu2XlOfyL+oTidHItm3DmLsRY0xU2d5siCuasxxpzIPG+O5f3o7VeNS
-pKUctb+Vbx7Za+tPYts4ztG+bqVUVZbtn9ERWCXAvuuAnbJMxIl4D7HXahZPJKtl
-UrpKgA+45p2NLB9MK5B9QkmZInxF0ex3IUkc6e3MN8pmcefcjjDpoEvWKlc+ocEA
-/ySwMcH38FRYB6XbwsAjdXm7jiLpA9ZA5MdfZzjmm3nRBDzujBjU/Pv1+PFPH4Lh
-rfAS5+HOvWLQwt5kKyr8w3GzfbWT7FF7z024x0rT6mo0chOMe33Ng/AOYSGFzisJ
-OrJieiEosNjdcFbfuvspQFsI0cRU/A==
-=dgpO
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.md5 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.md5
deleted file mode 100644
index ef64fa1aa54d..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.md5
+++ /dev/null
@@ -1 +0,0 @@
-9a7d415ecef034f2fd4a0b251055ec4b
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.md5.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.md5.asc
deleted file mode 100644
index a31ae245d37d..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.md5.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1sACgkQmix6mORX
-xT375Af+L1KawLLikLiiC6R2BCxufjcLHEzxh0RLjgDHpHXy8s/Bh5GgkTT4x/Hn
-gGJNT3Yz18rzpZaLnvNR/J9wOFEzLxRsgl1rumvTrrwhbIjac774oj3Z8Zv+W1T8
-KN1mtfUSLDZSRbmY0YByvPVtag1+FaIifxmIIFLny+xDzRVD1OZ38gOaxz91nqmd
-pgjR2eVmeYLX3oAIApVopYdKWXNwOMzdBQbNroPRKCOesmTqQi0sjuvgN7r5JoxN
-9vVzF1SFnAKnw/LQqL0KMrRzCBd+ncUk7A6D6RB0MM0V+TB9am9CsatxflRgQY+c
-vzu/BHY8k6lh44TECvAuNuSr01CkPA==
-=awJx
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.sha1 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.sha1
deleted file mode 100644
index dc6c2e7bcd60..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.sha1
+++ /dev/null
@@ -1 +0,0 @@
-9df95def633421b7415f807b0b5848c6975de806
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.sha1.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.sha1.asc
deleted file mode 100644
index df2673f47c2f..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.sha1.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1sACgkQmix6mORX
-xT1ubAgAtWXXzqRDIC+DhAaY3IfHyM/Dmlj58jLhTzta4xKQe9HykEjBlpkPSscp
-9R+O2aL4xfBUmKtVLORKoGN3oUPhdU8a5vfgI2itdDPWLkOfFE64OJtIOZKp4ST4
-i00Jsqd4GFryS3r+i5FL5MCCv+zG/OkylMIfcH1XVxynvwrJVY49Do+TmW4MOIFf
-4uDOd29XmEc8vCJBd3VZu0epHqcXhdiQy0ekdl3NdUimzRuXAckNhGNMoLWYhKaw
-voErlAtfDHMbYU1DebguEaiLi98N6IxX1aO0Lleg3JNveD7pLCjEEf7AW+7TYoz1
-QBvHABpVHzZ7Rg5VhZNIIrQ38zyZdg==
-=kz39
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.sha256 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.sha256
deleted file mode 100644
index dc5ad0866352..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.sha256
+++ /dev/null
@@ -1 +0,0 @@
-773b2d6350eb671af0b79328bd5334fba565f9041ca823fe7b55097dadf3dcf5
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.sha256.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.sha256.asc
deleted file mode 100644
index 2557a7a64e2f..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.sha256.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1sACgkQmix6mORX
-xT3njggAl61MfkbtByxMKX8fQo5Jqp+vtvyZoaDZj+6FKr0xust5Wr4Fi+4+7fkj
-KyXFCTZUTd0xokRNpC8bxeZhhVkeG0pq6DrTr5BTvJLMJwPWVvrtn+bzDN1FvMia
-iZqcOlWtbwTdcosmBxXtxwI1gavwFhHUGudBzrBs85qkMkDz9BH8Egb+z/owFfPh
-lB9NSzezj4axgr745Ov5gYCwZp44iDBTcLZDWSLGMTuC6VdrLTQVsNLxouGI/67E
-0oqLmlaqfWZEJktTMk0LHG5ymy0g40Gm8r2kuxRnFEDVJwXSfJRiTvxifB+YoEHp
-RAcpReQe8+iSStuGEKYmfwmyXTdXAA==
-=759w
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.sha512 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.sha512
deleted file mode 100644
index 7e12e81cba75..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.sha512
+++ /dev/null
@@ -1 +0,0 @@
-6294828fb9ce277d60e92c607d00b739e9266c3ebdca173fb32881c04f2d365d43bf9d77c0a07cf1dbcd09ad302f7458e938197f59ff5614a5d1d4e4146a0cf6
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.sha512.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.sha512.asc
deleted file mode 100644
index 5c37a3450966..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.sha512.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1sACgkQmix6mORX
-xT1H5wgAnmHJRXH+f1cZLlLKqMv7JMbuSFha3XsDPeL/XIEXmD9zj/R8IE7EKmpS
-uwkswZaOeIyc95J4FasxiZm5CExxzRSTQfXtK2lOl/7Gp84D+D6XXI28CUIRnOfo
-SeOyCFk2U3a6uTsRgi1FSnJRvLCs+0tB+bByKuVgGbfQdF0mtQ9rCxlqKKVa/dz6
-ertOXtz1A7fiLV44ovZG27GOciRJbogBmWfmNGPaQ+Ry8b8ICPf3SDdNSpp/nG2i
-YZxzIyX9xabzPGg7M1d4ClhrpJQRjJD1hJRIHCkwC8f8Y544iQ/MuAwd3hNIfjWP
-GJjgOl0iYjO8LPVaRdrHFkBUntVdHQ==
-=tlE4
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom
deleted file mode 100644
index 31433f629303..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom
+++ /dev/null
@@ -1,49 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
-    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
-  <!-- This module was also published with a richer model, Gradle metadata,  -->
-  <!-- which should be used instead. Do not delete the following line which  -->
-  <!-- is to indicate to Gradle or any Gradle module metadata file consumer  -->
-  <!-- that they should prefer consuming it instead. -->
-  <!-- do_not_remove: published-with-gradle-metadata -->
-  <modelVersion>4.0.0</modelVersion>
-  <groupId>org.springframework.example</groupId>
-  <artifactId>module-two</artifactId>
-  <version>1.0.0</version>
-  <name>module-two</name>
-  <description>Example module</description>
-  <url>https://spring.io/projects/spring-boot</url>
-  <organization>
-    <name>Spring</name>
-    <url>https://spring.io</url>
-  </organization>
-  <licenses>
-    <license>
-      <name>Apache License, Version 2.0</name>
-      <url>https://www.apache.org/licenses/LICENSE-2.0</url>
-    </license>
-  </licenses>
-  <developers>
-    <developer>
-      <name>Spring</name>
-      <email>ask@spring.io</email>
-      <organization>Spring</organization>
-      <organizationUrl>https://www.spring.io</organizationUrl>
-    </developer>
-  </developers>
-  <scm>
-    <connection>
-      scm:git:git://github.com/spring-projects/spring-boot.git
-    </connection>
-    <developerConnection>
-      scm:git:ssh://git@github.com/spring-projects/spring-boot.git
-    </developerConnection>
-    <url>https://github.com/spring-projects/spring-boot</url>
-  </scm>
-  <issueManagement>
-    <system>GitHub</system>
-    <url>
-      https://github.com/spring-projects/spring-boot/issues
-    </url>
-  </issueManagement>
-</project>
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.asc
deleted file mode 100644
index f3703ea391be..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT2kvAgAoc2k0ljj7L3Pj4rPz73K1SO3pDHdf+6S6pU7E4ao9FEFZBcB7YJjEmmQ
-U724HqU15PIkaJKI/v4Z612E1gMSMIQ8A0LnsFR9yQdvrsK1Ijv+CdPCdyvZsBfP
-3MgmWaRUOToK3BAAVV5y0dfUNFUyeKKxHNclJd6H0HUK02of8I7LBn/5ULK4QRaQ
-Lm3bUIT3PtjUfND+DK3QlczZ+YgOkIwTkLywYCYxblm9XJjWCRXaZI1MdUlA6SMs
-uEqtglQ9zEJgyue/JtWsIkAlzUbdyjo34Cg5HEZJ6RNzboXlRNFm83fcKyPhSy7V
-0xikP1INbKuZSU1ZE7/rRYIQ7ChK0g==
-=96NH
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.md5 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.md5
deleted file mode 100644
index 463446fec94b..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.md5
+++ /dev/null
@@ -1 +0,0 @@
-057258909d16c0f9ed33c8a83e4ef165
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.md5.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.md5.asc
deleted file mode 100644
index df9d49af1f43..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.md5.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1sACgkQmix6mORX
-xT1eXQgAhIYkhiSoV4lFMPT9GuN4OjkoQC+v9ev8HkmIdq8lpoB+StIYzI35hKLU
-kfm5d2aeVo7ifDdXh642p9oEXRfuDPfaLd9u8SZZBAdo4rolQZr4bl+JaUFzR79i
-nRozXQeJF1UrDUKMi0+YGQxlosTbdx7Romj2UdfEmL2ACetxxR3rQExgZl4O/OUm
-PHJtIrzO1xdbVxtKelILJ4D/PauqEqcqzC2gI5vObZJcRgxDU/wc2CVN9jruv07h
-UW+8IEFV8vexoHo+Kq/F9xaTW2b7oXnvfOJgWRbh3zGpxSluJwVINDyX39/ym/Dr
-FIO6BPWFKQPEUW2cY6C69jj7S+v8Ig==
-=PYoG
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.sha1 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.sha1
deleted file mode 100644
index 2b3f269aad0f..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.sha1
+++ /dev/null
@@ -1 +0,0 @@
-1a742c42aef877b6a2808a1b5c35fbe3189ce274
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.sha1.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.sha1.asc
deleted file mode 100644
index ea7c57f9c6ed..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.sha1.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEyBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1sACgkQmix6mORX
-xT0bmAf4+WOg0xkoyRDXf/1hFOXlimKqyF1K7I6PXx1dFokRr+tvOtfFZucCOf+f
-1hCvnBiPTQwwMPCgll0reTsH2nHfDVUcbugpxVDC3Yza0x3gHudBhPC7yv+osNIu
-sVlnMRYbG1RQGjE6BxHoBk9pdOcwgN7zk2Y4LfAbOKMTo7dhAjZavRx3aShEUwHy
-P9/kfxcWCL0tOSzWg5XpZuxFEdVMWNJvshFvP0j2/Nlr6ZL5o/AwtyZKMiZ8QcUb
-0satLj501JYI6pM2cm8N17T94+jCsQXZic/hUCNteXA4XbRcDBR2wQLd08/Ht0U4
-rHzZQNr5Ft5R5ScshTEVBwjcd/Kx
-=drUj
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.sha256 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.sha256
deleted file mode 100644
index 334afc14508c..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.sha256
+++ /dev/null
@@ -1 +0,0 @@
-7e855e618d4aba3eb4e63bbfc9960e4da319d8bdef0cca213a11e9b2d1991635
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.sha256.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.sha256.asc
deleted file mode 100644
index 433766e66069..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.sha256.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
-xT3w3QgAvHS75cBMEEZGWiCQ+lbwDMWY7rjQBwkpO1Sj6WxpKcWD2F4YmNni1z1k
-L71SDuLP0a+IbYcoDCkss7vxH6hx6Lm53e+2WwhaVGCO1A6N3a56rFyEFlATrn31
-mcRjLrN4wzysqqbeamzSt+R0UoWj7yiihtBuz7tkhjGP+df2qCrXNeuetrhukFOz
-P5RLd4PURYMMUMqqNZ8JNnRhdCVdSVUpfM+BDolNDaswDrvOI3jzjXD/6HCt0fcN
-Pt484kFDqGEx0iXvv+7shiExs31gex+fsn2ta9yOGYluF/8Rc4z+X0/59MewoCgC
-EelPT4oi4zirrIWzGp1bRF+jDi6feA==
-=stbf
------END PGP SIGNATURE-----
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.sha512 b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.sha512
deleted file mode 100644
index e7b24fdd95be..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.sha512
+++ /dev/null
@@ -1 +0,0 @@
-5b06587734aa146b452e78e34abffb38b8c820abf623975409ae003b5d90aded345fa8f92ed9d7a505a16db324c0649d2e16c7597bad8dc63f0a5c00123789e1
\ No newline at end of file
diff --git a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.sha512.asc b/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.sha512.asc
deleted file mode 100644
index d8d2cc73e369..000000000000
--- a/ci/images/releasescripts/src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.sha512.asc
+++ /dev/null
@@ -1,11 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1sACgkQmix6mORX
-xT3p3QgAp5doWrX6eMPD1I09NHMt29LI8wFa/xld7rof8OQLHDN55TLseqOvBU4V
-E82s5cm4Uk0ndnth/VUHqsJK6SsNX8/0N2bvOtWgUWBYdClcy6ZBWXjQIDFfCdqX
-LPqQN4nOT3ZZMrzTZhLsAJkbzvVaOzEtUYZWw1ZAIT8nPkud24stuuxKUtsAxfeD
-QqcgKng/sPx6DS2+NSmmzyCF9eSL70NBcBF6+RJ+4X0YtZRtX+wsic2MnKnVAnyX
-hPejxguJYhwWbn1yRdVWknCdffpiT09IC/7AS/yc8s1DdbS6XEae8uFl0OB5z5dx
-nnaHUvlFrAjDGsrYeW5h1ZkM8VwBxA==
-=2HWi
------END PGP SIGNATURE-----
diff --git a/ci/images/setup.sh b/ci/images/setup.sh
index 48df23b70732..f1d15e5149c0 100755
--- a/ci/images/setup.sh
+++ b/ci/images/setup.sh
@@ -2,12 +2,13 @@
 set -ex
 
 ###########################################################
-# UTILS
+# OS and UTILS
 ###########################################################
 
 export DEBIAN_FRONTEND=noninteractive
 apt-get update
-apt-get install --no-install-recommends -y tzdata ca-certificates net-tools libxml2-utils git curl libudev1 libxml2-utils iptables iproute2 jq
+apt-get install --no-install-recommends -y locales tzdata ca-certificates net-tools libxml2-utils git curl libudev1 libxml2-utils iptables iproute2 jq
+locale-gen en_US.utf8
 ln -fs /usr/share/zoneinfo/UTC /etc/localtime
 dpkg-reconfigure --frontend noninteractive tzdata
 rm -rf /var/lib/apt/lists/*
@@ -37,6 +38,7 @@ if [[ $# -eq 2 ]]; then
 	test -f /opt/openjdk-toolchain/bin/javac
 fi
 
+
 ###########################################################
 # DOCKER
 ###########################################################
@@ -52,6 +54,7 @@ chmod +x entrykit && \
 mv entrykit /bin/entrykit && \
 entrykit --symlink
 
+
 ###########################################################
 # DOCKER COMPOSE
 ###########################################################
@@ -59,9 +62,3 @@ mkdir -p /usr/local/lib/docker/cli-plugins
 DOCKER_COMPOSE_URL=$( ./get-docker-compose-url.sh )
 curl -L ${DOCKER_COMPOSE_URL} -o /usr/local/lib/docker/cli-plugins/docker-compose
 chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
-
-###########################################################
-# GRADLE ENTERPRISE
-###########################################################
-mkdir ~/.gradle
-echo 'systemProp.user.name=concourse' > ~/.gradle/gradle.properties
\ No newline at end of file
diff --git a/ci/parameters.yml b/ci/parameters.yml
index 530398e4dc2c..0aff59284632 100644
--- a/ci/parameters.yml
+++ b/ci/parameters.yml
@@ -1,10 +1,15 @@
-github-repo: "https://github.com/spring-projects/spring-boot.git"
-github-repo-name: "spring-projects/spring-boot"
+github-organization-name: "spring-projects"
+github-repository-name: "spring-boot"
+github-repository-uri: "https://github.com/spring-projects/spring-boot.git"
 homebrew-tap-repo: "https://github.com/spring-io/homebrew-tap.git"
 docker-hub-organization: "springci"
-artifactory-server: "https://repo.spring.io"
-branch: "3.1.x"
-milestone: "3.1.x"
+docker-hub-repository-prefix: "spring-boot"
+artifactory-snapshot-repository: "libs-snapshot-local"
+artifactory-staging-repository: "libs-staging-local"
+artifactory-url: "https://repo.spring.io"
+branch: "main"
+milestone: "3.2.x"
 build-name: "spring-boot"
 concourse-url: "https://ci.spring.io"
 task-timeout: 2h00m
+final-release: false
diff --git a/ci/pipeline.yml b/ci/pipeline.yml
index 99d016228400..d661686d422c 100644
--- a/ci/pipeline.yml
+++ b/ci/pipeline.yml
@@ -1,6 +1,6 @@
 anchors:
   git-repo-resource-source: &git-repo-resource-source
-    uri: ((github-repo))
+    uri: ((github-repository-uri))
     username: ((github-username))
     password: ((github-ci-release-token))
     branch: ((branch))
@@ -22,18 +22,18 @@ anchors:
     DOCKER_HUB_PASSWORD: ((docker-hub-password))
     DOCKER_HUB_AUTH: ((docker-hub-auth))
   github-task-params: &github-task-params
-    GITHUB_REPO: spring-boot
-    GITHUB_ORGANIZATION: spring-projects
+    GITHUB_REPO: ((github-repository-name))
+    GITHUB_ORGANIZATION: ((github-organization-name))
     GITHUB_PASSWORD: ((github-ci-release-token))
     GITHUB_USERNAME: ((github-username))
     MILESTONE: ((milestone))
   sontatype-task-params: &sonatype-task-params
-    SONATYPE_USER_TOKEN: ((sonatype-username))
-    SONATYPE_PASSWORD_TOKEN: ((sonatype-password))
+    SONATYPE_USERNAME: ((sonatype-username))
+    SONATYPE_PASSWORD: ((sonatype-password))
     SONATYPE_URL: ((sonatype-url))
     SONATYPE_STAGING_PROFILE_ID: ((sonatype-staging-profile-id))
   artifactory-task-params: &artifactory-task-params
-    ARTIFACTORY_SERVER: ((artifactory-server))
+    ARTIFACTORY_URL: ((artifactory-url))
     ARTIFACTORY_USERNAME: ((artifactory-username))
     ARTIFACTORY_PASSWORD: ((artifactory-password))
   sdkman-task-params: &sdkman-task-params
@@ -58,7 +58,7 @@ anchors:
   artifactory-repo-put-params: &artifactory-repo-put-params
     signing_key: ((signing-key))
     signing_passphrase: ((signing-passphrase))
-    repo: libs-snapshot-local
+    repo: ((artifactory-snapshot-repository))
     folder: distribution-repository
     build_uri: "https://ci.spring.io/teams/${BUILD_TEAM_NAME}/pipelines/${BUILD_PIPELINE_NAME}/jobs/${BUILD_JOB_NAME}/builds/${BUILD_NAME}"
     build_number: "${BUILD_JOB_NAME}-${BUILD_NAME}"
@@ -143,8 +143,8 @@ resources:
   type: github-release
   icon: briefcase-download-outline
   source:
-    owner: spring-projects
-    repository: spring-boot
+    owner: ((github-organization-name))
+    repository: ((github-repository-name))
     access_token: ((github-ci-release-token))
     pre_release: true
     release: false
@@ -152,15 +152,15 @@ resources:
   type: github-release
   icon: briefcase-download
   source:
-    owner: spring-projects
-    repository: spring-boot
+    owner: ((github-organization-name))
+    repository: ((github-repository-name))
     access_token: ((github-ci-release-token))
     pre_release: false
 - name: ci-images-git-repo
   type: git
   icon: github
   source:
-    uri: ((github-repo))
+    uri: ((github-repository-uri))
     branch: ((branch))
     paths: ["ci/images/*"]
 - name: ci-image
@@ -168,24 +168,24 @@ resources:
   icon: docker
   source:
     <<: *ci-registry-image-resource-source
-    repository: ((docker-hub-organization))/spring-boot-ci
-- name: ci-image-jdk20
+    repository: ((docker-hub-organization))/((docker-hub-repository-prefix))-ci
+- name: ci-image-jdk21
   type: registry-image
   icon: docker
   source:
     <<: *ci-registry-image-resource-source
-    repository: ((docker-hub-organization))/spring-boot-ci-jdk20
+    repository: ((docker-hub-organization))/((docker-hub-repository-prefix))-ci-jdk21
 - name: paketo-builder-base-image
   type: registry-image
   icon: docker
   source:
-    repository: paketobuildpacks/builder
-    tag: base
+    repository: paketobuildpacks/builder-jammy-base
+    tag: latest
 - name: artifactory-repo
   type: artifactory-resource
   icon: package-variant
   source:
-    uri: ((artifactory-server))
+    uri: ((artifactory-url))
     username: ((artifactory-username))
     password: ((artifactory-password))
     build_name: ((build-name))
@@ -195,18 +195,18 @@ resources:
   type: github-status-resource
   icon: eye-check-outline
   source:
-    repository: ((github-repo-name))
+    repository: ((github-organization-name))/((github-repository-name))
     access_token: ((github-ci-status-token))
     branch: ((branch))
     context: build
-- name: repo-status-jdk20-build
+- name: repo-status-jdk21-build
   type: github-status-resource
   icon: eye-check-outline
   source:
-    repository: ((github-repo-name))
+    repository: ((github-organization-name))/((github-repository-name))
     access_token: ((github-ci-status-token))
     branch: ((branch))
-    context: jdk20-build
+    context: jdk21-build
 - name: slack-alert
   type: slack-notification
   icon: slack
@@ -242,20 +242,20 @@ jobs:
         image: ci-image
       vars:
         ci-image-name: ci-image
-    - task: build-ci-image-jdk20
+    - task: build-ci-image-jdk21
       privileged: true
       file: git-repo/ci/tasks/build-ci-image.yml
       output_mapping:
-        image: ci-image-jdk20
+        image: ci-image-jdk21
       vars:
-        ci-image-name: ci-image-jdk20
+        ci-image-name: ci-image-jdk21
   - in_parallel:
     - put: ci-image
       params:
         image: ci-image/image.tar
-    - put: ci-image-jdk20
+    - put: ci-image-jdk21
       params:
-        image: ci-image-jdk20/image.tar
+        image: ci-image-jdk21/image.tar
 - name: detect-jdk-updates
   plan:
   - get: git-repo
@@ -269,12 +269,12 @@ jobs:
       params:
         <<: *github-task-params
         JDK_VERSION: java17
-    - task: detect-jdk20-update
+    - task: detect-jdk21-update
       image: ci-image
       file: git-repo/ci/tasks/detect-jdk-updates.yml
       params:
         <<: *github-task-params
-        JDK_VERSION: java20
+        JDK_VERSION: java21
 - name: detect-ubuntu-image-updates
   plan:
   - get: git-repo
@@ -334,34 +334,34 @@ jobs:
   - put: slack-alert
     params:
       <<: *slack-success-params
-- name: jdk20-build
+- name: jdk21-build
   serial: true
   public: true
   plan:
-    - get: ci-image-jdk20
+    - get: ci-image-jdk21
     - get: git-repo
       trigger: true
-    - put: repo-status-jdk20-build
+    - put: repo-status-jdk21-build
       params: { state: "pending", commit: "git-repo" }
     - do:
         - task: build-project
-          image: ci-image-jdk20
+          image: ci-image-jdk21
           privileged: true
           timeout: ((task-timeout))
           file: git-repo/ci/tasks/build-project.yml
           params:
             BRANCH: ((branch))
-            TOOLCHAIN_JAVA_VERSION: 20
+            TOOLCHAIN_JAVA_VERSION: 21
             <<: *gradle-enterprise-task-params
             <<: *docker-hub-task-params
       on_failure:
         do:
-          - put: repo-status-jdk20-build
+          - put: repo-status-jdk21-build
             params: { state: "failure", commit: "git-repo" }
           - put: slack-alert
             params:
               <<: *slack-fail-params
-    - put: repo-status-jdk20-build
+    - put: repo-status-jdk21-build
       params: { state: "success", commit: "git-repo" }
     - put: slack-alert
       params:
@@ -402,13 +402,14 @@ jobs:
     timeout: ((task-timeout))
     file: git-repo/ci/tasks/stage.yml
     params:
+      FINAL_RELEASE: ((final-release))
       RELEASE_TYPE: M
       <<: *gradle-enterprise-task-params
       <<: *docker-hub-task-params
   - put: artifactory-repo
     params:
       <<: *artifactory-repo-put-params
-      repo: libs-staging-local
+      repo: ((artifactory-staging-repository))
     get_params:
       threads: 8
   - put: git-repo
@@ -425,13 +426,14 @@ jobs:
     timeout: ((task-timeout))
     file: git-repo/ci/tasks/stage.yml
     params:
+      FINAL_RELEASE: ((final-release))
       RELEASE_TYPE: RC
       <<: *gradle-enterprise-task-params
       <<: *docker-hub-task-params
   - put: artifactory-repo
     params:
       <<: *artifactory-repo-put-params
-      repo: libs-staging-local
+      repo: ((artifactory-staging-repository))
     get_params:
       threads: 8
   - put: git-repo
@@ -448,13 +450,14 @@ jobs:
     timeout: ((task-timeout))
     file: git-repo/ci/tasks/stage.yml
     params:
+      FINAL_RELEASE: ((final-release))
       RELEASE_TYPE: RELEASE
       <<: *gradle-enterprise-task-params
       <<: *docker-hub-task-params
   - put: artifactory-repo
     params:
       <<: *artifactory-repo-put-params
-      repo: libs-staging-local
+      repo: ((artifactory-staging-repository))
     get_params:
       threads: 8
   - put: git-repo
@@ -463,7 +466,6 @@ jobs:
 - name: promote-milestone
   serial: true
   plan:
-  - get: ci-image
   - get: git-repo
     trigger: false
   - get: artifactory-repo
@@ -473,7 +475,6 @@ jobs:
       download_artifacts: false
       save_build_info: true
   - task: promote
-    image: ci-image
     file: git-repo/ci/tasks/promote.yml
     params:
       RELEASE_TYPE: M
@@ -492,7 +493,6 @@ jobs:
 - name: promote-rc
   serial: true
   plan:
-  - get: ci-image
   - get: git-repo
     trigger: false
   - get: artifactory-repo
@@ -502,7 +502,6 @@ jobs:
       download_artifacts: false
       save_build_info: true
   - task: promote
-    image: ci-image
     file: git-repo/ci/tasks/promote.yml
     params:
       RELEASE_TYPE: RC
@@ -521,7 +520,6 @@ jobs:
 - name: promote-release
   serial: true
   plan:
-  - get: ci-image
   - get: git-repo
     trigger: false
   - get: artifactory-repo
@@ -532,7 +530,6 @@ jobs:
       save_build_info: true
       threads: 8
   - task: promote
-    image: ci-image
     file: git-repo/ci/tasks/promote.yml
     params:
       RELEASE_TYPE: RELEASE
@@ -580,7 +577,6 @@ jobs:
 - name: publish-to-sdkman
   serial: true
   plan:
-    - get: ci-image
     - get: git-repo
     - get: artifactory-repo
       passed: [create-github-release]
@@ -588,7 +584,6 @@ jobs:
         download_artifacts: false
         save_build_info: true
     - task: publish-to-sdkman
-      image: ci-image
       file: git-repo/ci/tasks/publish-to-sdkman.yml
       params:
         <<: *sdkman-task-params
@@ -636,11 +631,11 @@ jobs:
     - put: slack-alert
       params:
         <<: *slack-success-params
-- name: jdk20-run-system-tests
+- name: jdk21-run-system-tests
   serial: true
   public: true
   plan:
-    - get: ci-image-jdk20
+    - get: ci-image-jdk21
     - get: git-repo
     - get: paketo-builder-base-image
       trigger: true
@@ -648,13 +643,13 @@ jobs:
       trigger: true
     - do:
         - task: run-system-tests
-          image: ci-image-jdk20
+          image: ci-image-jdk21
           privileged: true
           timeout: ((task-timeout))
           file: git-repo/ci/tasks/run-system-tests.yml
           params:
             BRANCH: ((branch))
-            TOOLCHAIN_JAVA_VERSION: 20
+            TOOLCHAIN_JAVA_VERSION: 21
             <<: *gradle-enterprise-task-params
             <<: *docker-hub-task-params
       on_failure:
@@ -667,11 +662,11 @@ jobs:
         <<: *slack-success-params
 groups:
 - name: "builds"
-  jobs: ["build", "jdk20-build", "windows-build"]
+  jobs: ["build", "jdk21-build", "windows-build"]
 - name: "releases"
   jobs: ["stage-milestone", "stage-rc", "stage-release", "promote-milestone", "promote-rc", "promote-release", "create-github-release", "publish-gradle-plugin", "publish-to-sdkman", "update-homebrew-tap"]
 - name: "system-tests"
-  jobs: ["run-system-tests", "jdk20-run-system-tests"]
+  jobs: ["run-system-tests", "jdk21-run-system-tests"]
 - name: "ci-images"
   jobs: ["build-ci-images", "detect-docker-updates", "detect-jdk-updates", "detect-ubuntu-image-updates"]
 
diff --git a/ci/scripts/common.sh b/ci/scripts/common.sh
index 0c0f901c5072..bdc3e2b6075a 100644
--- a/ci/scripts/common.sh
+++ b/ci/scripts/common.sh
@@ -1,8 +1,7 @@
 source /opt/concourse-java.sh
 
 setup_symlinks
-if [[ -d $PWD/embedmongo && ! -d $HOME/.embedmongo ]]; then
-	ln -s "$PWD/embedmongo" "$HOME/.embedmongo"
-fi
 
 cleanup_maven_repo "org.springframework.boot"
+
+echo 'systemProp.user.name=concourse' > ~/.gradle/gradle.properties
diff --git a/ci/scripts/detect-jdk-updates.sh b/ci/scripts/detect-jdk-updates.sh
index 22dc5dcbd960..e54fc3b8f228 100755
--- a/ci/scripts/detect-jdk-updates.sh
+++ b/ci/scripts/detect-jdk-updates.sh
@@ -12,9 +12,9 @@ case "$JDK_VERSION" in
 		 BASE_URL="https://api.bell-sw.com/v1/liberica/releases?version-feature=17"
 		 ISSUE_TITLE="Upgrade Java 17 version in CI image and .sdkmanrc"
 	;;
-	java20)
-		 BASE_URL="https://api.bell-sw.com/v1/liberica/releases?version-feature=20"
-		 ISSUE_TITLE="Upgrade Java 20 version in CI image"
+	java21)
+		 BASE_URL="https://api.bell-sw.com/v1/liberica/releases?version-feature=21"
+		 ISSUE_TITLE="Upgrade Java 21 version in CI image"
 	;;
 	*)
 		echo $"Unknown java version"
diff --git a/ci/scripts/promote.sh b/ci/scripts/promote.sh
index 14ad9861e6e1..bd1600191a79 100755
--- a/ci/scripts/promote.sh
+++ b/ci/scripts/promote.sh
@@ -1,13 +1,17 @@
 #!/bin/bash
 
-source $(dirname $0)/common.sh
+CONFIG_DIR=git-repo/ci/config
 
 version=$( cat artifactory-repo/build-info.json | jq -r '.buildInfo.modules[0].id' | sed 's/.*:.*:\(.*\)/\1/' )
 export BUILD_INFO_LOCATION=$(pwd)/artifactory-repo/build-info.json
 
-java -jar /spring-boot-release-scripts.jar publishToCentral $RELEASE_TYPE $BUILD_INFO_LOCATION artifactory-repo || { exit 1; }
+java -jar /concourse-release-scripts.jar \
+  --spring.config.location=${CONFIG_DIR}/release-scripts.yml \
+  publishToCentral $RELEASE_TYPE $BUILD_INFO_LOCATION artifactory-repo || { exit 1; }
 
-java -jar /spring-boot-release-scripts.jar promote $RELEASE_TYPE $BUILD_INFO_LOCATION || { exit 1; }
+java -jar /concourse-release-scripts.jar \
+  --spring.config.location=${CONFIG_DIR}/release-scripts.yml \
+  promote $RELEASE_TYPE $BUILD_INFO_LOCATION || { exit 1; }
 
 echo "Promotion complete"
 echo $version > version/version
diff --git a/ci/scripts/publish-to-sdkman.sh b/ci/scripts/publish-to-sdkman.sh
index 00bb6adc26e5..9cf273d2345e 100755
--- a/ci/scripts/publish-to-sdkman.sh
+++ b/ci/scripts/publish-to-sdkman.sh
@@ -1,9 +1,11 @@
 #!/bin/bash
 
-source $(dirname $0)/common.sh
+CONFIG_DIR=git-repo/ci/config
 
 version=$( cat artifactory-repo/build-info.json | jq -r '.buildInfo.modules[0].id' | sed 's/.*:.*:\(.*\)/\1/' )
 
-java -jar /spring-boot-release-scripts.jar publishToSdkman $RELEASE_TYPE $version $LATEST_GA || { exit 1; }
+java -jar /concourse-release-scripts.jar \
+  --spring.config.location=${CONFIG_DIR}/release-scripts.yml \
+  publishToSdkman $RELEASE_TYPE $version $LATEST_GA || { exit 1; }
 
 echo "Push to SDKMAN complete"
diff --git a/ci/scripts/stage.sh b/ci/scripts/stage.sh
index bfc2690198e5..981aeb2ec7ca 100755
--- a/ci/scripts/stage.sh
+++ b/ci/scripts/stage.sh
@@ -38,7 +38,7 @@ git tag -a "v$stageVersion" -m"Release v$stageVersion" > /dev/null
 ./gradlew --no-daemon --max-workers=4 -PdeploymentRepository=${repository} build publishAllPublicationsToDeploymentRepository
 
 git reset --hard HEAD^ > /dev/null
-if [[ $nextVersion != $snapshotVersion ]]; then
+if [[ $FINAL_RELEASE != true && $nextVersion != $snapshotVersion ]]; then
 	echo "Setting next development version (v$nextVersion)"
 	sed -i "s/version=$snapshotVersion/version=$nextVersion/" gradle.properties
 	git add gradle.properties > /dev/null
diff --git a/ci/tasks/build-project.yml b/ci/tasks/build-project.yml
index db3003af6e0c..ccc12d032430 100644
--- a/ci/tasks/build-project.yml
+++ b/ci/tasks/build-project.yml
@@ -7,7 +7,7 @@ outputs:
 - name: git-repo
 caches:
 - path: gradle
-- path: embedmongo
+- path: maven
 params:
   BRANCH:
   CI: true
diff --git a/ci/tasks/promote.yml b/ci/tasks/promote.yml
index 2e5f9d36b65b..aa700a1bf11f 100644
--- a/ci/tasks/promote.yml
+++ b/ci/tasks/promote.yml
@@ -1,17 +1,25 @@
 ---
 platform: linux
+image_resource:
+  type: registry-image
+  source:
+    repository: springio/concourse-release-scripts
+    tag: '0.4.0'
+    username: ((docker-hub-username))
+    password: ((docker-hub-password))
 inputs:
-- name: git-repo
-- name: artifactory-repo
+  - name: git-repo
+  - name: artifactory-repo
 outputs:
-- name: version
+  - name: version
 params:
   RELEASE_TYPE:
-  ARTIFACTORY_SERVER:
+  ARTIFACTORY_URL:
   ARTIFACTORY_USERNAME:
   ARTIFACTORY_PASSWORD:
-  SONATYPE_USER_TOKEN:
-  SONATYPE_PASSWORD_TOKEN:
+  SONATYPE_USERNAME:
+  SONATYPE_PASSWORD:
+  SONATYPE_URL:
   SONATYPE_STAGING_PROFILE_ID:
 run:
   path: git-repo/ci/scripts/promote.sh
diff --git a/ci/tasks/publish-to-sdkman.yml b/ci/tasks/publish-to-sdkman.yml
index 0215e2943bee..3b9fa93ebbb4 100755
--- a/ci/tasks/publish-to-sdkman.yml
+++ b/ci/tasks/publish-to-sdkman.yml
@@ -1,8 +1,15 @@
 ---
 platform: linux
+image_resource:
+  type: registry-image
+  source:
+    repository: springio/concourse-release-scripts
+    tag: '0.4.0'
+    username: ((docker-hub-username))
+    password: ((docker-hub-password))
 inputs:
-  - name: artifactory-repo
   - name: git-repo
+  - name: artifactory-repo
 params:
   RELEASE_TYPE:
   BRANCH:
diff --git a/ci/tasks/stage.yml b/ci/tasks/stage.yml
index 3ea8550eb524..feff204848e7 100644
--- a/ci/tasks/stage.yml
+++ b/ci/tasks/stage.yml
@@ -8,6 +8,7 @@ outputs:
 params:
   RELEASE_TYPE:
   CI: true
+  FINAL_RELEASE:
   GRADLE_ENTERPRISE_CACHE_URL:
   GRADLE_ENTERPRISE_CACHE_USERNAME:
   GRADLE_ENTERPRISE_CACHE_PASSWORD:
diff --git a/eclipse/spring-boot-project.setup b/eclipse/spring-boot-project.setup
index 6cd6605e50d8..0e060372d33f 100644
--- a/eclipse/spring-boot-project.setup
+++ b/eclipse/spring-boot-project.setup
@@ -11,8 +11,8 @@
     xmlns:setup.workingsets="http://www.eclipse.org/oomph/setup/workingsets/1.0"
     xmlns:workingsets="http://www.eclipse.org/oomph/workingsets/1.0"
     xsi:schemaLocation="http://www.eclipse.org/oomph/setup/jdt/1.0 http://git.eclipse.org/c/oomph/org.eclipse.oomph.git/plain/setups/models/JDT.ecore http://www.eclipse.org/buildship/oomph/1.0 https://raw.githubusercontent.com/eclipse/buildship/master/org.eclipse.buildship.oomph/model/GradleImport-1.0.ecore http://www.eclipse.org/oomph/predicates/1.0 http://git.eclipse.org/c/oomph/org.eclipse.oomph.git/plain/setups/models/Predicates.ecore http://www.eclipse.org/oomph/setup/workingsets/1.0 http://git.eclipse.org/c/oomph/org.eclipse.oomph.git/plain/setups/models/SetupWorkingSets.ecore http://www.eclipse.org/oomph/workingsets/1.0 http://git.eclipse.org/c/oomph/org.eclipse.oomph.git/plain/setups/models/WorkingSets.ecore"
-    name="spring.boot.3.1.x"
-    label="Spring Boot 3.1.x">
+    name="spring.boot.3.2.x"
+    label="Spring Boot 3.2.x">
   <setupTask
       xsi:type="setup:VariableTask"
       type="FOLDER"
@@ -136,7 +136,7 @@
         name="spring-boot-tools">
       <predicate
           xsi:type="predicates:NamePredicate"
-          pattern="spring-boot-(tools|antlib|configuration-.*|loader|.*-tools|.*-layertools|.*-plugin|autoconfigure-processor|buildpack.*)"/>
+          pattern="spring-boot-(tools|antlib|configuration-.*|loader|loader-classic|.*-tools|.*-layertools|.*-plugin|autoconfigure-processor|buildpack.*)"/>
     </workingSet>
     <workingSet
         name="spring-boot-starters">
diff --git a/git/hooks/prepare-forward-merge b/git/hooks/prepare-forward-merge
index e9f03a32b594..786afcfb312a 100755
--- a/git/hooks/prepare-forward-merge
+++ b/git/hooks/prepare-forward-merge
@@ -4,7 +4,7 @@ require 'net/http'
 require 'yaml'
 require 'logger'
 
-$main_branch = "3.1.x"
+$main_branch = "3.2.x"
 
 $log = Logger.new(STDOUT)
 $log.level = Logger::WARN
diff --git a/gradle.properties b/gradle.properties
index c49004089b08..2fa85a373d54 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,12 +1,18 @@
-version=3.1.2-SNAPSHOT
+version=3.2.0
 
 org.gradle.caching=true
 org.gradle.parallel=true
 org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
 
-kotlinVersion=1.8.22
-nativeBuildToolsVersion=0.9.23
-springFrameworkVersion=6.0.11
-tomcatVersion=10.1.11
+assertjVersion=3.24.2
+commonsCodecVersion=1.16.0
+hamcrestVersion=2.2
+jacksonVersion=2.15.3
+junitJupiterVersion=5.10.1
+kotlinVersion=1.9.20
+mavenVersion=3.9.4
+nativeBuildToolsVersion=0.9.28
+springFrameworkVersion=6.1.1
+tomcatVersion=10.1.16
 
 kotlin.stdlib.default.dependency=false
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 4e86b9270786..b1624c473c42 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-bin.zip
 networkTimeout=10000
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
diff --git a/idea/codeStyleConfig.xml b/idea/codeStyleConfig.xml
deleted file mode 100644
index 84141ac28fda..000000000000
--- a/idea/codeStyleConfig.xml
+++ /dev/null
@@ -1,128 +0,0 @@
-<code_scheme name="SpringBoot" version="173">
-	<option name="AUTODETECT_INDENTS" value="false"/>
-	<option name="OTHER_INDENT_OPTIONS">
-		<value>
-			<option name="USE_TAB_CHARACTER" value="true"/>
-		</value>
-	</option>
-	<option name="CLASS_COUNT_TO_USE_IMPORT_ON_DEMAND" value="50"/>
-	<option name="NAMES_COUNT_TO_USE_IMPORT_ON_DEMAND" value="500"/>
-	<option name="IMPORT_LAYOUT_TABLE">
-		<value>
-			<package name="java" withSubpackages="true" static="false"/>
-			<emptyLine/>
-			<package name="javax" withSubpackages="true" static="false"/>
-			<emptyLine/>
-			<package name="" withSubpackages="true" static="false"/>
-			<emptyLine/>
-			<package name="org.springframework" withSubpackages="true" static="false"/>
-			<emptyLine/>
-			<package name="" withSubpackages="true" static="true"/>
-		</value>
-	</option>
-	<option name="RIGHT_MARGIN" value="90"/>
-	<option name="ENABLE_JAVADOC_FORMATTING" value="false"/>
-	<option name="JD_ALIGN_PARAM_COMMENTS" value="false"/>
-	<option name="JD_ALIGN_EXCEPTION_COMMENTS" value="false"/>
-	<option name="JD_KEEP_EMPTY_LINES" value="false"/>
-	<GroovyCodeStyleSettings>
-		<option name="CLASS_COUNT_TO_USE_IMPORT_ON_DEMAND" value="500"/>
-		<option name="NAMES_COUNT_TO_USE_IMPORT_ON_DEMAND" value="500"/>
-		<option name="IMPORT_LAYOUT_TABLE">
-			<value>
-				<emptyLine/>
-				<package name="javax" withSubpackages="true" static="false"/>
-				<package name="java" withSubpackages="true" static="false"/>
-				<emptyLine/>
-				<package name="" withSubpackages="true" static="false"/>
-				<emptyLine/>
-				<package name="org.springframework" withSubpackages="true"
-						 static="false"/>
-				<emptyLine/>
-				<package name="" withSubpackages="true" static="true"/>
-			</value>
-		</option>
-	</GroovyCodeStyleSettings>
-	<JavaCodeStyleSettings>
-		<option name="CLASS_NAMES_IN_JAVADOC" value="3"/>
-		<option name="INSERT_INNER_CLASS_IMPORTS" value="true"/>
-		<option name="CLASS_COUNT_TO_USE_IMPORT_ON_DEMAND" value="50"/>
-		<option name="NAMES_COUNT_TO_USE_IMPORT_ON_DEMAND" value="500"/>
-		<option name="PACKAGES_TO_USE_IMPORT_ON_DEMAND">
-			<value/>
-		</option>
-		<option name="IMPORT_LAYOUT_TABLE">
-			<value>
-				<package name="java" withSubpackages="true" static="false"/>
-				<emptyLine/>
-				<package name="javax" withSubpackages="true" static="false"/>
-				<emptyLine/>
-				<package name="" withSubpackages="true" static="false"/>
-				<emptyLine/>
-				<package name="org.springframework" withSubpackages="true"
-						 static="false"/>
-				<emptyLine/>
-				<package name="" withSubpackages="true" static="true"/>
-			</value>
-		</option>
-		<option name="ENABLE_JAVADOC_FORMATTING" value="false"/>
-		<option name="JD_ALIGN_PARAM_COMMENTS" value="false"/>
-		<option name="JD_ALIGN_EXCEPTION_COMMENTS" value="false"/>
-		<option name="JD_KEEP_INVALID_TAGS" value="false"/>
-		<option name="JD_KEEP_EMPTY_LINES" value="false"/>
-	</JavaCodeStyleSettings>
-	<JetCodeStyleSettings>
-		<option name="PACKAGES_TO_USE_STAR_IMPORTS">
-			<value>
-				<package name="java.util" withSubpackages="false" static="false"/>
-				<package name="kotlinx.android.synthetic" withSubpackages="false"
-						 static="false"/>
-			</value>
-		</option>
-		<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="20"/>
-		<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="20"/>
-	</JetCodeStyleSettings>
-	<XML>
-		<option name="XML_LEGACY_SETTINGS_IMPORTED" value="true"/>
-	</XML>
-	<editorconfig>
-		<option name="ENABLED" value="false"/>
-	</editorconfig>
-	<codeStyleSettings language="Groovy">
-		<indentOptions>
-			<option name="USE_TAB_CHARACTER" value="true"/>
-		</indentOptions>
-	</codeStyleSettings>
-	<codeStyleSettings language="JAVA">
-		<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="1"/>
-		<option name="BLANK_LINES_AROUND_FIELD" value="1"/>
-		<option name="BLANK_LINES_AROUND_FIELD_IN_INTERFACE" value="1"/>
-		<option name="ELSE_ON_NEW_LINE" value="true"/>
-		<option name="CATCH_ON_NEW_LINE" value="true"/>
-		<option name="FINALLY_ON_NEW_LINE" value="true"/>
-		<option name="ALIGN_MULTILINE_PARAMETERS" value="false"/>
-		<option name="SPACE_WITHIN_ARRAY_INITIALIZER_BRACES" value="true"/>
-		<option name="SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE" value="true"/>
-		<option name="BINARY_OPERATION_SIGN_ON_NEXT_LINE" value="true"/>
-		<option name="KEEP_SIMPLE_CLASSES_IN_ONE_LINE" value="true"/>
-		<option name="KEEP_MULTIPLE_EXPRESSIONS_IN_ONE_LINE" value="true"/>
-		<indentOptions>
-			<option name="USE_TAB_CHARACTER" value="true"/>
-		</indentOptions>
-	</codeStyleSettings>
-	<codeStyleSettings language="JSON">
-		<indentOptions>
-			<option name="TAB_SIZE" value="2"/>
-		</indentOptions>
-	</codeStyleSettings>
-	<codeStyleSettings language="XML">
-		<indentOptions>
-			<option name="USE_TAB_CHARACTER" value="true"/>
-		</indentOptions>
-	</codeStyleSettings>
-	<codeStyleSettings language="kotlin">
-		<indentOptions>
-			<option name="USE_TAB_CHARACTER" value="true"/>
-		</indentOptions>
-	</codeStyleSettings>
-</code_scheme>
diff --git a/settings.gradle b/settings.gradle
index 101c44479d93..9dc4ab6ee40d 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -53,11 +53,13 @@ include "spring-boot-project:spring-boot-tools:spring-boot-autoconfigure-process
 include "spring-boot-project:spring-boot-tools:spring-boot-buildpack-platform"
 include "spring-boot-project:spring-boot-tools:spring-boot-cli"
 include "spring-boot-project:spring-boot-tools:spring-boot-configuration-metadata"
+include "spring-boot-project:spring-boot-tools:spring-boot-configuration-metadata-changelog-generator"
 include "spring-boot-project:spring-boot-tools:spring-boot-configuration-processor"
 include "spring-boot-project:spring-boot-tools:spring-boot-gradle-plugin"
 include "spring-boot-project:spring-boot-tools:spring-boot-gradle-test-support"
 include "spring-boot-project:spring-boot-tools:spring-boot-jarmode-layertools"
 include "spring-boot-project:spring-boot-tools:spring-boot-loader"
+include "spring-boot-project:spring-boot-tools:spring-boot-loader-classic"
 include "spring-boot-project:spring-boot-tools:spring-boot-loader-tools"
 include "spring-boot-project:spring-boot-tools:spring-boot-maven-plugin"
 include "spring-boot-project:spring-boot-tools:spring-boot-properties-migrator"
@@ -75,6 +77,7 @@ include "spring-boot-project:spring-boot-test-autoconfigure"
 include "spring-boot-tests:spring-boot-integration-tests:spring-boot-configuration-processor-tests"
 include "spring-boot-tests:spring-boot-integration-tests:spring-boot-launch-script-tests"
 include "spring-boot-tests:spring-boot-integration-tests:spring-boot-loader-tests"
+include "spring-boot-tests:spring-boot-integration-tests:spring-boot-loader-classic-tests"
 include "spring-boot-tests:spring-boot-integration-tests:spring-boot-server-tests"
 include "spring-boot-system-tests:spring-boot-deployment-tests"
 include "spring-boot-system-tests:spring-boot-image-tests"
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle b/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle
index 078187aff0cd..c901a3fc0af2 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle
@@ -37,7 +37,7 @@ dependencies {
 	optional("io.dropwizard.metrics:metrics-jmx")
 	optional("io.lettuce:lettuce-core")
 	optional("io.micrometer:micrometer-observation")
-	optional("io.micrometer:micrometer-core")
+	optional("io.micrometer:micrometer-jakarta9")
 	optional("io.micrometer:micrometer-tracing")
 	optional("io.micrometer:micrometer-tracing-bridge-brave")
 	optional("io.micrometer:micrometer-tracing-bridge-otel")
@@ -68,11 +68,13 @@ dependencies {
 	optional("io.micrometer:micrometer-registry-signalfx")
 	optional("io.micrometer:micrometer-registry-statsd")
 	optional("io.micrometer:micrometer-registry-wavefront")
+	optional("io.zipkin.reporter2:zipkin-reporter-brave")
 	optional("io.zipkin.reporter2:zipkin-sender-urlconnection")
 	optional("io.opentelemetry:opentelemetry-exporter-zipkin")
 	optional("io.opentelemetry:opentelemetry-exporter-otlp")
 	optional("io.projectreactor.netty:reactor-netty-http")
 	optional("io.r2dbc:r2dbc-pool")
+	optional("io.r2dbc:r2dbc-proxy")
 	optional("io.r2dbc:r2dbc-spi")
 	optional("jakarta.jms:jakarta.jms-api")
 	optional("jakarta.persistence:jakarta.persistence-api")
@@ -159,9 +161,7 @@ dependencies {
 	testImplementation("org.assertj:assertj-core")
 	testImplementation("org.awaitility:awaitility")
 	testImplementation("org.cache2k:cache2k-api")
-	testImplementation("org.eclipse.jetty:jetty-webapp") {
-		exclude group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-servlet-api"
-	}
+	testImplementation("org.eclipse.jetty.ee10:jetty-ee10-webapp")
 	testImplementation("org.glassfish.jersey.ext:jersey-spring6")
 	testImplementation("org.glassfish.jersey.media:jersey-media-json-jackson")
 	testImplementation("org.hamcrest:hamcrest")
@@ -249,8 +249,12 @@ tasks.withType(org.asciidoctor.gradle.jvm.AbstractAsciidoctorTask) {
 	dependsOn dependencyVersions
 	doFirst {
 		def versionConstraints = dependencyVersions.versionConstraints
+		def toAntoraVersion = version -> {
+			String formatted = version.split("\\.").take(2).join('.')
+			return version.endsWith("-SNAPSHOT") ? formatted + "-SNAPSHOT" : formatted
+		}
 		def integrationVersion = versionConstraints["org.springframework.integration:spring-integration-core"]
-		def integrationDocs = String.format("https://docs.spring.io/spring-integration/docs/%s/reference/html/", integrationVersion)
+		String integrationDocs = String.format("https://docs.spring.io/spring-integration/reference/%s", toAntoraVersion(integrationVersion))
 		attributes "spring-integration-docs": integrationDocs
 	}
 	dependsOn documentationTest
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/env.adoc b/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/env.adoc
index ac5f2a7568e2..4e75bfec4f2d 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/env.adoc
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/env.adoc
@@ -14,6 +14,7 @@ The resulting response is similar to the following:
 
 include::{snippets}/env/all/http-response.adoc[]
 
+NOTE: Sanitization of sensitive values has been switched off for this example.
 
 
 [[env.entire.response-structure]]
@@ -37,7 +38,7 @@ The resulting response is similar to the following:
 
 include::{snippets}/env/single/http-response.adoc[]
 
-
+NOTE: Sanitization of sensitive values has been switched off for this example.
 
 [[env.single-property.response-structure]]
 === Response Structure
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/integrationgraph.adoc b/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/integrationgraph.adoc
index 5709ca935833..dad34a128c32 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/integrationgraph.adoc
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/integrationgraph.adoc
@@ -19,7 +19,7 @@ include::{snippets}/integrationgraph/graph/http-response.adoc[]
 [[integrationgraph.retrieving.response-structure]]
 === Response Structure
 The response contains all Spring Integration components used within the application, as well as the links between them.
-More information about the structure can be found in the {spring-integration-docs}index-single.html#integration-graph[reference documentation].
+More information about the structure can be found in the {spring-integration-docs}/index.html#integration-graph[reference documentation].
 
 
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityService.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityService.java
index 4add4c094315..d75725e68d6f 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityService.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityService.java
@@ -53,8 +53,6 @@ class ReactiveCloudFoundrySecurityService {
 
 	private final String cloudControllerUrl;
 
-	private Mono<String> uaaUrl;
-
 	ReactiveCloudFoundrySecurityService(WebClient.Builder webClientBuilder, String cloudControllerUrl,
 			boolean skipSslValidation) {
 		Assert.notNull(webClientBuilder, "WebClient must not be null");
@@ -149,7 +147,7 @@ private Map<String, String> extractTokenKeys(Map<String, Object> response) {
 	 * @return the UAA url Mono
 	 */
 	Mono<String> getUaaUrl() {
-		this.uaaUrl = this.webClient.get()
+		return this.webClient.get()
 			.uri(this.cloudControllerUrl + "/info")
 			.retrieve()
 			.bodyToMono(Map.class)
@@ -157,7 +155,6 @@ Mono<String> getUaaUrl() {
 			.cache()
 			.onErrorMap((ex) -> new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE,
 					"Unable to fetch token keys from UAA."));
-		return this.uaaUrl;
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveTokenValidator.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveTokenValidator.java
index e31fc3797aee..e9f6dd5a62a9 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveTokenValidator.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveTokenValidator.java
@@ -24,9 +24,8 @@
 import java.security.spec.InvalidKeySpecException;
 import java.security.spec.X509EncodedKeySpec;
 import java.util.Base64;
+import java.util.Collections;
 import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.TimeUnit;
 
 import reactor.core.publisher.Mono;
@@ -44,7 +43,7 @@ class ReactiveTokenValidator {
 
 	private final ReactiveCloudFoundrySecurityService securityService;
 
-	private volatile ConcurrentMap<String, String> cachedTokenKeys = new ConcurrentHashMap<>();
+	private volatile Map<String, String> cachedTokenKeys = Collections.emptyMap();
 
 	ReactiveTokenValidator(ReactiveCloudFoundrySecurityService securityService) {
 		this.securityService = securityService;
@@ -92,7 +91,7 @@ private Mono<String> getTokenKey(Token token) {
 	}
 
 	private void cacheTokenKeys(Map<String, String> tokenKeys) {
-		this.cachedTokenKeys = new ConcurrentHashMap<>(tokenKeys);
+		this.cachedTokenKeys = Map.copyOf(tokenKeys);
 	}
 
 	private boolean hasValidSignature(Token token, String key) {
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfiguration.java
index 35ba2cab29e9..08a688c53bef 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfiguration.java
@@ -67,7 +67,6 @@
 import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
 import org.springframework.security.web.util.matcher.OrRequestMatcher;
 import org.springframework.security.web.util.matcher.RequestMatcher;
-import org.springframework.util.CollectionUtils;
 import org.springframework.web.cors.CorsConfiguration;
 import org.springframework.web.servlet.DispatcherServlet;
 
@@ -125,8 +124,8 @@ public CloudFoundryWebEndpointServletHandlerMapping cloudFoundryWebEndpointServl
 		allEndpoints.addAll(webEndpoints);
 		allEndpoints.addAll(servletEndpointsSupplier.getEndpoints());
 		allEndpoints.addAll(controllerEndpointsSupplier.getEndpoints());
-		return new CloudFoundryWebEndpointServletHandlerMapping(new EndpointMapping("/cloudfoundryapplication"),
-				webEndpoints, endpointMediaTypes, getCorsConfiguration(), securityInterceptor, allEndpoints);
+		return new CloudFoundryWebEndpointServletHandlerMapping(new EndpointMapping(BASE_PATH), webEndpoints,
+				endpointMediaTypes, getCorsConfiguration(), securityInterceptor, allEndpoints);
 	}
 
 	private CloudFoundrySecurityInterceptor getSecurityInterceptor(RestTemplateBuilder restTemplateBuilder,
@@ -189,9 +188,7 @@ public void customize(WebSecurity web) {
 				.forEach((path) -> requestMatchers.add(new AntPathRequestMatcher(path + "/**")));
 			requestMatchers.add(new AntPathRequestMatcher(BASE_PATH));
 			requestMatchers.add(new AntPathRequestMatcher(BASE_PATH + "/"));
-			if (!CollectionUtils.isEmpty(requestMatchers)) {
-				web.ignoring().requestMatchers(new OrRequestMatcher(requestMatchers));
-			}
+			web.ignoring().requestMatchers(new OrRequestMatcher(requestMatchers));
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityInterceptor.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityInterceptor.java
index 01eafcf65125..c5c4b2c8e4d2 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityInterceptor.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityInterceptor.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -86,7 +86,7 @@ SecurityResponse preHandle(HttpServletRequest request, EndpointId endpointId) {
 		return SecurityResponse.success();
 	}
 
-	private void check(HttpServletRequest request, EndpointId endpointId) throws Exception {
+	private void check(HttpServletRequest request, EndpointId endpointId) {
 		Token token = getToken(request);
 		this.tokenValidator.validate(token);
 		AccessLevel accessLevel = this.cloudFoundrySecurityService.getAccessLevel(token.toString(), this.applicationId);
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/condition/OnAvailableEndpointCondition.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/condition/OnAvailableEndpointCondition.java
index 4478b0ed9426..a485aa2a4a10 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/condition/OnAvailableEndpointCondition.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/condition/OnAvailableEndpointCondition.java
@@ -143,11 +143,8 @@ private ConditionOutcome getEnablementOutcome(Environment environment,
 	}
 
 	private Boolean isEnabledByDefault(Environment environment) {
-		Optional<Boolean> enabledByDefault = enabledByDefaultCache.get(environment);
-		if (enabledByDefault == null) {
-			enabledByDefault = Optional.ofNullable(environment.getProperty(ENABLED_BY_DEFAULT_KEY, Boolean.class));
-			enabledByDefaultCache.put(environment, enabledByDefault);
-		}
+		Optional<Boolean> enabledByDefault = enabledByDefaultCache.computeIfAbsent(environment,
+				(ignore) -> Optional.ofNullable(environment.getProperty(ENABLED_BY_DEFAULT_KEY, Boolean.class)));
 		return enabledByDefault.orElse(null);
 	}
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/expose/IncludeExcludeEndpointFilter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/expose/IncludeExcludeEndpointFilter.java
index d2c738b5e2ec..00affa4cfff3 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/expose/IncludeExcludeEndpointFilter.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/expose/IncludeExcludeEndpointFilter.java
@@ -150,7 +150,7 @@ private static class EndpointPatterns {
 		private final Set<EndpointId> endpointIds;
 
 		EndpointPatterns(String[] patterns) {
-			this((patterns != null) ? Arrays.asList(patterns) : (Collection<String>) null);
+			this((patterns != null) ? Arrays.asList(patterns) : null);
 		}
 
 		EndpointPatterns(Collection<String> patterns) {
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/jersey/JerseyWebEndpointManagementContextConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/jersey/JerseyWebEndpointManagementContextConfiguration.java
index 24736e2647d0..dd7a8668ca4c 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/jersey/JerseyWebEndpointManagementContextConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/jersey/JerseyWebEndpointManagementContextConfiguration.java
@@ -103,7 +103,8 @@ JerseyAdditionalHealthEndpointPathsManagementResourcesRegistrar jerseyDifferentP
 		ExposableWebEndpoint health = webEndpoints.stream()
 			.filter((endpoint) -> endpoint.getEndpointId().equals(HEALTH_ENDPOINT_ID))
 			.findFirst()
-			.get();
+			.orElseThrow(
+					() -> new IllegalStateException("No endpoint with id '%s' found".formatted(HEALTH_ENDPOINT_ID)));
 		return new JerseyAdditionalHealthEndpointPathsManagementResourcesRegistrar(health, healthEndpointGroups);
 	}
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/reactive/WebFluxEndpointManagementContextConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/reactive/WebFluxEndpointManagementContextConfiguration.java
index 9e117dd3b7c9..94ea4766f50f 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/reactive/WebFluxEndpointManagementContextConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/reactive/WebFluxEndpointManagementContextConfiguration.java
@@ -120,7 +120,8 @@ public AdditionalHealthEndpointPathsWebFluxHandlerMapping managementHealthEndpoi
 		ExposableWebEndpoint health = webEndpoints.stream()
 			.filter((endpoint) -> endpoint.getEndpointId().equals(HealthEndpoint.ID))
 			.findFirst()
-			.get();
+			.orElseThrow(
+					() -> new IllegalStateException("No endpoint with id '%s' found".formatted(HealthEndpoint.ID)));
 		return new AdditionalHealthEndpointPathsWebFluxHandlerMapping(new EndpointMapping(""), health,
 				groups.getAllWithAdditionalPath(WebServerNamespace.MANAGEMENT));
 	}
@@ -162,16 +163,16 @@ static class ServerCodecConfigurerEndpointObjectMapperBeanPostProcessor implemen
 
 		@Override
 		public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
-			if (bean instanceof ServerCodecConfigurer) {
-				process((ServerCodecConfigurer) bean);
+			if (bean instanceof ServerCodecConfigurer serverCodecConfigurer) {
+				process(serverCodecConfigurer);
 			}
 			return bean;
 		}
 
 		private void process(ServerCodecConfigurer configurer) {
 			for (HttpMessageWriter<?> writer : configurer.getWriters()) {
-				if (writer instanceof EncoderHttpMessageWriter) {
-					process(((EncoderHttpMessageWriter<?>) writer).getEncoder());
+				if (writer instanceof EncoderHttpMessageWriter<?> encoderHttpMessageWriter) {
+					process((encoderHttpMessageWriter).getEncoder());
 				}
 			}
 		}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/servlet/WebMvcEndpointManagementContextConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/servlet/WebMvcEndpointManagementContextConfiguration.java
index f271c663ab95..451e08b61396 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/servlet/WebMvcEndpointManagementContextConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/servlet/WebMvcEndpointManagementContextConfiguration.java
@@ -115,7 +115,8 @@ public AdditionalHealthEndpointPathsWebMvcHandlerMapping managementHealthEndpoin
 		ExposableWebEndpoint health = webEndpoints.stream()
 			.filter((endpoint) -> endpoint.getEndpointId().equals(HealthEndpoint.ID))
 			.findFirst()
-			.get();
+			.orElseThrow(
+					() -> new IllegalStateException("No endpoint with id '%s' found".formatted(HealthEndpoint.ID)));
 		return new AdditionalHealthEndpointPathsWebMvcHandlerMapping(health,
 				groups.getAllWithAdditionalPath(WebServerNamespace.MANAGEMENT));
 	}
@@ -157,8 +158,8 @@ static class EndpointObjectMapperWebMvcConfigurer implements WebMvcConfigurer {
 		@Override
 		public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
 			for (HttpMessageConverter<?> converter : converters) {
-				if (converter instanceof MappingJackson2HttpMessageConverter) {
-					configure((MappingJackson2HttpMessageConverter) converter);
+				if (converter instanceof MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter) {
+					configure(mappingJackson2HttpMessageConverter);
 				}
 			}
 		}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/AbstractCompositeHealthContributorConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/AbstractCompositeHealthContributorConfiguration.java
index 5a7454b08a50..3b5aeda866da 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/AbstractCompositeHealthContributorConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/AbstractCompositeHealthContributorConfiguration.java
@@ -16,12 +16,9 @@
 
 package org.springframework.boot.actuate.autoconfigure.health;
 
-import java.lang.reflect.Constructor;
 import java.util.Map;
 import java.util.function.Function;
 
-import org.springframework.beans.BeanUtils;
-import org.springframework.core.ResolvableType;
 import org.springframework.util.Assert;
 
 /**
@@ -39,18 +36,6 @@ public abstract class AbstractCompositeHealthContributorConfiguration<C, I exten
 
 	private final Function<B, I> indicatorFactory;
 
-	/**
-	 * Creates a {@code AbstractCompositeHealthContributorConfiguration} that will use
-	 * reflection to create health indicator instances.
-	 * @deprecated since 3.0.0 in favor of
-	 * {@link #AbstractCompositeHealthContributorConfiguration(Function)}
-	 */
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	protected AbstractCompositeHealthContributorConfiguration() {
-		this.indicatorFactory = new ReflectionIndicatorFactory(
-				ResolvableType.forClass(AbstractCompositeHealthContributorConfiguration.class, getClass()));
-	}
-
 	/**
 	 * Creates a {@code AbstractCompositeHealthContributorConfiguration} that will use the
 	 * given {@code indicatorFactory} to create health indicator instances.
@@ -75,34 +60,4 @@ protected I createIndicator(B bean) {
 		return this.indicatorFactory.apply(bean);
 	}
 
-	private class ReflectionIndicatorFactory implements Function<B, I> {
-
-		private final Class<?> indicatorType;
-
-		private final Class<?> beanType;
-
-		ReflectionIndicatorFactory(ResolvableType type) {
-			this.indicatorType = type.resolveGeneric(1);
-			this.beanType = type.resolveGeneric(2);
-		}
-
-		@Override
-		public I apply(B bean) {
-			try {
-				return BeanUtils.instantiateClass(getConstructor(), bean);
-			}
-			catch (Exception ex) {
-				throw new IllegalStateException("Unable to create health indicator %s for bean type %s"
-					.formatted(this.indicatorType, this.beanType), ex);
-			}
-
-		}
-
-		@SuppressWarnings("unchecked")
-		private Constructor<I> getConstructor() throws NoSuchMethodException {
-			return (Constructor<I>) this.indicatorType.getDeclaredConstructor(this.beanType);
-		}
-
-	}
-
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/CompositeHealthContributorConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/CompositeHealthContributorConfiguration.java
index 7901e1307552..4b979e94d19e 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/CompositeHealthContributorConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/CompositeHealthContributorConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -36,18 +36,6 @@
 public abstract class CompositeHealthContributorConfiguration<I extends HealthIndicator, B>
 		extends AbstractCompositeHealthContributorConfiguration<HealthContributor, I, B> {
 
-	/**
-	 * Creates a {@code CompositeHealthContributorConfiguration} that will use reflection
-	 * to create {@link HealthIndicator} instances.
-	 * @deprecated since 3.0.0 in favor of
-	 * {@link #CompositeHealthContributorConfiguration(Function)}
-	 */
-	@SuppressWarnings("removal")
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	public CompositeHealthContributorConfiguration() {
-		super();
-	}
-
 	/**
 	 * Creates a {@code CompositeHealthContributorConfiguration} that will use the given
 	 * {@code indicatorFactory} to create {@link HealthIndicator} instances.
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/CompositeReactiveHealthContributorConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/CompositeReactiveHealthContributorConfiguration.java
index 57b45ff1a10f..12c4ff22a88e 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/CompositeReactiveHealthContributorConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/CompositeReactiveHealthContributorConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -36,18 +36,6 @@
 public abstract class CompositeReactiveHealthContributorConfiguration<I extends ReactiveHealthIndicator, B>
 		extends AbstractCompositeHealthContributorConfiguration<ReactiveHealthContributor, I, B> {
 
-	/**
-	 * Creates a {@code CompositeReactiveHealthContributorConfiguration} that will use
-	 * reflection to create {@link ReactiveHealthIndicator} instances.
-	 * @deprecated since 3.0.0 in favor of
-	 * {@link #CompositeReactiveHealthContributorConfiguration(Function)}
-	 */
-	@SuppressWarnings("removal")
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	public CompositeReactiveHealthContributorConfiguration() {
-		super();
-	}
-
 	/**
 	 * Creates a {@code CompositeReactiveHealthContributorConfiguration} that will use the
 	 * given {@code indicatorFactory} to create {@link ReactiveHealthIndicator} instances.
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointReactiveWebExtensionConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointReactiveWebExtensionConfiguration.java
index 6e80745fa7e7..4a8d814ebd4e 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointReactiveWebExtensionConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointReactiveWebExtensionConfiguration.java
@@ -70,7 +70,8 @@ AdditionalHealthEndpointPathsWebFluxHandlerMapping healthEndpointWebFluxHandlerM
 			ExposableWebEndpoint health = webEndpoints.stream()
 				.filter((endpoint) -> endpoint.getEndpointId().equals(HealthEndpoint.ID))
 				.findFirst()
-				.get();
+				.orElseThrow(
+						() -> new IllegalStateException("No endpoint with id '%s' found".formatted(HealthEndpoint.ID)));
 			return new AdditionalHealthEndpointPathsWebFluxHandlerMapping(new EndpointMapping(""), health,
 					groups.getAllWithAdditionalPath(WebServerNamespace.SERVER));
 		}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointWebExtensionConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointWebExtensionConfiguration.java
index b0924d928018..a973b2f0fa4c 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointWebExtensionConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointWebExtensionConfiguration.java
@@ -81,7 +81,8 @@ private static ExposableWebEndpoint getHealthEndpoint(WebEndpointsSupplier webEn
 		return webEndpoints.stream()
 			.filter((endpoint) -> endpoint.getEndpointId().equals(HealthEndpoint.ID))
 			.findFirst()
-			.get();
+			.orElseThrow(
+					() -> new IllegalStateException("No endpoint with id '%s' found".formatted(HealthEndpoint.ID)));
 	}
 
 	@ConditionalOnBean(DispatcherServlet.class)
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/NoSuchHealthContributorFailureAnalyzer.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/NoSuchHealthContributorFailureAnalyzer.java
new file mode 100644
index 000000000000..b3ea8f0165e8
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/NoSuchHealthContributorFailureAnalyzer.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.health;
+
+import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointConfiguration.HealthEndpointGroupMembershipValidator.NoSuchHealthContributorException;
+import org.springframework.boot.diagnostics.AbstractFailureAnalyzer;
+import org.springframework.boot.diagnostics.FailureAnalysis;
+
+/**
+ * An {@link AbstractFailureAnalyzer} that performs analysis of failures caused by a
+ * {@link NoSuchHealthContributorException}.
+ *
+ * @author Moritz Halbritter
+ */
+class NoSuchHealthContributorFailureAnalyzer extends AbstractFailureAnalyzer<NoSuchHealthContributorException> {
+
+	@Override
+	protected FailureAnalysis analyze(Throwable rootFailure, NoSuchHealthContributorException cause) {
+		return new FailureAnalysis(cause.getMessage(), "Update your application to correct the invalid configuration.\n"
+				+ "You can also set 'management.endpoint.health.validate-group-membership' to false to disable the validation.",
+				cause);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/influx/InfluxDbHealthContributorAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/influx/InfluxDbHealthContributorAutoConfiguration.java
index 7f93279fde82..2a9b13603f90 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/influx/InfluxDbHealthContributorAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/influx/InfluxDbHealthContributorAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -37,11 +37,16 @@
  *
  * @author EddĂș MelĂ©ndez
  * @since 2.0.0
+ * @deprecated since 3.2.0 for removal in 3.4.0 in favor of the
+ * <a href="https://github.com/influxdata/influxdb-client-java">new client</a> and its own
+ * Spring Boot integration.
  */
+@SuppressWarnings("removal")
 @AutoConfiguration(after = InfluxDbAutoConfiguration.class)
 @ConditionalOnClass(InfluxDB.class)
 @ConditionalOnBean(InfluxDB.class)
 @ConditionalOnEnabledHealthIndicator("influxdb")
+@Deprecated(since = "3.2.0", forRemoval = true)
 public class InfluxDbHealthContributorAutoConfiguration
 		extends CompositeHealthContributorConfiguration<InfluxDbHealthIndicator, InfluxDB> {
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAspectsAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAspectsAutoConfiguration.java
new file mode 100644
index 000000000000..dbeeb8b27d6e
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAspectsAutoConfiguration.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.metrics;
+
+import io.micrometer.core.aop.CountedAspect;
+import io.micrometer.core.aop.MeterTagAnnotationHandler;
+import io.micrometer.core.aop.TimedAspect;
+import io.micrometer.core.instrument.MeterRegistry;
+import org.aspectj.weaver.Advice;
+
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.context.annotation.Bean;
+
+/**
+ * {@link EnableAutoConfiguration Auto-configuration} for Micrometer-based metrics
+ * aspects.
+ *
+ * @author Jonatan Ivanov
+ * @since 3.2.0
+ */
+@AutoConfiguration(after = { MetricsAutoConfiguration.class, CompositeMeterRegistryAutoConfiguration.class })
+@ConditionalOnClass({ MeterRegistry.class, Advice.class })
+@ConditionalOnBean(MeterRegistry.class)
+public class MetricsAspectsAutoConfiguration {
+
+	@Bean
+	@ConditionalOnMissingBean
+	CountedAspect countedAspect(MeterRegistry registry) {
+		return new CountedAspect(registry);
+	}
+
+	@Bean
+	@ConditionalOnMissingBean
+	TimedAspect timedAspect(MeterRegistry registry,
+			ObjectProvider<MeterTagAnnotationHandler> meterTagAnnotationHandler) {
+		TimedAspect timedAspect = new TimedAspect(registry);
+		meterTagAnnotationHandler.ifAvailable(timedAspect::setMeterTagAnnotationHandler);
+		return timedAspect;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAutoConfiguration.java
index 16eaab791d98..dfb7e73a5f61 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAutoConfiguration.java
@@ -16,8 +16,11 @@
 
 package org.springframework.boot.actuate.autoconfigure.metrics;
 
+import java.util.List;
+
 import io.micrometer.core.annotation.Timed;
 import io.micrometer.core.instrument.Clock;
+import io.micrometer.core.instrument.MeterRegistry;
 import io.micrometer.core.instrument.binder.MeterBinder;
 import io.micrometer.core.instrument.config.MeterFilter;
 
@@ -28,7 +31,9 @@
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.context.ApplicationContext;
+import org.springframework.context.ApplicationListener;
 import org.springframework.context.annotation.Bean;
+import org.springframework.context.event.ContextClosedEvent;
 import org.springframework.core.annotation.Order;
 
 /**
@@ -36,6 +41,7 @@
  *
  * @author Jon Schneider
  * @author Stephane Nicoll
+ * @author Moritz Halbritter
  * @since 2.0.0
  */
 @AutoConfiguration(before = CompositeMeterRegistryAutoConfiguration.class)
@@ -64,4 +70,32 @@ public PropertiesMeterFilter propertiesMeterFilter(MetricsProperties properties)
 		return new PropertiesMeterFilter(properties);
 	}
 
+	@Bean
+	MeterRegistryCloser meterRegistryCloser(ObjectProvider<MeterRegistry> meterRegistries) {
+		return new MeterRegistryCloser(meterRegistries.orderedStream().toList());
+	}
+
+	/**
+	 * Ensures that {@link MeterRegistry meter registries} are closed early in the
+	 * shutdown process.
+	 */
+	static class MeterRegistryCloser implements ApplicationListener<ContextClosedEvent> {
+
+		private final List<MeterRegistry> meterRegistries;
+
+		MeterRegistryCloser(List<MeterRegistry> meterRegistries) {
+			this.meterRegistries = meterRegistries;
+		}
+
+		@Override
+		public void onApplicationEvent(ContextClosedEvent event) {
+			for (MeterRegistry meterRegistry : this.meterRegistries) {
+				if (!meterRegistry.isClosed()) {
+					meterRegistry.close();
+				}
+			}
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsProperties.java
index f06176bb95dc..2bafe29923f2 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsProperties.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsProperties.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -79,6 +79,8 @@ public Map<String, Boolean> getEnable() {
 		return this.enable;
 	}
 
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	@DeprecatedConfigurationProperty(replacement = "management.observations.key-values", since = "3.2.0")
 	public Map<String, String> getTags() {
 		return this.tags;
 	}
@@ -115,8 +117,6 @@ public Server getServer() {
 
 		public static class Client {
 
-			private final ClientRequest request = new ClientRequest();
-
 			/**
 			 * Maximum number of unique URI tag values allowed. After the max number of
 			 * tag values is reached, metrics with additional tag values are denied by
@@ -124,10 +124,6 @@ public static class Client {
 			 */
 			private int maxUriTags = 100;
 
-			public ClientRequest getRequest() {
-				return this.request;
-			}
-
 			public int getMaxUriTags() {
 				return this.maxUriTags;
 			}
@@ -136,32 +132,10 @@ public void setMaxUriTags(int maxUriTags) {
 				this.maxUriTags = maxUriTags;
 			}
 
-			public static class ClientRequest {
-
-				/**
-				 * Name of the metric for sent requests.
-				 */
-				private String metricName = "http.client.requests";
-
-				@Deprecated(since = "3.0.0", forRemoval = true)
-				@DeprecatedConfigurationProperty(replacement = "management.observations.http.client.requests.name")
-				public String getMetricName() {
-					return this.metricName;
-				}
-
-				@Deprecated(since = "3.0.0", forRemoval = true)
-				public void setMetricName(String metricName) {
-					this.metricName = metricName;
-				}
-
-			}
-
 		}
 
 		public static class Server {
 
-			private final ServerRequest request = new ServerRequest();
-
 			/**
 			 * Maximum number of unique URI tag values allowed. After the max number of
 			 * tag values is reached, metrics with additional tag values are denied by
@@ -169,10 +143,6 @@ public static class Server {
 			 */
 			private int maxUriTags = 100;
 
-			public ServerRequest getRequest() {
-				return this.request;
-			}
-
 			public int getMaxUriTags() {
 				return this.maxUriTags;
 			}
@@ -181,27 +151,6 @@ public void setMaxUriTags(int maxUriTags) {
 				this.maxUriTags = maxUriTags;
 			}
 
-			public static class ServerRequest {
-
-				/**
-				 * Name of the metric for received requests.
-				 */
-				private String metricName = "http.server.requests";
-
-				@Deprecated(since = "3.0.0", forRemoval = true)
-				@DeprecatedConfigurationProperty(replacement = "management.observations.http.server.requests.name")
-				public String getMetricName() {
-					return this.metricName;
-				}
-
-				@Deprecated(since = "3.0.0", forRemoval = true)
-				@DeprecatedConfigurationProperty(replacement = "management.observations.http.server.requests.name")
-				public void setMetricName(String metricName) {
-					this.metricName = metricName;
-				}
-
-			}
-
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/PropertiesMeterFilter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/PropertiesMeterFilter.java
index bf506756478a..6293056f58a4 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/PropertiesMeterFilter.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/PropertiesMeterFilter.java
@@ -50,6 +50,7 @@ public class PropertiesMeterFilter implements MeterFilter {
 
 	private final MeterFilter mapFilter;
 
+	@SuppressWarnings("removal")
 	public PropertiesMeterFilter(MetricsProperties properties) {
 		Assert.notNull(properties, "Properties must not be null");
 		this.properties = properties;
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/atlas/AtlasProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/atlas/AtlasProperties.java
index f6fcfcce3595..1d476f0790ad 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/atlas/AtlasProperties.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/atlas/AtlasProperties.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -78,6 +78,21 @@ public class AtlasProperties {
 	 */
 	private boolean lwcEnabled;
 
+	/**
+	 * Step size (reporting frequency) to use for streaming to Atlas LWC. This is the
+	 * highest supported resolution for getting an on-demand stream of the data. It must
+	 * be less than or equal to management.metrics.export.atlas.step and
+	 * management.metrics.export.atlas.step should be an even multiple of this value.
+	 */
+	private Duration lwcStep = Duration.ofSeconds(5);
+
+	/**
+	 * Whether expressions with the same step size as Atlas publishing should be ignored
+	 * for streaming. Used for cases where data being published to Atlas is also sent into
+	 * streaming from the backend.
+	 */
+	private boolean lwcIgnorePublishStep = true;
+
 	/**
 	 * Frequency for refreshing config settings from the LWC service.
 	 */
@@ -170,6 +185,22 @@ public void setLwcEnabled(boolean lwcEnabled) {
 		this.lwcEnabled = lwcEnabled;
 	}
 
+	public Duration getLwcStep() {
+		return this.lwcStep;
+	}
+
+	public void setLwcStep(Duration lwcStep) {
+		this.lwcStep = lwcStep;
+	}
+
+	public boolean isLwcIgnorePublishStep() {
+		return this.lwcIgnorePublishStep;
+	}
+
+	public void setLwcIgnorePublishStep(boolean lwcIgnorePublishStep) {
+		this.lwcIgnorePublishStep = lwcIgnorePublishStep;
+	}
+
 	public Duration getConfigRefreshFrequency() {
 		return this.configRefreshFrequency;
 	}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/atlas/AtlasPropertiesConfigAdapter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/atlas/AtlasPropertiesConfigAdapter.java
index a79323f1174a..32458682b987 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/atlas/AtlasPropertiesConfigAdapter.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/atlas/AtlasPropertiesConfigAdapter.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -84,6 +84,16 @@ public boolean lwcEnabled() {
 		return get(AtlasProperties::isLwcEnabled, AtlasConfig.super::lwcEnabled);
 	}
 
+	@Override
+	public Duration lwcStep() {
+		return get(AtlasProperties::getLwcStep, AtlasConfig.super::lwcStep);
+	}
+
+	@Override
+	public boolean lwcIgnorePublishStep() {
+		return get(AtlasProperties::isLwcIgnorePublishStep, AtlasConfig.super::lwcIgnorePublishStep);
+	}
+
 	@Override
 	public Duration configRefreshFrequency() {
 		return get(AtlasProperties::getConfigRefreshFrequency, AtlasConfig.super::configRefreshFrequency);
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatraceProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatraceProperties.java
index 91c710100843..189ef09a86b5 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatraceProperties.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatraceProperties.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -140,6 +140,12 @@ public static class V2 {
 		 */
 		private boolean useDynatraceSummaryInstruments = true;
 
+		/**
+		 * Whether to export meter metadata (unit and description) to the Dynatrace
+		 * backend.
+		 */
+		private boolean exportMeterMetadata = true;
+
 		public Map<String, String> getDefaultDimensions() {
 			return this.defaultDimensions;
 		}
@@ -172,6 +178,14 @@ public void setUseDynatraceSummaryInstruments(boolean useDynatraceSummaryInstrum
 			this.useDynatraceSummaryInstruments = useDynatraceSummaryInstruments;
 		}
 
+		public boolean isExportMeterMetadata() {
+			return this.exportMeterMetadata;
+		}
+
+		public void setExportMeterMetadata(boolean exportMeterMetadata) {
+			this.exportMeterMetadata = exportMeterMetadata;
+		}
+
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatracePropertiesConfigAdapter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatracePropertiesConfigAdapter.java
index 82135f989860..bbdc14db563a 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatracePropertiesConfigAdapter.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatracePropertiesConfigAdapter.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -95,6 +95,11 @@ public boolean useDynatraceSummaryInstruments() {
 		return get(v2(V2::isUseDynatraceSummaryInstruments), DynatraceConfig.super::useDynatraceSummaryInstruments);
 	}
 
+	@Override
+	public boolean exportMeterMetadata() {
+		return get(v2(V2::isExportMeterMetadata), DynatraceConfig.super::exportMeterMetadata);
+	}
+
 	private <V> Function<DynatraceProperties, V> v1(Function<V1, V> getter) {
 		return (properties) -> getter.apply(properties.getV1());
 	}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsConnectionDetails.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsConnectionDetails.java
new file mode 100644
index 000000000000..eeef0ae685bc
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsConnectionDetails.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.metrics.export.otlp;
+
+import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
+
+/**
+ * Details required to establish a connection to an OpenTelemetry Collector service.
+ *
+ * @author EddĂș MelĂ©ndez
+ * @since 3.2.0
+ */
+public interface OtlpMetricsConnectionDetails extends ConnectionDetails {
+
+	/**
+	 * Address to where metrics will be published.
+	 * @return the address to where metrics will be published
+	 */
+	String getUrl();
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsExportAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsExportAutoConfiguration.java
index 29e89c29e50a..c7da21f488d1 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsExportAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsExportAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -24,6 +24,7 @@
 import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration;
 import org.springframework.boot.actuate.autoconfigure.metrics.export.ConditionalOnEnabledMetricsExport;
 import org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration;
+import org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryProperties;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
@@ -31,11 +32,13 @@
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.context.annotation.Bean;
+import org.springframework.core.env.Environment;
 
 /**
  * {@link EnableAutoConfiguration Auto-configuration} for exporting metrics to OTLP.
  *
  * @author EddĂș MelĂ©ndez
+ * @author Moritz Halbritter
  * @since 3.0.0
  */
 @AutoConfiguration(
@@ -44,19 +47,27 @@
 @ConditionalOnBean(Clock.class)
 @ConditionalOnClass(OtlpMeterRegistry.class)
 @ConditionalOnEnabledMetricsExport("otlp")
-@EnableConfigurationProperties(OtlpProperties.class)
+@EnableConfigurationProperties({ OtlpProperties.class, OpenTelemetryProperties.class })
 public class OtlpMetricsExportAutoConfiguration {
 
 	private final OtlpProperties properties;
 
-	public OtlpMetricsExportAutoConfiguration(OtlpProperties properties) {
+	OtlpMetricsExportAutoConfiguration(OtlpProperties properties) {
 		this.properties = properties;
 	}
 
 	@Bean
 	@ConditionalOnMissingBean
-	public OtlpConfig otlpConfig() {
-		return new OtlpPropertiesConfigAdapter(this.properties);
+	OtlpMetricsConnectionDetails otlpMetricsConnectionDetails() {
+		return new PropertiesOtlpMetricsConnectionDetails(this.properties);
+	}
+
+	@Bean
+	@ConditionalOnMissingBean
+	OtlpConfig otlpConfig(OpenTelemetryProperties openTelemetryProperties,
+			OtlpMetricsConnectionDetails connectionDetails, Environment environment) {
+		return new OtlpPropertiesConfigAdapter(this.properties, openTelemetryProperties, connectionDetails,
+				environment);
 	}
 
 	@Bean
@@ -65,4 +76,22 @@ public OtlpMeterRegistry otlpMeterRegistry(OtlpConfig otlpConfig, Clock clock) {
 		return new OtlpMeterRegistry(otlpConfig, clock);
 	}
 
+	/**
+	 * Adapts {@link OtlpProperties} to {@link OtlpMetricsConnectionDetails}.
+	 */
+	static class PropertiesOtlpMetricsConnectionDetails implements OtlpMetricsConnectionDetails {
+
+		private final OtlpProperties properties;
+
+		PropertiesOtlpMetricsConnectionDetails(OtlpProperties properties) {
+			this.properties = properties;
+		}
+
+		@Override
+		public String getUrl() {
+			return this.properties.getUrl();
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpProperties.java
index 701d45c30896..e9a038d3e664 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpProperties.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpProperties.java
@@ -17,11 +17,13 @@
 package org.springframework.boot.actuate.autoconfigure.metrics.export.otlp;
 
 import java.util.Map;
+import java.util.concurrent.TimeUnit;
 
 import io.micrometer.registry.otlp.AggregationTemporality;
 
 import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.StepRegistryProperties;
 import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.context.properties.DeprecatedConfigurationProperty;
 
 /**
  * {@link ConfigurationProperties @ConfigurationProperties} for configuring OTLP metrics
@@ -55,6 +57,11 @@ public class OtlpProperties extends StepRegistryProperties {
 	 */
 	private Map<String, String> headers;
 
+	/**
+	 * Time unit for exported metrics.
+	 */
+	private TimeUnit baseTimeUnit = TimeUnit.MILLISECONDS;
+
 	public String getUrl() {
 		return this.url;
 	}
@@ -71,10 +78,13 @@ public void setAggregationTemporality(AggregationTemporality aggregationTemporal
 		this.aggregationTemporality = aggregationTemporality;
 	}
 
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	@DeprecatedConfigurationProperty(replacement = "management.opentelemetry.resource-attributes", since = "3.2.0")
 	public Map<String, String> getResourceAttributes() {
 		return this.resourceAttributes;
 	}
 
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	public void setResourceAttributes(Map<String, String> resourceAttributes) {
 		this.resourceAttributes = resourceAttributes;
 	}
@@ -87,4 +97,12 @@ public void setHeaders(Map<String, String> headers) {
 		this.headers = headers;
 	}
 
+	public TimeUnit getBaseTimeUnit() {
+		return this.baseTimeUnit;
+	}
+
+	public void setBaseTimeUnit(TimeUnit baseTimeUnit) {
+		this.baseTimeUnit = baseTimeUnit;
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesConfigAdapter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesConfigAdapter.java
index 814298d364e3..f329d7bf17ae 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesConfigAdapter.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesConfigAdapter.java
@@ -16,23 +16,45 @@
 
 package org.springframework.boot.actuate.autoconfigure.metrics.export.otlp;
 
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.Map;
+import java.util.concurrent.TimeUnit;
 
 import io.micrometer.registry.otlp.AggregationTemporality;
 import io.micrometer.registry.otlp.OtlpConfig;
 
 import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.StepRegistryPropertiesConfigAdapter;
+import org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryProperties;
+import org.springframework.core.env.Environment;
+import org.springframework.util.CollectionUtils;
 
 /**
  * Adapter to convert {@link OtlpProperties} to an {@link OtlpConfig}.
  *
  * @author EddĂș MelĂ©ndez
  * @author Jonatan Ivanov
+ * @author Moritz Halbritter
  */
 class OtlpPropertiesConfigAdapter extends StepRegistryPropertiesConfigAdapter<OtlpProperties> implements OtlpConfig {
 
-	OtlpPropertiesConfigAdapter(OtlpProperties properties) {
+	/**
+	 * Default value for application name if {@code spring.application.name} is not set.
+	 */
+	private static final String DEFAULT_APPLICATION_NAME = "application";
+
+	private final OpenTelemetryProperties openTelemetryProperties;
+
+	private final OtlpMetricsConnectionDetails connectionDetails;
+
+	private final Environment environment;
+
+	OtlpPropertiesConfigAdapter(OtlpProperties properties, OpenTelemetryProperties openTelemetryProperties,
+			OtlpMetricsConnectionDetails connectionDetails, Environment environment) {
 		super(properties);
+		this.connectionDetails = connectionDetails;
+		this.openTelemetryProperties = openTelemetryProperties;
+		this.environment = environment;
 	}
 
 	@Override
@@ -42,7 +64,7 @@ public String prefix() {
 
 	@Override
 	public String url() {
-		return get(OtlpProperties::getUrl, OtlpConfig.super::url);
+		return get((properties) -> this.connectionDetails.getUrl(), OtlpConfig.super::url);
 	}
 
 	@Override
@@ -51,8 +73,17 @@ public AggregationTemporality aggregationTemporality() {
 	}
 
 	@Override
+	@SuppressWarnings("removal")
 	public Map<String, String> resourceAttributes() {
-		return get(OtlpProperties::getResourceAttributes, OtlpConfig.super::resourceAttributes);
+		Map<String, String> resourceAttributes = this.openTelemetryProperties.getResourceAttributes();
+		Map<String, String> result = new HashMap<>((!CollectionUtils.isEmpty(resourceAttributes)) ? resourceAttributes
+				: get(OtlpProperties::getResourceAttributes, OtlpConfig.super::resourceAttributes));
+		result.computeIfAbsent("service.name", (key) -> getApplicationName());
+		return Collections.unmodifiableMap(result);
+	}
+
+	private String getApplicationName() {
+		return this.environment.getProperty("spring.application.name", DEFAULT_APPLICATION_NAME);
 	}
 
 	@Override
@@ -60,4 +91,9 @@ public Map<String, String> headers() {
 		return get(OtlpProperties::getHeaders, OtlpConfig.super::headers);
 	}
 
+	@Override
+	public TimeUnit baseTimeUnit() {
+		return get(OtlpProperties::getBaseTimeUnit, OtlpConfig.super::baseTimeUnit);
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxProperties.java
index efd953395cb2..3a5735537a3f 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxProperties.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxProperties.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -54,6 +54,11 @@ public class SignalFxProperties extends StepRegistryProperties {
 	 */
 	private String source;
 
+	/**
+	 * Type of histogram to publish.
+	 */
+	private HistogramType publishedHistogramType = HistogramType.DEFAULT;
+
 	@Override
 	public Duration getStep() {
 		return this.step;
@@ -88,4 +93,31 @@ public void setSource(String source) {
 		this.source = source;
 	}
 
+	public HistogramType getPublishedHistogramType() {
+		return this.publishedHistogramType;
+	}
+
+	public void setPublishedHistogramType(HistogramType publishedHistogramType) {
+		this.publishedHistogramType = publishedHistogramType;
+	}
+
+	public enum HistogramType {
+
+		/**
+		 * Default, time-based histogram.
+		 */
+		DEFAULT,
+
+		/**
+		 * Cumulative histogram.
+		 */
+		CUMULATIVE,
+
+		/**
+		 * Delta histogram.
+		 */
+		DELTA;
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxPropertiesConfigAdapter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxPropertiesConfigAdapter.java
index d5d16d32bf05..754e3cb7c4df 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxPropertiesConfigAdapter.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxPropertiesConfigAdapter.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -19,6 +19,7 @@
 import io.micrometer.signalfx.SignalFxConfig;
 
 import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.StepRegistryPropertiesConfigAdapter;
+import org.springframework.boot.actuate.autoconfigure.metrics.export.signalfx.SignalFxProperties.HistogramType;
 
 /**
  * Adapter to convert {@link SignalFxProperties} to a {@link SignalFxConfig}.
@@ -54,4 +55,22 @@ public String source() {
 		return get(SignalFxProperties::getSource, SignalFxConfig.super::source);
 	}
 
+	@Override
+	public boolean publishCumulativeHistogram() {
+		return get(this::isPublishCumulativeHistogram, SignalFxConfig.super::publishCumulativeHistogram);
+	}
+
+	private boolean isPublishCumulativeHistogram(SignalFxProperties properties) {
+		return HistogramType.CUMULATIVE == properties.getPublishedHistogramType();
+	}
+
+	@Override
+	public boolean publishDeltaHistogram() {
+		return get(this::isPublishDeltaHistogram, SignalFxConfig.super::publishDeltaHistogram);
+	}
+
+	private boolean isPublishDeltaHistogram(SignalFxProperties properties) {
+		return HistogramType.DELTA == properties.getPublishedHistogramType();
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/stackdriver/StackdriverProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/stackdriver/StackdriverProperties.java
index b16b83f81a0c..4557f3f5390c 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/stackdriver/StackdriverProperties.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/stackdriver/StackdriverProperties.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -54,6 +54,12 @@ public class StackdriverProperties extends StepRegistryProperties {
 	 */
 	private boolean useSemanticMetricTypes = false;
 
+	/**
+	 * Prefix for metric type. Valid prefixes are described in the Google Cloud
+	 * documentation (https://cloud.google.com/monitoring/custom-metrics#identifier).
+	 */
+	private String metricTypePrefix = "custom.googleapis.com/";
+
 	public String getProjectId() {
 		return this.projectId;
 	}
@@ -86,4 +92,12 @@ public void setUseSemanticMetricTypes(boolean useSemanticMetricTypes) {
 		this.useSemanticMetricTypes = useSemanticMetricTypes;
 	}
 
+	public String getMetricTypePrefix() {
+		return this.metricTypePrefix;
+	}
+
+	public void setMetricTypePrefix(String metricTypePrefix) {
+		this.metricTypePrefix = metricTypePrefix;
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/stackdriver/StackdriverPropertiesConfigAdapter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/stackdriver/StackdriverPropertiesConfigAdapter.java
index e762b6da8745..b4334c741465 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/stackdriver/StackdriverPropertiesConfigAdapter.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/stackdriver/StackdriverPropertiesConfigAdapter.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -60,4 +60,9 @@ public boolean useSemanticMetricTypes() {
 		return get(StackdriverProperties::isUseSemanticMetricTypes, StackdriverConfig.super::useSemanticMetricTypes);
 	}
 
+	@Override
+	public String metricTypePrefix() {
+		return get(StackdriverProperties::getMetricTypePrefix, StackdriverConfig.super::metricTypePrefix);
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/wavefront/WavefrontPropertiesConfigAdapter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/wavefront/WavefrontPropertiesConfigAdapter.java
index 6baf69378f8e..bd898ddad050 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/wavefront/WavefrontPropertiesConfigAdapter.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/wavefront/WavefrontPropertiesConfigAdapter.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,6 +16,7 @@
 
 package org.springframework.boot.actuate.autoconfigure.metrics.export.wavefront;
 
+import com.wavefront.sdk.common.clients.service.token.TokenService.Type;
 import io.micrometer.wavefront.WavefrontConfig;
 
 import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.PushRegistryPropertiesConfigAdapter;
@@ -69,4 +70,24 @@ public String globalPrefix() {
 		return get(Export::getGlobalPrefix, WavefrontConfig.super::globalPrefix);
 	}
 
+	@Override
+	public boolean reportMinuteDistribution() {
+		return get(Export::isReportMinuteDistribution, WavefrontConfig.super::reportMinuteDistribution);
+	}
+
+	@Override
+	public boolean reportHourDistribution() {
+		return get(Export::isReportHourDistribution, WavefrontConfig.super::reportHourDistribution);
+	}
+
+	@Override
+	public boolean reportDayDistribution() {
+		return get(Export::isReportDayDistribution, WavefrontConfig.super::reportDayDistribution);
+	}
+
+	@Override
+	public Type apiTokenType() {
+		return this.properties.getWavefrontApiTokenType();
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/jersey/JerseyServerMetricsAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/jersey/JerseyServerMetricsAutoConfiguration.java
index fdb018f2276d..2fc5de48dd3f 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/jersey/JerseyServerMetricsAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/jersey/JerseyServerMetricsAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -29,9 +29,9 @@
 
 import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration;
 import org.springframework.boot.actuate.autoconfigure.metrics.MetricsProperties;
-import org.springframework.boot.actuate.autoconfigure.metrics.MetricsProperties.Web.Server;
 import org.springframework.boot.actuate.autoconfigure.metrics.OnlyOnceLoggingDenyMeterFilter;
 import org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration;
+import org.springframework.boot.actuate.autoconfigure.observation.ObservationProperties;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
@@ -57,13 +57,12 @@
 @ConditionalOnClass({ ResourceConfig.class, MetricsApplicationEventListener.class })
 @ConditionalOnBean({ MeterRegistry.class, ResourceConfig.class })
 @EnableConfigurationProperties(MetricsProperties.class)
-@SuppressWarnings("removal")
 public class JerseyServerMetricsAutoConfiguration {
 
-	private final MetricsProperties properties;
+	private final ObservationProperties observationProperties;
 
-	public JerseyServerMetricsAutoConfiguration(MetricsProperties properties) {
-		this.properties = properties;
+	public JerseyServerMetricsAutoConfiguration(ObservationProperties observationProperties) {
+		this.observationProperties = observationProperties;
 	}
 
 	@Bean
@@ -75,19 +74,19 @@ public DefaultJerseyTagsProvider jerseyTagsProvider() {
 	@Bean
 	public ResourceConfigCustomizer jerseyServerMetricsResourceConfigCustomizer(MeterRegistry meterRegistry,
 			JerseyTagsProvider tagsProvider) {
-		Server server = this.properties.getWeb().getServer();
-		return (config) -> config.register(new MetricsApplicationEventListener(meterRegistry, tagsProvider,
-				server.getRequest().getMetricName(), true, new AnnotationUtilsAnnotationFinder()));
+		String metricName = this.observationProperties.getHttp().getServer().getRequests().getName();
+		return (config) -> config.register(new MetricsApplicationEventListener(meterRegistry, tagsProvider, metricName,
+				true, new AnnotationUtilsAnnotationFinder()));
 	}
 
 	@Bean
 	@Order(0)
-	public MeterFilter jerseyMetricsUriTagFilter() {
-		String metricName = this.properties.getWeb().getServer().getRequest().getMetricName();
+	public MeterFilter jerseyMetricsUriTagFilter(MetricsProperties metricsProperties) {
+		String metricName = this.observationProperties.getHttp().getServer().getRequests().getName();
 		MeterFilter filter = new OnlyOnceLoggingDenyMeterFilter(
 				() -> String.format("Reached the maximum number of URI tags for '%s'.", metricName));
-		return MeterFilter.maximumAllowableTags(metricName, "uri", this.properties.getWeb().getServer().getMaxUriTags(),
-				filter);
+		return MeterFilter.maximumAllowableTags(metricName, "uri",
+				metricsProperties.getWeb().getServer().getMaxUriTags(), filter);
 	}
 
 	/**
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/task/TaskExecutorMetricsAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/task/TaskExecutorMetricsAutoConfiguration.java
index 8f6dcfdaf9f2..10c880d898ed 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/task/TaskExecutorMetricsAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/task/TaskExecutorMetricsAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -25,6 +25,7 @@
 import io.micrometer.core.instrument.binder.jvm.ExecutorServiceMetrics;
 
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.LazyInitializationExcludeFilter;
 import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration;
 import org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
@@ -33,6 +34,7 @@
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration;
 import org.springframework.boot.autoconfigure.task.TaskSchedulingAutoConfiguration;
+import org.springframework.context.annotation.Bean;
 import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
 import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
 
@@ -63,6 +65,11 @@ else if (executor instanceof ThreadPoolTaskScheduler threadPoolTaskScheduler) {
 		});
 	}
 
+	@Bean
+	static LazyInitializationExcludeFilter eagerTaskExecutorMetrics() {
+		return LazyInitializationExcludeFilter.forBeanTypes(TaskExecutorMetricsAutoConfiguration.class);
+	}
+
 	private void monitor(MeterRegistry registry, ThreadPoolExecutor threadPoolExecutor, String name) {
 		if (threadPoolExecutor != null) {
 			new ExecutorServiceMetrics(threadPoolExecutor, name, Collections.emptyList()).bindTo(registry);
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationAutoConfiguration.java
index 216bc4262408..b9230b45dd12 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationAutoConfiguration.java
@@ -27,9 +27,11 @@
 import io.micrometer.observation.ObservationHandler;
 import io.micrometer.observation.ObservationPredicate;
 import io.micrometer.observation.ObservationRegistry;
+import io.micrometer.observation.aop.ObservedAspect;
 import io.micrometer.tracing.Tracer;
 import io.micrometer.tracing.handler.TracingAwareMeterObservationHandler;
 import io.micrometer.tracing.handler.TracingObservationHandler;
+import org.aspectj.weaver.Advice;
 
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration;
@@ -43,6 +45,7 @@
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.core.annotation.Order;
 
 /**
  * {@link EnableAutoConfiguration Auto-configuration} for the Micrometer Observation API.
@@ -50,6 +53,7 @@
  * @author Moritz Halbritter
  * @author Brian Clozel
  * @author Jonatan Ivanov
+ * @author Vedran Pavic
  * @since 3.0.0
  */
 @AutoConfiguration(after = { CompositeMeterRegistryAutoConfiguration.class, MicrometerTracingAutoConfiguration.class })
@@ -75,6 +79,12 @@ ObservationRegistry observationRegistry() {
 		return ObservationRegistry.create();
 	}
 
+	@Bean
+	@Order(0)
+	PropertiesObservationFilterPredicate propertiesObservationFilter(ObservationProperties properties) {
+		return new PropertiesObservationFilterPredicate(properties);
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	@ConditionalOnClass(MeterRegistry.class)
 	@ConditionalOnMissingClass("io.micrometer.tracing.Tracer")
@@ -142,4 +152,16 @@ TracingAwareMeterObservationHandler<Observation.Context> tracingAwareMeterObserv
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	@ConditionalOnClass(Advice.class)
+	static class ObservedAspectConfiguration {
+
+		@Bean
+		@ConditionalOnMissingBean
+		ObservedAspect observedAspect(ObservationRegistry observationRegistry) {
+			return new ObservedAspect(observationRegistry);
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationHandlerGrouping.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationHandlerGrouping.java
index 163186947e8a..df964c813366 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationHandlerGrouping.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationHandlerGrouping.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,6 +16,7 @@
 
 package org.springframework.boot.actuate.autoconfigure.observation;
 
+import java.util.ArrayList;
 import java.util.List;
 
 import io.micrometer.observation.ObservationHandler;
@@ -30,6 +31,7 @@
  * Groups {@link ObservationHandler ObservationHandlers} by type.
  *
  * @author Andy Wilkinson
+ * @author Moritz Halbritter
  */
 @SuppressWarnings("rawtypes")
 class ObservationHandlerGrouping {
@@ -46,13 +48,14 @@ class ObservationHandlerGrouping {
 
 	void apply(List<ObservationHandler<?>> handlers, ObservationConfig config) {
 		MultiValueMap<Class<? extends ObservationHandler>, ObservationHandler<?>> groupings = new LinkedMultiValueMap<>();
+		List<ObservationHandler<?>> handlersWithoutCategory = new ArrayList<>();
 		for (ObservationHandler<?> handler : handlers) {
 			Class<? extends ObservationHandler> category = findCategory(handler);
 			if (category != null) {
 				groupings.add(category, handler);
 			}
 			else {
-				config.observationHandler(handler);
+				handlersWithoutCategory.add(handler);
 			}
 		}
 		for (Class<? extends ObservationHandler> category : this.categories) {
@@ -61,6 +64,9 @@ void apply(List<ObservationHandler<?>> handlers, ObservationConfig config) {
 				config.observationHandler(new FirstMatchingCompositeObservationHandler(handlerGroup));
 			}
 		}
+		for (ObservationHandler<?> observationHandler : handlersWithoutCategory) {
+			config.observationHandler(observationHandler);
+		}
 	}
 
 	private Class<? extends ObservationHandler> findCategory(ObservationHandler<?> handler) {
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationProperties.java
index e4668d7f4b59..08de1a01c104 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationProperties.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationProperties.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,6 +16,9 @@
 
 package org.springframework.boot.actuate.autoconfigure.observation;
 
+import java.util.LinkedHashMap;
+import java.util.Map;
+
 import org.springframework.boot.context.properties.ConfigurationProperties;
 
 /**
@@ -23,6 +26,7 @@
  * observations.
  *
  * @author Brian Clozel
+ * @author Moritz Halbritter
  * @since 3.0.0
  */
 @ConfigurationProperties("management.observations")
@@ -30,10 +34,37 @@ public class ObservationProperties {
 
 	private final Http http = new Http();
 
+	/**
+	 * Common key-values that are applied to every observation.
+	 */
+	private Map<String, String> keyValues = new LinkedHashMap<>();
+
+	/**
+	 * Whether observations starting with the specified name should be enabled. The
+	 * longest match wins, the key 'all' can also be used to configure all observations.
+	 */
+	private Map<String, Boolean> enable = new LinkedHashMap<>();
+
+	public Map<String, Boolean> getEnable() {
+		return this.enable;
+	}
+
+	public void setEnable(Map<String, Boolean> enable) {
+		this.enable = enable;
+	}
+
 	public Http getHttp() {
 		return this.http;
 	}
 
+	public Map<String, String> getKeyValues() {
+		return this.keyValues;
+	}
+
+	public void setKeyValues(Map<String, String> keyValues) {
+		this.keyValues = keyValues;
+	}
+
 	public static class Http {
 
 		private final Client client = new Client();
@@ -59,10 +90,9 @@ public ClientRequests getRequests() {
 			public static class ClientRequests {
 
 				/**
-				 * Name of the observation for client requests. If empty, will use the
-				 * default "http.client.requests".
+				 * Name of the observation for client requests.
 				 */
-				private String name;
+				private String name = "http.client.requests";
 
 				public String getName() {
 					return this.name;
@@ -87,10 +117,9 @@ public ServerRequests getRequests() {
 			public static class ServerRequests {
 
 				/**
-				 * Name of the observation for server requests. If empty, will use the
-				 * default "http.server.requests".
+				 * Name of the observation for server requests.
 				 */
-				private String name;
+				private String name = "http.server.requests";
 
 				public String getName() {
 					return this.name;
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/PropertiesObservationFilterPredicate.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/PropertiesObservationFilterPredicate.java
new file mode 100644
index 000000000000..1154668798af
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/PropertiesObservationFilterPredicate.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.observation;
+
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.function.Supplier;
+
+import io.micrometer.common.KeyValues;
+import io.micrometer.observation.Observation.Context;
+import io.micrometer.observation.ObservationFilter;
+import io.micrometer.observation.ObservationPredicate;
+
+import org.springframework.util.StringUtils;
+
+/**
+ * {@link ObservationFilter} to apply settings from {@link ObservationProperties}.
+ *
+ * @author Moritz Halbritter
+ */
+class PropertiesObservationFilterPredicate implements ObservationFilter, ObservationPredicate {
+
+	private final ObservationFilter commonKeyValuesFilter;
+
+	private final ObservationProperties properties;
+
+	PropertiesObservationFilterPredicate(ObservationProperties properties) {
+		this.properties = properties;
+		this.commonKeyValuesFilter = createCommonKeyValuesFilter(properties);
+	}
+
+	@Override
+	public Context map(Context context) {
+		return this.commonKeyValuesFilter.map(context);
+	}
+
+	@Override
+	public boolean test(String name, Context context) {
+		return lookupWithFallbackToAll(this.properties.getEnable(), name, true);
+	}
+
+	private static <T> T lookupWithFallbackToAll(Map<String, T> values, String name, T defaultValue) {
+		if (values.isEmpty()) {
+			return defaultValue;
+		}
+		return doLookup(values, name, () -> values.getOrDefault("all", defaultValue));
+	}
+
+	private static <T> T doLookup(Map<String, T> values, String name, Supplier<T> defaultValue) {
+		while (StringUtils.hasLength(name)) {
+			T result = values.get(name);
+			if (result != null) {
+				return result;
+			}
+			int lastDot = name.lastIndexOf('.');
+			name = (lastDot != -1) ? name.substring(0, lastDot) : "";
+		}
+		return defaultValue.get();
+	}
+
+	private static ObservationFilter createCommonKeyValuesFilter(ObservationProperties properties) {
+		if (properties.getKeyValues().isEmpty()) {
+			return (context) -> context;
+		}
+		KeyValues keyValues = KeyValues.of(properties.getKeyValues().entrySet(), Entry::getKey, Entry::getValue);
+		return (context) -> context.addLowCardinalityKeyValues(keyValues);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/graphql/GraphQlObservationAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/graphql/GraphQlObservationAutoConfiguration.java
index 5c447db00fba..86b5ed0aee35 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/graphql/GraphQlObservationAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/graphql/GraphQlObservationAutoConfiguration.java
@@ -43,7 +43,6 @@
 @AutoConfiguration(after = ObservationAutoConfiguration.class)
 @ConditionalOnBean(ObservationRegistry.class)
 @ConditionalOnClass({ GraphQL.class, GraphQlSource.class, Observation.class })
-@SuppressWarnings("removal")
 public class GraphQlObservationAutoConfiguration {
 
 	@Bean
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/jms/JmsTemplateObservationAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/jms/JmsTemplateObservationAutoConfiguration.java
new file mode 100644
index 000000000000..9629468e6051
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/jms/JmsTemplateObservationAutoConfiguration.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.observation.jms;
+
+import io.micrometer.jakarta9.instrument.jms.JmsPublishObservationContext;
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationRegistry;
+import jakarta.jms.Message;
+
+import org.springframework.beans.BeansException;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.config.BeanPostProcessor;
+import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.jms.JmsAutoConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.jms.core.JmsTemplate;
+
+/**
+ * {@link EnableAutoConfiguration Auto-configuration} for instrumenting
+ * {@link JmsTemplate} beans for Observability.
+ *
+ * @author Brian Clozel
+ * @since 3.2.0
+ */
+@AutoConfiguration(after = { JmsAutoConfiguration.class, ObservationAutoConfiguration.class })
+@ConditionalOnBean({ ObservationRegistry.class, JmsTemplate.class })
+@ConditionalOnClass({ Observation.class, Message.class, JmsTemplate.class, JmsPublishObservationContext.class })
+public class JmsTemplateObservationAutoConfiguration {
+
+	@Bean
+	static JmsTemplateObservationPostProcessor jmsTemplateObservationPostProcessor(
+			ObjectProvider<ObservationRegistry> observationRegistry) {
+		return new JmsTemplateObservationPostProcessor(observationRegistry);
+	}
+
+	static class JmsTemplateObservationPostProcessor implements BeanPostProcessor {
+
+		private final ObjectProvider<ObservationRegistry> observationRegistry;
+
+		JmsTemplateObservationPostProcessor(ObjectProvider<ObservationRegistry> observationRegistry) {
+			this.observationRegistry = observationRegistry;
+		}
+
+		@Override
+		public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
+			if (bean instanceof JmsTemplate jmsTemplate) {
+				this.observationRegistry.ifAvailable(jmsTemplate::setObservationRegistry);
+			}
+			return bean;
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/jms/package-info.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/jms/package-info.java
new file mode 100644
index 000000000000..417a73aed67f
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/jms/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Auto-configuration for JMS observations.
+ */
+package org.springframework.boot.actuate.autoconfigure.observation.jms;
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientHttpObservationConventionAdapter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientHttpObservationConventionAdapter.java
deleted file mode 100644
index 87205527f5e2..000000000000
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientHttpObservationConventionAdapter.java
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.autoconfigure.observation.web.client;
-
-import io.micrometer.common.KeyValues;
-import io.micrometer.core.instrument.Tag;
-
-import org.springframework.boot.actuate.metrics.web.client.RestTemplateExchangeTagsProvider;
-import org.springframework.http.client.observation.ClientRequestObservationContext;
-import org.springframework.http.client.observation.ClientRequestObservationConvention;
-
-/**
- * Adapter class that applies {@link RestTemplateExchangeTagsProvider} tags as a
- * {@link ClientRequestObservationConvention}.
- *
- * @author Brian Clozel
- */
-@SuppressWarnings({ "removal" })
-class ClientHttpObservationConventionAdapter implements ClientRequestObservationConvention {
-
-	private final String metricName;
-
-	private final RestTemplateExchangeTagsProvider tagsProvider;
-
-	ClientHttpObservationConventionAdapter(String metricName, RestTemplateExchangeTagsProvider tagsProvider) {
-		this.metricName = metricName;
-		this.tagsProvider = tagsProvider;
-	}
-
-	@Override
-	@SuppressWarnings("deprecation")
-	public KeyValues getLowCardinalityKeyValues(ClientRequestObservationContext context) {
-		Iterable<Tag> tags = this.tagsProvider.getTags(context.getUriTemplate(), context.getCarrier(),
-				context.getResponse());
-		return KeyValues.of(tags, Tag::getKey, Tag::getValue);
-	}
-
-	@Override
-	public KeyValues getHighCardinalityKeyValues(ClientRequestObservationContext context) {
-		return KeyValues.empty();
-	}
-
-	@Override
-	public String getName() {
-		return this.metricName;
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientObservationConventionAdapter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientObservationConventionAdapter.java
deleted file mode 100644
index 0f3230a5bf8a..000000000000
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientObservationConventionAdapter.java
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.autoconfigure.observation.web.client;
-
-import io.micrometer.common.KeyValues;
-import io.micrometer.core.instrument.Tag;
-import io.micrometer.observation.Observation;
-
-import org.springframework.boot.actuate.metrics.web.reactive.client.WebClientExchangeTagsProvider;
-import org.springframework.core.Conventions;
-import org.springframework.web.reactive.function.client.ClientRequest;
-import org.springframework.web.reactive.function.client.ClientRequestObservationContext;
-import org.springframework.web.reactive.function.client.ClientRequestObservationConvention;
-import org.springframework.web.reactive.function.client.WebClient;
-
-/**
- * Adapter class that applies {@link WebClientExchangeTagsProvider} tags as a
- * {@link ClientRequestObservationConvention}.
- *
- * @author Brian Clozel
- */
-@SuppressWarnings("removal")
-class ClientObservationConventionAdapter implements ClientRequestObservationConvention {
-
-	private static final String URI_TEMPLATE_ATTRIBUTE = Conventions.getQualifiedAttributeName(WebClient.class,
-			"uriTemplate");
-
-	private final String metricName;
-
-	private final WebClientExchangeTagsProvider tagsProvider;
-
-	ClientObservationConventionAdapter(String metricName, WebClientExchangeTagsProvider tagsProvider) {
-		this.metricName = metricName;
-		this.tagsProvider = tagsProvider;
-	}
-
-	@Override
-	public boolean supportsContext(Observation.Context context) {
-		return context instanceof ClientRequestObservationContext;
-	}
-
-	@Override
-	public KeyValues getLowCardinalityKeyValues(ClientRequestObservationContext context) {
-		ClientRequest request = context.getRequest();
-		if (request == null) {
-			request = context.getCarrier().attribute(URI_TEMPLATE_ATTRIBUTE, context.getUriTemplate()).build();
-		}
-		Iterable<Tag> tags = this.tagsProvider.tags(request, context.getResponse(), context.getError());
-		return KeyValues.of(tags, Tag::getKey, Tag::getValue);
-	}
-
-	@Override
-	public KeyValues getHighCardinalityKeyValues(ClientRequestObservationContext context) {
-		return KeyValues.empty();
-	}
-
-	@Override
-	public String getName() {
-		return this.metricName;
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/HttpClientObservationsAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/HttpClientObservationsAutoConfiguration.java
index 23323d25238c..60595014ef39 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/HttpClientObservationsAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/HttpClientObservationsAutoConfiguration.java
@@ -31,6 +31,7 @@
 import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
 import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration;
 import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -48,13 +49,15 @@
  * @author Stephane Nicoll
  * @author Raheela Aslam
  * @author Brian Clozel
+ * @author Moritz Halbritter
  * @since 3.0.0
  */
 @AutoConfiguration(after = { ObservationAutoConfiguration.class, CompositeMeterRegistryAutoConfiguration.class,
-		RestTemplateAutoConfiguration.class, WebClientAutoConfiguration.class })
+		RestTemplateAutoConfiguration.class, WebClientAutoConfiguration.class, RestClientAutoConfiguration.class })
 @ConditionalOnClass(Observation.class)
 @ConditionalOnBean(ObservationRegistry.class)
-@Import({ RestTemplateObservationConfiguration.class, WebClientObservationConfiguration.class })
+@Import({ RestTemplateObservationConfiguration.class, WebClientObservationConfiguration.class,
+		RestClientObservationConfiguration.class })
 @EnableConfigurationProperties({ MetricsProperties.class, ObservationProperties.class })
 public class HttpClientObservationsAutoConfiguration {
 
@@ -65,13 +68,10 @@ static class MeterFilterConfiguration {
 
 		@Bean
 		@Order(0)
-		@SuppressWarnings("removal")
 		MeterFilter metricsHttpClientUriTagFilter(ObservationProperties observationProperties,
 				MetricsProperties metricsProperties) {
 			Client clientProperties = metricsProperties.getWeb().getClient();
-			String metricName = clientProperties.getRequest().getMetricName();
-			String observationName = observationProperties.getHttp().getClient().getRequests().getName();
-			String name = (observationName != null) ? observationName : metricName;
+			String name = observationProperties.getHttp().getClient().getRequests().getName();
 			MeterFilter denyFilter = new OnlyOnceLoggingDenyMeterFilter(
 					() -> "Reached the maximum number of URI tags for '%s'. Are you using 'uriVariables'?"
 						.formatted(name));
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestClientObservationConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestClientObservationConfiguration.java
new file mode 100644
index 000000000000..6b97d6c65e51
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestClientObservationConfiguration.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.observation.web.client;
+
+import io.micrometer.observation.ObservationRegistry;
+
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.boot.actuate.autoconfigure.observation.ObservationProperties;
+import org.springframework.boot.actuate.metrics.web.client.ObservationRestClientCustomizer;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.web.client.RestClientCustomizer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.client.observation.ClientRequestObservationConvention;
+import org.springframework.http.client.observation.DefaultClientRequestObservationConvention;
+import org.springframework.web.client.RestClient;
+
+/**
+ * Configure the instrumentation of {@link RestClient}.
+ *
+ * @author Moritz Halbritter
+ */
+@Configuration(proxyBeanMethods = false)
+@ConditionalOnClass(RestClient.class)
+@ConditionalOnBean(RestClient.Builder.class)
+class RestClientObservationConfiguration {
+
+	@Bean
+	RestClientCustomizer observationRestClientCustomizer(ObservationRegistry observationRegistry,
+			ObjectProvider<ClientRequestObservationConvention> customConvention,
+			ObservationProperties observationProperties) {
+		String name = observationProperties.getHttp().getClient().getRequests().getName();
+		ClientRequestObservationConvention observationConvention = customConvention
+			.getIfAvailable(() -> new DefaultClientRequestObservationConvention(name));
+		return new ObservationRestClientCustomizer(observationRegistry, observationConvention);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestTemplateObservationConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestTemplateObservationConfiguration.java
index 0f62f2849b19..81fb154a230a 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestTemplateObservationConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestTemplateObservationConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -19,10 +19,8 @@
 import io.micrometer.observation.ObservationRegistry;
 
 import org.springframework.beans.factory.ObjectProvider;
-import org.springframework.boot.actuate.autoconfigure.metrics.MetricsProperties;
 import org.springframework.boot.actuate.autoconfigure.observation.ObservationProperties;
 import org.springframework.boot.actuate.metrics.web.client.ObservationRestTemplateCustomizer;
-import org.springframework.boot.actuate.metrics.web.client.RestTemplateExchangeTagsProvider;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.boot.web.client.RestTemplateBuilder;
@@ -40,39 +38,16 @@
 @Configuration(proxyBeanMethods = false)
 @ConditionalOnClass(RestTemplate.class)
 @ConditionalOnBean(RestTemplateBuilder.class)
-@SuppressWarnings("removal")
 class RestTemplateObservationConfiguration {
 
 	@Bean
 	ObservationRestTemplateCustomizer observationRestTemplateCustomizer(ObservationRegistry observationRegistry,
 			ObjectProvider<ClientRequestObservationConvention> customConvention,
-			ObservationProperties observationProperties, MetricsProperties metricsProperties,
-			ObjectProvider<RestTemplateExchangeTagsProvider> optionalTagsProvider) {
-		String name = observationName(observationProperties, metricsProperties);
-		ClientRequestObservationConvention observationConvention = createConvention(customConvention.getIfAvailable(),
-				name, optionalTagsProvider.getIfAvailable());
+			ObservationProperties observationProperties) {
+		String name = observationProperties.getHttp().getClient().getRequests().getName();
+		ClientRequestObservationConvention observationConvention = customConvention
+			.getIfAvailable(() -> new DefaultClientRequestObservationConvention(name));
 		return new ObservationRestTemplateCustomizer(observationRegistry, observationConvention);
 	}
 
-	private static String observationName(ObservationProperties observationProperties,
-			MetricsProperties metricsProperties) {
-		String metricName = metricsProperties.getWeb().getClient().getRequest().getMetricName();
-		String observationName = observationProperties.getHttp().getClient().getRequests().getName();
-		return (observationName != null) ? observationName : metricName;
-	}
-
-	private static ClientRequestObservationConvention createConvention(
-			ClientRequestObservationConvention customConvention, String name,
-			RestTemplateExchangeTagsProvider tagsProvider) {
-		if (customConvention != null) {
-			return customConvention;
-		}
-		else if (tagsProvider != null) {
-			return new ClientHttpObservationConventionAdapter(name, tagsProvider);
-		}
-		else {
-			return new DefaultClientRequestObservationConvention(name);
-		}
-	}
-
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/WebClientObservationConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/WebClientObservationConfiguration.java
index ce531912e1dc..2df9c4bf9104 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/WebClientObservationConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/WebClientObservationConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -22,7 +22,6 @@
 import org.springframework.boot.actuate.autoconfigure.metrics.MetricsProperties;
 import org.springframework.boot.actuate.autoconfigure.observation.ObservationProperties;
 import org.springframework.boot.actuate.metrics.web.reactive.client.ObservationWebClientCustomizer;
-import org.springframework.boot.actuate.metrics.web.reactive.client.WebClientExchangeTagsProvider;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
@@ -37,39 +36,16 @@
  */
 @Configuration(proxyBeanMethods = false)
 @ConditionalOnClass(WebClient.class)
-@SuppressWarnings("removal")
 class WebClientObservationConfiguration {
 
 	@Bean
 	ObservationWebClientCustomizer observationWebClientCustomizer(ObservationRegistry observationRegistry,
 			ObjectProvider<ClientRequestObservationConvention> customConvention,
-			ObservationProperties observationProperties, ObjectProvider<WebClientExchangeTagsProvider> tagsProvider,
-			MetricsProperties metricsProperties) {
-		String name = observationName(observationProperties, metricsProperties);
-		ClientRequestObservationConvention observationConvention = createConvention(customConvention.getIfAvailable(),
-				tagsProvider.getIfAvailable(), name);
+			ObservationProperties observationProperties, MetricsProperties metricsProperties) {
+		String name = observationProperties.getHttp().getClient().getRequests().getName();
+		ClientRequestObservationConvention observationConvention = customConvention
+			.getIfAvailable(() -> new DefaultClientRequestObservationConvention(name));
 		return new ObservationWebClientCustomizer(observationRegistry, observationConvention);
 	}
 
-	private static ClientRequestObservationConvention createConvention(
-			ClientRequestObservationConvention customConvention, WebClientExchangeTagsProvider tagsProvider,
-			String name) {
-		if (customConvention != null) {
-			return customConvention;
-		}
-		else if (tagsProvider != null) {
-			return new ClientObservationConventionAdapter(name, tagsProvider);
-		}
-		else {
-			return new DefaultClientRequestObservationConvention(name);
-		}
-	}
-
-	private static String observationName(ObservationProperties observationProperties,
-			MetricsProperties metricsProperties) {
-		String metricName = metricsProperties.getWeb().getClient().getRequest().getMetricName();
-		String observationName = observationProperties.getHttp().getClient().getRequests().getName();
-		return (observationName != null) ? observationName : metricName;
-	}
-
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/ServerRequestObservationConventionAdapter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/ServerRequestObservationConventionAdapter.java
deleted file mode 100644
index 43689a962a05..000000000000
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/ServerRequestObservationConventionAdapter.java
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.autoconfigure.observation.web.reactive;
-
-import java.util.List;
-
-import io.micrometer.common.KeyValues;
-import io.micrometer.core.instrument.Tag;
-
-import org.springframework.boot.actuate.metrics.web.reactive.server.DefaultWebFluxTagsProvider;
-import org.springframework.boot.actuate.metrics.web.reactive.server.WebFluxTagsContributor;
-import org.springframework.boot.actuate.metrics.web.reactive.server.WebFluxTagsProvider;
-import org.springframework.http.codec.ServerCodecConfigurer;
-import org.springframework.http.server.reactive.observation.ServerRequestObservationContext;
-import org.springframework.http.server.reactive.observation.ServerRequestObservationConvention;
-import org.springframework.web.server.adapter.DefaultServerWebExchange;
-import org.springframework.web.server.i18n.AcceptHeaderLocaleContextResolver;
-import org.springframework.web.server.i18n.LocaleContextResolver;
-import org.springframework.web.server.session.DefaultWebSessionManager;
-import org.springframework.web.server.session.WebSessionManager;
-
-/**
- * Adapter class that applies {@link WebFluxTagsProvider} tags as a
- * {@link ServerRequestObservationConvention}.
- *
- * @author Brian Clozel
- */
-@SuppressWarnings("removal")
-@Deprecated(since = "3.0.0", forRemoval = true)
-class ServerRequestObservationConventionAdapter implements ServerRequestObservationConvention {
-
-	private final WebSessionManager webSessionManager = new DefaultWebSessionManager();
-
-	private final ServerCodecConfigurer serverCodecConfigurer = ServerCodecConfigurer.create();
-
-	private final LocaleContextResolver localeContextResolver = new AcceptHeaderLocaleContextResolver();
-
-	private final String name;
-
-	private final WebFluxTagsProvider tagsProvider;
-
-	ServerRequestObservationConventionAdapter(String name, WebFluxTagsProvider tagsProvider) {
-		this.name = name;
-		this.tagsProvider = tagsProvider;
-	}
-
-	ServerRequestObservationConventionAdapter(String name, List<WebFluxTagsContributor> contributors) {
-		this(name, new DefaultWebFluxTagsProvider(contributors));
-	}
-
-	@Override
-	public String getName() {
-		return this.name;
-	}
-
-	@Override
-	public KeyValues getLowCardinalityKeyValues(ServerRequestObservationContext context) {
-		DefaultServerWebExchange serverWebExchange = new DefaultServerWebExchange(context.getCarrier(),
-				context.getResponse(), this.webSessionManager, this.serverCodecConfigurer, this.localeContextResolver);
-		serverWebExchange.getAttributes().putAll(context.getAttributes());
-		Iterable<Tag> tags = this.tagsProvider.httpRequestTags(serverWebExchange, context.getError());
-		return KeyValues.of(tags, Tag::getKey, Tag::getValue);
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/WebFluxObservationAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/WebFluxObservationAutoConfiguration.java
index a998e6b5d24b..0ccb94d55b5c 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/WebFluxObservationAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/WebFluxObservationAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,37 +16,24 @@
 
 package org.springframework.boot.actuate.autoconfigure.observation.web.reactive;
 
-import java.util.List;
-
 import io.micrometer.core.instrument.MeterRegistry;
 import io.micrometer.core.instrument.config.MeterFilter;
 import io.micrometer.observation.Observation;
 import io.micrometer.observation.ObservationRegistry;
 
-import org.springframework.beans.factory.ObjectProvider;
-import org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration;
-import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration;
 import org.springframework.boot.actuate.autoconfigure.metrics.MetricsProperties;
 import org.springframework.boot.actuate.autoconfigure.metrics.OnlyOnceLoggingDenyMeterFilter;
 import org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration;
 import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
 import org.springframework.boot.actuate.autoconfigure.observation.ObservationProperties;
-import org.springframework.boot.actuate.metrics.web.reactive.server.WebFluxTagsContributor;
-import org.springframework.boot.actuate.metrics.web.reactive.server.WebFluxTagsProvider;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.core.Ordered;
 import org.springframework.core.annotation.Order;
-import org.springframework.http.server.reactive.observation.DefaultServerRequestObservationConvention;
-import org.springframework.http.server.reactive.observation.ServerRequestObservationConvention;
-import org.springframework.web.filter.reactive.ServerHttpObservationFilter;
 
 /**
  * {@link EnableAutoConfiguration Auto-configuration} for instrumentation of Spring
@@ -57,75 +44,22 @@
  * @author Dmytro Nosan
  * @since 3.0.0
  */
-@AutoConfiguration(after = { MetricsAutoConfiguration.class, CompositeMeterRegistryAutoConfiguration.class,
-		SimpleMetricsExportAutoConfiguration.class, ObservationAutoConfiguration.class })
-@ConditionalOnClass(Observation.class)
-@ConditionalOnBean(ObservationRegistry.class)
+@AutoConfiguration(after = { SimpleMetricsExportAutoConfiguration.class, ObservationAutoConfiguration.class })
+@ConditionalOnClass({ Observation.class, MeterRegistry.class })
+@ConditionalOnBean({ ObservationRegistry.class, MeterRegistry.class })
 @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
 @EnableConfigurationProperties({ MetricsProperties.class, ObservationProperties.class })
-@SuppressWarnings("removal")
 public class WebFluxObservationAutoConfiguration {
 
-	private final MetricsProperties metricsProperties;
-
-	private final ObservationProperties observationProperties;
-
-	public WebFluxObservationAutoConfiguration(MetricsProperties metricsProperties,
-			ObservationProperties observationProperties) {
-		this.metricsProperties = metricsProperties;
-		this.observationProperties = observationProperties;
-	}
-
 	@Bean
-	@ConditionalOnMissingBean
-	@Order(Ordered.HIGHEST_PRECEDENCE + 1)
-	public ServerHttpObservationFilter webfluxObservationFilter(ObservationRegistry registry,
-			ObjectProvider<ServerRequestObservationConvention> customConvention,
-			ObjectProvider<WebFluxTagsProvider> tagConfigurer,
-			ObjectProvider<WebFluxTagsContributor> contributorsProvider) {
-		String observationName = this.observationProperties.getHttp().getServer().getRequests().getName();
-		String metricName = this.metricsProperties.getWeb().getServer().getRequest().getMetricName();
-		String name = (observationName != null) ? observationName : metricName;
-		WebFluxTagsProvider tagsProvider = tagConfigurer.getIfAvailable();
-		List<WebFluxTagsContributor> tagsContributors = contributorsProvider.orderedStream().toList();
-		ServerRequestObservationConvention convention = createConvention(customConvention.getIfAvailable(), name,
-				tagsProvider, tagsContributors);
-		return new ServerHttpObservationFilter(registry, convention);
-	}
-
-	private static ServerRequestObservationConvention createConvention(
-			ServerRequestObservationConvention customConvention, String name, WebFluxTagsProvider tagsProvider,
-			List<WebFluxTagsContributor> tagsContributors) {
-		if (customConvention != null) {
-			return customConvention;
-		}
-		if (tagsProvider != null) {
-			return new ServerRequestObservationConventionAdapter(name, tagsProvider);
-		}
-		if (!tagsContributors.isEmpty()) {
-			return new ServerRequestObservationConventionAdapter(name, tagsContributors);
-		}
-		return new DefaultServerRequestObservationConvention(name);
-	}
-
-	@Configuration(proxyBeanMethods = false)
-	@ConditionalOnClass(MeterRegistry.class)
-	@ConditionalOnBean(MeterRegistry.class)
-	static class MeterFilterConfiguration {
-
-		@Bean
-		@Order(0)
-		MeterFilter metricsHttpServerUriTagFilter(MetricsProperties metricsProperties,
-				ObservationProperties observationProperties) {
-			String observationName = observationProperties.getHttp().getServer().getRequests().getName();
-			String name = (observationName != null) ? observationName
-					: metricsProperties.getWeb().getServer().getRequest().getMetricName();
-			MeterFilter filter = new OnlyOnceLoggingDenyMeterFilter(
-					() -> "Reached the maximum number of URI tags for '%s'.".formatted(name));
-			return MeterFilter.maximumAllowableTags(name, "uri", metricsProperties.getWeb().getServer().getMaxUriTags(),
-					filter);
-		}
-
+	@Order(0)
+	MeterFilter metricsHttpServerUriTagFilter(MetricsProperties metricsProperties,
+			ObservationProperties observationProperties) {
+		String name = observationProperties.getHttp().getServer().getRequests().getName();
+		MeterFilter filter = new OnlyOnceLoggingDenyMeterFilter(
+				() -> "Reached the maximum number of URI tags for '%s'.".formatted(name));
+		return MeterFilter.maximumAllowableTags(name, "uri", metricsProperties.getWeb().getServer().getMaxUriTags(),
+				filter);
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/ServerRequestObservationConventionAdapter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/ServerRequestObservationConventionAdapter.java
deleted file mode 100644
index df52cbca7407..000000000000
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/ServerRequestObservationConventionAdapter.java
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.autoconfigure.observation.web.servlet;
-
-import java.util.List;
-
-import io.micrometer.common.KeyValues;
-import io.micrometer.core.instrument.Tag;
-import io.micrometer.observation.Observation;
-
-import org.springframework.boot.actuate.metrics.web.servlet.DefaultWebMvcTagsProvider;
-import org.springframework.boot.actuate.metrics.web.servlet.WebMvcTagsContributor;
-import org.springframework.boot.actuate.metrics.web.servlet.WebMvcTagsProvider;
-import org.springframework.http.server.observation.ServerRequestObservationContext;
-import org.springframework.http.server.observation.ServerRequestObservationConvention;
-import org.springframework.util.Assert;
-import org.springframework.web.servlet.HandlerMapping;
-
-/**
- * Adapter class that applies {@link WebMvcTagsProvider} tags as a
- * {@link ServerRequestObservationConvention}.
- *
- * @author Brian Clozel
- */
-@SuppressWarnings("removal")
-@Deprecated(since = "3.0.0", forRemoval = true)
-class ServerRequestObservationConventionAdapter implements ServerRequestObservationConvention {
-
-	private final String observationName;
-
-	private final WebMvcTagsProvider tagsProvider;
-
-	ServerRequestObservationConventionAdapter(String observationName, WebMvcTagsProvider tagsProvider,
-			List<WebMvcTagsContributor> contributors) {
-		Assert.state((tagsProvider != null) || (contributors != null),
-				"adapter should adapt to a WebMvcTagsProvider or a list of contributors");
-		this.observationName = observationName;
-		this.tagsProvider = (tagsProvider != null) ? tagsProvider : new DefaultWebMvcTagsProvider(contributors);
-	}
-
-	@Override
-	public String getName() {
-		return this.observationName;
-	}
-
-	@Override
-	public boolean supportsContext(Observation.Context context) {
-		return context instanceof ServerRequestObservationContext;
-	}
-
-	@Override
-	public KeyValues getLowCardinalityKeyValues(ServerRequestObservationContext context) {
-		Iterable<Tag> tags = this.tagsProvider.getTags(context.getCarrier(), context.getResponse(), getHandler(context),
-				context.getError());
-		return KeyValues.of(tags, Tag::getKey, Tag::getValue);
-	}
-
-	private Object getHandler(ServerRequestObservationContext context) {
-		return context.getCarrier().getAttribute(HandlerMapping.BEST_MATCHING_HANDLER_ATTRIBUTE);
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/WebMvcObservationAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/WebMvcObservationAutoConfiguration.java
index 5a70728ac125..2b4aa96c3933 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/WebMvcObservationAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/WebMvcObservationAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,8 +16,6 @@
 
 package org.springframework.boot.actuate.autoconfigure.observation.web.servlet;
 
-import java.util.List;
-
 import io.micrometer.core.instrument.MeterRegistry;
 import io.micrometer.core.instrument.config.MeterFilter;
 import io.micrometer.observation.Observation;
@@ -32,8 +30,6 @@
 import org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration;
 import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
 import org.springframework.boot.actuate.autoconfigure.observation.ObservationProperties;
-import org.springframework.boot.actuate.metrics.web.servlet.WebMvcTagsContributor;
-import org.springframework.boot.actuate.metrics.web.servlet.WebMvcTagsProvider;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
@@ -66,28 +62,16 @@
 @ConditionalOnClass({ DispatcherServlet.class, Observation.class })
 @ConditionalOnBean(ObservationRegistry.class)
 @EnableConfigurationProperties({ MetricsProperties.class, ObservationProperties.class })
-@SuppressWarnings("removal")
 public class WebMvcObservationAutoConfiguration {
 
-	private final MetricsProperties metricsProperties;
-
-	private final ObservationProperties observationProperties;
-
-	public WebMvcObservationAutoConfiguration(ObservationProperties observationProperties,
-			MetricsProperties metricsProperties) {
-		this.observationProperties = observationProperties;
-		this.metricsProperties = metricsProperties;
-	}
-
 	@Bean
 	@ConditionalOnMissingFilterBean
 	public FilterRegistrationBean<ServerHttpObservationFilter> webMvcObservationFilter(ObservationRegistry registry,
 			ObjectProvider<ServerRequestObservationConvention> customConvention,
-			ObjectProvider<WebMvcTagsProvider> customTagsProvider,
-			ObjectProvider<WebMvcTagsContributor> contributorsProvider) {
-		String name = httpRequestsMetricName(this.observationProperties, this.metricsProperties);
-		ServerRequestObservationConvention convention = createConvention(customConvention.getIfAvailable(), name,
-				customTagsProvider.getIfAvailable(), contributorsProvider.orderedStream().toList());
+			ObservationProperties observationProperties) {
+		String name = observationProperties.getHttp().getServer().getRequests().getName();
+		ServerRequestObservationConvention convention = customConvention
+			.getIfAvailable(() -> new DefaultServerRequestObservationConvention(name));
 		ServerHttpObservationFilter filter = new ServerHttpObservationFilter(registry, convention);
 		FilterRegistrationBean<ServerHttpObservationFilter> registration = new FilterRegistrationBean<>(filter);
 		registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 1);
@@ -95,27 +79,6 @@ public FilterRegistrationBean<ServerHttpObservationFilter> webMvcObservationFilt
 		return registration;
 	}
 
-	private static ServerRequestObservationConvention createConvention(
-			ServerRequestObservationConvention customConvention, String name, WebMvcTagsProvider tagsProvider,
-			List<WebMvcTagsContributor> contributors) {
-		if (customConvention != null) {
-			return customConvention;
-		}
-		else if (tagsProvider != null || contributors.size() > 0) {
-			return new ServerRequestObservationConventionAdapter(name, tagsProvider, contributors);
-		}
-		else {
-			return new DefaultServerRequestObservationConvention(name);
-		}
-	}
-
-	private static String httpRequestsMetricName(ObservationProperties observationProperties,
-			MetricsProperties metricsProperties) {
-		String observationName = observationProperties.getHttp().getServer().getRequests().getName();
-		return (observationName != null) ? observationName
-				: metricsProperties.getWeb().getServer().getRequest().getMetricName();
-	}
-
 	@Configuration(proxyBeanMethods = false)
 	@ConditionalOnClass(MeterRegistry.class)
 	@ConditionalOnBean(MeterRegistry.class)
@@ -123,9 +86,9 @@ static class MeterFilterConfiguration {
 
 		@Bean
 		@Order(0)
-		MeterFilter metricsHttpServerUriTagFilter(MetricsProperties metricsProperties,
-				ObservationProperties observationProperties) {
-			String name = httpRequestsMetricName(observationProperties, metricsProperties);
+		MeterFilter metricsHttpServerUriTagFilter(ObservationProperties observationProperties,
+				MetricsProperties metricsProperties) {
+			String name = observationProperties.getHttp().getServer().getRequests().getName();
 			MeterFilter filter = new OnlyOnceLoggingDenyMeterFilter(
 					() -> String.format("Reached the maximum number of URI tags for '%s'.", name));
 			return MeterFilter.maximumAllowableTags(name, "uri", metricsProperties.getWeb().getServer().getMaxUriTags(),
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryAutoConfiguration.java
new file mode 100644
index 000000000000..f87bfa6548dd
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryAutoConfiguration.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.opentelemetry;
+
+import io.opentelemetry.api.OpenTelemetry;
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.context.propagation.ContextPropagators;
+import io.opentelemetry.sdk.OpenTelemetrySdk;
+import io.opentelemetry.sdk.OpenTelemetrySdkBuilder;
+import io.opentelemetry.sdk.logs.SdkLoggerProvider;
+import io.opentelemetry.sdk.metrics.SdkMeterProvider;
+import io.opentelemetry.sdk.resources.Resource;
+import io.opentelemetry.sdk.resources.ResourceBuilder;
+import io.opentelemetry.sdk.trace.SdkTracerProvider;
+
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.core.env.Environment;
+
+/**
+ * {@link EnableAutoConfiguration Auto-configuration} for OpenTelemetry.
+ *
+ * @author Moritz Halbritter
+ * @since 3.2.0
+ */
+@AutoConfiguration
+@ConditionalOnClass(OpenTelemetrySdk.class)
+@EnableConfigurationProperties(OpenTelemetryProperties.class)
+public class OpenTelemetryAutoConfiguration {
+
+	/**
+	 * Default value for application name if {@code spring.application.name} is not set.
+	 */
+	private static final String DEFAULT_APPLICATION_NAME = "application";
+
+	private static final AttributeKey<String> ATTRIBUTE_KEY_SERVICE_NAME = AttributeKey.stringKey("service.name");
+
+	@Bean
+	@ConditionalOnMissingBean(OpenTelemetry.class)
+	OpenTelemetrySdk openTelemetry(ObjectProvider<SdkTracerProvider> tracerProvider,
+			ObjectProvider<ContextPropagators> propagators, ObjectProvider<SdkLoggerProvider> loggerProvider,
+			ObjectProvider<SdkMeterProvider> meterProvider) {
+		OpenTelemetrySdkBuilder builder = OpenTelemetrySdk.builder();
+		tracerProvider.ifAvailable(builder::setTracerProvider);
+		propagators.ifAvailable(builder::setPropagators);
+		loggerProvider.ifAvailable(builder::setLoggerProvider);
+		meterProvider.ifAvailable(builder::setMeterProvider);
+		return builder.build();
+	}
+
+	@Bean
+	@ConditionalOnMissingBean
+	Resource openTelemetryResource(Environment environment, OpenTelemetryProperties properties) {
+		String applicationName = environment.getProperty("spring.application.name", DEFAULT_APPLICATION_NAME);
+		return Resource.getDefault()
+			.merge(Resource.create(Attributes.of(ATTRIBUTE_KEY_SERVICE_NAME, applicationName)))
+			.merge(toResource(properties));
+	}
+
+	private static Resource toResource(OpenTelemetryProperties properties) {
+		ResourceBuilder builder = Resource.builder();
+		properties.getResourceAttributes().forEach(builder::put);
+		return builder.build();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryProperties.java
new file mode 100644
index 000000000000..4c973ecf578b
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryProperties.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.opentelemetry;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * Configuration properties for OpenTelemetry.
+ *
+ * @author Moritz Halbritter
+ * @since 3.2.0
+ */
+@ConfigurationProperties(prefix = "management.opentelemetry")
+public class OpenTelemetryProperties {
+
+	/**
+	 * Resource attributes.
+	 */
+	private Map<String, String> resourceAttributes = new HashMap<>();
+
+	public Map<String, String> getResourceAttributes() {
+		return this.resourceAttributes;
+	}
+
+	public void setResourceAttributes(Map<String, String> resourceAttributes) {
+		this.resourceAttributes = resourceAttributes;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/package-info.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/package-info.java
new file mode 100644
index 000000000000..c1aab18823c6
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Auto-configuration for OpenTelemetry.
+ */
+package org.springframework.boot.actuate.autoconfigure.opentelemetry;
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/r2dbc/R2dbcObservationAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/r2dbc/R2dbcObservationAutoConfiguration.java
new file mode 100644
index 000000000000..75f619c1bdfe
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/r2dbc/R2dbcObservationAutoConfiguration.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.r2dbc;
+
+import io.micrometer.observation.ObservationRegistry;
+import io.r2dbc.proxy.ProxyConnectionFactory;
+import io.r2dbc.proxy.observation.ObservationProxyExecutionListener;
+import io.r2dbc.proxy.observation.QueryObservationConvention;
+import io.r2dbc.proxy.observation.QueryParametersTagProvider;
+import io.r2dbc.spi.ConnectionFactory;
+import io.r2dbc.spi.ConnectionFactoryOptions;
+
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.r2dbc.ConnectionFactoryDecorator;
+import org.springframework.boot.r2dbc.OptionsCapableConnectionFactory;
+import org.springframework.context.annotation.Bean;
+
+/**
+ * {@link EnableAutoConfiguration Auto-configuration} for R2DBC observability support.
+ *
+ * @author Moritz Halbritter
+ * @since 3.2.0
+ */
+@AutoConfiguration(after = ObservationAutoConfiguration.class)
+@ConditionalOnClass({ ConnectionFactory.class, ProxyConnectionFactory.class })
+@EnableConfigurationProperties(R2dbcObservationProperties.class)
+public class R2dbcObservationAutoConfiguration {
+
+	@Bean
+	@ConditionalOnBean(ObservationRegistry.class)
+	ConnectionFactoryDecorator connectionFactoryDecorator(R2dbcObservationProperties properties,
+			ObservationRegistry observationRegistry,
+			ObjectProvider<QueryObservationConvention> queryObservationConvention,
+			ObjectProvider<QueryParametersTagProvider> queryParametersTagProvider) {
+		return (connectionFactory) -> {
+			HostAndPort hostAndPort = extractHostAndPort(connectionFactory);
+			ObservationProxyExecutionListener listener = new ObservationProxyExecutionListener(observationRegistry,
+					connectionFactory, hostAndPort.host(), hostAndPort.port());
+			listener.setIncludeParameterValues(properties.isIncludeParameterValues());
+			queryObservationConvention.ifAvailable(listener::setQueryObservationConvention);
+			queryParametersTagProvider.ifAvailable(listener::setQueryParametersTagProvider);
+			return ProxyConnectionFactory.builder(connectionFactory).listener(listener).build();
+		};
+	}
+
+	private HostAndPort extractHostAndPort(ConnectionFactory connectionFactory) {
+		OptionsCapableConnectionFactory optionsCapableConnectionFactory = OptionsCapableConnectionFactory
+			.unwrapFrom(connectionFactory);
+		if (optionsCapableConnectionFactory == null) {
+			return HostAndPort.empty();
+		}
+		ConnectionFactoryOptions options = optionsCapableConnectionFactory.getOptions();
+		Object host = options.getValue(ConnectionFactoryOptions.HOST);
+		Object port = options.getValue(ConnectionFactoryOptions.PORT);
+		if (!(host instanceof String hostAsString) || !(port instanceof Integer portAsInt)) {
+			return HostAndPort.empty();
+		}
+		return new HostAndPort(hostAsString, portAsInt);
+	}
+
+	private record HostAndPort(String host, Integer port) {
+		static HostAndPort empty() {
+			return new HostAndPort(null, null);
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/r2dbc/R2dbcObservationProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/r2dbc/R2dbcObservationProperties.java
new file mode 100644
index 000000000000..4eedf3e12282
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/r2dbc/R2dbcObservationProperties.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.r2dbc;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * Configuration properties for R2DBC observability.
+ *
+ * @author Moritz Halbritter
+ * @since 3.2.0
+ */
+@ConfigurationProperties("management.observations.r2dbc")
+public class R2dbcObservationProperties {
+
+	/**
+	 * Whether to tag actual query parameter values.
+	 */
+	private boolean includeParameterValues;
+
+	public boolean isIncludeParameterValues() {
+		return this.includeParameterValues;
+	}
+
+	public void setIncludeParameterValues(boolean includeParameterValues) {
+		this.includeParameterValues = includeParameterValues;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/scheduling/ScheduledTasksObservabilityAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/scheduling/ScheduledTasksObservabilityAutoConfiguration.java
new file mode 100644
index 000000000000..a4014d2d3eb5
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/scheduling/ScheduledTasksObservabilityAutoConfiguration.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.scheduling;
+
+import io.micrometer.observation.ObservationRegistry;
+
+import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.context.annotation.Bean;
+import org.springframework.scheduling.annotation.SchedulingConfigurer;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
+import org.springframework.scheduling.config.ScheduledTaskRegistrar;
+
+/**
+ * {@link EnableAutoConfiguration Auto-configuration} to enable observability for
+ * scheduled tasks.
+ *
+ * @author Moritz Halbritter
+ * @since 3.2.0
+ */
+@AutoConfiguration(after = ObservationAutoConfiguration.class)
+@ConditionalOnBean(ObservationRegistry.class)
+@ConditionalOnClass(ThreadPoolTaskScheduler.class)
+public class ScheduledTasksObservabilityAutoConfiguration {
+
+	@Bean
+	ObservabilitySchedulingConfigurer observabilitySchedulingConfigurer(ObservationRegistry observationRegistry) {
+		return new ObservabilitySchedulingConfigurer(observationRegistry);
+	}
+
+	static final class ObservabilitySchedulingConfigurer implements SchedulingConfigurer {
+
+		private final ObservationRegistry observationRegistry;
+
+		ObservabilitySchedulingConfigurer(ObservationRegistry observationRegistry) {
+			this.observationRegistry = observationRegistry;
+		}
+
+		@Override
+		public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
+			taskRegistrar.setObservationRegistry(this.observationRegistry);
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/BraveAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/BraveAutoConfiguration.java
index b0e25e91495b..47d65fe69cbc 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/BraveAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/BraveAutoConfiguration.java
@@ -37,7 +37,9 @@
 import brave.propagation.CurrentTraceContext;
 import brave.propagation.CurrentTraceContext.ScopeDecorator;
 import brave.propagation.CurrentTraceContextCustomizer;
+import brave.propagation.Propagation;
 import brave.propagation.Propagation.Factory;
+import brave.propagation.Propagation.KeyFactory;
 import brave.propagation.ThreadLocalCurrentTraceContext;
 import brave.sampler.Sampler;
 import io.micrometer.tracing.brave.bridge.BraveBaggageManager;
@@ -77,7 +79,6 @@
 @AutoConfiguration(before = MicrometerTracingAutoConfiguration.class)
 @ConditionalOnClass({ Tracer.class, BraveTracer.class })
 @EnableConfigurationProperties(TracingProperties.class)
-@ConditionalOnEnabledTracing
 public class BraveAutoConfiguration {
 
 	private static final BraveBaggageManager BRAVE_BAGGAGE_MANAGER = new BraveBaggageManager();
@@ -188,7 +189,7 @@ static class BraveNoBaggageConfiguration {
 		@Bean
 		@ConditionalOnMissingBean
 		Factory propagationFactory(TracingProperties properties) {
-			return CompositePropagationFactory.create(properties.getPropagation(), null);
+			return CompositePropagationFactory.create(properties.getPropagation());
 		}
 
 	}
@@ -207,13 +208,33 @@ static class BraveBaggageConfiguration {
 		@ConditionalOnMissingBean
 		BaggagePropagation.FactoryBuilder propagationFactoryBuilder(
 				ObjectProvider<BaggagePropagationCustomizer> baggagePropagationCustomizers) {
-			CompositePropagationFactory factory = CompositePropagationFactory
-				.create(this.tracingProperties.getPropagation(), BRAVE_BAGGAGE_MANAGER);
-			FactoryBuilder builder = BaggagePropagation.newFactoryBuilder(factory);
-			baggagePropagationCustomizers.orderedStream().forEach((customizer) -> customizer.customize(builder));
+			// There's a chicken-and-egg problem here: to create a builder, we need a
+			// factory. But the CompositePropagationFactory needs data from the builder.
+			// We create a throw-away builder with a throw-away factory, and then copy the
+			// config to the real builder.
+			FactoryBuilder throwAwayBuilder = BaggagePropagation.newFactoryBuilder(createThrowAwayFactory());
+			baggagePropagationCustomizers.orderedStream()
+				.forEach((customizer) -> customizer.customize(throwAwayBuilder));
+			CompositePropagationFactory propagationFactory = CompositePropagationFactory.create(
+					this.tracingProperties.getPropagation(), BRAVE_BAGGAGE_MANAGER,
+					LocalBaggageFields.extractFrom(throwAwayBuilder));
+			FactoryBuilder builder = BaggagePropagation.newFactoryBuilder(propagationFactory);
+			throwAwayBuilder.configs().forEach(builder::add);
 			return builder;
 		}
 
+		@SuppressWarnings("deprecation")
+		private Factory createThrowAwayFactory() {
+			return new Factory() {
+
+				@Override
+				public <K> Propagation<K> create(KeyFactory<K> keyFactory) {
+					return null;
+				}
+
+			};
+		}
+
 		@Bean
 		@Order(0)
 		BaggagePropagationCustomizer remoteFieldsBaggagePropagationCustomizer() {
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/CompositePropagationFactory.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/CompositePropagationFactory.java
index 0ec3db30c2b7..4e3b09b1e915 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/CompositePropagationFactory.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/CompositePropagationFactory.java
@@ -17,7 +17,6 @@
 package org.springframework.boot.actuate.autoconfigure.tracing;
 
 import java.util.Collection;
-import java.util.Collections;
 import java.util.List;
 import java.util.function.Predicate;
 import java.util.stream.Stream;
@@ -86,14 +85,24 @@ public TraceContext decorate(TraceContext context) {
 	}
 
 	/**
-	 * Creates a new {@link CompositePropagationFactory}, which uses the given
-	 * {@code injectionTypes} for injection and {@code extractionTypes} for extraction.
+	 * Creates a new {@link CompositePropagationFactory}.
+	 * @param properties the propagation properties
+	 * @return the {@link CompositePropagationFactory}
+	 */
+	static CompositePropagationFactory create(TracingProperties.Propagation properties) {
+		return create(properties, null, null);
+	}
+
+	/**
+	 * Creates a new {@link CompositePropagationFactory}.
 	 * @param properties the propagation properties
 	 * @param baggageManager the baggage manager to use, or {@code null}
+	 * @param localFields the local fields, or {@code null}
 	 * @return the {@link CompositePropagationFactory}
 	 */
-	static CompositePropagationFactory create(TracingProperties.Propagation properties, BaggageManager baggageManager) {
-		PropagationFactoryMapper mapper = new PropagationFactoryMapper(baggageManager);
+	static CompositePropagationFactory create(TracingProperties.Propagation properties, BaggageManager baggageManager,
+			LocalBaggageFields localFields) {
+		PropagationFactoryMapper mapper = new PropagationFactoryMapper(baggageManager, localFields);
 		List<Factory> injectors = properties.getEffectiveProducedTypes().stream().map(mapper::map).toList();
 		List<Factory> extractors = properties.getEffectiveConsumedTypes().stream().map(mapper::map).toList();
 		return new CompositePropagationFactory(injectors, extractors);
@@ -107,8 +116,11 @@ private static class PropagationFactoryMapper {
 
 		private final BaggageManager baggageManager;
 
-		PropagationFactoryMapper(BaggageManager baggageManager) {
+		private final LocalBaggageFields localFields;
+
+		PropagationFactoryMapper(BaggageManager baggageManager, LocalBaggageFields localFields) {
 			this.baggageManager = baggageManager;
+			this.localFields = (localFields != null) ? localFields : LocalBaggageFields.empty();
 		}
 
 		Propagation.Factory map(PropagationType type) {
@@ -124,7 +136,7 @@ Propagation.Factory map(PropagationType type) {
 		 * @return the B3 propagation factory
 		 */
 		private Propagation.Factory b3Single() {
-			return B3Propagation.newFactoryBuilder().injectFormat(B3Propagation.Format.SINGLE_NO_PARENT).build();
+			return B3Propagation.newFactoryBuilder().injectFormat(B3Propagation.Format.SINGLE).build();
 		}
 
 		/**
@@ -140,8 +152,10 @@ private Propagation.Factory b3Multi() {
 		 * @return the W3C propagation factory
 		 */
 		private Propagation.Factory w3c() {
-			return (this.baggageManager != null) ? new W3CPropagation(this.baggageManager, Collections.emptyList())
-					: new W3CPropagation();
+			if (this.baggageManager == null) {
+				return new W3CPropagation();
+			}
+			return new W3CPropagation(this.baggageManager, this.localFields.asList());
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/CompositeTextMapPropagator.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/CompositeTextMapPropagator.java
index 5db4f629a0ea..aef0a11f01a3 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/CompositeTextMapPropagator.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/CompositeTextMapPropagator.java
@@ -40,6 +40,7 @@
  * configure different formats for injecting and for extracting.
  *
  * @author Moritz Halbritter
+ * @author Scott Frederick
  */
 class CompositeTextMapPropagator implements TextMapPropagator {
 
@@ -81,6 +82,10 @@ Collection<TextMapPropagator> getInjectors() {
 		return this.injectors;
 	}
 
+	Collection<TextMapPropagator> getExtractors() {
+		return this.extractors;
+	}
+
 	@Override
 	public Collection<String> fields() {
 		return this.fields;
@@ -113,8 +118,7 @@ public <C> Context extract(Context context, C carrier, TextMapGetter<C> getter)
 	}
 
 	/**
-	 * Creates a new {@link CompositeTextMapPropagator}, which uses the given
-	 * {@code injectionTypes} for injection and {@code extractionTypes} for extraction.
+	 * Creates a new {@link CompositeTextMapPropagator}.
 	 * @param properties the tracing properties
 	 * @param baggagePropagator the baggage propagator to use, or {@code null}
 	 * @return the {@link CompositeTextMapPropagator}
@@ -128,7 +132,7 @@ static TextMapPropagator create(TracingProperties.Propagation properties, TextMa
 		if (baggagePropagator != null) {
 			injectors.add(baggagePropagator);
 		}
-		List<TextMapPropagator> extractors = properties.getEffectiveProducedTypes().stream().map(mapper::map).toList();
+		List<TextMapPropagator> extractors = properties.getEffectiveConsumedTypes().stream().map(mapper::map).toList();
 		return new CompositeTextMapPropagator(injectors, extractors, baggagePropagator);
 	}
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/LocalBaggageFields.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/LocalBaggageFields.java
new file mode 100644
index 000000000000..dfd3f495ae7c
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/LocalBaggageFields.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.tracing;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import brave.baggage.BaggagePropagation;
+import brave.baggage.BaggagePropagationConfig;
+import brave.baggage.BaggagePropagationConfig.SingleBaggageField;
+
+import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
+
+/**
+ * Local baggage fields.
+ *
+ * @author Moritz Halbritter
+ */
+class LocalBaggageFields {
+
+	private final List<String> fields;
+
+	LocalBaggageFields(List<String> fields) {
+		Assert.notNull(fields, "fields must not be null");
+		this.fields = fields;
+	}
+
+	/**
+	 * Returns the local fields as a list.
+	 * @return the list
+	 */
+	List<String> asList() {
+		return Collections.unmodifiableList(this.fields);
+	}
+
+	/**
+	 * Extracts the local fields from the given propagation factory builder.
+	 * @param builder the propagation factory builder to extract the local fields from
+	 * @return the local fields
+	 */
+	static LocalBaggageFields extractFrom(BaggagePropagation.FactoryBuilder builder) {
+		List<String> localFields = new ArrayList<>();
+		for (BaggagePropagationConfig config : builder.configs()) {
+			if (config instanceof SingleBaggageField field) {
+				if (CollectionUtils.isEmpty(field.keyNames())) {
+					localFields.add(field.field().name());
+				}
+			}
+		}
+		return new LocalBaggageFields(localFields);
+	}
+
+	/**
+	 * Creates empty local fields.
+	 * @return the empty local fields
+	 */
+	static LocalBaggageFields empty() {
+		return new LocalBaggageFields(Collections.emptyList());
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/LogCorrelationEnvironmentPostProcessor.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/LogCorrelationEnvironmentPostProcessor.java
new file mode 100644
index 000000000000..b0479bd29c3f
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/LogCorrelationEnvironmentPostProcessor.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.tracing;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.env.EnvironmentPostProcessor;
+import org.springframework.boot.logging.LoggingSystem;
+import org.springframework.core.env.ConfigurableEnvironment;
+import org.springframework.core.env.EnumerablePropertySource;
+import org.springframework.core.env.Environment;
+import org.springframework.core.env.PropertySource;
+import org.springframework.util.ClassUtils;
+
+/**
+ * {@link EnvironmentPostProcessor} to add a {@link PropertySource} to support log
+ * correlation IDs when Micrometer Tracing is present. Adds support for the
+ * {@value LoggingSystem#EXPECT_CORRELATION_ID_PROPERTY} property by delegating to
+ * {@code management.tracing.enabled}.
+ *
+ * @author Jonatan Ivanov
+ * @author Phillip Webb
+ */
+class LogCorrelationEnvironmentPostProcessor implements EnvironmentPostProcessor {
+
+	@Override
+	public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
+		if (ClassUtils.isPresent("io.micrometer.tracing.Tracer", application.getClassLoader())) {
+			environment.getPropertySources().addLast(new LogCorrelationPropertySource(this, environment));
+		}
+	}
+
+	/**
+	 * Log correlation {@link PropertySource}.
+	 */
+	private static class LogCorrelationPropertySource extends EnumerablePropertySource<Object> {
+
+		private static final String NAME = "logCorrelation";
+
+		private final Environment environment;
+
+		LogCorrelationPropertySource(Object source, Environment environment) {
+			super(NAME, source);
+			this.environment = environment;
+		}
+
+		@Override
+		public String[] getPropertyNames() {
+			return new String[] { LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY };
+		}
+
+		@Override
+		public Object getProperty(String name) {
+			if (name.equals(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY)) {
+				return this.environment.getProperty("management.tracing.enabled", Boolean.class, Boolean.TRUE);
+			}
+			return null;
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/MicrometerTracingAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/MicrometerTracingAutoConfiguration.java
index e91e41a5b057..93d8acaa0e3d 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/MicrometerTracingAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/MicrometerTracingAutoConfiguration.java
@@ -17,17 +17,26 @@
 package org.springframework.boot.actuate.autoconfigure.tracing;
 
 import io.micrometer.tracing.Tracer;
+import io.micrometer.tracing.annotation.DefaultNewSpanParser;
+import io.micrometer.tracing.annotation.ImperativeMethodInvocationProcessor;
+import io.micrometer.tracing.annotation.MethodInvocationProcessor;
+import io.micrometer.tracing.annotation.NewSpanParser;
+import io.micrometer.tracing.annotation.SpanAspect;
+import io.micrometer.tracing.annotation.SpanTagAnnotationHandler;
 import io.micrometer.tracing.handler.DefaultTracingObservationHandler;
 import io.micrometer.tracing.handler.PropagatingReceiverTracingObservationHandler;
 import io.micrometer.tracing.handler.PropagatingSenderTracingObservationHandler;
 import io.micrometer.tracing.propagation.Propagator;
+import org.aspectj.weaver.Advice;
 
+import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
 import org.springframework.core.Ordered;
 import org.springframework.core.annotation.Order;
 
@@ -35,11 +44,12 @@
  * {@link EnableAutoConfiguration Auto-configuration} for the Micrometer Tracing API.
  *
  * @author Moritz Halbritter
+ * @author Jonatan Ivanov
  * @since 3.0.0
  */
 @AutoConfiguration
 @ConditionalOnClass(Tracer.class)
-@ConditionalOnEnabledTracing
+@ConditionalOnBean(Tracer.class)
 public class MicrometerTracingAutoConfiguration {
 
 	/**
@@ -61,7 +71,6 @@ public class MicrometerTracingAutoConfiguration {
 
 	@Bean
 	@ConditionalOnMissingBean
-	@ConditionalOnBean(Tracer.class)
 	@Order(DEFAULT_TRACING_OBSERVATION_HANDLER_ORDER)
 	public DefaultTracingObservationHandler defaultTracingObservationHandler(Tracer tracer) {
 		return new DefaultTracingObservationHandler(tracer);
@@ -69,7 +78,7 @@ public DefaultTracingObservationHandler defaultTracingObservationHandler(Tracer
 
 	@Bean
 	@ConditionalOnMissingBean
-	@ConditionalOnBean({ Tracer.class, Propagator.class })
+	@ConditionalOnBean(Propagator.class)
 	@Order(SENDER_TRACING_OBSERVATION_HANDLER_ORDER)
 	public PropagatingSenderTracingObservationHandler<?> propagatingSenderTracingObservationHandler(Tracer tracer,
 			Propagator propagator) {
@@ -78,11 +87,39 @@ public PropagatingSenderTracingObservationHandler<?> propagatingSenderTracingObs
 
 	@Bean
 	@ConditionalOnMissingBean
-	@ConditionalOnBean({ Tracer.class, Propagator.class })
+	@ConditionalOnBean(Propagator.class)
 	@Order(RECEIVER_TRACING_OBSERVATION_HANDLER_ORDER)
 	public PropagatingReceiverTracingObservationHandler<?> propagatingReceiverTracingObservationHandler(Tracer tracer,
 			Propagator propagator) {
 		return new PropagatingReceiverTracingObservationHandler<>(tracer, propagator);
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	@ConditionalOnClass(Advice.class)
+	static class SpanAspectConfiguration {
+
+		@Bean
+		@ConditionalOnMissingBean(NewSpanParser.class)
+		DefaultNewSpanParser newSpanParser() {
+			return new DefaultNewSpanParser();
+		}
+
+		@Bean
+		@ConditionalOnMissingBean(MethodInvocationProcessor.class)
+		ImperativeMethodInvocationProcessor imperativeMethodInvocationProcessor(NewSpanParser newSpanParser,
+				Tracer tracer, ObjectProvider<SpanTagAnnotationHandler> spanTagAnnotationHandler) {
+			ImperativeMethodInvocationProcessor methodInvocationProcessor = new ImperativeMethodInvocationProcessor(
+					newSpanParser, tracer);
+			spanTagAnnotationHandler.ifAvailable(methodInvocationProcessor::setSpanTagAnnotationHandler);
+			return methodInvocationProcessor;
+		}
+
+		@Bean
+		@ConditionalOnMissingBean
+		SpanAspect spanAspect(MethodInvocationProcessor methodInvocationProcessor) {
+			return new SpanAspect(methodInvocationProcessor);
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/OpenTelemetryAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/OpenTelemetryAutoConfiguration.java
index 60a15e904f61..92e75afc0761 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/OpenTelemetryAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/OpenTelemetryAutoConfiguration.java
@@ -36,20 +36,19 @@
 import io.micrometer.tracing.otel.bridge.Slf4JEventListener;
 import io.micrometer.tracing.otel.propagation.BaggageTextMapPropagator;
 import io.opentelemetry.api.OpenTelemetry;
-import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.metrics.MeterProvider;
 import io.opentelemetry.api.trace.Tracer;
 import io.opentelemetry.context.ContextStorage;
 import io.opentelemetry.context.propagation.ContextPropagators;
 import io.opentelemetry.context.propagation.TextMapPropagator;
-import io.opentelemetry.sdk.OpenTelemetrySdk;
 import io.opentelemetry.sdk.resources.Resource;
 import io.opentelemetry.sdk.trace.SdkTracerProvider;
 import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder;
 import io.opentelemetry.sdk.trace.SpanProcessor;
 import io.opentelemetry.sdk.trace.export.BatchSpanProcessor;
+import io.opentelemetry.sdk.trace.export.BatchSpanProcessorBuilder;
 import io.opentelemetry.sdk.trace.export.SpanExporter;
 import io.opentelemetry.sdk.trace.samplers.Sampler;
-import io.opentelemetry.semconv.resource.attributes.ResourceAttributes;
 
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.boot.SpringBootVersion;
@@ -61,27 +60,20 @@
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
-import org.springframework.core.env.Environment;
 
 /**
- * {@link EnableAutoConfiguration Auto-configuration} for OpenTelemetry.
+ * {@link EnableAutoConfiguration Auto-configuration} for OpenTelemetry tracing.
  *
  * @author Moritz Halbritter
  * @author Marcin Grzejszczak
  * @author Yanming Zhou
  * @since 3.0.0
  */
-@AutoConfiguration(before = MicrometerTracingAutoConfiguration.class)
-@ConditionalOnEnabledTracing
+@AutoConfiguration(value = "openTelemetryTracingAutoConfiguration", before = MicrometerTracingAutoConfiguration.class)
 @ConditionalOnClass({ OtelTracer.class, SdkTracerProvider.class, OpenTelemetry.class })
 @EnableConfigurationProperties(TracingProperties.class)
 public class OpenTelemetryAutoConfiguration {
 
-	/**
-	 * Default value for application name if {@code spring.application.name} is not set.
-	 */
-	private static final String DEFAULT_APPLICATION_NAME = "application";
-
 	private final TracingProperties tracingProperties;
 
 	OpenTelemetryAutoConfiguration(TracingProperties tracingProperties) {
@@ -90,23 +82,10 @@ public class OpenTelemetryAutoConfiguration {
 
 	@Bean
 	@ConditionalOnMissingBean
-	OpenTelemetry openTelemetry(SdkTracerProvider sdkTracerProvider, ContextPropagators contextPropagators) {
-		return OpenTelemetrySdk.builder()
-			.setTracerProvider(sdkTracerProvider)
-			.setPropagators(contextPropagators)
-			.build();
-	}
-
-	@Bean
-	@ConditionalOnMissingBean
-	SdkTracerProvider otelSdkTracerProvider(Environment environment, ObjectProvider<SpanProcessor> spanProcessors,
-			Sampler sampler, ObjectProvider<SdkTracerProviderBuilderCustomizer> customizers) {
-		String applicationName = environment.getProperty("spring.application.name", DEFAULT_APPLICATION_NAME);
-		Resource springResource = Resource.create(Attributes.of(ResourceAttributes.SERVICE_NAME, applicationName));
-		SdkTracerProviderBuilder builder = SdkTracerProvider.builder()
-			.setSampler(sampler)
-			.setResource(Resource.getDefault().merge(springResource));
-		spanProcessors.orderedStream().forEach(builder::addSpanProcessor);
+	SdkTracerProvider otelSdkTracerProvider(Resource resource, SpanProcessors spanProcessors, Sampler sampler,
+			ObjectProvider<SdkTracerProviderBuilderCustomizer> customizers) {
+		SdkTracerProviderBuilder builder = SdkTracerProvider.builder().setSampler(sampler).setResource(resource);
+		spanProcessors.forEach(builder::addSpanProcessor);
 		customizers.orderedStream().forEach((customizer) -> customizer.customize(builder));
 		return builder.build();
 	}
@@ -125,14 +104,26 @@ Sampler otelSampler() {
 	}
 
 	@Bean
-	SpanProcessor otelSpanProcessor(ObjectProvider<SpanExporter> spanExporters,
+	@ConditionalOnMissingBean
+	SpanProcessors spanProcessors(ObjectProvider<SpanProcessor> spanProcessors) {
+		return SpanProcessors.of(spanProcessors.orderedStream().toList());
+	}
+
+	@Bean
+	BatchSpanProcessor otelSpanProcessor(SpanExporters spanExporters,
 			ObjectProvider<SpanExportingPredicate> spanExportingPredicates, ObjectProvider<SpanReporter> spanReporters,
-			ObjectProvider<SpanFilter> spanFilters) {
-		return BatchSpanProcessor
-			.builder(new CompositeSpanExporter(spanExporters.orderedStream().toList(),
-					spanExportingPredicates.orderedStream().toList(), spanReporters.orderedStream().toList(),
-					spanFilters.orderedStream().toList()))
-			.build();
+			ObjectProvider<SpanFilter> spanFilters, ObjectProvider<MeterProvider> meterProvider) {
+		BatchSpanProcessorBuilder builder = BatchSpanProcessor
+			.builder(new CompositeSpanExporter(spanExporters.list(), spanExportingPredicates.orderedStream().toList(),
+					spanReporters.orderedStream().toList(), spanFilters.orderedStream().toList()));
+		meterProvider.ifAvailable(builder::setMeterProvider);
+		return builder.build();
+	}
+
+	@Bean
+	@ConditionalOnMissingBean
+	SpanExporters spanExporters(ObjectProvider<SpanExporter> spanExporters) {
+		return SpanExporters.of(spanExporters.orderedStream().toList());
 	}
 
 	@Bean
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanExporters.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanExporters.java
new file mode 100644
index 000000000000..a44f8ce0e035
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanExporters.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.tracing;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Spliterator;
+
+import io.opentelemetry.sdk.trace.export.SpanExporter;
+
+import org.springframework.util.Assert;
+
+/**
+ * A collection of {@link SpanExporter span exporters}.
+ *
+ * @author Moritz Halbritter
+ * @since 3.2.0
+ */
+@FunctionalInterface
+public interface SpanExporters extends Iterable<SpanExporter> {
+
+	/**
+	 * Returns the list of {@link SpanExporter span exporters}.
+	 * @return the list of span exporters
+	 */
+	List<SpanExporter> list();
+
+	@Override
+	default Iterator<SpanExporter> iterator() {
+		return list().iterator();
+	}
+
+	@Override
+	default Spliterator<SpanExporter> spliterator() {
+		return list().spliterator();
+	}
+
+	/**
+	 * Constructs a {@link SpanExporters} instance with the given {@link SpanExporter span
+	 * exporters}.
+	 * @param spanExporters the span exporters
+	 * @return the constructed {@link SpanExporters} instance
+	 */
+	static SpanExporters of(SpanExporter... spanExporters) {
+		return of(Arrays.asList(spanExporters));
+	}
+
+	/**
+	 * Constructs a {@link SpanExporters} instance with the given list of
+	 * {@link SpanExporter span exporters}.
+	 * @param spanExporters the list of span exporters
+	 * @return the constructed {@link SpanExporters} instance
+	 */
+	static SpanExporters of(Collection<? extends SpanExporter> spanExporters) {
+		Assert.notNull(spanExporters, "SpanExporters must not be null");
+		List<SpanExporter> copy = List.copyOf(spanExporters);
+		return () -> copy;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanProcessors.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanProcessors.java
new file mode 100644
index 000000000000..ca8c55498d07
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanProcessors.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.tracing;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Spliterator;
+
+import io.opentelemetry.sdk.trace.SpanProcessor;
+
+import org.springframework.util.Assert;
+
+/**
+ * A collection of {@link SpanProcessor span processors}.
+ *
+ * @author Moritz Halbritter
+ * @since 3.2.0
+ */
+@FunctionalInterface
+public interface SpanProcessors extends Iterable<SpanProcessor> {
+
+	/**
+	 * Returns the list of {@link SpanProcessor span processors}.
+	 * @return the list of span processors
+	 */
+	List<SpanProcessor> list();
+
+	@Override
+	default Iterator<SpanProcessor> iterator() {
+		return list().iterator();
+	}
+
+	@Override
+	default Spliterator<SpanProcessor> spliterator() {
+		return list().spliterator();
+	}
+
+	/**
+	 * Constructs a {@link SpanProcessors} instance with the given {@link SpanProcessor
+	 * span processors}.
+	 * @param spanProcessors the span processors
+	 * @return the constructed {@link SpanProcessors} instance
+	 */
+	static SpanProcessors of(SpanProcessor... spanProcessors) {
+		return of(Arrays.asList(spanProcessors));
+	}
+
+	/**
+	 * Constructs a {@link SpanProcessors} instance with the given list of
+	 * {@link SpanProcessor span processors}.
+	 * @param spanProcessors the list of span processors
+	 * @return the constructed {@link SpanProcessors} instance
+	 */
+	static SpanProcessors of(Collection<? extends SpanProcessor> spanProcessors) {
+		Assert.notNull(spanProcessors, "SpanProcessors must not be null");
+		List<SpanProcessor> copy = List.copyOf(spanProcessors);
+		return () -> copy;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfiguration.java
index c9493f0d0d99..abb3253f2f99 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfiguration.java
@@ -16,22 +16,17 @@
 
 package org.springframework.boot.actuate.autoconfigure.tracing.otlp;
 
-import java.util.Map.Entry;
-
 import io.micrometer.tracing.otel.bridge.OtelTracer;
 import io.opentelemetry.api.OpenTelemetry;
 import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter;
-import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporterBuilder;
 import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter;
 import io.opentelemetry.sdk.trace.SdkTracerProvider;
 
-import org.springframework.boot.actuate.autoconfigure.tracing.ConditionalOnEnabledTracing;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
-import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
 
 /**
  * {@link EnableAutoConfiguration Auto-configuration} for OTLP. Brave does not support
@@ -45,26 +40,14 @@
  * define an {@link OtlpGrpcSpanExporter} and this auto-configuration will back off.
  *
  * @author Jonatan Ivanov
+ * @author Moritz Halbritter
+ * @author EddĂș MelĂ©ndez
  * @since 3.1.0
  */
 @AutoConfiguration
-@ConditionalOnEnabledTracing
 @ConditionalOnClass({ OtelTracer.class, SdkTracerProvider.class, OpenTelemetry.class, OtlpHttpSpanExporter.class })
 @EnableConfigurationProperties(OtlpProperties.class)
+@Import({ OtlpTracingConfigurations.ConnectionDetails.class, OtlpTracingConfigurations.Exporters.class })
 public class OtlpAutoConfiguration {
 
-	@Bean
-	@ConditionalOnMissingBean(value = OtlpHttpSpanExporter.class,
-			type = "io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter")
-	OtlpHttpSpanExporter otlpHttpSpanExporter(OtlpProperties properties) {
-		OtlpHttpSpanExporterBuilder builder = OtlpHttpSpanExporter.builder()
-			.setEndpoint(properties.getEndpoint())
-			.setTimeout(properties.getTimeout())
-			.setCompression(properties.getCompression().name().toLowerCase());
-		for (Entry<String, String> header : properties.getHeaders().entrySet()) {
-			builder.addHeader(header.getKey(), header.getValue());
-		}
-		return builder.build();
-	}
-
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpProperties.java
index efb1a32f7553..371de8491146 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpProperties.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpProperties.java
@@ -34,7 +34,7 @@ public class OtlpProperties {
 	/**
 	 * URL to the OTel collector's HTTP API.
 	 */
-	private String endpoint = "http://localhost:4318/v1/traces";
+	private String endpoint;
 
 	/**
 	 * Call timeout for the OTel Collector to process an exported batch of data. This
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpTracingConfigurations.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpTracingConfigurations.java
new file mode 100644
index 000000000000..492e43792d02
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpTracingConfigurations.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.tracing.otlp;
+
+import java.util.Map.Entry;
+
+import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter;
+import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporterBuilder;
+
+import org.springframework.boot.actuate.autoconfigure.tracing.ConditionalOnEnabledTracing;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Configurations imported by {@link OtlpAutoConfiguration}.
+ *
+ * @author Moritz Halbritter
+ */
+class OtlpTracingConfigurations {
+
+	@Configuration(proxyBeanMethods = false)
+	static class ConnectionDetails {
+
+		@Bean
+		@ConditionalOnMissingBean
+		@ConditionalOnProperty(prefix = "management.otlp.tracing", name = "endpoint")
+		OtlpTracingConnectionDetails otlpTracingConnectionDetails(OtlpProperties properties) {
+			return new PropertiesOtlpTracingConnectionDetails(properties);
+		}
+
+		/**
+		 * Adapts {@link OtlpProperties} to {@link OtlpTracingConnectionDetails}.
+		 */
+		static class PropertiesOtlpTracingConnectionDetails implements OtlpTracingConnectionDetails {
+
+			private final OtlpProperties properties;
+
+			PropertiesOtlpTracingConnectionDetails(OtlpProperties properties) {
+				this.properties = properties;
+			}
+
+			@Override
+			public String getUrl() {
+				return this.properties.getEndpoint();
+			}
+
+		}
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	static class Exporters {
+
+		@Bean
+		@ConditionalOnMissingBean(value = OtlpHttpSpanExporter.class,
+				type = "io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter")
+		@ConditionalOnBean(OtlpTracingConnectionDetails.class)
+		@ConditionalOnEnabledTracing
+		OtlpHttpSpanExporter otlpHttpSpanExporter(OtlpProperties properties,
+				OtlpTracingConnectionDetails connectionDetails) {
+			OtlpHttpSpanExporterBuilder builder = OtlpHttpSpanExporter.builder()
+				.setEndpoint(connectionDetails.getUrl())
+				.setTimeout(properties.getTimeout())
+				.setCompression(properties.getCompression().name().toLowerCase());
+			for (Entry<String, String> header : properties.getHeaders().entrySet()) {
+				builder.addHeader(header.getKey(), header.getValue());
+			}
+			return builder.build();
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpTracingConnectionDetails.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpTracingConnectionDetails.java
new file mode 100644
index 000000000000..a84b11d64da3
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpTracingConnectionDetails.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.tracing.otlp;
+
+import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
+
+/**
+ * Details required to establish a connection to an OpenTelemetry service.
+ *
+ * @author EddĂș MelĂ©ndez
+ * @since 3.2.0
+ */
+public interface OtlpTracingConnectionDetails extends ConnectionDetails {
+
+	/**
+	 * Address to where tracing will be published.
+	 * @return the address to where tracing will be published
+	 */
+	String getUrl();
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/prometheus/PrometheusExemplarsAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/prometheus/PrometheusExemplarsAutoConfiguration.java
index 9032a0712ad5..4176d2c2462b 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/prometheus/PrometheusExemplarsAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/prometheus/PrometheusExemplarsAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -22,7 +22,6 @@
 
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.boot.actuate.autoconfigure.metrics.export.prometheus.PrometheusMetricsExportAutoConfiguration;
-import org.springframework.boot.actuate.autoconfigure.tracing.ConditionalOnEnabledTracing;
 import org.springframework.boot.actuate.autoconfigure.tracing.MicrometerTracingAutoConfiguration;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
@@ -43,7 +42,6 @@
 		after = MicrometerTracingAutoConfiguration.class)
 @ConditionalOnBean(Tracer.class)
 @ConditionalOnClass({ Tracer.class, SpanContextSupplier.class })
-@ConditionalOnEnabledTracing
 public class PrometheusExemplarsAutoConfiguration {
 
 	@Bean
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/wavefront/WavefrontTracingAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/wavefront/WavefrontTracingAutoConfiguration.java
index 81a0dd0863c3..f3fab744bcc4 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/wavefront/WavefrontTracingAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/wavefront/WavefrontTracingAutoConfiguration.java
@@ -52,7 +52,6 @@
 @AutoConfiguration(after = { MetricsAutoConfiguration.class, CompositeMeterRegistryAutoConfiguration.class,
 		WavefrontAutoConfiguration.class })
 @ConditionalOnClass({ WavefrontSender.class, WavefrontSpanHandler.class })
-@ConditionalOnEnabledTracing
 @EnableConfigurationProperties(WavefrontProperties.class)
 @Import(WavefrontSenderConfiguration.class)
 public class WavefrontTracingAutoConfiguration {
@@ -60,6 +59,7 @@ public class WavefrontTracingAutoConfiguration {
 	@Bean
 	@ConditionalOnMissingBean
 	@ConditionalOnBean(WavefrontSender.class)
+	@ConditionalOnEnabledTracing
 	WavefrontSpanHandler wavefrontSpanHandler(WavefrontProperties properties, WavefrontSender wavefrontSender,
 			SpanMetrics spanMetrics, ApplicationTags applicationTags) {
 		return new WavefrontSpanHandler(properties.getSender().getMaxQueueSize(), wavefrontSender, spanMetrics,
@@ -96,6 +96,7 @@ static class WavefrontBrave {
 
 		@Bean
 		@ConditionalOnMissingBean
+		@ConditionalOnEnabledTracing
 		WavefrontBraveSpanHandler wavefrontBraveSpanHandler(WavefrontSpanHandler wavefrontSpanHandler) {
 			return new WavefrontBraveSpanHandler(wavefrontSpanHandler);
 		}
@@ -108,6 +109,7 @@ static class WavefrontOpenTelemetry {
 
 		@Bean
 		@ConditionalOnMissingBean
+		@ConditionalOnEnabledTracing
 		WavefrontOtelSpanExporter wavefrontOtelSpanExporter(WavefrontSpanHandler wavefrontSpanHandler) {
 			return new WavefrontOtelSpanExporter(wavefrontSpanHandler);
 		}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinAutoConfiguration.java
index 971b9d514ec1..daff635f8631 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinAutoConfiguration.java
@@ -21,7 +21,6 @@
 import zipkin2.codec.SpanBytesEncoder;
 import zipkin2.reporter.Sender;
 
-import org.springframework.boot.actuate.autoconfigure.tracing.ConditionalOnEnabledTracing;
 import org.springframework.boot.actuate.autoconfigure.tracing.zipkin.ZipkinConfigurations.BraveConfiguration;
 import org.springframework.boot.actuate.autoconfigure.tracing.zipkin.ZipkinConfigurations.OpenTelemetryConfiguration;
 import org.springframework.boot.actuate.autoconfigure.tracing.zipkin.ZipkinConfigurations.ReporterConfiguration;
@@ -48,7 +47,6 @@
 @ConditionalOnClass(Sender.class)
 @Import({ SenderConfiguration.class, ReporterConfiguration.class, BraveConfiguration.class,
 		OpenTelemetryConfiguration.class })
-@ConditionalOnEnabledTracing
 @EnableConfigurationProperties(ZipkinProperties.class)
 public class ZipkinAutoConfiguration {
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurations.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurations.java
index 722b502befa5..f4ecc9503125 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurations.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurations.java
@@ -26,6 +26,7 @@
 import zipkin2.reporter.urlconnection.URLConnectionSender;
 
 import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.boot.actuate.autoconfigure.tracing.ConditionalOnEnabledTracing;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@@ -118,7 +119,8 @@ ZipkinWebClientSender webClientSender(ZipkinProperties properties,
 				.getIfAvailable(() -> new PropertiesZipkinConnectionDetails(properties));
 			WebClient.Builder builder = WebClient.builder();
 			customizers.orderedStream().forEach((customizer) -> customizer.customize(builder));
-			return new ZipkinWebClientSender(connectionDetails.getSpanEndpoint(), builder.build());
+			return new ZipkinWebClientSender(connectionDetails.getSpanEndpoint(), builder.build(),
+					properties.getConnectTimeout().plus(properties.getReadTimeout()));
 		}
 
 	}
@@ -142,6 +144,7 @@ static class BraveConfiguration {
 		@Bean
 		@ConditionalOnMissingBean
 		@ConditionalOnBean(Reporter.class)
+		@ConditionalOnEnabledTracing
 		ZipkinSpanHandler zipkinSpanHandler(Reporter<Span> spanReporter) {
 			return (ZipkinSpanHandler) ZipkinSpanHandler.newBuilder(spanReporter).build();
 		}
@@ -155,6 +158,7 @@ static class OpenTelemetryConfiguration {
 		@Bean
 		@ConditionalOnMissingBean
 		@ConditionalOnBean(Sender.class)
+		@ConditionalOnEnabledTracing
 		ZipkinSpanExporter zipkinSpanExporter(BytesEncoder<Span> encoder, Sender sender) {
 			return ZipkinSpanExporter.builder().setEncoder(encoder).setSender(sender).build();
 		}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinWebClientSender.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinWebClientSender.java
index b9992bd5754d..2ef8cb74c09a 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinWebClientSender.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinWebClientSender.java
@@ -16,6 +16,8 @@
 
 package org.springframework.boot.actuate.autoconfigure.tracing.zipkin;
 
+import java.time.Duration;
+
 import reactor.core.publisher.Mono;
 import zipkin2.Call;
 import zipkin2.Callback;
@@ -28,6 +30,7 @@
  * An {@link HttpSender} which uses {@link WebClient} for HTTP communication.
  *
  * @author Stefan Bratanov
+ * @author Moritz Halbritter
  */
 class ZipkinWebClientSender extends HttpSender {
 
@@ -35,14 +38,17 @@ class ZipkinWebClientSender extends HttpSender {
 
 	private final WebClient webClient;
 
-	ZipkinWebClientSender(String endpoint, WebClient webClient) {
+	private final Duration timeout;
+
+	ZipkinWebClientSender(String endpoint, WebClient webClient, Duration timeout) {
 		this.endpoint = endpoint;
 		this.webClient = webClient;
+		this.timeout = timeout;
 	}
 
 	@Override
 	public HttpPostCall sendSpans(byte[] batchedEncodedSpans) {
-		return new WebClientHttpPostCall(this.endpoint, batchedEncodedSpans, this.webClient);
+		return new WebClientHttpPostCall(this.endpoint, batchedEncodedSpans, this.webClient, this.timeout);
 	}
 
 	private static class WebClientHttpPostCall extends HttpPostCall {
@@ -51,15 +57,18 @@ private static class WebClientHttpPostCall extends HttpPostCall {
 
 		private final WebClient webClient;
 
-		WebClientHttpPostCall(String endpoint, byte[] body, WebClient webClient) {
+		private final Duration timeout;
+
+		WebClientHttpPostCall(String endpoint, byte[] body, WebClient webClient, Duration timeout) {
 			super(body);
 			this.endpoint = endpoint;
 			this.webClient = webClient;
+			this.timeout = timeout;
 		}
 
 		@Override
 		public Call<Void> clone() {
-			return new WebClientHttpPostCall(this.endpoint, getUncompressedBody(), this.webClient);
+			return new WebClientHttpPostCall(this.endpoint, getUncompressedBody(), this.webClient, this.timeout);
 		}
 
 		@Override
@@ -79,7 +88,8 @@ private Mono<ResponseEntity<Void>> sendRequest() {
 				.headers(this::addDefaultHeaders)
 				.bodyValue(getBody())
 				.retrieve()
-				.toBodilessEntity();
+				.toBodilessEntity()
+				.timeout(this.timeout);
 		}
 
 		private void addDefaultHeaders(HttpHeaders headers) {
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontProperties.java
index 5803ecaaf3b4..ce313c3360d1 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontProperties.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontProperties.java
@@ -25,6 +25,8 @@
 import java.util.Map;
 import java.util.Set;
 
+import com.wavefront.sdk.common.clients.service.token.TokenService.Type;
+
 import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.PushRegistryProperties;
 import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException;
@@ -57,6 +59,11 @@ public class WavefrontProperties {
 	 */
 	private String apiToken;
 
+	/**
+	 * Type of the API token.
+	 */
+	private TokenType apiTokenType;
+
 	/**
 	 * Application configuration.
 	 */
@@ -132,7 +139,7 @@ public URI getEffectiveUri() {
 	 * @return the API token
 	 */
 	public String getApiTokenOrThrow() {
-		if (this.apiToken == null && !usesProxy()) {
+		if (this.apiTokenType != TokenType.NO_TOKEN && this.apiToken == null && !usesProxy()) {
 			throw new InvalidConfigurationPropertyValueException("management.wavefront.api-token", null,
 					"This property is mandatory whenever publishing directly to the Wavefront API");
 		}
@@ -167,6 +174,31 @@ public void setTraceDerivedCustomTagKeys(Set<String> traceDerivedCustomTagKeys)
 		this.traceDerivedCustomTagKeys = traceDerivedCustomTagKeys;
 	}
 
+	public TokenType getApiTokenType() {
+		return this.apiTokenType;
+	}
+
+	public void setApiTokenType(TokenType apiTokenType) {
+		this.apiTokenType = apiTokenType;
+	}
+
+	/**
+	 * Returns the {@link Type Wavefront token type}.
+	 * @return the Wavefront token type
+	 * @since 3.2.0
+	 */
+	public Type getWavefrontApiTokenType() {
+		if (this.apiTokenType == null) {
+			return usesProxy() ? Type.NO_TOKEN : Type.WAVEFRONT_API_TOKEN;
+		}
+		return switch (this.apiTokenType) {
+			case NO_TOKEN -> Type.NO_TOKEN;
+			case WAVEFRONT_API_TOKEN -> Type.WAVEFRONT_API_TOKEN;
+			case CSP_API_TOKEN -> Type.CSP_API_TOKEN;
+			case CSP_CLIENT_CREDENTIALS -> Type.CSP_CLIENT_CREDENTIALS;
+		};
+	}
+
 	public static class Application {
 
 		/**
@@ -318,6 +350,21 @@ public static class Export extends PushRegistryProperties {
 			 */
 			private String globalPrefix;
 
+			/**
+			 * Whether to report histogram distributions aggregated into minute intervals.
+			 */
+			private boolean reportMinuteDistribution = true;
+
+			/**
+			 * Whether to report histogram distributions aggregated into hour intervals.
+			 */
+			private boolean reportHourDistribution;
+
+			/**
+			 * Whether to report histogram distributions aggregated into day intervals.
+			 */
+			private boolean reportDayDistribution;
+
 			public String getGlobalPrefix() {
 				return this.globalPrefix;
 			}
@@ -342,8 +389,58 @@ public void setBatchSize(Integer batchSize) {
 				throw new UnsupportedOperationException("Use Sender.setBatchSize(int) instead");
 			}
 
+			public boolean isReportMinuteDistribution() {
+				return this.reportMinuteDistribution;
+			}
+
+			public void setReportMinuteDistribution(boolean reportMinuteDistribution) {
+				this.reportMinuteDistribution = reportMinuteDistribution;
+			}
+
+			public boolean isReportHourDistribution() {
+				return this.reportHourDistribution;
+			}
+
+			public void setReportHourDistribution(boolean reportHourDistribution) {
+				this.reportHourDistribution = reportHourDistribution;
+			}
+
+			public boolean isReportDayDistribution() {
+				return this.reportDayDistribution;
+			}
+
+			public void setReportDayDistribution(boolean reportDayDistribution) {
+				this.reportDayDistribution = reportDayDistribution;
+			}
+
 		}
 
 	}
 
+	/**
+	 * Wavefront token type.
+	 *
+	 * @since 3.2.0
+	 */
+	public enum TokenType {
+
+		/**
+		 * No token.
+		 */
+		NO_TOKEN,
+		/**
+		 * Wavefront API token.
+		 */
+		WAVEFRONT_API_TOKEN,
+		/**
+		 * CSP API token.
+		 */
+		CSP_API_TOKEN,
+		/**
+		 * CSP client credentials.
+		 */
+		CSP_CLIENT_CREDENTIALS
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontSenderConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontSenderConfiguration.java
index 6cb11ae31df3..f7ffa4d06929 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontSenderConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontSenderConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -21,13 +21,17 @@
 import com.wavefront.sdk.common.WavefrontSender;
 import com.wavefront.sdk.common.clients.WavefrontClient.Builder;
 
+import org.springframework.boot.actuate.autoconfigure.metrics.export.ConditionalOnEnabledMetricsExport;
 import org.springframework.boot.actuate.autoconfigure.metrics.export.wavefront.WavefrontMetricsExportAutoConfiguration;
+import org.springframework.boot.actuate.autoconfigure.tracing.ConditionalOnEnabledTracing;
 import org.springframework.boot.actuate.autoconfigure.tracing.wavefront.WavefrontTracingAutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.AnyNestedCondition;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.boot.context.properties.PropertyMapper;
 import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Conditional;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.util.unit.DataSize;
 
@@ -46,8 +50,10 @@ public class WavefrontSenderConfiguration {
 
 	@Bean
 	@ConditionalOnMissingBean
+	@Conditional(WavefrontTracingOrMetricsCondition.class)
 	public WavefrontSender wavefrontSender(WavefrontProperties properties) {
-		Builder builder = new Builder(properties.getEffectiveUri().toString(), properties.getApiTokenOrThrow());
+		Builder builder = new Builder(properties.getEffectiveUri().toString(), properties.getWavefrontApiTokenType(),
+				properties.getApiTokenOrThrow());
 		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
 		WavefrontProperties.Sender sender = properties.getSender();
 		map.from(sender.getMaxQueueSize()).to(builder::maxQueueSize);
@@ -57,4 +63,22 @@ public WavefrontSender wavefrontSender(WavefrontProperties properties) {
 		return builder.build();
 	}
 
+	static final class WavefrontTracingOrMetricsCondition extends AnyNestedCondition {
+
+		WavefrontTracingOrMetricsCondition() {
+			super(ConfigurationPhase.REGISTER_BEAN);
+		}
+
+		@ConditionalOnEnabledTracing
+		static class TracingCondition {
+
+		}
+
+		@ConditionalOnEnabledMetricsExport("wavefront")
+		static class MetricsCondition {
+
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/ManagementContextFactory.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/ManagementContextFactory.java
index 0871218563fb..4b98cdfc51a7 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/ManagementContextFactory.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/ManagementContextFactory.java
@@ -60,8 +60,8 @@ public ConfigurableApplicationContext createManagementContext(ApplicationContext
 		Environment parentEnvironment = parentContext.getEnvironment();
 		ConfigurableEnvironment childEnvironment = ApplicationContextFactory.DEFAULT
 			.createEnvironment(this.webApplicationType);
-		if (parentEnvironment instanceof ConfigurableEnvironment) {
-			childEnvironment.setConversionService(((ConfigurableEnvironment) parentEnvironment).getConversionService());
+		if (parentEnvironment instanceof ConfigurableEnvironment configurableEnvironment) {
+			childEnvironment.setConversionService((configurableEnvironment).getConversionService());
 		}
 		ConfigurableApplicationContext managementContext = ApplicationContextFactory.DEFAULT
 			.create(this.webApplicationType);
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/reactive/ReactiveManagementChildContextConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/reactive/ReactiveManagementChildContextConfiguration.java
index 657f9dbda20d..d28ec5d41561 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/reactive/ReactiveManagementChildContextConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/reactive/ReactiveManagementChildContextConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -26,8 +26,10 @@
 import org.springframework.boot.actuate.autoconfigure.web.server.ManagementWebServerFactoryCustomizer;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type;
+import org.springframework.boot.autoconfigure.web.embedded.JettyVirtualThreadsWebServerFactoryCustomizer;
 import org.springframework.boot.autoconfigure.web.embedded.JettyWebServerFactoryCustomizer;
 import org.springframework.boot.autoconfigure.web.embedded.NettyWebServerFactoryCustomizer;
+import org.springframework.boot.autoconfigure.web.embedded.TomcatVirtualThreadsWebServerFactoryCustomizer;
 import org.springframework.boot.autoconfigure.web.embedded.TomcatWebServerFactoryCustomizer;
 import org.springframework.boot.autoconfigure.web.embedded.UndertowWebServerFactoryCustomizer;
 import org.springframework.boot.autoconfigure.web.reactive.ReactiveWebServerFactoryCustomizer;
@@ -76,8 +78,10 @@ static class ReactiveManagementWebServerFactoryCustomizer
 
 		ReactiveManagementWebServerFactoryCustomizer(ListableBeanFactory beanFactory) {
 			super(beanFactory, ReactiveWebServerFactoryCustomizer.class, TomcatWebServerFactoryCustomizer.class,
-					TomcatReactiveWebServerFactoryCustomizer.class, JettyWebServerFactoryCustomizer.class,
-					UndertowWebServerFactoryCustomizer.class, NettyWebServerFactoryCustomizer.class);
+					TomcatReactiveWebServerFactoryCustomizer.class,
+					TomcatVirtualThreadsWebServerFactoryCustomizer.class, JettyWebServerFactoryCustomizer.class,
+					JettyVirtualThreadsWebServerFactoryCustomizer.class, UndertowWebServerFactoryCustomizer.class,
+					NettyWebServerFactoryCustomizer.class);
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/server/ChildManagementContextInitializer.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/server/ChildManagementContextInitializer.java
index a4d423dfce39..579e7d7126d5 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/server/ChildManagementContextInitializer.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/server/ChildManagementContextInitializer.java
@@ -33,12 +33,13 @@
 import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
 import org.springframework.boot.context.event.ApplicationFailedEvent;
 import org.springframework.boot.web.context.ConfigurableWebServerApplicationContext;
-import org.springframework.boot.web.context.WebServerInitializedEvent;
+import org.springframework.boot.web.context.WebServerGracefulShutdownLifecycle;
 import org.springframework.context.ApplicationContext;
 import org.springframework.context.ApplicationContextInitializer;
 import org.springframework.context.ApplicationEvent;
 import org.springframework.context.ApplicationListener;
 import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.context.SmartLifecycle;
 import org.springframework.context.annotation.AnnotationConfigRegistry;
 import org.springframework.context.aot.ApplicationContextAotGenerator;
 import org.springframework.context.event.ContextClosedEvent;
@@ -55,8 +56,7 @@
  * @author Andy Wilkinson
  * @author Phillip Webb
  */
-class ChildManagementContextInitializer
-		implements ApplicationListener<WebServerInitializedEvent>, BeanRegistrationAotProcessor {
+class ChildManagementContextInitializer implements BeanRegistrationAotProcessor, SmartLifecycle {
 
 	private final ManagementContextFactory managementContextFactory;
 
@@ -64,6 +64,8 @@ class ChildManagementContextInitializer
 
 	private final ApplicationContextInitializer<ConfigurableApplicationContext> applicationContextInitializer;
 
+	private volatile ConfigurableApplicationContext managementContext;
+
 	ChildManagementContextInitializer(ManagementContextFactory managementContextFactory,
 			ApplicationContext parentContext) {
 		this(managementContextFactory, parentContext, null);
@@ -79,14 +81,35 @@ private ChildManagementContextInitializer(ManagementContextFactory managementCon
 	}
 
 	@Override
-	public void onApplicationEvent(WebServerInitializedEvent event) {
-		if (event.getApplicationContext().equals(this.parentContext)) {
+	public void start() {
+		if (this.managementContext == null) {
 			ConfigurableApplicationContext managementContext = createManagementContext();
 			registerBeans(managementContext);
 			managementContext.refresh();
+			this.managementContext = managementContext;
+		}
+		else {
+			this.managementContext.start();
+		}
+	}
+
+	@Override
+	public void stop() {
+		if (this.managementContext != null) {
+			this.managementContext.stop();
 		}
 	}
 
+	@Override
+	public boolean isRunning() {
+		return this.managementContext != null && this.managementContext.isRunning();
+	}
+
+	@Override
+	public int getPhase() {
+		return WebServerGracefulShutdownLifecycle.SMART_LIFECYCLE_PHASE + 512;
+	}
+
 	@Override
 	public BeanRegistrationAotContribution processAheadOfTime(RegisteredBean registeredBean) {
 		Assert.isInstanceOf(ConfigurableApplicationContext.class, this.parentContext);
@@ -217,8 +240,8 @@ private void propagateCloseIfNecessary(ApplicationContext applicationContext) {
 		}
 
 		static void addIfPossible(ApplicationContext parentContext, ConfigurableApplicationContext childContext) {
-			if (parentContext instanceof ConfigurableApplicationContext) {
-				add((ConfigurableApplicationContext) parentContext, childContext);
+			if (parentContext instanceof ConfigurableApplicationContext configurableApplicationContext) {
+				add(configurableApplicationContext, childContext);
 			}
 		}
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/servlet/ServletManagementChildContextConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/servlet/ServletManagementChildContextConfiguration.java
index 44de3225efc5..71beda39b6ba 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/servlet/ServletManagementChildContextConfiguration.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/servlet/ServletManagementChildContextConfiguration.java
@@ -39,7 +39,9 @@
 import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type;
 import org.springframework.boot.autoconfigure.condition.SearchStrategy;
 import org.springframework.boot.autoconfigure.web.ServerProperties;
+import org.springframework.boot.autoconfigure.web.embedded.JettyVirtualThreadsWebServerFactoryCustomizer;
 import org.springframework.boot.autoconfigure.web.embedded.JettyWebServerFactoryCustomizer;
+import org.springframework.boot.autoconfigure.web.embedded.TomcatVirtualThreadsWebServerFactoryCustomizer;
 import org.springframework.boot.autoconfigure.web.embedded.TomcatWebServerFactoryCustomizer;
 import org.springframework.boot.autoconfigure.web.embedded.UndertowWebServerFactoryCustomizer;
 import org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryCustomizer;
@@ -122,7 +124,8 @@ static class ServletManagementWebServerFactoryCustomizer
 
 		ServletManagementWebServerFactoryCustomizer(ListableBeanFactory beanFactory) {
 			super(beanFactory, ServletWebServerFactoryCustomizer.class, TomcatServletWebServerFactoryCustomizer.class,
-					TomcatWebServerFactoryCustomizer.class, JettyWebServerFactoryCustomizer.class,
+					TomcatWebServerFactoryCustomizer.class, TomcatVirtualThreadsWebServerFactoryCustomizer.class,
+					JettyWebServerFactoryCustomizer.class, JettyVirtualThreadsWebServerFactoryCustomizer.class,
 					UndertowServletWebServerFactoryCustomizer.class, UndertowWebServerFactoryCustomizer.class);
 		}
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json
index 9fb2160d5b2a..16e1dc652c57 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json
@@ -1605,6 +1605,13 @@
         "level": "error"
       }
     },
+    {
+      "name": "management.metrics.export.signalfx.published-histogram-type",
+      "deprecation": {
+        "level": "error",
+        "replacement": "management.signalfx.metrics.export.published-histogram-type"
+      }
+    },
     {
       "name": "management.metrics.export.signalfx.read-timeout",
       "type": "java.time.Duration",
@@ -1980,11 +1987,19 @@
         "reason": "Should be applied at the ObservationRegistry level."
       }
     },
+    {
+      "name": "management.metrics.web.client.request.metric-name",
+      "type": "java.lang.String",
+      "deprecation": {
+        "replacement": "management.observations.http.client.requests.name",
+        "level": "error"
+      }
+    },
     {
       "name": "management.metrics.web.client.requests-metric-name",
       "type": "java.lang.String",
       "deprecation": {
-        "replacement": "management.metrics.web.client.request.metric-name",
+        "replacement": "management.observations.http.client.requests.name",
         "level": "error"
       }
     },
@@ -2030,14 +2045,26 @@
         "reason": "Not needed anymore, direct instrumentation in Spring MVC."
       }
     },
+    {
+      "name": "management.metrics.web.server.request.metric-name",
+      "type": "java.lang.String",
+      "deprecation": {
+        "replacement": "management.observations.http.server.requests.name",
+        "level": "error"
+      }
+    },
     {
       "name": "management.metrics.web.server.requests-metric-name",
       "type": "java.lang.String",
       "deprecation": {
-        "replacement": "management.metrics.web.server.request.metric-name",
+        "replacement": "management.observations.http.server.requests.name",
         "level": "error"
       }
     },
+    {
+      "name": "management.otlp.metrics.export.base-time-unit",
+      "defaultValue": "milliseconds"
+    },
     {
       "name": "management.otlp.tracing.compression",
       "defaultValue": "none"
@@ -2146,6 +2173,10 @@
       "name": "management.server.ssl.trust-store-type",
       "description": "Type of the trust store."
     },
+    {
+      "name": "management.signalfx.metrics.export.published-histogram-type",
+      "defaultValue": "default"
+    },
     {
       "name": "management.simple.metrics.export.mode",
       "defaultValue": "cumulative"
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring.factories
index 7d4fdd7b4051..a32bb38f5a58 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring.factories
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring.factories
@@ -1,3 +1,8 @@
 # Failure Analyzers
 org.springframework.boot.diagnostics.FailureAnalyzer=\
-org.springframework.boot.actuate.autoconfigure.metrics.ValidationFailureAnalyzer
+org.springframework.boot.actuate.autoconfigure.metrics.ValidationFailureAnalyzer,\
+org.springframework.boot.actuate.autoconfigure.health.NoSuchHealthContributorFailureAnalyzer
+
+# Environment Post Processors
+org.springframework.boot.env.EnvironmentPostProcessor=\
+org.springframework.boot.actuate.autoconfigure.tracing.LogCorrelationEnvironmentPostProcessor
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
index affbe7607f9e..b1e23fd863fe 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -43,6 +43,7 @@ org.springframework.boot.actuate.autoconfigure.metrics.JvmMetricsAutoConfigurati
 org.springframework.boot.actuate.autoconfigure.metrics.KafkaMetricsAutoConfiguration
 org.springframework.boot.actuate.autoconfigure.metrics.Log4J2MetricsAutoConfiguration
 org.springframework.boot.actuate.autoconfigure.metrics.LogbackMetricsAutoConfiguration
+org.springframework.boot.actuate.autoconfigure.metrics.MetricsAspectsAutoConfiguration
 org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration
 org.springframework.boot.actuate.autoconfigure.metrics.MetricsEndpointAutoConfiguration
 org.springframework.boot.actuate.autoconfigure.metrics.SystemMetricsAutoConfiguration
@@ -70,6 +71,7 @@ org.springframework.boot.actuate.autoconfigure.metrics.export.statsd.StatsdMetri
 org.springframework.boot.actuate.autoconfigure.metrics.export.wavefront.WavefrontMetricsExportAutoConfiguration
 org.springframework.boot.actuate.autoconfigure.observation.batch.BatchObservationAutoConfiguration
 org.springframework.boot.actuate.autoconfigure.observation.graphql.GraphQlObservationAutoConfiguration
+org.springframework.boot.actuate.autoconfigure.observation.jms.JmsTemplateObservationAutoConfiguration
 org.springframework.boot.actuate.autoconfigure.metrics.integration.IntegrationMetricsAutoConfiguration
 org.springframework.boot.actuate.autoconfigure.metrics.jdbc.DataSourcePoolMetricsAutoConfiguration
 org.springframework.boot.actuate.autoconfigure.metrics.jersey.JerseyServerMetricsAutoConfiguration
@@ -88,11 +90,14 @@ org.springframework.boot.actuate.autoconfigure.data.mongo.MongoReactiveHealthCon
 org.springframework.boot.actuate.autoconfigure.neo4j.Neo4jHealthContributorAutoConfiguration
 org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration
 org.springframework.boot.actuate.autoconfigure.observation.web.servlet.WebMvcObservationAutoConfiguration
+org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryAutoConfiguration
 org.springframework.boot.actuate.autoconfigure.quartz.QuartzEndpointAutoConfiguration
 org.springframework.boot.actuate.autoconfigure.r2dbc.ConnectionFactoryHealthContributorAutoConfiguration
+org.springframework.boot.actuate.autoconfigure.r2dbc.R2dbcObservationAutoConfiguration
 org.springframework.boot.actuate.autoconfigure.data.redis.RedisHealthContributorAutoConfiguration
 org.springframework.boot.actuate.autoconfigure.data.redis.RedisReactiveHealthContributorAutoConfiguration
 org.springframework.boot.actuate.autoconfigure.scheduling.ScheduledTasksEndpointAutoConfiguration
+org.springframework.boot.actuate.autoconfigure.scheduling.ScheduledTasksObservabilityAutoConfiguration
 org.springframework.boot.actuate.autoconfigure.security.reactive.ReactiveManagementWebSecurityAutoConfiguration
 org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration
 org.springframework.boot.actuate.autoconfigure.session.SessionsEndpointAutoConfiguration
@@ -111,4 +116,4 @@ org.springframework.boot.actuate.autoconfigure.web.exchanges.HttpExchangesEndpoi
 org.springframework.boot.actuate.autoconfigure.web.mappings.MappingsEndpointAutoConfiguration
 org.springframework.boot.actuate.autoconfigure.web.reactive.ReactiveManagementContextAutoConfiguration
 org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration
-org.springframework.boot.actuate.autoconfigure.web.servlet.ServletManagementContextAutoConfiguration
\ No newline at end of file
+org.springframework.boot.actuate.autoconfigure.web.servlet.ServletManagementContextAutoConfiguration
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryReactiveHealthEndpointWebExtensionTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryReactiveHealthEndpointWebExtensionTests.java
index e4eeaedbd9be..9c68348c9fcf 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryReactiveHealthEndpointWebExtensionTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryReactiveHealthEndpointWebExtensionTests.java
@@ -35,10 +35,13 @@
 import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
 import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
 import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration;
-import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration;
 import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration;
 import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration;
 import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.core.userdetails.MapReactiveUserDetailsService;
+import org.springframework.security.core.userdetails.User;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -52,15 +55,14 @@ class CloudFoundryReactiveHealthEndpointWebExtensionTests {
 	private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner()
 		.withPropertyValues("VCAP_APPLICATION={}")
 		.withConfiguration(AutoConfigurations.of(ReactiveSecurityAutoConfiguration.class,
-				ReactiveUserDetailsServiceAutoConfiguration.class, WebFluxAutoConfiguration.class,
-				JacksonAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class,
-				PropertyPlaceholderAutoConfiguration.class,
+				WebFluxAutoConfiguration.class, JacksonAutoConfiguration.class,
+				HttpMessageConvertersAutoConfiguration.class, PropertyPlaceholderAutoConfiguration.class,
 				ReactiveCloudFoundryActuatorAutoConfigurationTests.WebClientCustomizerConfig.class,
 				WebClientAutoConfiguration.class, ManagementContextAutoConfiguration.class,
 				EndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class,
 				HealthContributorAutoConfiguration.class, HealthEndpointAutoConfiguration.class,
 				ReactiveCloudFoundryActuatorAutoConfiguration.class))
-		.withUserConfiguration(TestHealthIndicator.class);
+		.withUserConfiguration(TestHealthIndicator.class, UserDetailsServiceConfiguration.class);
 
 	@Test
 	void healthComponentsAlwaysPresent() {
@@ -82,4 +84,15 @@ public Health health() {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class UserDetailsServiceConfiguration {
+
+		@Bean
+		MapReactiveUserDetailsService userDetailsService() {
+			return new MapReactiveUserDetailsService(
+					User.withUsername("alice").password("secret").roles("admin").build());
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointIntegrationTests.java
index 3c718d1d4533..99bb7885735d 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointIntegrationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointIntegrationTests.java
@@ -75,22 +75,23 @@
  */
 class CloudFoundryWebFluxEndpointIntegrationTests {
 
-	private static final ReactiveTokenValidator tokenValidator = mock(ReactiveTokenValidator.class);
+	private final ReactiveTokenValidator tokenValidator = mock(ReactiveTokenValidator.class);
 
-	private static final ReactiveCloudFoundrySecurityService securityService = mock(
-			ReactiveCloudFoundrySecurityService.class);
+	private final ReactiveCloudFoundrySecurityService securityService = mock(ReactiveCloudFoundrySecurityService.class);
 
 	private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner(
 			AnnotationConfigReactiveWebServerApplicationContext::new)
 		.withConfiguration(AutoConfigurations.of(WebFluxAutoConfiguration.class, HttpHandlerAutoConfiguration.class,
 				ReactiveWebServerFactoryAutoConfiguration.class))
 		.withUserConfiguration(TestEndpointConfiguration.class)
+		.withBean(ReactiveTokenValidator.class, () -> this.tokenValidator)
+		.withBean(ReactiveCloudFoundrySecurityService.class, () -> this.securityService)
 		.withPropertyValues("server.port=0");
 
 	@Test
 	void operationWithSecurityInterceptorForbidden() {
-		given(tokenValidator.validate(any())).willReturn(Mono.empty());
-		given(securityService.getAccessLevel(any(), eq("app-id"))).willReturn(Mono.just(AccessLevel.RESTRICTED));
+		given(this.tokenValidator.validate(any())).willReturn(Mono.empty());
+		given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(Mono.just(AccessLevel.RESTRICTED));
 		this.contextRunner.run(withWebTestClient((client) -> client.get()
 			.uri("/cfApplication/test")
 			.accept(MediaType.APPLICATION_JSON)
@@ -102,8 +103,8 @@ void operationWithSecurityInterceptorForbidden() {
 
 	@Test
 	void operationWithSecurityInterceptorSuccess() {
-		given(tokenValidator.validate(any())).willReturn(Mono.empty());
-		given(securityService.getAccessLevel(any(), eq("app-id"))).willReturn(Mono.just(AccessLevel.FULL));
+		given(this.tokenValidator.validate(any())).willReturn(Mono.empty());
+		given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(Mono.just(AccessLevel.FULL));
 		this.contextRunner.run(withWebTestClient((client) -> client.get()
 			.uri("/cfApplication/test")
 			.accept(MediaType.APPLICATION_JSON)
@@ -131,8 +132,8 @@ void responseToOptionsRequestIncludesCorsHeaders() {
 
 	@Test
 	void linksToOtherEndpointsWithFullAccess() {
-		given(tokenValidator.validate(any())).willReturn(Mono.empty());
-		given(securityService.getAccessLevel(any(), eq("app-id"))).willReturn(Mono.just(AccessLevel.FULL));
+		given(this.tokenValidator.validate(any())).willReturn(Mono.empty());
+		given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(Mono.just(AccessLevel.FULL));
 		this.contextRunner.run(withWebTestClient((client) -> client.get()
 			.uri("/cfApplication")
 			.accept(MediaType.APPLICATION_JSON)
@@ -169,7 +170,7 @@ void linksToOtherEndpointsWithFullAccess() {
 	void linksToOtherEndpointsForbidden() {
 		CloudFoundryAuthorizationException exception = new CloudFoundryAuthorizationException(Reason.INVALID_TOKEN,
 				"invalid-token");
-		willThrow(exception).given(tokenValidator).validate(any());
+		willThrow(exception).given(this.tokenValidator).validate(any());
 		this.contextRunner.run(withWebTestClient((client) -> client.get()
 			.uri("/cfApplication")
 			.accept(MediaType.APPLICATION_JSON)
@@ -181,8 +182,8 @@ void linksToOtherEndpointsForbidden() {
 
 	@Test
 	void linksToOtherEndpointsWithRestrictedAccess() {
-		given(tokenValidator.validate(any())).willReturn(Mono.empty());
-		given(securityService.getAccessLevel(any(), eq("app-id"))).willReturn(Mono.just(AccessLevel.RESTRICTED));
+		given(this.tokenValidator.validate(any())).willReturn(Mono.empty());
+		given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(Mono.just(AccessLevel.RESTRICTED));
 		this.contextRunner.run(withWebTestClient((client) -> client.get()
 			.uri("/cfApplication")
 			.accept(MediaType.APPLICATION_JSON)
@@ -232,7 +233,8 @@ private String mockAccessToken() {
 	static class CloudFoundryReactiveConfiguration {
 
 		@Bean
-		CloudFoundrySecurityInterceptor interceptor() {
+		CloudFoundrySecurityInterceptor interceptor(ReactiveTokenValidator tokenValidator,
+				ReactiveCloudFoundrySecurityService securityService) {
 			return new CloudFoundrySecurityInterceptor(tokenValidator, securityService, "app-id");
 		}
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfigurationTests.java
index 2f4c99c0bc3a..e9424f872c66 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfigurationTests.java
@@ -50,7 +50,6 @@
 import org.springframework.boot.autoconfigure.info.ProjectInfoAutoConfiguration;
 import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
 import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration;
-import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration;
 import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration;
 import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration;
 import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
@@ -61,6 +60,8 @@
 import org.springframework.http.HttpMethod;
 import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
 import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.security.core.userdetails.MapReactiveUserDetailsService;
+import org.springframework.security.core.userdetails.User;
 import org.springframework.security.web.server.SecurityWebFilterChain;
 import org.springframework.security.web.server.WebFilterChainProxy;
 import org.springframework.test.util.ReflectionTestUtils;
@@ -84,15 +85,16 @@ class ReactiveCloudFoundryActuatorAutoConfigurationTests {
 	private static final String V3_JSON = ApiVersion.V3.getProducedMimeType().toString();
 
 	private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner()
-		.withConfiguration(AutoConfigurations.of(ReactiveSecurityAutoConfiguration.class,
-				ReactiveUserDetailsServiceAutoConfiguration.class, WebFluxAutoConfiguration.class,
-				JacksonAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class,
-				PropertyPlaceholderAutoConfiguration.class, WebClientCustomizerConfig.class,
-				WebClientAutoConfiguration.class, ManagementContextAutoConfiguration.class,
-				EndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class,
-				HealthContributorAutoConfiguration.class, HealthEndpointAutoConfiguration.class,
-				InfoContributorAutoConfiguration.class, InfoEndpointAutoConfiguration.class,
-				ProjectInfoAutoConfiguration.class, ReactiveCloudFoundryActuatorAutoConfiguration.class));
+		.withConfiguration(
+				AutoConfigurations.of(ReactiveSecurityAutoConfiguration.class, WebFluxAutoConfiguration.class,
+						JacksonAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class,
+						PropertyPlaceholderAutoConfiguration.class, WebClientCustomizerConfig.class,
+						WebClientAutoConfiguration.class, ManagementContextAutoConfiguration.class,
+						EndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class,
+						HealthContributorAutoConfiguration.class, HealthEndpointAutoConfiguration.class,
+						InfoContributorAutoConfiguration.class, InfoEndpointAutoConfiguration.class,
+						ProjectInfoAutoConfiguration.class, ReactiveCloudFoundryActuatorAutoConfiguration.class))
+		.withUserConfiguration(UserDetailsServiceConfiguration.class);
 
 	private static final String BASE_PATH = "/cloudfoundryapplication";
 
@@ -358,4 +360,15 @@ WebClientCustomizer webClientCustomizer() {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class UserDetailsServiceConfiguration {
+
+		@Bean
+		MapReactiveUserDetailsService userDetailsService() {
+			return new MapReactiveUserDetailsService(
+					User.withUsername("alice").password("secret").roles("admin").build());
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityServiceTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityServiceTests.java
index 29258b9f20aa..ac73c0fc21ca 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityServiceTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityServiceTests.java
@@ -51,13 +51,11 @@ class ReactiveCloudFoundrySecurityServiceTests {
 
 	private MockWebServer server;
 
-	private WebClient.Builder builder;
-
 	@BeforeEach
 	void setup() {
 		this.server = new MockWebServer();
-		this.builder = WebClient.builder().baseUrl(this.server.url("/").toString());
-		this.securityService = new ReactiveCloudFoundrySecurityService(this.builder, CLOUD_CONTROLLER, false);
+		WebClient.Builder builder = WebClient.builder().baseUrl(this.server.url("/").toString());
+		this.securityService = new ReactiveCloudFoundrySecurityService(builder, CLOUD_CONTROLLER, false);
 	}
 
 	@AfterEach
@@ -183,7 +181,7 @@ void fetchTokenKeysWhenNoKeysReturnedFromUAA() throws Exception {
 			response.setHeader("Content-Type", "application/json");
 		});
 		StepVerifier.create(this.securityService.fetchTokenKeys())
-			.consumeNextWith((tokenKeys) -> assertThat(tokenKeys).hasSize(0))
+			.consumeNextWith((tokenKeys) -> assertThat(tokenKeys).isEmpty())
 			.expectComplete()
 			.verify();
 		expectRequest((request) -> assertThat(request.getPath()).isEqualTo("/my-cloud-controller.com/info"));
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryMvcWebEndpointIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryMvcWebEndpointIntegrationTests.java
index 9ff41ba1f316..09c2e72a25a9 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryMvcWebEndpointIntegrationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryMvcWebEndpointIntegrationTests.java
@@ -43,6 +43,7 @@
 import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
 import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
 import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpointDiscoverer;
+import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
 import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
 import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext;
 import org.springframework.context.ApplicationContext;
@@ -70,13 +71,13 @@
  */
 class CloudFoundryMvcWebEndpointIntegrationTests {
 
-	private static final TokenValidator tokenValidator = mock(TokenValidator.class);
+	private final TokenValidator tokenValidator = mock(TokenValidator.class);
 
-	private static final CloudFoundrySecurityService securityService = mock(CloudFoundrySecurityService.class);
+	private final CloudFoundrySecurityService securityService = mock(CloudFoundrySecurityService.class);
 
 	@Test
 	void operationWithSecurityInterceptorForbidden() {
-		given(securityService.getAccessLevel(any(), eq("app-id"))).willReturn(AccessLevel.RESTRICTED);
+		given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(AccessLevel.RESTRICTED);
 		load(TestEndpointConfiguration.class,
 				(client) -> client.get()
 					.uri("/cfApplication/test")
@@ -89,7 +90,7 @@ void operationWithSecurityInterceptorForbidden() {
 
 	@Test
 	void operationWithSecurityInterceptorSuccess() {
-		given(securityService.getAccessLevel(any(), eq("app-id"))).willReturn(AccessLevel.FULL);
+		given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(AccessLevel.FULL);
 		load(TestEndpointConfiguration.class,
 				(client) -> client.get()
 					.uri("/cfApplication/test")
@@ -119,7 +120,7 @@ void responseToOptionsRequestIncludesCorsHeaders() {
 
 	@Test
 	void linksToOtherEndpointsWithFullAccess() {
-		given(securityService.getAccessLevel(any(), eq("app-id"))).willReturn(AccessLevel.FULL);
+		given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(AccessLevel.FULL);
 		load(TestEndpointConfiguration.class,
 				(client) -> client.get()
 					.uri("/cfApplication")
@@ -157,7 +158,7 @@ void linksToOtherEndpointsWithFullAccess() {
 	void linksToOtherEndpointsForbidden() {
 		CloudFoundryAuthorizationException exception = new CloudFoundryAuthorizationException(Reason.INVALID_TOKEN,
 				"invalid-token");
-		willThrow(exception).given(tokenValidator).validate(any());
+		willThrow(exception).given(this.tokenValidator).validate(any());
 		load(TestEndpointConfiguration.class,
 				(client) -> client.get()
 					.uri("/cfApplication")
@@ -170,7 +171,7 @@ void linksToOtherEndpointsForbidden() {
 
 	@Test
 	void linksToOtherEndpointsWithRestrictedAccess() {
-		given(securityService.getAccessLevel(any(), eq("app-id"))).willReturn(AccessLevel.RESTRICTED);
+		given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(AccessLevel.RESTRICTED);
 		load(TestEndpointConfiguration.class,
 				(client) -> client.get()
 					.uri("/cfApplication")
@@ -198,26 +199,23 @@ void linksToOtherEndpointsWithRestrictedAccess() {
 					.doesNotExist());
 	}
 
-	private AnnotationConfigServletWebServerApplicationContext createApplicationContext(Class<?>... config) {
-		return new AnnotationConfigServletWebServerApplicationContext(config);
+	private void load(Class<?> configuration, Consumer<WebTestClient> clientConsumer) {
+		BiConsumer<ApplicationContext, WebTestClient> consumer = (context, client) -> clientConsumer.accept(client);
+		new WebApplicationContextRunner(AnnotationConfigServletWebServerApplicationContext::new)
+			.withUserConfiguration(configuration, CloudFoundryMvcConfiguration.class)
+			.withBean(TokenValidator.class, () -> this.tokenValidator)
+			.withBean(CloudFoundrySecurityService.class, () -> this.securityService)
+			.run((context) -> consumer.accept(context, WebTestClient.bindToServer()
+				.baseUrl("http://localhost:" + getPort(
+						(AnnotationConfigServletWebServerApplicationContext) context.getSourceApplicationContext()))
+				.responseTimeout(Duration.ofMinutes(5))
+				.build()));
 	}
 
 	private int getPort(AnnotationConfigServletWebServerApplicationContext context) {
 		return context.getWebServer().getPort();
 	}
 
-	private void load(Class<?> configuration, Consumer<WebTestClient> clientConsumer) {
-		BiConsumer<ApplicationContext, WebTestClient> consumer = (context, client) -> clientConsumer.accept(client);
-		try (AnnotationConfigServletWebServerApplicationContext context = createApplicationContext(configuration,
-				CloudFoundryMvcConfiguration.class)) {
-			consumer.accept(context,
-					WebTestClient.bindToServer()
-						.baseUrl("http://localhost:" + getPort(context))
-						.responseTimeout(Duration.ofMinutes(5))
-						.build());
-		}
-	}
-
 	private String mockAccessToken() {
 		return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b3B0YWwu"
 				+ "Y29tIiwiZXhwIjoxNDI2NDIwODAwLCJhd2Vzb21lIjp0cnVlfQ."
@@ -229,7 +227,8 @@ private String mockAccessToken() {
 	static class CloudFoundryMvcConfiguration {
 
 		@Bean
-		CloudFoundrySecurityInterceptor interceptor() {
+		CloudFoundrySecurityInterceptor interceptor(TokenValidator tokenValidator,
+				CloudFoundrySecurityService securityService) {
 			return new CloudFoundrySecurityInterceptor(tokenValidator, securityService, "app-id");
 		}
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/CompositeHealthContributorConfigurationReflectionTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/CompositeHealthContributorConfigurationReflectionTests.java
deleted file mode 100644
index c6100f971b7b..000000000000
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/CompositeHealthContributorConfigurationReflectionTests.java
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.autoconfigure.health;
-
-import org.springframework.boot.actuate.autoconfigure.health.CompositeHealthContributorConfigurationReflectionTests.TestHealthIndicator;
-import org.springframework.boot.actuate.health.AbstractHealthIndicator;
-import org.springframework.boot.actuate.health.Health.Builder;
-import org.springframework.boot.actuate.health.HealthContributor;
-
-/**
- * Tests for {@link CompositeHealthContributorConfiguration} using reflection to create
- * indicator instances.
- *
- * @author Phillip Webb
- */
-@SuppressWarnings("removal")
-@Deprecated(since = "3.0.0", forRemoval = true)
-class CompositeHealthContributorConfigurationReflectionTests
-		extends AbstractCompositeHealthContributorConfigurationTests<HealthContributor, TestHealthIndicator> {
-
-	@Override
-	protected AbstractCompositeHealthContributorConfiguration<HealthContributor, TestHealthIndicator, TestBean> newComposite() {
-		return new ReflectiveTestCompositeHealthContributorConfiguration();
-	}
-
-	static class ReflectiveTestCompositeHealthContributorConfiguration
-			extends CompositeHealthContributorConfiguration<TestHealthIndicator, TestBean> {
-
-	}
-
-	static class TestHealthIndicator extends AbstractHealthIndicator {
-
-		TestHealthIndicator(TestBean testBean) {
-		}
-
-		@Override
-		protected void doHealthCheck(Builder builder) throws Exception {
-			builder.up();
-		}
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/CompositeReactiveHealthContributorConfigurationReflectionTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/CompositeReactiveHealthContributorConfigurationReflectionTests.java
deleted file mode 100644
index 183a3c7bd3a7..000000000000
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/CompositeReactiveHealthContributorConfigurationReflectionTests.java
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.autoconfigure.health;
-
-import reactor.core.publisher.Mono;
-
-import org.springframework.boot.actuate.autoconfigure.health.CompositeReactiveHealthContributorConfigurationReflectionTests.TestReactiveHealthIndicator;
-import org.springframework.boot.actuate.health.AbstractReactiveHealthIndicator;
-import org.springframework.boot.actuate.health.Health;
-import org.springframework.boot.actuate.health.Health.Builder;
-import org.springframework.boot.actuate.health.ReactiveHealthContributor;
-
-/**
- * Tests for {@link CompositeReactiveHealthContributorConfiguration} using reflection to
- * create indicator instances.
- *
- * @author Phillip Webb
- */
-@SuppressWarnings("removal")
-@Deprecated(since = "3.0.0", forRemoval = true)
-class CompositeReactiveHealthContributorConfigurationReflectionTests extends
-		AbstractCompositeHealthContributorConfigurationTests<ReactiveHealthContributor, TestReactiveHealthIndicator> {
-
-	@Override
-	protected AbstractCompositeHealthContributorConfiguration<ReactiveHealthContributor, TestReactiveHealthIndicator, TestBean> newComposite() {
-		return new TestCompositeReactiveHealthContributorConfiguration();
-	}
-
-	static class TestCompositeReactiveHealthContributorConfiguration
-			extends CompositeReactiveHealthContributorConfiguration<TestReactiveHealthIndicator, TestBean> {
-
-	}
-
-	static class TestReactiveHealthIndicator extends AbstractReactiveHealthIndicator {
-
-		TestReactiveHealthIndicator(TestBean testBean) {
-		}
-
-		@Override
-		protected Mono<Health> doHealthCheck(Builder builder) {
-			return Mono.just(builder.up().build());
-		}
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/NoSuchHealthContributorFailureAnalyzerTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/NoSuchHealthContributorFailureAnalyzerTests.java
new file mode 100644
index 000000000000..6ce31bbf0cd1
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/NoSuchHealthContributorFailureAnalyzerTests.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.health;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointConfiguration.HealthEndpointGroupMembershipValidator.NoSuchHealthContributorException;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.diagnostics.FailureAnalysis;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link NoSuchHealthContributorFailureAnalyzer}.
+ *
+ * @author Moritz Halbritter
+ */
+class NoSuchHealthContributorFailureAnalyzerTests {
+
+	private final ApplicationContextRunner runner = new ApplicationContextRunner()
+		.withConfiguration(AutoConfigurations.of(HealthEndpointAutoConfiguration.class));
+
+	@Test
+	void analyzesMissingRequiredConfiguration() throws Throwable {
+		FailureAnalysis analysis = new NoSuchHealthContributorFailureAnalyzer().analyze(createFailure());
+		assertThat(analysis).isNotNull();
+		assertThat(analysis.getDescription())
+			.isEqualTo("Included health contributor 'dummy' in group 'readiness' does not exist");
+		assertThat(analysis.getAction()).isEqualTo("Update your application to correct the invalid configuration.\n"
+				+ "You can also set 'management.endpoint.health.validate-group-membership' to false to disable the validation.");
+	}
+
+	private Throwable createFailure() throws Throwable {
+		AtomicReference<Throwable> failure = new AtomicReference<>();
+		this.runner.withPropertyValues("management.endpoint.health.group.readiness.include=dummy").run((context) -> {
+			assertThat(context).hasFailed();
+			failure.set(context.getStartupFailure());
+		});
+		Throwable throwable = failure.get();
+		if (throwable instanceof NoSuchHealthContributorException) {
+			return throwable;
+		}
+		throw throwable;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/influx/InfluxDbHealthContributorAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/influx/InfluxDbHealthContributorAutoConfigurationTests.java
index 4ed923d5041f..64d2757f26dd 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/influx/InfluxDbHealthContributorAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/influx/InfluxDbHealthContributorAutoConfigurationTests.java
@@ -32,6 +32,8 @@
  *
  * @author EddĂș MelĂ©ndez
  */
+@SuppressWarnings("removal")
+@Deprecated(since = "3.2.0", forRemoval = true)
 class InfluxDbHealthContributorAutoConfigurationTests {
 
 	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebEndpointsAutoConfigurationIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebEndpointsAutoConfigurationIntegrationTests.java
index f2d6c56aca3b..c131bd263a4e 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebEndpointsAutoConfigurationIntegrationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebEndpointsAutoConfigurationIntegrationTests.java
@@ -19,6 +19,8 @@
 import org.junit.jupiter.api.Test;
 
 import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.actuate.autoconfigure.tracing.BraveAutoConfiguration;
+import org.springframework.boot.actuate.autoconfigure.tracing.OpenTelemetryAutoConfiguration;
 import org.springframework.boot.actuate.health.HealthEndpointWebExtension;
 import org.springframework.boot.actuate.health.ReactiveHealthEndpointWebExtension;
 import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
@@ -40,6 +42,7 @@
 import org.springframework.boot.context.annotation.UserConfigurations;
 import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
 import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
+import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -57,6 +60,7 @@ void healthEndpointWebExtensionIsAutoConfigured() {
 	}
 
 	@Test
+	@ClassPathExclusions({ "spring-security-oauth2-client-*.jar", "spring-security-oauth2-resource-server-*.jar" })
 	void healthEndpointReactiveWebExtensionIsAutoConfigured() {
 		reactiveWebRunner()
 			.run((context) -> assertThat(context).hasSingleBean(ReactiveHealthEndpointWebExtension.class));
@@ -80,7 +84,8 @@ private ReactiveWebApplicationContextRunner reactiveWebRunner() {
 			MongoReactiveAutoConfiguration.class, MongoReactiveDataAutoConfiguration.class,
 			RepositoryRestMvcAutoConfiguration.class, HazelcastAutoConfiguration.class,
 			ElasticsearchDataAutoConfiguration.class, RedisAutoConfiguration.class,
-			RedisRepositoriesAutoConfiguration.class })
+			RedisRepositoriesAutoConfiguration.class, BraveAutoConfiguration.class,
+			OpenTelemetryAutoConfiguration.class })
 	@SpringBootConfiguration
 	static class WebEndpointTestApplication {
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAspectsAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAspectsAutoConfigurationTests.java
new file mode 100644
index 000000000000..215eba7b481b
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAspectsAutoConfigurationTests.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.metrics;
+
+import io.micrometer.core.aop.CountedAspect;
+import io.micrometer.core.aop.MeterTagAnnotationHandler;
+import io.micrometer.core.aop.TimedAspect;
+import io.micrometer.core.instrument.MeterRegistry;
+import org.aspectj.weaver.Advice;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.actuate.autoconfigure.metrics.test.MetricsRun;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.FilteredClassLoader;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link MetricsAspectsAutoConfiguration}.
+ *
+ * @author Jonatan Ivanov
+ */
+class MetricsAspectsAutoConfigurationTests {
+
+	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().with(MetricsRun.simple())
+		.withConfiguration(AutoConfigurations.of(MetricsAspectsAutoConfiguration.class));
+
+	@Test
+	void shouldConfigureAspects() {
+		this.contextRunner.run((context) -> {
+			assertThat(context).hasSingleBean(CountedAspect.class);
+			assertThat(context).hasSingleBean(TimedAspect.class);
+		});
+	}
+
+	@Test
+	void shouldConfigureMeterTagAnnotationHandler() {
+		this.contextRunner.withUserConfiguration(MeterTagAnnotationHandlerConfiguration.class).run((context) -> {
+			assertThat(context).hasSingleBean(CountedAspect.class);
+			assertThat(ReflectionTestUtils.getField(context.getBean(TimedAspect.class), "meterTagAnnotationHandler"))
+				.isSameAs(context.getBean(MeterTagAnnotationHandler.class));
+		});
+	}
+
+	@Test
+	void shouldNotConfigureAspectsIfMicrometerIsMissing() {
+		this.contextRunner.withClassLoader(new FilteredClassLoader(MeterRegistry.class)).run((context) -> {
+			assertThat(context).doesNotHaveBean(CountedAspect.class);
+			assertThat(context).doesNotHaveBean(TimedAspect.class);
+		});
+	}
+
+	@Test
+	void shouldNotConfigureAspectsIfAspectjIsMissing() {
+		this.contextRunner.withClassLoader(new FilteredClassLoader(Advice.class)).run((context) -> {
+			assertThat(context).doesNotHaveBean(CountedAspect.class);
+			assertThat(context).doesNotHaveBean(TimedAspect.class);
+		});
+	}
+
+	@Test
+	void shouldNotConfigureAspectsIfMeterRegistryBeanIsMissing() {
+		new ApplicationContextRunner().run((context) -> {
+			assertThat(context).doesNotHaveBean(MeterRegistry.class);
+			assertThat(context).doesNotHaveBean(CountedAspect.class);
+			assertThat(context).doesNotHaveBean(TimedAspect.class);
+		});
+	}
+
+	@Test
+	void shouldBackOffIfAspectBeansExist() {
+		this.contextRunner.withUserConfiguration(CustomAspectsConfiguration.class).run((context) -> {
+			assertThat(context).hasSingleBean(CountedAspect.class).hasBean("customCountedAspect");
+			assertThat(context).hasSingleBean(TimedAspect.class).hasBean("customTimedAspect");
+		});
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	static class CustomAspectsConfiguration {
+
+		@Bean
+		CountedAspect customCountedAspect(MeterRegistry registry) {
+			return new CountedAspect(registry);
+		}
+
+		@Bean
+		TimedAspect customTimedAspect(MeterRegistry registry) {
+			return new TimedAspect(registry);
+		}
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	static class MeterTagAnnotationHandlerConfiguration {
+
+		@Bean
+		MeterTagAnnotationHandler meterTagAnnotationHandler() {
+			return new MeterTagAnnotationHandler(null, null);
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAutoConfigurationTests.java
index c04c31435a72..b7f4876bb3fe 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAutoConfigurationTests.java
@@ -25,6 +25,7 @@
 import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
 import org.junit.jupiter.api.Test;
 
+import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration.MeterRegistryCloser;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.context.annotation.Bean;
@@ -40,6 +41,7 @@
  * Tests for {@link MetricsAutoConfiguration}.
  *
  * @author Andy Wilkinson
+ * @author Moritz Halbritter
  */
 class MetricsAutoConfigurationTests {
 
@@ -72,6 +74,21 @@ void configuresMeterRegistries() {
 		});
 	}
 
+	@Test
+	void shouldSupplyMeterRegistryCloser() {
+		this.contextRunner.run((context) -> assertThat(context).hasSingleBean(MeterRegistryCloser.class));
+	}
+
+	@Test
+	void meterRegistryCloserShouldCloseRegistryOnShutdown() {
+		this.contextRunner.withUserConfiguration(MeterRegistryConfiguration.class).run((context) -> {
+			MeterRegistry meterRegistry = context.getBean(MeterRegistry.class);
+			assertThat(meterRegistry.isClosed()).isFalse();
+			context.close();
+			assertThat(meterRegistry.isClosed()).isTrue();
+		});
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class CustomClockConfiguration {
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/ValidationFailureAnalyzerTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/ValidationFailureAnalyzerTests.java
index 15352a43c928..4abd897e9a10 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/ValidationFailureAnalyzerTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/ValidationFailureAnalyzerTests.java
@@ -30,7 +30,7 @@
 import org.springframework.context.annotation.Import;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.fail;
+import static org.assertj.core.api.Assertions.fail;
 
 /**
  * Tests for {@link ValidationFailureAnalyzer}.
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/appoptics/AppOpticsPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/appoptics/AppOpticsPropertiesConfigAdapterTests.java
index 6b5de57c7fae..54ab9c2aba80 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/appoptics/AppOpticsPropertiesConfigAdapterTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/appoptics/AppOpticsPropertiesConfigAdapterTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -30,6 +30,10 @@
 class AppOpticsPropertiesConfigAdapterTests
 		extends StepRegistryPropertiesConfigAdapterTests<AppOpticsProperties, AppOpticsPropertiesConfigAdapter> {
 
+	AppOpticsPropertiesConfigAdapterTests() {
+		super(AppOpticsPropertiesConfigAdapter.class);
+	}
+
 	@Override
 	protected AppOpticsProperties createProperties() {
 		return new AppOpticsProperties();
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/atlas/AtlasPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/atlas/AtlasPropertiesConfigAdapterTests.java
index b84575c0f760..418f85609eac 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/atlas/AtlasPropertiesConfigAdapterTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/atlas/AtlasPropertiesConfigAdapterTests.java
@@ -20,6 +20,8 @@
 
 import org.junit.jupiter.api.Test;
 
+import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.AbstractPropertiesConfigAdapterTests;
+
 import static org.assertj.core.api.Assertions.assertThat;
 
 /**
@@ -27,7 +29,12 @@
  *
  * @author Mirko Sobeck
  */
-class AtlasPropertiesConfigAdapterTests {
+class AtlasPropertiesConfigAdapterTests
+		extends AbstractPropertiesConfigAdapterTests<AtlasProperties, AtlasPropertiesConfigAdapter> {
+
+	AtlasPropertiesConfigAdapterTests() {
+		super(AtlasPropertiesConfigAdapter.class);
+	}
 
 	@Test
 	void whenPropertiesStepIsSetAdapterStepReturnsIt() {
@@ -116,4 +123,25 @@ void whenPropertiesEvalUriIsSetAdapterEvalUriReturnsIt() {
 			.isEqualTo("https://atlas.example.com/evaluate");
 	}
 
+	@Test
+	void whenPropertiesLwcStepIsSetAdapterLwcStepReturnsIt() {
+		AtlasProperties properties = new AtlasProperties();
+		properties.setLwcStep(Duration.ofSeconds(30));
+		assertThat(new AtlasPropertiesConfigAdapter(properties).lwcStep()).isEqualTo(Duration.ofSeconds(30));
+	}
+
+	@Test
+	void whenPropertiesLwcIgnorePublishStepIsSetAdapterLwcIgnorePublishStepReturnsIt() {
+		AtlasProperties properties = new AtlasProperties();
+		properties.setLwcIgnorePublishStep(false);
+		assertThat(new AtlasPropertiesConfigAdapter(properties).lwcIgnorePublishStep()).isFalse();
+	}
+
+	@Test
+	@Override
+	protected void adapterOverridesAllConfigMethods() {
+		adapterOverridesAllConfigMethodsExcept("autoStart", "commonTags", "debugRegistry", "publisher", "rollupPolicy",
+				"validTagCharacters");
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/atlas/AtlasPropertiesTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/atlas/AtlasPropertiesTests.java
index 7d345ad9c162..06289a8958ef 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/atlas/AtlasPropertiesTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/atlas/AtlasPropertiesTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -41,6 +41,8 @@ void defaultValuesAreConsistent() {
 		assertThat(properties.getUri()).isEqualTo(config.uri());
 		assertThat(properties.getMeterTimeToLive()).isEqualTo(config.meterTTL());
 		assertThat(properties.isLwcEnabled()).isEqualTo(config.lwcEnabled());
+		assertThat(properties.getLwcStep()).isEqualTo(config.lwcStep());
+		assertThat(properties.isLwcIgnorePublishStep()).isEqualTo(config.lwcIgnorePublishStep());
 		assertThat(properties.getConfigRefreshFrequency()).isEqualTo(config.configRefreshFrequency());
 		assertThat(properties.getConfigTimeToLive()).isEqualTo(config.configTTL());
 		assertThat(properties.getConfigUri()).isEqualTo(config.configUri());
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/datadog/DatadogPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/datadog/DatadogPropertiesConfigAdapterTests.java
index b47892c439a5..c95c74b46514 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/datadog/DatadogPropertiesConfigAdapterTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/datadog/DatadogPropertiesConfigAdapterTests.java
@@ -31,6 +31,10 @@
 class DatadogPropertiesConfigAdapterTests
 		extends StepRegistryPropertiesConfigAdapterTests<DatadogProperties, DatadogPropertiesConfigAdapter> {
 
+	DatadogPropertiesConfigAdapterTests() {
+		super(DatadogPropertiesConfigAdapter.class);
+	}
+
 	@Override
 	protected DatadogProperties createProperties() {
 		return new DatadogProperties();
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatracePropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatracePropertiesConfigAdapterTests.java
index 16a8dd2bf971..eab487b49399 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatracePropertiesConfigAdapterTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatracePropertiesConfigAdapterTests.java
@@ -21,6 +21,8 @@
 import io.micrometer.dynatrace.DynatraceApiVersion;
 import org.junit.jupiter.api.Test;
 
+import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.AbstractPropertiesConfigAdapterTests;
+
 import static org.assertj.core.api.Assertions.assertThat;
 
 /**
@@ -29,7 +31,12 @@
  * @author Andy Wilkinson
  * @author Georg Pirklbauer
  */
-class DynatracePropertiesConfigAdapterTests {
+class DynatracePropertiesConfigAdapterTests
+		extends AbstractPropertiesConfigAdapterTests<DynatraceProperties, DynatracePropertiesConfigAdapter> {
+
+	DynatracePropertiesConfigAdapterTests() {
+		super(DynatracePropertiesConfigAdapter.class);
+	}
 
 	@Test
 	void whenPropertiesUriIsSetAdapterUriReturnsIt() {
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatracePropertiesTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatracePropertiesTests.java
index 69f3651bc30b..5a784ead6dd1 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatracePropertiesTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatracePropertiesTests.java
@@ -38,6 +38,7 @@ void defaultValuesAreConsistent() {
 		assertThat(properties.getV1().getTechnologyType()).isEqualTo(config.technologyType());
 		assertThat(properties.getV2().isUseDynatraceSummaryInstruments())
 			.isEqualTo(config.useDynatraceSummaryInstruments());
+		assertThat(properties.getV2().isExportMeterMetadata()).isEqualTo(config.exportMeterMetadata());
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/elastic/ElasticPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/elastic/ElasticPropertiesConfigAdapterTests.java
index 8e5313370358..0738af128059 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/elastic/ElasticPropertiesConfigAdapterTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/elastic/ElasticPropertiesConfigAdapterTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -18,6 +18,8 @@
 
 import org.junit.jupiter.api.Test;
 
+import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.AbstractPropertiesConfigAdapterTests;
+
 import static org.assertj.core.api.Assertions.assertThat;
 
 /**
@@ -25,7 +27,12 @@
  *
  * @author Andy Wilkinson
  */
-class ElasticPropertiesConfigAdapterTests {
+class ElasticPropertiesConfigAdapterTests
+		extends AbstractPropertiesConfigAdapterTests<ElasticProperties, ElasticPropertiesConfigAdapter> {
+
+	ElasticPropertiesConfigAdapterTests() {
+		super(ElasticPropertiesConfigAdapter.class);
+	}
 
 	@Test
 	void whenPropertiesHostsIsSetAdapterHostsReturnsIt() {
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/ganglia/GangliaPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/ganglia/GangliaPropertiesConfigAdapterTests.java
index d837b0019288..b808f1b7c6e3 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/ganglia/GangliaPropertiesConfigAdapterTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/ganglia/GangliaPropertiesConfigAdapterTests.java
@@ -22,6 +22,8 @@
 import info.ganglia.gmetric4j.gmetric.GMetric.UDPAddressingMode;
 import org.junit.jupiter.api.Test;
 
+import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.AbstractPropertiesConfigAdapterTests;
+
 import static org.assertj.core.api.Assertions.assertThat;
 
 /**
@@ -29,7 +31,12 @@
  *
  * @author Mirko Sobeck
  */
-class GangliaPropertiesConfigAdapterTests {
+class GangliaPropertiesConfigAdapterTests
+		extends AbstractPropertiesConfigAdapterTests<GangliaProperties, GangliaPropertiesConfigAdapter> {
+
+	GangliaPropertiesConfigAdapterTests() {
+		super(GangliaPropertiesConfigAdapter.class);
+	}
 
 	@Test
 	void whenPropertiesEnabledIsSetAdapterEnabledReturnsIt() {
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/graphite/GraphitePropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/graphite/GraphitePropertiesConfigAdapterTests.java
index ef2de2d69d0b..fe27c3cb27e6 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/graphite/GraphitePropertiesConfigAdapterTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/graphite/GraphitePropertiesConfigAdapterTests.java
@@ -22,6 +22,8 @@
 import io.micrometer.graphite.GraphiteProtocol;
 import org.junit.jupiter.api.Test;
 
+import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.AbstractPropertiesConfigAdapterTests;
+
 import static org.assertj.core.api.Assertions.assertThat;
 
 /**
@@ -29,7 +31,12 @@
  *
  * @author Mirko Sobeck
  */
-class GraphitePropertiesConfigAdapterTests {
+class GraphitePropertiesConfigAdapterTests
+		extends AbstractPropertiesConfigAdapterTests<GraphiteProperties, GraphitePropertiesConfigAdapter> {
+
+	GraphitePropertiesConfigAdapterTests() {
+		super(GraphitePropertiesConfigAdapter.class);
+	}
 
 	@Test
 	void whenPropertiesEnabledIsSetAdapterEnabledReturnsIt() {
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/humio/HumioPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/humio/HumioPropertiesConfigAdapterTests.java
index 37b99bb9bc48..9eb31796ac08 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/humio/HumioPropertiesConfigAdapterTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/humio/HumioPropertiesConfigAdapterTests.java
@@ -20,6 +20,8 @@
 
 import org.junit.jupiter.api.Test;
 
+import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.AbstractPropertiesConfigAdapterTests;
+
 import static org.assertj.core.api.Assertions.assertThat;
 
 /**
@@ -27,7 +29,12 @@
  *
  * @author Andy Wilkinson
  */
-class HumioPropertiesConfigAdapterTests {
+class HumioPropertiesConfigAdapterTests
+		extends AbstractPropertiesConfigAdapterTests<HumioProperties, HumioPropertiesConfigAdapter> {
+
+	HumioPropertiesConfigAdapterTests() {
+		super(HumioPropertiesConfigAdapter.class);
+	}
 
 	@Test
 	void whenApiTokenIsSetAdapterApiTokenReturnsIt() {
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/influx/InfluxPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/influx/InfluxPropertiesConfigAdapterTests.java
index 2d86262c668a..b545bb3e1ae2 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/influx/InfluxPropertiesConfigAdapterTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/influx/InfluxPropertiesConfigAdapterTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -19,6 +19,8 @@
 import io.micrometer.influx.InfluxApiVersion;
 import org.junit.jupiter.api.Test;
 
+import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.AbstractPropertiesConfigAdapterTests;
+
 import static org.assertj.core.api.Assertions.assertThat;
 
 /**
@@ -26,7 +28,12 @@
  *
  * @author Stephane Nicoll
  */
-class InfluxPropertiesConfigAdapterTests {
+class InfluxPropertiesConfigAdapterTests
+		extends AbstractPropertiesConfigAdapterTests<InfluxProperties, InfluxPropertiesConfigAdapter> {
+
+	InfluxPropertiesConfigAdapterTests() {
+		super(InfluxPropertiesConfigAdapter.class);
+	}
 
 	@Test
 	void adaptInfluxV1BasicConfig() {
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/jmx/JmxPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/jmx/JmxPropertiesConfigAdapterTests.java
index 6ee12bfd5ada..87ef86171e49 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/jmx/JmxPropertiesConfigAdapterTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/jmx/JmxPropertiesConfigAdapterTests.java
@@ -20,6 +20,8 @@
 
 import org.junit.jupiter.api.Test;
 
+import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.AbstractPropertiesConfigAdapterTests;
+
 import static org.assertj.core.api.Assertions.assertThat;
 
 /**
@@ -27,7 +29,12 @@
  *
  * @author Mirko Sobeck
  */
-class JmxPropertiesConfigAdapterTests {
+class JmxPropertiesConfigAdapterTests
+		extends AbstractPropertiesConfigAdapterTests<JmxProperties, JmxPropertiesConfigAdapter> {
+
+	JmxPropertiesConfigAdapterTests() {
+		super(JmxPropertiesConfigAdapter.class);
+	}
 
 	@Test
 	void whenPropertiesStepIsSetAdapterStepReturnsIt() {
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/kairos/KairosPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/kairos/KairosPropertiesConfigAdapterTests.java
index 94e6617c26ec..02c4e7019cd8 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/kairos/KairosPropertiesConfigAdapterTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/kairos/KairosPropertiesConfigAdapterTests.java
@@ -30,6 +30,10 @@
 class KairosPropertiesConfigAdapterTests
 		extends StepRegistryPropertiesConfigAdapterTests<KairosProperties, KairosPropertiesConfigAdapter> {
 
+	KairosPropertiesConfigAdapterTests() {
+		super(KairosPropertiesConfigAdapter.class);
+	}
+
 	@Override
 	protected KairosProperties createProperties() {
 		return new KairosProperties();
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/newrelic/NewRelicPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/newrelic/NewRelicPropertiesConfigAdapterTests.java
index f3e3e80df5ae..1b3dd086dd16 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/newrelic/NewRelicPropertiesConfigAdapterTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/newrelic/NewRelicPropertiesConfigAdapterTests.java
@@ -31,6 +31,10 @@
 class NewRelicPropertiesConfigAdapterTests
 		extends StepRegistryPropertiesConfigAdapterTests<NewRelicProperties, NewRelicPropertiesConfigAdapter> {
 
+	NewRelicPropertiesConfigAdapterTests() {
+		super(NewRelicPropertiesConfigAdapter.class);
+	}
+
 	@Override
 	protected NewRelicProperties createProperties() {
 		return new NewRelicProperties();
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsExportAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsExportAutoConfigurationTests.java
index 09752dd0c7ff..f303e4a2cfdc 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsExportAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsExportAutoConfigurationTests.java
@@ -21,6 +21,7 @@
 import io.micrometer.registry.otlp.OtlpMeterRegistry;
 import org.junit.jupiter.api.Test;
 
+import org.springframework.boot.actuate.autoconfigure.metrics.export.otlp.OtlpMetricsExportAutoConfiguration.PropertiesOtlpMetricsConnectionDetails;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.context.annotation.Bean;
@@ -83,6 +84,23 @@ void allowsRegistryToBeCustomized() {
 				.hasBean("customRegistry"));
 	}
 
+	@Test
+	void definesPropertiesBasedConnectionDetailsByDefault() {
+		this.contextRunner.withUserConfiguration(BaseConfiguration.class)
+			.run((context) -> assertThat(context).hasSingleBean(PropertiesOtlpMetricsConnectionDetails.class));
+	}
+
+	@Test
+	void testConnectionFactoryWithOverridesWhenUsingCustomConnectionDetails() {
+		this.contextRunner.withUserConfiguration(BaseConfiguration.class, ConnectionDetailsConfiguration.class)
+			.run((context) -> {
+				assertThat(context).hasSingleBean(OtlpMetricsConnectionDetails.class)
+					.doesNotHaveBean(PropertiesOtlpMetricsConnectionDetails.class);
+				OtlpConfig config = context.getBean(OtlpConfig.class);
+				assertThat(config.url()).isEqualTo("http://localhost:12345/v1/metrics");
+			});
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class BaseConfiguration {
 
@@ -115,4 +133,14 @@ OtlpMeterRegistry customRegistry(OtlpConfig config, Clock clock) {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class ConnectionDetailsConfiguration {
+
+		@Bean
+		OtlpMetricsConnectionDetails otlpConnectionDetails() {
+			return () -> "http://localhost:12345/v1/metrics";
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesConfigAdapterTests.java
index d2fc02a7f412..25151b63a64c 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesConfigAdapterTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesConfigAdapterTests.java
@@ -16,55 +16,132 @@
 
 package org.springframework.boot.actuate.autoconfigure.metrics.export.otlp;
 
+import java.util.Collections;
 import java.util.Map;
+import java.util.concurrent.TimeUnit;
 
 import io.micrometer.registry.otlp.AggregationTemporality;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+import org.springframework.boot.actuate.autoconfigure.metrics.export.otlp.OtlpMetricsExportAutoConfiguration.PropertiesOtlpMetricsConnectionDetails;
+import org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryProperties;
+import org.springframework.mock.env.MockEnvironment;
+
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.entry;
 
 /**
  * Tests for {@link OtlpPropertiesConfigAdapter}.
  *
  * @author EddĂș MelĂ©ndez
+ * @author Moritz Halbritter
  */
 class OtlpPropertiesConfigAdapterTests {
 
+	private OtlpProperties properties;
+
+	private OpenTelemetryProperties openTelemetryProperties;
+
+	private MockEnvironment environment;
+
+	private OtlpMetricsConnectionDetails connectionDetails;
+
+	@BeforeEach
+	void setUp() {
+		this.properties = new OtlpProperties();
+		this.openTelemetryProperties = new OpenTelemetryProperties();
+		this.environment = new MockEnvironment();
+		this.connectionDetails = new PropertiesOtlpMetricsConnectionDetails(this.properties);
+	}
+
 	@Test
 	void whenPropertiesUrlIsSetAdapterUrlReturnsIt() {
-		OtlpProperties properties = new OtlpProperties();
-		properties.setUrl("http://another-url:4318/v1/metrics");
-		assertThat(new OtlpPropertiesConfigAdapter(properties).url()).isEqualTo("http://another-url:4318/v1/metrics");
+		this.properties.setUrl("http://another-url:4318/v1/metrics");
+		assertThat(createAdapter().url()).isEqualTo("http://another-url:4318/v1/metrics");
 	}
 
 	@Test
 	void whenPropertiesAggregationTemporalityIsNotSetAdapterAggregationTemporalityReturnsCumulative() {
-		OtlpProperties properties = new OtlpProperties();
-		assertThat(new OtlpPropertiesConfigAdapter(properties).aggregationTemporality())
-			.isSameAs(AggregationTemporality.CUMULATIVE);
+		assertThat(createAdapter().aggregationTemporality()).isSameAs(AggregationTemporality.CUMULATIVE);
 	}
 
 	@Test
 	void whenPropertiesAggregationTemporalityIsSetAdapterAggregationTemporalityReturnsIt() {
-		OtlpProperties properties = new OtlpProperties();
-		properties.setAggregationTemporality(AggregationTemporality.DELTA);
-		assertThat(new OtlpPropertiesConfigAdapter(properties).aggregationTemporality())
-			.isSameAs(AggregationTemporality.DELTA);
+		this.properties.setAggregationTemporality(AggregationTemporality.DELTA);
+		assertThat(createAdapter().aggregationTemporality()).isSameAs(AggregationTemporality.DELTA);
 	}
 
 	@Test
+	@SuppressWarnings("removal")
 	void whenPropertiesResourceAttributesIsSetAdapterResourceAttributesReturnsIt() {
-		OtlpProperties properties = new OtlpProperties();
-		properties.setResourceAttributes(Map.of("service.name", "boot-service"));
-		assertThat(new OtlpPropertiesConfigAdapter(properties).resourceAttributes()).containsEntry("service.name",
-				"boot-service");
+		this.properties.setResourceAttributes(Map.of("service.name", "boot-service"));
+		assertThat(createAdapter().resourceAttributes()).containsEntry("service.name", "boot-service");
 	}
 
 	@Test
 	void whenPropertiesHeadersIsSetAdapterHeadersReturnsIt() {
-		OtlpProperties properties = new OtlpProperties();
-		properties.setHeaders(Map.of("header", "value"));
-		assertThat(new OtlpPropertiesConfigAdapter(properties).headers()).containsEntry("header", "value");
+		this.properties.setHeaders(Map.of("header", "value"));
+		assertThat(createAdapter().headers()).containsEntry("header", "value");
+	}
+
+	@Test
+	void whenPropertiesBaseTimeUnitIsNotSetAdapterBaseTimeUnitReturnsMillis() {
+		assertThat(createAdapter().baseTimeUnit()).isSameAs(TimeUnit.MILLISECONDS);
+	}
+
+	@Test
+	void whenPropertiesBaseTimeUnitIsSetAdapterBaseTimeUnitReturnsIt() {
+		this.properties.setBaseTimeUnit(TimeUnit.SECONDS);
+		assertThat(createAdapter().baseTimeUnit()).isSameAs(TimeUnit.SECONDS);
+	}
+
+	@Test
+	@SuppressWarnings("removal")
+	void openTelemetryPropertiesShouldOverrideOtlpPropertiesIfNotEmpty() {
+		this.properties.setResourceAttributes(Map.of("a", "alpha"));
+		this.openTelemetryProperties.setResourceAttributes(Map.of("b", "beta"));
+		assertThat(createAdapter().resourceAttributes()).contains(entry("b", "beta"));
+		assertThat(createAdapter().resourceAttributes()).doesNotContain(entry("a", "alpha"));
+	}
+
+	@Test
+	@SuppressWarnings("removal")
+	void openTelemetryPropertiesShouldNotOverrideOtlpPropertiesIfEmpty() {
+		this.properties.setResourceAttributes(Map.of("a", "alpha"));
+		this.openTelemetryProperties.setResourceAttributes(Collections.emptyMap());
+		assertThat(createAdapter().resourceAttributes()).contains(entry("a", "alpha"));
+	}
+
+	@Test
+	@SuppressWarnings("removal")
+	void serviceNameOverridesApplicationName() {
+		this.environment.setProperty("spring.application.name", "alpha");
+		this.properties.setResourceAttributes(Map.of("service.name", "beta"));
+		assertThat(createAdapter().resourceAttributes()).containsEntry("service.name", "beta");
+	}
+
+	@Test
+	void serviceNameOverridesApplicationNameWhenUsingOtelProperties() {
+		this.environment.setProperty("spring.application.name", "alpha");
+		this.openTelemetryProperties.setResourceAttributes(Map.of("service.name", "beta"));
+		assertThat(createAdapter().resourceAttributes()).containsEntry("service.name", "beta");
+	}
+
+	@Test
+	void shouldUseApplicationNameIfServiceNameIsNotSet() {
+		this.environment.setProperty("spring.application.name", "alpha");
+		assertThat(createAdapter().resourceAttributes()).containsEntry("service.name", "alpha");
+	}
+
+	@Test
+	void shouldUseDefaultApplicationNameIfApplicationNameIsNotSet() {
+		assertThat(createAdapter().resourceAttributes()).containsEntry("service.name", "application");
+	}
+
+	private OtlpPropertiesConfigAdapter createAdapter() {
+		return new OtlpPropertiesConfigAdapter(this.properties, this.openTelemetryProperties, this.connectionDetails,
+				this.environment);
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesTests.java
index 69f945b66ce5..3046e2279dca 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesTests.java
@@ -37,6 +37,7 @@ void defaultValuesAreConsistent() {
 		assertStepRegistryDefaultValues(properties, config);
 		assertThat(properties.getUrl()).isEqualTo(config.url());
 		assertThat(properties.getAggregationTemporality()).isSameAs(config.aggregationTemporality());
+		assertThat(properties.getBaseTimeUnit()).isSameAs(config.baseTimeUnit());
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/prometheus/PrometheusPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/prometheus/PrometheusPropertiesConfigAdapterTests.java
index 4fa217688479..68929bf7b605 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/prometheus/PrometheusPropertiesConfigAdapterTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/prometheus/PrometheusPropertiesConfigAdapterTests.java
@@ -21,6 +21,8 @@
 import io.micrometer.prometheus.HistogramFlavor;
 import org.junit.jupiter.api.Test;
 
+import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.AbstractPropertiesConfigAdapterTests;
+
 import static org.assertj.core.api.Assertions.assertThat;
 
 /**
@@ -28,7 +30,12 @@
  *
  * @author Mirko Sobeck
  */
-class PrometheusPropertiesConfigAdapterTests {
+class PrometheusPropertiesConfigAdapterTests
+		extends AbstractPropertiesConfigAdapterTests<PrometheusProperties, PrometheusPropertiesConfigAdapter> {
+
+	PrometheusPropertiesConfigAdapterTests() {
+		super(PrometheusPropertiesConfigAdapter.class);
+	}
 
 	@Test
 	void whenPropertiesDescriptionsIsSetAdapterDescriptionsReturnsIt() {
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/properties/AbstractPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/properties/AbstractPropertiesConfigAdapterTests.java
new file mode 100644
index 000000000000..01082a6e16e3
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/properties/AbstractPropertiesConfigAdapterTests.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.metrics.export.properties;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+
+import io.micrometer.core.instrument.config.validate.Validated;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.core.annotation.AnnotatedElementUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Base class for testing properties config adapters.
+ *
+ * @param <P> the properties used by the adapter
+ * @param <A> the adapter under test
+ * @author Andy Wilkinson
+ * @author Mirko Sobeck
+ */
+public abstract class AbstractPropertiesConfigAdapterTests<P, A extends PropertiesConfigAdapter<P>> {
+
+	private final Class<? extends A> adapter;
+
+	protected AbstractPropertiesConfigAdapterTests(Class<? extends A> adapter) {
+		this.adapter = adapter;
+	}
+
+	@Test
+	protected void adapterOverridesAllConfigMethods() {
+		adapterOverridesAllConfigMethodsExcept();
+	}
+
+	protected final void adapterOverridesAllConfigMethodsExcept(String... nonConfigMethods) {
+		Class<?> config = findImplementedConfig();
+		Set<String> expectedConfigMethodNames = Arrays.stream(config.getDeclaredMethods())
+			.filter(Method::isDefault)
+			.filter(this::hasNoParameters)
+			.filter(this::isNotValidationMethod)
+			.filter(this::isNotDeprecated)
+			.map(Method::getName)
+			.collect(Collectors.toCollection(TreeSet::new));
+		expectedConfigMethodNames.removeAll(Arrays.asList(nonConfigMethods));
+		Set<String> actualConfigMethodNames = new TreeSet<>();
+		Class<?> currentClass = this.adapter;
+		while (!Object.class.equals(currentClass)) {
+			actualConfigMethodNames.addAll(Arrays.stream(currentClass.getDeclaredMethods())
+				.map(Method::getName)
+				.filter(expectedConfigMethodNames::contains)
+				.toList());
+			currentClass = currentClass.getSuperclass();
+		}
+		assertThat(actualConfigMethodNames).containsExactlyInAnyOrderElementsOf(expectedConfigMethodNames);
+	}
+
+	private Class<?> findImplementedConfig() {
+		Class<?>[] interfaces = this.adapter.getInterfaces();
+		if (interfaces.length == 1) {
+			return interfaces[0];
+		}
+		throw new IllegalStateException(this.adapter + " is not a config implementation");
+	}
+
+	private boolean isNotDeprecated(Method method) {
+		return !AnnotatedElementUtils.hasAnnotation(method, Deprecated.class);
+	}
+
+	private boolean hasNoParameters(Method method) {
+		return method.getParameterCount() == 0;
+	}
+
+	private boolean isNotValidationMethod(Method method) {
+		return !Validated.class.equals(method.getReturnType());
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/properties/PushRegistryPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/properties/PushRegistryPropertiesConfigAdapterTests.java
index 94c604a3f1d9..4b18da38c9c3 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/properties/PushRegistryPropertiesConfigAdapterTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/properties/PushRegistryPropertiesConfigAdapterTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -30,7 +30,12 @@
  * @author Stephane Nicoll
  * @author Artsiom Yudovin
  */
-public abstract class PushRegistryPropertiesConfigAdapterTests<P extends PushRegistryProperties, A extends PushRegistryPropertiesConfigAdapter<P>> {
+public abstract class PushRegistryPropertiesConfigAdapterTests<P extends PushRegistryProperties, A extends PushRegistryPropertiesConfigAdapter<P>>
+		extends AbstractPropertiesConfigAdapterTests<P, PropertiesConfigAdapter<P>> {
+
+	protected PushRegistryPropertiesConfigAdapterTests(Class<A> adapter) {
+		super(adapter);
+	}
 
 	protected abstract P createProperties();
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/properties/StepRegistryPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/properties/StepRegistryPropertiesConfigAdapterTests.java
index a0a9754b0352..5124af840ebb 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/properties/StepRegistryPropertiesConfigAdapterTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/properties/StepRegistryPropertiesConfigAdapterTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -27,4 +27,8 @@
 public abstract class StepRegistryPropertiesConfigAdapterTests<P extends StepRegistryProperties, A extends StepRegistryPropertiesConfigAdapter<P>>
 		extends PushRegistryPropertiesConfigAdapterTests<P, A> {
 
+	protected StepRegistryPropertiesConfigAdapterTests(Class<A> adapter) {
+		super(adapter);
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxPropertiesConfigAdapterTests.java
index a03af634c06c..d664a342148a 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxPropertiesConfigAdapterTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxPropertiesConfigAdapterTests.java
@@ -19,6 +19,7 @@
 import org.junit.jupiter.api.Test;
 
 import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.StepRegistryPropertiesConfigAdapterTests;
+import org.springframework.boot.actuate.autoconfigure.metrics.export.signalfx.SignalFxProperties.HistogramType;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -30,6 +31,10 @@
 class SignalFxPropertiesConfigAdapterTests
 		extends StepRegistryPropertiesConfigAdapterTests<SignalFxProperties, SignalFxPropertiesConfigAdapter> {
 
+	protected SignalFxPropertiesConfigAdapterTests() {
+		super(SignalFxPropertiesConfigAdapter.class);
+	}
+
 	@Override
 	protected SignalFxProperties createProperties() {
 		SignalFxProperties signalFxProperties = new SignalFxProperties();
@@ -62,4 +67,20 @@ void whenPropertiesSourceIsSetAdapterSourceReturnsIt() {
 		assertThat(createConfigAdapter(properties).source()).isEqualTo("DESKTOP-GA5");
 	}
 
+	@Test
+	void whenPropertiesPublishHistogramTypeIsCumulativeAdapterPublishCumulativeHistogramReturnsIt() {
+		SignalFxProperties properties = createProperties();
+		properties.setPublishedHistogramType(HistogramType.CUMULATIVE);
+		assertThat(createConfigAdapter(properties).publishCumulativeHistogram()).isTrue();
+		assertThat(createConfigAdapter(properties).publishDeltaHistogram()).isFalse();
+	}
+
+	@Test
+	void whenPropertiesPublishHistogramTypeIsDeltaAdapterPublishDeltaHistogramReturnsIt() {
+		SignalFxProperties properties = createProperties();
+		properties.setPublishedHistogramType(HistogramType.DELTA);
+		assertThat(createConfigAdapter(properties).publishDeltaHistogram()).isTrue();
+		assertThat(createConfigAdapter(properties).publishCumulativeHistogram()).isFalse();
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxPropertiesTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxPropertiesTests.java
index 19ea7cdc9d8d..ff7b6bf380e8 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxPropertiesTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxPropertiesTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -20,6 +20,7 @@
 import org.junit.jupiter.api.Test;
 
 import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.StepRegistryPropertiesTests;
+import org.springframework.boot.actuate.autoconfigure.metrics.export.signalfx.SignalFxProperties.HistogramType;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -38,6 +39,11 @@ void defaultValuesAreConsistent() {
 		// access token is mandatory
 		assertThat(properties.getUri()).isEqualTo(config.uri());
 		// source has no static default value
+		// Not publishing cumulative or delta histograms implies that the default
+		// histogram type should be published.
+		assertThat(config.publishCumulativeHistogram()).isFalse();
+		assertThat(config.publishDeltaHistogram()).isFalse();
+		assertThat(properties.getPublishedHistogramType()).isEqualTo(HistogramType.DEFAULT);
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/simple/SimplePropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/simple/SimplePropertiesConfigAdapterTests.java
index 5b7ba9a65016..5f977dc51cef 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/simple/SimplePropertiesConfigAdapterTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/simple/SimplePropertiesConfigAdapterTests.java
@@ -21,6 +21,8 @@
 import io.micrometer.core.instrument.simple.CountingMode;
 import org.junit.jupiter.api.Test;
 
+import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.AbstractPropertiesConfigAdapterTests;
+
 import static org.assertj.core.api.Assertions.assertThat;
 
 /**
@@ -28,7 +30,12 @@
  *
  * @author Mirko Sobeck
  */
-class SimplePropertiesConfigAdapterTests {
+class SimplePropertiesConfigAdapterTests
+		extends AbstractPropertiesConfigAdapterTests<SimpleProperties, SimplePropertiesConfigAdapter> {
+
+	SimplePropertiesConfigAdapterTests() {
+		super(SimplePropertiesConfigAdapter.class);
+	}
 
 	@Test
 	void whenPropertiesStepIsSetAdapterStepReturnsIt() {
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/stackdriver/StackdriverPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/stackdriver/StackdriverPropertiesConfigAdapterTests.java
index 9b2f2fe96400..3c9b8a3aca7d 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/stackdriver/StackdriverPropertiesConfigAdapterTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/stackdriver/StackdriverPropertiesConfigAdapterTests.java
@@ -21,6 +21,8 @@
 
 import org.junit.jupiter.api.Test;
 
+import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.AbstractPropertiesConfigAdapterTests;
+
 import static org.assertj.core.api.Assertions.assertThat;
 
 /**
@@ -28,7 +30,12 @@
  *
  * @author Johannes Graf
  */
-class StackdriverPropertiesConfigAdapterTests {
+class StackdriverPropertiesConfigAdapterTests
+		extends AbstractPropertiesConfigAdapterTests<StackdriverProperties, StackdriverPropertiesConfigAdapter> {
+
+	StackdriverPropertiesConfigAdapterTests() {
+		super(StackdriverPropertiesConfigAdapter.class);
+	}
 
 	@Test
 	void whenPropertiesProjectIdIsSetAdapterProjectIdReturnsIt() {
@@ -62,4 +69,18 @@ void whenPropertiesUseSemanticMetricTypesIsSetAdapterUseSemanticMetricTypesRetur
 		assertThat(new StackdriverPropertiesConfigAdapter(properties).useSemanticMetricTypes()).isTrue();
 	}
 
+	@Test
+	void whenPropertiesMetricTypePrefixIsSetAdapterMetricTypePrefixReturnsIt() {
+		StackdriverProperties properties = new StackdriverProperties();
+		properties.setMetricTypePrefix("external.googleapis.com/prometheus");
+		assertThat(new StackdriverPropertiesConfigAdapter(properties).metricTypePrefix())
+			.isEqualTo("external.googleapis.com/prometheus");
+	}
+
+	@Test
+	@Override
+	protected void adapterOverridesAllConfigMethods() {
+		adapterOverridesAllConfigMethodsExcept("credentials");
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/stackdriver/StackdriverPropertiesTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/stackdriver/StackdriverPropertiesTests.java
index 103344d4617c..20031fce870b 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/stackdriver/StackdriverPropertiesTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/stackdriver/StackdriverPropertiesTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -37,6 +37,7 @@ void defaultValuesAreConsistent() {
 		assertStepRegistryDefaultValues(properties, config);
 		assertThat(properties.getResourceType()).isEqualTo(config.resourceType());
 		assertThat(properties.isUseSemanticMetricTypes()).isEqualTo(config.useSemanticMetricTypes());
+		assertThat(properties.getMetricTypePrefix()).isEqualTo(config.metricTypePrefix());
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/statsd/StatsdPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/statsd/StatsdPropertiesConfigAdapterTests.java
index 9eae3d00dc2a..4fdfddfa9075 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/statsd/StatsdPropertiesConfigAdapterTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/statsd/StatsdPropertiesConfigAdapterTests.java
@@ -22,6 +22,8 @@
 import io.micrometer.statsd.StatsdProtocol;
 import org.junit.jupiter.api.Test;
 
+import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.AbstractPropertiesConfigAdapterTests;
+
 import static org.assertj.core.api.Assertions.assertThat;
 
 /**
@@ -29,7 +31,12 @@
  *
  * @author Johnny Lim
  */
-class StatsdPropertiesConfigAdapterTests {
+class StatsdPropertiesConfigAdapterTests
+		extends AbstractPropertiesConfigAdapterTests<StatsdProperties, StatsdPropertiesConfigAdapter> {
+
+	protected StatsdPropertiesConfigAdapterTests() {
+		super(StatsdPropertiesConfigAdapter.class);
+	}
 
 	@Test
 	void whenPropertiesEnabledIsSetAdapterEnabledReturnsIt() {
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/wavefront/WavefrontPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/wavefront/WavefrontPropertiesConfigAdapterTests.java
index 6660d9319955..2acd34bbe5bc 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/wavefront/WavefrontPropertiesConfigAdapterTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/wavefront/WavefrontPropertiesConfigAdapterTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -18,11 +18,15 @@
 
 import java.net.URI;
 
+import com.wavefront.sdk.common.clients.service.token.TokenService.Type;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 
 import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.PushRegistryPropertiesConfigAdapterTests;
 import org.springframework.boot.actuate.autoconfigure.wavefront.WavefrontProperties;
 import org.springframework.boot.actuate.autoconfigure.wavefront.WavefrontProperties.Metrics.Export;
+import org.springframework.boot.actuate.autoconfigure.wavefront.WavefrontProperties.TokenType;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -35,6 +39,10 @@
 class WavefrontPropertiesConfigAdapterTests extends
 		PushRegistryPropertiesConfigAdapterTests<WavefrontProperties.Metrics.Export, WavefrontPropertiesConfigAdapter> {
 
+	protected WavefrontPropertiesConfigAdapterTests() {
+		super(WavefrontPropertiesConfigAdapter.class);
+	}
+
 	@Override
 	protected WavefrontProperties.Metrics.Export createProperties() {
 		return new WavefrontProperties.Metrics.Export();
@@ -58,7 +66,7 @@ void whenPropertiesGlobalPrefixIsSetAdapterGlobalPrefixReturnsIt() {
 	protected void whenPropertiesBatchSizeIsSetAdapterBatchSizeReturnsIt() {
 		WavefrontProperties properties = new WavefrontProperties();
 		properties.getSender().setBatchSize(10042);
-		assertThat(createConfigAdapter(properties.getMetrics().getExport()).batchSize()).isEqualTo(10042);
+		assertThat(new WavefrontPropertiesConfigAdapter(properties).batchSize()).isEqualTo(10042);
 	}
 
 	@Test
@@ -82,4 +90,41 @@ void whenPropertiesSourceIsSetAdapterSourceReturnsIt() {
 		assertThat(new WavefrontPropertiesConfigAdapter(properties).source()).isEqualTo("DESKTOP-GA5");
 	}
 
+	@Test
+	void whenPropertiesReportMinuteDistributionIsSetAdapterReportMinuteDistributionReturnsIt() {
+		Export properties = createProperties();
+		properties.setReportMinuteDistribution(false);
+		assertThat(createConfigAdapter(properties).reportMinuteDistribution()).isFalse();
+	}
+
+	@Test
+	void whenPropertiesReportHourDistributionIsSetAdapterReportHourDistributionReturnsIt() {
+		Export properties = createProperties();
+		properties.setReportHourDistribution(true);
+		assertThat(createConfigAdapter(properties).reportHourDistribution()).isTrue();
+	}
+
+	@Test
+	void whenPropertiesReportDayDistributionIsSetAdapterReportDayDistributionReturnsIt() {
+		Export properties = createProperties();
+		properties.setReportDayDistribution(true);
+		assertThat(createConfigAdapter(properties).reportDayDistribution()).isTrue();
+	}
+
+	@ParameterizedTest
+	@CsvSource(textBlock = """
+			null,					WAVEFRONT_API_TOKEN
+			NO_TOKEN,				NO_TOKEN
+			WAVEFRONT_API_TOKEN,	WAVEFRONT_API_TOKEN
+			CSP_API_TOKEN,			CSP_API_TOKEN
+			CSP_CLIENT_CREDENTIALS,	CSP_CLIENT_CREDENTIALS
+			""")
+	void whenTokenTypeIsSetAdapterReturnsIt(String property, String wavefront) {
+		TokenType propertyTokenType = property.equals("null") ? null : TokenType.valueOf(property);
+		Type wavefrontTokenType = Type.valueOf(wavefront);
+		WavefrontProperties properties = new WavefrontProperties();
+		properties.setApiTokenType(propertyTokenType);
+		assertThat(new WavefrontPropertiesConfigAdapter(properties).apiTokenType()).isEqualTo(wavefrontTokenType);
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/orm/jpa/HibernateMetricsAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/orm/jpa/HibernateMetricsAutoConfigurationTests.java
index ddad7997559d..21d94348e143 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/orm/jpa/HibernateMetricsAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/orm/jpa/HibernateMetricsAutoConfigurationTests.java
@@ -50,7 +50,7 @@
 import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
 import static org.mockito.BDDMockito.given;
 import static org.mockito.Mockito.mock;
 
@@ -117,8 +117,8 @@ void entityManagerFactoryInstrumentationIsDisabledIfNotHibernateSessionFactory()
 			.withUserConfiguration(NonHibernateEntityManagerFactoryConfiguration.class)
 			.run((context) -> {
 				// ensure EntityManagerFactory is not a Hibernate SessionFactory
-				assertThatThrownBy(() -> context.getBean(EntityManagerFactory.class).unwrap(SessionFactory.class))
-					.isInstanceOf(PersistenceException.class);
+				assertThatExceptionOfType(PersistenceException.class)
+					.isThrownBy(() -> context.getBean(EntityManagerFactory.class).unwrap(SessionFactory.class));
 				MeterRegistry registry = context.getBean(MeterRegistry.class);
 				assertThat(registry.find("hibernate.statements").meter()).isNull();
 			});
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/task/TaskExecutorMetricsAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/task/TaskExecutorMetricsAutoConfigurationTests.java
index 35087e1e4042..21c23e3bb789 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/task/TaskExecutorMetricsAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/task/TaskExecutorMetricsAutoConfigurationTests.java
@@ -24,6 +24,7 @@
 import io.micrometer.core.instrument.search.MeterNotFoundException;
 import org.junit.jupiter.api.Test;
 
+import org.springframework.boot.LazyInitializationBeanFactoryPostProcessor;
 import org.springframework.boot.actuate.autoconfigure.metrics.test.MetricsRun;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration;
@@ -64,6 +65,21 @@ void taskExecutorUsingAutoConfigurationIsInstrumented() {
 			});
 	}
 
+	@Test
+	void taskExecutorIsInstrumentedWhenUsingLazyInitialization() {
+		this.contextRunner.withConfiguration(AutoConfigurations.of(TaskExecutionAutoConfiguration.class))
+			.withBean(LazyInitializationBeanFactoryPostProcessor.class)
+			.run((context) -> {
+				MeterRegistry registry = context.getBean(MeterRegistry.class);
+				Collection<FunctionCounter> meters = registry.get("executor.completed").functionCounters();
+				assertThat(meters).singleElement()
+					.satisfies(
+							(meter) -> assertThat(meter.getId().getTag("name")).isEqualTo("applicationTaskExecutor"));
+				assertThatExceptionOfType(MeterNotFoundException.class)
+					.isThrownBy(() -> registry.get("executor").timer());
+			});
+	}
+
 	@Test
 	void taskExecutorsWithCustomNamesAreInstrumented() {
 		this.contextRunner.withBean("firstTaskExecutor", Executor.class, ThreadPoolTaskExecutor::new)
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/web/jetty/JettyMetricsAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/web/jetty/JettyMetricsAutoConfigurationTests.java
index d53632c54410..0e8437a192be 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/web/jetty/JettyMetricsAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/web/jetty/JettyMetricsAutoConfigurationTests.java
@@ -31,7 +31,6 @@
 import org.springframework.boot.context.event.ApplicationStartedEvent;
 import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
 import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
-import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides;
 import org.springframework.boot.web.embedded.jetty.JettyReactiveWebServerFactory;
 import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory;
 import org.springframework.boot.web.reactive.context.AnnotationConfigReactiveWebServerApplicationContext;
@@ -50,7 +49,6 @@
  * @author Andy Wilkinson
  * @author Chris Bono
  */
-@Servlet5ClassPathOverrides
 class JettyMetricsAutoConfigurationTests {
 
 	@Test
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationAutoConfigurationTests.java
index 28e4bd0100e2..16b837042245 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationAutoConfigurationTests.java
@@ -34,9 +34,11 @@
 import io.micrometer.observation.ObservationHandler.FirstMatchingCompositeObservationHandler;
 import io.micrometer.observation.ObservationPredicate;
 import io.micrometer.observation.ObservationRegistry;
+import io.micrometer.observation.aop.ObservedAspect;
 import io.micrometer.tracing.Tracer;
 import io.micrometer.tracing.handler.TracingAwareMeterObservationHandler;
 import io.micrometer.tracing.handler.TracingObservationHandler;
+import org.aspectj.weaver.Advice;
 import org.junit.jupiter.api.Test;
 import org.mockito.Answers;
 
@@ -50,7 +52,7 @@
 import org.springframework.core.annotation.Order;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
 import static org.mockito.Mockito.mock;
 
 /**
@@ -58,6 +60,7 @@
  *
  * @author Moritz Halbritter
  * @author Jonatan Ivanov
+ * @author Vedran Pavic
  */
 class ObservationAutoConfigurationTests {
 
@@ -77,6 +80,7 @@ void beansShouldNotBeSuppliedWhenMicrometerObservationIsNotOnClassPath() {
 				assertThat(context).hasSingleBean(MeterRegistry.class);
 				assertThat(context).doesNotHaveBean(ObservationRegistry.class);
 				assertThat(context).doesNotHaveBean(ObservationHandler.class);
+				assertThat(context).doesNotHaveBean(ObservedAspect.class);
 				assertThat(context).doesNotHaveBean(ObservationHandlerGrouping.class);
 			});
 	}
@@ -88,6 +92,7 @@ void supplyObservationRegistryWhenMicrometerCoreAndTracingAreNotOnClassPath() {
 				ObservationRegistry observationRegistry = context.getBean(ObservationRegistry.class);
 				Observation.start("test-observation", observationRegistry).stop();
 				assertThat(context).doesNotHaveBean(ObservationHandler.class);
+				assertThat(context).hasSingleBean(ObservedAspect.class);
 				assertThat(context).doesNotHaveBean(ObservationHandlerGrouping.class);
 			});
 	}
@@ -99,6 +104,7 @@ void supplyMeterHandlerAndGroupingWhenMicrometerCoreIsOnClassPathButTracingIsNot
 			Observation.start("test-observation", observationRegistry).stop();
 			assertThat(context).hasSingleBean(ObservationHandler.class);
 			assertThat(context).hasSingleBean(DefaultMeterObservationHandler.class);
+			assertThat(context).hasSingleBean(ObservedAspect.class);
 			assertThat(context).hasSingleBean(ObservationHandlerGrouping.class);
 			assertThat(context).hasBean("metricsObservationHandlerGrouping");
 		});
@@ -110,6 +116,7 @@ void supplyOnlyTracingObservationHandlerGroupingWhenMicrometerCoreIsNotOnClassPa
 			ObservationRegistry observationRegistry = context.getBean(ObservationRegistry.class);
 			Observation.start("test-observation", observationRegistry).stop();
 			assertThat(context).doesNotHaveBean(ObservationHandler.class);
+			assertThat(context).hasSingleBean(ObservedAspect.class);
 			assertThat(context).hasSingleBean(ObservationHandlerGrouping.class);
 			assertThat(context).hasBean("tracingObservationHandlerGrouping");
 		});
@@ -123,6 +130,7 @@ void supplyMeterHandlerAndGroupingWhenMicrometerCoreAndTracingAreOnClassPath() {
 			// TracingAwareMeterObservationHandler that we don't test here
 			Observation.start("test-observation", observationRegistry);
 			assertThat(context).hasSingleBean(ObservationHandler.class);
+			assertThat(context).hasSingleBean(ObservedAspect.class);
 			assertThat(context).hasSingleBean(TracingAwareMeterObservationHandler.class);
 			assertThat(context).hasSingleBean(ObservationHandlerGrouping.class);
 			assertThat(context).hasBean("metricsAndTracingObservationHandlerGrouping");
@@ -138,6 +146,7 @@ void supplyMeterHandlerAndGroupingWhenMicrometerCoreAndTracingAreOnClassPathButT
 				Observation.start("test-observation", observationRegistry).stop();
 				assertThat(context).hasSingleBean(ObservationHandler.class);
 				assertThat(context).hasSingleBean(DefaultMeterObservationHandler.class);
+				assertThat(context).hasSingleBean(ObservedAspect.class);
 				assertThat(context).hasSingleBean(ObservationHandlerGrouping.class);
 				assertThat(context).hasBean("metricsAndTracingObservationHandlerGrouping");
 			});
@@ -155,6 +164,7 @@ void autoConfiguresDefaultMeterObservationHandler() {
 			assertThat(meterRegistry.get("test-observation").timer().count()).isOne();
 			assertThat(context).hasSingleBean(DefaultMeterObservationHandler.class);
 			assertThat(context).hasSingleBean(ObservationHandler.class);
+			assertThat(context).hasSingleBean(ObservedAspect.class);
 		});
 	}
 
@@ -164,6 +174,20 @@ void allowsDefaultMeterObservationHandlerToBeDisabled() {
 			.run((context) -> assertThat(context).doesNotHaveBean(ObservationHandler.class));
 	}
 
+	@Test
+	void allowsObservedAspectToBeDisabled() {
+		this.contextRunner.withClassLoader(new FilteredClassLoader(Advice.class))
+			.run((context) -> assertThat(context).doesNotHaveBean(ObservedAspect.class));
+	}
+
+	@Test
+	void allowsObservedAspectToBeCustomized() {
+		this.contextRunner.withUserConfiguration(CustomObservedAspectConfiguration.class)
+			.run((context) -> assertThat(context).hasSingleBean(ObservedAspect.class)
+				.getBean(ObservedAspect.class)
+				.isSameAs(context.getBean("customObservedAspect")));
+	}
+
 	@Test
 	void autoConfiguresObservationPredicates() {
 		this.contextRunner.withUserConfiguration(ObservationPredicates.class).run((context) -> {
@@ -174,8 +198,8 @@ void autoConfiguresObservationPredicates() {
 			Observation.start("observation2", observationRegistry).stop();
 			MeterRegistry meterRegistry = context.getBean(MeterRegistry.class);
 			assertThat(meterRegistry.get("observation1").timer().count()).isOne();
-			assertThatThrownBy(() -> meterRegistry.get("observation2").timer())
-				.isInstanceOf(MeterNotFoundException.class);
+			assertThatExceptionOfType(MeterNotFoundException.class)
+				.isThrownBy(() -> meterRegistry.get("observation2").timer());
 		});
 	}
 
@@ -189,6 +213,22 @@ void autoConfiguresObservationFilters() {
 		});
 	}
 
+	@Test
+	void shouldSupplyPropertiesObservationFilterBean() {
+		this.contextRunner
+			.run((context) -> assertThat(context).hasSingleBean(PropertiesObservationFilterPredicate.class));
+	}
+
+	@Test
+	void shouldApplyCommonKeyValuesToObservations() {
+		this.contextRunner.withPropertyValues("management.observations.key-values.a=alpha").run((context) -> {
+			ObservationRegistry observationRegistry = context.getBean(ObservationRegistry.class);
+			Observation.start("keyvalues", observationRegistry).stop();
+			MeterRegistry meterRegistry = context.getBean(MeterRegistry.class);
+			assertThat(meterRegistry.get("keyvalues").tag("a", "alpha").timer().count()).isOne();
+		});
+	}
+
 	@Test
 	void autoConfiguresGlobalObservationConventions() {
 		this.contextRunner.withUserConfiguration(CustomGlobalObservationConvention.class).run((context) -> {
@@ -207,14 +247,13 @@ void autoConfiguresObservationHandlers() {
 			Observation.start("test-observation", observationRegistry).stop();
 			assertThat(context).doesNotHaveBean(DefaultMeterObservationHandler.class);
 			assertThat(handlers).hasSize(2);
-			// Regular handlers are registered first
-			assertThat(handlers.get(0)).isInstanceOf(CustomObservationHandler.class);
 			// Multiple MeterObservationHandler are wrapped in
-			// FirstMatchingCompositeObservationHandler, which calls only the first
-			// one
-			assertThat(handlers.get(1)).isInstanceOf(CustomMeterObservationHandler.class);
-			assertThat(((CustomMeterObservationHandler) handlers.get(1)).getName())
+			// FirstMatchingCompositeObservationHandler, which calls only the first one
+			assertThat(handlers.get(0)).isInstanceOf(CustomMeterObservationHandler.class);
+			assertThat(((CustomMeterObservationHandler) handlers.get(0)).getName())
 				.isEqualTo("customMeterObservationHandler1");
+			// Regular handlers are registered last
+			assertThat(handlers.get(1)).isInstanceOf(CustomObservationHandler.class);
 			assertThat(context).doesNotHaveBean(DefaultMeterObservationHandler.class);
 			assertThat(context).doesNotHaveBean(TracingAwareMeterObservationHandler.class);
 		});
@@ -257,25 +296,44 @@ void autoConfiguresObservationHandlerWhenTracingIsActive() {
 			List<ObservationHandler<?>> handlers = context.getBean(CalledHandlers.class).getCalledHandlers();
 			Observation.start("test-observation", observationRegistry).stop();
 			assertThat(handlers).hasSize(3);
-			// Regular handlers are registered first
-			assertThat(handlers.get(0)).isInstanceOf(CustomObservationHandler.class);
 			// Multiple TracingObservationHandler are wrapped in
-			// FirstMatchingCompositeObservationHandler, which calls only the first
-			// one
-			assertThat(handlers.get(1)).isInstanceOf(CustomTracingObservationHandler.class);
-			assertThat(((CustomTracingObservationHandler) handlers.get(1)).getName())
+			// FirstMatchingCompositeObservationHandler, which calls only the first one
+			assertThat(handlers.get(0)).isInstanceOf(CustomTracingObservationHandler.class);
+			assertThat(((CustomTracingObservationHandler) handlers.get(0)).getName())
 				.isEqualTo("customTracingHandler1");
 			// Multiple MeterObservationHandler are wrapped in
-			// FirstMatchingCompositeObservationHandler, which calls only the first
-			// one
-			assertThat(handlers.get(2)).isInstanceOf(CustomMeterObservationHandler.class);
-			assertThat(((CustomMeterObservationHandler) handlers.get(2)).getName())
+			// FirstMatchingCompositeObservationHandler, which calls only the first one
+			assertThat(handlers.get(1)).isInstanceOf(CustomMeterObservationHandler.class);
+			assertThat(((CustomMeterObservationHandler) handlers.get(1)).getName())
 				.isEqualTo("customMeterObservationHandler1");
+			// Regular handlers are registered last
+			assertThat(handlers.get(2)).isInstanceOf(CustomObservationHandler.class);
 			assertThat(context).doesNotHaveBean(TracingAwareMeterObservationHandler.class);
 			assertThat(context).doesNotHaveBean(DefaultMeterObservationHandler.class);
 		});
 	}
 
+	@Test
+	void shouldNotDisableSpringSecurityObservationsByDefault() {
+		this.contextRunner.run((context) -> {
+			ObservationRegistry observationRegistry = context.getBean(ObservationRegistry.class);
+			Observation.start("spring.security.filterchains", observationRegistry).stop();
+			MeterRegistry meterRegistry = context.getBean(MeterRegistry.class);
+			assertThat(meterRegistry.get("spring.security.filterchains").timer().count()).isOne();
+		});
+	}
+
+	@Test
+	void shouldDisableSpringSecurityObservationsIfPropertyIsSet() {
+		this.contextRunner.withPropertyValues("management.observations.enable.spring.security=false").run((context) -> {
+			ObservationRegistry observationRegistry = context.getBean(ObservationRegistry.class);
+			Observation.start("spring.security.filterchains", observationRegistry).stop();
+			MeterRegistry meterRegistry = context.getBean(MeterRegistry.class);
+			assertThatExceptionOfType(MeterNotFoundException.class)
+				.isThrownBy(() -> meterRegistry.get("spring.security.filterchains").timer());
+		});
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class ObservationPredicates {
 
@@ -303,6 +361,16 @@ ObservationFilter observationFilterTwo() {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class CustomObservedAspectConfiguration {
+
+		@Bean
+		ObservedAspect customObservedAspect(ObservationRegistry observationRegistry) {
+			return new ObservedAspect(observationRegistry);
+		}
+
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class CustomGlobalObservationConvention {
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationHandlerGroupingTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationHandlerGroupingTests.java
new file mode 100644
index 000000000000..b42d0a155c81
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationHandlerGroupingTests.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.observation;
+
+import java.lang.reflect.Method;
+import java.util.List;
+
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.Observation.Context;
+import io.micrometer.observation.ObservationHandler;
+import io.micrometer.observation.ObservationHandler.FirstMatchingCompositeObservationHandler;
+import io.micrometer.observation.ObservationRegistry.ObservationConfig;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.util.ReflectionUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ObservationHandlerGrouping}.
+ *
+ * @author Moritz Halbritter
+ */
+class ObservationHandlerGroupingTests {
+
+	@Test
+	void shouldGroupCategoriesIntoFirstMatchingHandlerAndRespectCategoryOrder() {
+		ObservationHandlerGrouping grouping = new ObservationHandlerGrouping(
+				List.of(ObservationHandlerA.class, ObservationHandlerB.class));
+		ObservationConfig config = new ObservationConfig();
+		ObservationHandlerA handlerA1 = new ObservationHandlerA("a1");
+		ObservationHandlerA handlerA2 = new ObservationHandlerA("a2");
+		ObservationHandlerB handlerB1 = new ObservationHandlerB("b1");
+		ObservationHandlerB handlerB2 = new ObservationHandlerB("b2");
+		grouping.apply(List.of(handlerB1, handlerB2, handlerA1, handlerA2), config);
+		List<ObservationHandler<?>> handlers = getObservationHandlers(config);
+		assertThat(handlers).hasSize(2);
+		// Category A is first
+		assertThat(handlers.get(0)).isInstanceOf(FirstMatchingCompositeObservationHandler.class);
+		FirstMatchingCompositeObservationHandler firstMatching0 = (FirstMatchingCompositeObservationHandler) handlers
+			.get(0);
+		assertThat(firstMatching0.getHandlers()).containsExactly(handlerA1, handlerA2);
+		// Category B is second
+		assertThat(handlers.get(1)).isInstanceOf(FirstMatchingCompositeObservationHandler.class);
+		FirstMatchingCompositeObservationHandler firstMatching1 = (FirstMatchingCompositeObservationHandler) handlers
+			.get(1);
+		assertThat(firstMatching1.getHandlers()).containsExactly(handlerB1, handlerB2);
+	}
+
+	@Test
+	void uncategorizedHandlersShouldBeOrderedAfterCategories() {
+		ObservationHandlerGrouping grouping = new ObservationHandlerGrouping(ObservationHandlerA.class);
+		ObservationConfig config = new ObservationConfig();
+		ObservationHandlerA handlerA1 = new ObservationHandlerA("a1");
+		ObservationHandlerA handlerA2 = new ObservationHandlerA("a2");
+		ObservationHandlerB handlerB1 = new ObservationHandlerB("b1");
+		grouping.apply(List.of(handlerB1, handlerA1, handlerA2), config);
+		List<ObservationHandler<?>> handlers = getObservationHandlers(config);
+		assertThat(handlers).hasSize(2);
+		// Category A is first
+		assertThat(handlers.get(0)).isInstanceOf(FirstMatchingCompositeObservationHandler.class);
+		FirstMatchingCompositeObservationHandler firstMatching0 = (FirstMatchingCompositeObservationHandler) handlers
+			.get(0);
+		// Uncategorized handlers follow
+		assertThat(firstMatching0.getHandlers()).containsExactly(handlerA1, handlerA2);
+		assertThat(handlers.get(1)).isEqualTo(handlerB1);
+	}
+
+	@SuppressWarnings("unchecked")
+	private static List<ObservationHandler<?>> getObservationHandlers(ObservationConfig config) {
+		Method method = ReflectionUtils.findMethod(ObservationConfig.class, "getObservationHandlers");
+		ReflectionUtils.makeAccessible(method);
+		return (List<ObservationHandler<?>>) ReflectionUtils.invokeMethod(method, config);
+	}
+
+	private static class NamedObservationHandler implements ObservationHandler<Observation.Context> {
+
+		private final String name;
+
+		NamedObservationHandler(String name) {
+			this.name = name;
+		}
+
+		@Override
+		public boolean supportsContext(Context context) {
+			return true;
+		}
+
+		@Override
+		public String toString() {
+			return getClass().getSimpleName() + "{name='" + this.name + "'}";
+		}
+
+	}
+
+	private static class ObservationHandlerA extends NamedObservationHandler {
+
+		ObservationHandlerA(String name) {
+			super(name);
+		}
+
+	}
+
+	private static class ObservationHandlerB extends NamedObservationHandler {
+
+		ObservationHandlerB(String name) {
+			super(name);
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/PropertiesObservationFilterPredicateTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/PropertiesObservationFilterPredicateTests.java
new file mode 100644
index 000000000000..4afd4f601e67
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/PropertiesObservationFilterPredicateTests.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.observation;
+
+import io.micrometer.common.KeyValue;
+import io.micrometer.common.KeyValues;
+import io.micrometer.observation.Observation.Context;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link PropertiesObservationFilterPredicate}.
+ *
+ * @author Moritz Halbritter
+ */
+class PropertiesObservationFilterPredicateTests {
+
+	@Test
+	void shouldDoNothingIfKeyValuesAreEmpty() {
+		PropertiesObservationFilterPredicate filter = createFilter();
+		Context mapped = mapContext(filter, "a", "alpha");
+		assertThat(mapped.getLowCardinalityKeyValues()).containsExactly(KeyValue.of("a", "alpha"));
+	}
+
+	@Test
+	void shouldAddKeyValues() {
+		PropertiesObservationFilterPredicate filter = createFilter("b", "beta");
+		Context mapped = mapContext(filter, "a", "alpha");
+		assertThat(mapped.getLowCardinalityKeyValues()).containsExactly(KeyValue.of("a", "alpha"),
+				KeyValue.of("b", "beta"));
+	}
+
+	@Test
+	void shouldFilter() {
+		PropertiesObservationFilterPredicate predicate = createPredicate("spring.security");
+		Context context = new Context();
+		assertThat(predicate.test("spring.security.filterchains", context)).isFalse();
+		assertThat(predicate.test("spring.security", context)).isFalse();
+		assertThat(predicate.test("spring.data", context)).isTrue();
+		assertThat(predicate.test("spring", context)).isTrue();
+	}
+
+	@Test
+	void filterShouldFallbackToAll() {
+		PropertiesObservationFilterPredicate predicate = createPredicate("all");
+		Context context = new Context();
+		assertThat(predicate.test("spring.security.filterchains", context)).isFalse();
+		assertThat(predicate.test("spring.security", context)).isFalse();
+		assertThat(predicate.test("spring.data", context)).isFalse();
+		assertThat(predicate.test("spring", context)).isFalse();
+	}
+
+	@Test
+	void shouldNotFilterIfDisabledNamesIsEmpty() {
+		PropertiesObservationFilterPredicate predicate = createPredicate();
+		Context context = new Context();
+		assertThat(predicate.test("spring.security.filterchains", context)).isTrue();
+		assertThat(predicate.test("spring.security", context)).isTrue();
+		assertThat(predicate.test("spring.data", context)).isTrue();
+		assertThat(predicate.test("spring", context)).isTrue();
+	}
+
+	private static Context mapContext(PropertiesObservationFilterPredicate filter, String... initialKeyValues) {
+		Context context = new Context();
+		context.addLowCardinalityKeyValues(KeyValues.of(initialKeyValues));
+		return filter.map(context);
+	}
+
+	private static PropertiesObservationFilterPredicate createFilter(String... keyValues) {
+		ObservationProperties properties = new ObservationProperties();
+		for (int i = 0; i < keyValues.length; i += 2) {
+			properties.getKeyValues().put(keyValues[i], keyValues[i + 1]);
+		}
+		return new PropertiesObservationFilterPredicate(properties);
+	}
+
+	private static PropertiesObservationFilterPredicate createPredicate(String... disabledNames) {
+		ObservationProperties properties = new ObservationProperties();
+		for (String name : disabledNames) {
+			properties.getEnable().put(name, false);
+		}
+		return new PropertiesObservationFilterPredicate(properties);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/jms/JmsTemplateObservationAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/jms/JmsTemplateObservationAutoConfigurationTests.java
new file mode 100644
index 000000000000..dd097676b2d5
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/jms/JmsTemplateObservationAutoConfigurationTests.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.observation.jms;
+
+import jakarta.jms.ConnectionFactory;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.jms.JmsAutoConfiguration;
+import org.springframework.boot.test.context.FilteredClassLoader;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.jms.core.JmsTemplate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link JmsTemplateObservationAutoConfiguration}.
+ *
+ * @author Brian Clozel
+ */
+class JmsTemplateObservationAutoConfigurationTests {
+
+	ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+		.withConfiguration(AutoConfigurations.of(JmsAutoConfiguration.class, ObservationAutoConfiguration.class,
+				JmsTemplateObservationAutoConfiguration.class))
+		.withUserConfiguration(JmsConnectionConfiguration.class);
+
+	@Test
+	void shouldConfigureObservationRegistryOnTemplate() {
+		this.contextRunner.run((context) -> {
+			JmsTemplate jmsTemplate = context.getBean(JmsTemplate.class);
+			assertThat(jmsTemplate).extracting("observationRegistry").isNotNull();
+		});
+	}
+
+	@Test
+	void shouldBackOffWhenMicrometerJakartaIsNotPresent() {
+		this.contextRunner.withClassLoader(new FilteredClassLoader("io.micrometer.jakarta")).run((context) -> {
+			JmsTemplate jmsTemplate = context.getBean(JmsTemplate.class);
+			assertThat(jmsTemplate).extracting("observationRegistry").isNull();
+		});
+	}
+
+	static class JmsConnectionConfiguration {
+
+		@Bean
+		ConnectionFactory connectionFactory() {
+			return mock(ConnectionFactory.class);
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientHttpObservationConventionAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientHttpObservationConventionAdapterTests.java
deleted file mode 100644
index 814e71a4c720..000000000000
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientHttpObservationConventionAdapterTests.java
+++ /dev/null
@@ -1,90 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.autoconfigure.observation.web.client;
-
-import java.net.URI;
-
-import io.micrometer.common.KeyValue;
-import io.micrometer.observation.Observation;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.boot.actuate.metrics.web.client.DefaultRestTemplateExchangeTagsProvider;
-import org.springframework.http.HttpMethod;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.client.ClientHttpRequest;
-import org.springframework.http.client.ClientHttpResponse;
-import org.springframework.http.client.observation.ClientRequestObservationContext;
-import org.springframework.mock.http.client.MockClientHttpRequest;
-import org.springframework.mock.http.client.MockClientHttpResponse;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Tests for {@link ClientHttpObservationConventionAdapter}.
- *
- * @author Brian Clozel
- */
-@SuppressWarnings({ "deprecation", "removal" })
-class ClientHttpObservationConventionAdapterTests {
-
-	private static final String TEST_METRIC_NAME = "test.metric.name";
-
-	private final ClientHttpObservationConventionAdapter convention = new ClientHttpObservationConventionAdapter(
-			TEST_METRIC_NAME, new DefaultRestTemplateExchangeTagsProvider());
-
-	private final ClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, URI.create("/resource/test"));
-
-	private final ClientHttpResponse response = new MockClientHttpResponse("foo".getBytes(), HttpStatus.OK);
-
-	private ClientRequestObservationContext context;
-
-	@BeforeEach
-	void setup() {
-		this.context = new ClientRequestObservationContext(this.request);
-		this.context.setResponse(this.response);
-		this.context.setUriTemplate("/resource/{name}");
-	}
-
-	@Test
-	void shouldUseConfiguredName() {
-		assertThat(this.convention.getName()).isEqualTo(TEST_METRIC_NAME);
-	}
-
-	@Test
-	void shouldOnlySupportClientHttpObservationContext() {
-		assertThat(this.convention.supportsContext(this.context)).isTrue();
-		assertThat(this.convention.supportsContext(new OtherContext())).isFalse();
-	}
-
-	@Test
-	void shouldPushTagsAsLowCardinalityKeyValues() {
-		assertThat(this.convention.getLowCardinalityKeyValues(this.context)).contains(KeyValue.of("status", "200"),
-				KeyValue.of("outcome", "SUCCESS"), KeyValue.of("uri", "/resource/{name}"),
-				KeyValue.of("method", "GET"));
-	}
-
-	@Test
-	void shouldNotPushAnyHighCardinalityKeyValue() {
-		assertThat(this.convention.getHighCardinalityKeyValues(this.context)).isEmpty();
-	}
-
-	static class OtherContext extends Observation.Context {
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientObservationConventionAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientObservationConventionAdapterTests.java
deleted file mode 100644
index cd73e29efba0..000000000000
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientObservationConventionAdapterTests.java
+++ /dev/null
@@ -1,100 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.autoconfigure.observation.web.client;
-
-import java.net.URI;
-
-import io.micrometer.common.KeyValue;
-import io.micrometer.observation.Observation;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.boot.actuate.metrics.web.reactive.client.DefaultWebClientExchangeTagsProvider;
-import org.springframework.http.HttpMethod;
-import org.springframework.http.HttpStatus;
-import org.springframework.web.reactive.function.client.ClientRequest;
-import org.springframework.web.reactive.function.client.ClientRequestObservationContext;
-import org.springframework.web.reactive.function.client.ClientResponse;
-import org.springframework.web.reactive.function.client.WebClient;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Tests for {@link ClientObservationConventionAdapter}.
- *
- * @author Brian Clozel
- */
-@SuppressWarnings({ "deprecation", "removal" })
-class ClientObservationConventionAdapterTests {
-
-	private static final String TEST_METRIC_NAME = "test.metric.name";
-
-	private final ClientObservationConventionAdapter convention = new ClientObservationConventionAdapter(
-			TEST_METRIC_NAME, new DefaultWebClientExchangeTagsProvider());
-
-	private final ClientRequest.Builder requestBuilder = ClientRequest
-		.create(HttpMethod.GET, URI.create("/resource/test"))
-		.attribute(WebClient.class.getName() + ".uriTemplate", "/resource/{name}");
-
-	private final ClientResponse response = ClientResponse.create(HttpStatus.OK).body("foo").build();
-
-	private ClientRequestObservationContext context;
-
-	@BeforeEach
-	void setup() {
-		this.context = new ClientRequestObservationContext();
-		this.context.setCarrier(this.requestBuilder);
-		this.context.setResponse(this.response);
-		this.context.setUriTemplate("/resource/{name}");
-	}
-
-	@Test
-	void shouldUseConfiguredName() {
-		assertThat(this.convention.getName()).isEqualTo(TEST_METRIC_NAME);
-	}
-
-	@Test
-	void shouldOnlySupportClientObservationContext() {
-		assertThat(this.convention.supportsContext(this.context)).isTrue();
-		assertThat(this.convention.supportsContext(new OtherContext())).isFalse();
-	}
-
-	@Test
-	void shouldPushTagsAsLowCardinalityKeyValues() {
-		this.context.setRequest(this.requestBuilder.build());
-		assertThat(this.convention.getLowCardinalityKeyValues(this.context)).contains(KeyValue.of("status", "200"),
-				KeyValue.of("outcome", "SUCCESS"), KeyValue.of("uri", "/resource/{name}"),
-				KeyValue.of("method", "GET"));
-	}
-
-	@Test
-	void doesNotFailWithEmptyRequest() {
-		assertThat(this.convention.getLowCardinalityKeyValues(this.context)).contains(KeyValue.of("status", "200"),
-				KeyValue.of("outcome", "SUCCESS"), KeyValue.of("uri", "/resource/{name}"),
-				KeyValue.of("method", "GET"));
-	}
-
-	@Test
-	void shouldNotPushAnyHighCardinalityKeyValue() {
-		assertThat(this.convention.getHighCardinalityKeyValues(this.context)).isEmpty();
-	}
-
-	static class OtherContext extends Observation.Context {
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestClientObservationConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestClientObservationConfigurationTests.java
new file mode 100644
index 000000000000..1400c4f6c027
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestClientObservationConfigurationTests.java
@@ -0,0 +1,175 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.observation.web.client;
+
+import io.micrometer.common.KeyValues;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.observation.ObservationRegistry;
+import io.micrometer.observation.tck.TestObservationRegistry;
+import io.micrometer.observation.tck.TestObservationRegistryAssert;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.boot.actuate.autoconfigure.metrics.test.MetricsRun;
+import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
+import org.springframework.boot.actuate.metrics.web.client.ObservationRestClientCustomizer;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
+import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+import org.springframework.boot.test.web.client.MockServerRestClientCustomizer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.client.observation.ClientRequestObservationContext;
+import org.springframework.http.client.observation.DefaultClientRequestObservationConvention;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.client.RestClient.Builder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
+
+/**
+ * Tests for {@link RestClientObservationConfiguration}.
+ *
+ * @author Brian Clozel
+ * @author Moritz Halbritter
+ */
+@ExtendWith(OutputCaptureExtension.class)
+class RestClientObservationConfigurationTests {
+
+	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+		.withBean(ObservationRegistry.class, TestObservationRegistry::create)
+		.withConfiguration(AutoConfigurations.of(ObservationAutoConfiguration.class, RestClientAutoConfiguration.class,
+				HttpClientObservationsAutoConfiguration.class));
+
+	@Test
+	void contributesCustomizerBean() {
+		this.contextRunner.run((context) -> assertThat(context).hasSingleBean(ObservationRestClientCustomizer.class));
+	}
+
+	@Test
+	void restClientCreatedWithBuilderIsInstrumented() {
+		this.contextRunner.run((context) -> {
+			RestClient restClient = buildRestClient(context);
+			restClient.get().uri("/projects/{project}", "spring-boot").retrieve().toBodilessEntity();
+			TestObservationRegistry registry = context.getBean(TestObservationRegistry.class);
+			TestObservationRegistryAssert.assertThat(registry)
+				.hasObservationWithNameEqualToIgnoringCase("http.client.requests");
+		});
+	}
+
+	@Test
+	void restClientCreatedWithBuilderUsesCustomConventionName() {
+		final String observationName = "test.metric.name";
+		this.contextRunner.withPropertyValues("management.observations.http.client.requests.name=" + observationName)
+			.run((context) -> {
+				RestClient restClient = buildRestClient(context);
+				restClient.get().uri("/projects/{project}", "spring-boot").retrieve().toBodilessEntity();
+				TestObservationRegistry registry = context.getBean(TestObservationRegistry.class);
+				TestObservationRegistryAssert.assertThat(registry)
+					.hasObservationWithNameEqualToIgnoringCase(observationName);
+			});
+	}
+
+	@Test
+	void restClientCreatedWithBuilderUsesCustomConvention() {
+		this.contextRunner.withUserConfiguration(CustomConvention.class).run((context) -> {
+			RestClient restClient = buildRestClient(context);
+			restClient.get().uri("/projects/{project}", "spring-boot").retrieve().toBodilessEntity();
+			TestObservationRegistry registry = context.getBean(TestObservationRegistry.class);
+			TestObservationRegistryAssert.assertThat(registry)
+				.hasObservationWithNameEqualTo("http.client.requests")
+				.that()
+				.hasLowCardinalityKeyValue("project", "spring-boot");
+		});
+	}
+
+	@Test
+	void afterMaxUrisReachedFurtherUrisAreDenied(CapturedOutput output) {
+		this.contextRunner.with(MetricsRun.simple())
+			.withPropertyValues("management.metrics.web.client.max-uri-tags=2")
+			.run((context) -> {
+				RestClientWithMockServer restClientWithMockServer = buildRestClientAndMockServer(context);
+				MockRestServiceServer server = restClientWithMockServer.mockServer();
+				RestClient restClient = restClientWithMockServer.restClient();
+				for (int i = 0; i < 3; i++) {
+					server.expect(requestTo("/test/" + i)).andRespond(withStatus(HttpStatus.OK));
+				}
+				for (int i = 0; i < 3; i++) {
+					restClient.get().uri("/test/" + i, String.class).retrieve().toBodilessEntity();
+				}
+				TestObservationRegistry registry = context.getBean(TestObservationRegistry.class);
+				TestObservationRegistryAssert.assertThat(registry)
+					.hasNumberOfObservationsWithNameEqualTo("http.client.requests", 3);
+				MeterRegistry meterRegistry = context.getBean(MeterRegistry.class);
+				assertThat(meterRegistry.find("http.client.requests").timers()).hasSize(2);
+				assertThat(output).contains("Reached the maximum number of URI tags for 'http.client.requests'.")
+					.contains("Are you using 'uriVariables'?");
+			});
+	}
+
+	@Test
+	void backsOffWhenRestClientBuilderIsMissing() {
+		new ApplicationContextRunner().with(MetricsRun.simple())
+			.withConfiguration(AutoConfigurations.of(ObservationAutoConfiguration.class,
+					HttpClientObservationsAutoConfiguration.class))
+			.run((context) -> assertThat(context).doesNotHaveBean(ObservationRestClientCustomizer.class));
+	}
+
+	private RestClient buildRestClient(AssertableApplicationContext context) {
+		RestClientWithMockServer restClientWithMockServer = buildRestClientAndMockServer(context);
+		restClientWithMockServer.mockServer()
+			.expect(requestTo("/projects/spring-boot"))
+			.andRespond(withStatus(HttpStatus.OK));
+		return restClientWithMockServer.restClient();
+	}
+
+	private RestClientWithMockServer buildRestClientAndMockServer(AssertableApplicationContext context) {
+		Builder builder = context.getBean(Builder.class);
+		MockServerRestClientCustomizer customizer = new MockServerRestClientCustomizer();
+		customizer.customize(builder);
+		return new RestClientWithMockServer(builder.build(), customizer.getServer());
+	}
+
+	private record RestClientWithMockServer(RestClient restClient, MockRestServiceServer mockServer) {
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	static class CustomConventionConfiguration {
+
+		@Bean
+		CustomConvention customConvention() {
+			return new CustomConvention();
+		}
+
+	}
+
+	static class CustomConvention extends DefaultClientRequestObservationConvention {
+
+		@Override
+		public KeyValues getLowCardinalityKeyValues(ClientRequestObservationContext context) {
+			return super.getLowCardinalityKeyValues(context).and("project", "spring-boot");
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestClientObservationConfigurationWithoutMetricsTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestClientObservationConfigurationWithoutMetricsTests.java
new file mode 100644
index 000000000000..3aa82c08c26b
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestClientObservationConfigurationWithoutMetricsTests.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.observation.web.client;
+
+import io.micrometer.observation.ObservationRegistry;
+import io.micrometer.observation.tck.TestObservationRegistry;
+import io.micrometer.observation.tck.TestObservationRegistryAssert;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
+import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+import org.springframework.boot.test.web.client.MockServerRestClientCustomizer;
+import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.client.RestClient.Builder;
+
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
+
+/**
+ * Tests for {@link RestClientObservationConfiguration} without Micrometer Metrics.
+ *
+ * @author Brian Clozel
+ * @author Andy Wilkinson
+ * @author Moritz Halbritter
+ */
+@ExtendWith(OutputCaptureExtension.class)
+@ClassPathExclusions("micrometer-core-*.jar")
+class RestClientObservationConfigurationWithoutMetricsTests {
+
+	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+		.withBean(ObservationRegistry.class, TestObservationRegistry::create)
+		.withConfiguration(AutoConfigurations.of(ObservationAutoConfiguration.class, RestClientAutoConfiguration.class,
+				HttpClientObservationsAutoConfiguration.class));
+
+	@Test
+	void restClientCreatedWithBuilderIsInstrumented() {
+		this.contextRunner.run((context) -> {
+			RestClient restClient = buildRestClient(context);
+			restClient.get().uri("/projects/{project}", "spring-boot").retrieve().toBodilessEntity();
+			TestObservationRegistry registry = context.getBean(TestObservationRegistry.class);
+			TestObservationRegistryAssert.assertThat(registry)
+				.hasObservationWithNameEqualToIgnoringCase("http.client.requests");
+		});
+	}
+
+	private RestClient buildRestClient(AssertableApplicationContext context) {
+		Builder builder = context.getBean(Builder.class);
+		MockServerRestClientCustomizer customizer = new MockServerRestClientCustomizer();
+		customizer.customize(builder);
+		customizer.getServer().expect(requestTo("/projects/spring-boot")).andRespond(withStatus(HttpStatus.OK));
+		return builder.build();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestTemplateObservationConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestTemplateObservationConfigurationTests.java
index 6116649a14dc..92b4367cad0c 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestTemplateObservationConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestTemplateObservationConfigurationTests.java
@@ -18,8 +18,6 @@
 
 import io.micrometer.common.KeyValues;
 import io.micrometer.core.instrument.MeterRegistry;
-import io.micrometer.core.instrument.Tag;
-import io.micrometer.core.instrument.Tags;
 import io.micrometer.observation.ObservationRegistry;
 import io.micrometer.observation.tck.TestObservationRegistry;
 import io.micrometer.observation.tck.TestObservationRegistryAssert;
@@ -28,9 +26,7 @@
 
 import org.springframework.boot.actuate.autoconfigure.metrics.test.MetricsRun;
 import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
-import org.springframework.boot.actuate.metrics.web.client.DefaultRestTemplateExchangeTagsProvider;
 import org.springframework.boot.actuate.metrics.web.client.ObservationRestTemplateCustomizer;
-import org.springframework.boot.actuate.metrics.web.client.RestTemplateExchangeTagsProvider;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration;
 import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
@@ -40,9 +36,7 @@
 import org.springframework.boot.web.client.RestTemplateBuilder;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
-import org.springframework.http.HttpRequest;
 import org.springframework.http.HttpStatus;
-import org.springframework.http.client.ClientHttpResponse;
 import org.springframework.http.client.observation.ClientRequestObservationContext;
 import org.springframework.http.client.observation.DefaultClientRequestObservationConvention;
 import org.springframework.test.web.client.MockRestServiceServer;
@@ -58,7 +52,6 @@
  * @author Brian Clozel
  */
 @ExtendWith(OutputCaptureExtension.class)
-@SuppressWarnings("removal")
 class RestTemplateObservationConfigurationTests {
 
 	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
@@ -68,8 +61,7 @@ class RestTemplateObservationConfigurationTests {
 
 	@Test
 	void contributesCustomizerBean() {
-		this.contextRunner.run((context) -> assertThat(context).hasSingleBean(ObservationRestTemplateCustomizer.class)
-			.doesNotHaveBean(DefaultRestTemplateExchangeTagsProvider.class));
+		this.contextRunner.run((context) -> assertThat(context).hasSingleBean(ObservationRestTemplateCustomizer.class));
 	}
 
 	@Test
@@ -96,32 +88,6 @@ void restTemplateCreatedWithBuilderUsesCustomConventionName() {
 			});
 	}
 
-	@Test
-	void restTemplateCreatedWithBuilderUsesCustomMetricName() {
-		final String metricName = "test.metric.name";
-		this.contextRunner.withPropertyValues("management.metrics.web.client.request.metric-name=" + metricName)
-			.run((context) -> {
-				RestTemplate restTemplate = buildRestTemplate(context);
-				restTemplate.getForEntity("/projects/{project}", Void.class, "spring-boot");
-				TestObservationRegistry registry = context.getBean(TestObservationRegistry.class);
-				TestObservationRegistryAssert.assertThat(registry)
-					.hasObservationWithNameEqualToIgnoringCase(metricName);
-			});
-	}
-
-	@Test
-	void restTemplateCreatedWithBuilderUsesCustomTagsProvider() {
-		this.contextRunner.withUserConfiguration(CustomTagsConfiguration.class).run((context) -> {
-			RestTemplate restTemplate = buildRestTemplate(context);
-			restTemplate.getForEntity("/projects/{project}", Void.class, "spring-boot");
-			TestObservationRegistry registry = context.getBean(TestObservationRegistry.class);
-			TestObservationRegistryAssert.assertThat(registry)
-				.hasObservationWithNameEqualTo("http.client.requests")
-				.that()
-				.hasLowCardinalityKeyValue("project", "spring-boot");
-		});
-	}
-
 	@Test
 	void restTemplateCreatedWithBuilderUsesCustomConvention() {
 		this.contextRunner.withUserConfiguration(CustomConvention.class).run((context) -> {
@@ -163,8 +129,7 @@ void backsOffWhenRestTemplateBuilderIsMissing() {
 		new ApplicationContextRunner().with(MetricsRun.simple())
 			.withConfiguration(AutoConfigurations.of(ObservationAutoConfiguration.class,
 					HttpClientObservationsAutoConfiguration.class))
-			.run((context) -> assertThat(context).doesNotHaveBean(DefaultRestTemplateExchangeTagsProvider.class)
-				.doesNotHaveBean(ObservationRestTemplateCustomizer.class));
+			.run((context) -> assertThat(context).doesNotHaveBean(ObservationRestTemplateCustomizer.class));
 	}
 
 	private RestTemplate buildRestTemplate(AssertableApplicationContext context) {
@@ -174,26 +139,6 @@ private RestTemplate buildRestTemplate(AssertableApplicationContext context) {
 		return restTemplate;
 	}
 
-	@Configuration(proxyBeanMethods = false)
-	static class CustomTagsConfiguration {
-
-		@Bean
-		CustomTagsProvider customTagsProvider() {
-			return new CustomTagsProvider();
-		}
-
-	}
-
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	static class CustomTagsProvider implements RestTemplateExchangeTagsProvider {
-
-		@Override
-		public Iterable<Tag> getTags(String urlTemplate, HttpRequest request, ClientHttpResponse response) {
-			return Tags.of("project", "spring-boot");
-		}
-
-	}
-
 	@Configuration(proxyBeanMethods = false)
 	static class CustomConventionConfiguration {
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/WebClientObservationConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/WebClientObservationConfigurationTests.java
index 9a94712edc78..8a917298b0e0 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/WebClientObservationConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/WebClientObservationConfigurationTests.java
@@ -29,9 +29,7 @@
 
 import org.springframework.boot.actuate.autoconfigure.metrics.test.MetricsRun;
 import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
-import org.springframework.boot.actuate.metrics.web.reactive.client.DefaultWebClientExchangeTagsProvider;
 import org.springframework.boot.actuate.metrics.web.reactive.client.ObservationWebClientCustomizer;
-import org.springframework.boot.actuate.metrics.web.reactive.client.WebClientExchangeTagsProvider;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration;
 import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
@@ -59,7 +57,6 @@
  * @author Stephane Nicoll
  */
 @ExtendWith(OutputCaptureExtension.class)
-@SuppressWarnings("removal")
 class WebClientObservationConfigurationTests {
 
 	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().with(MetricsRun.simple())
@@ -69,8 +66,7 @@ class WebClientObservationConfigurationTests {
 
 	@Test
 	void contributesCustomizerBean() {
-		this.contextRunner.run((context) -> assertThat(context).hasSingleBean(ObservationWebClientCustomizer.class)
-			.doesNotHaveBean(DefaultWebClientExchangeTagsProvider.class));
+		this.contextRunner.run((context) -> assertThat(context).hasSingleBean(ObservationWebClientCustomizer.class));
 	}
 
 	@Test
@@ -82,14 +78,6 @@ void webClientCreatedWithBuilderIsInstrumented() {
 		});
 	}
 
-	@Test
-	void shouldNotOverrideCustomTagsProvider() {
-		this.contextRunner.withUserConfiguration(CustomTagsProviderConfig.class)
-			.run((context) -> assertThat(context).getBeans(WebClientExchangeTagsProvider.class)
-				.hasSize(1)
-				.containsKey("customTagsProvider"));
-	}
-
 	@Test
 	void shouldUseCustomConventionIfAvailable() {
 		this.contextRunner.withUserConfiguration(CustomConvention.class).run((context) -> {
@@ -170,16 +158,6 @@ private WebClient mockWebClient(WebClient.Builder builder) {
 		return builder.clientConnector(connector).build();
 	}
 
-	@Configuration(proxyBeanMethods = false)
-	static class CustomTagsProviderConfig {
-
-		@Bean
-		WebClientExchangeTagsProvider customTagsProvider() {
-			return mock(WebClientExchangeTagsProvider.class);
-		}
-
-	}
-
 	@Configuration(proxyBeanMethods = false)
 	static class CustomConventionConfig {
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/ServerRequestObservationConventionAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/ServerRequestObservationConventionAdapterTests.java
deleted file mode 100644
index 9d32817dc80c..000000000000
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/ServerRequestObservationConventionAdapterTests.java
+++ /dev/null
@@ -1,64 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.autoconfigure.observation.web.reactive;
-
-import java.util.Map;
-
-import io.micrometer.common.KeyValue;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.boot.actuate.metrics.web.reactive.server.DefaultWebFluxTagsProvider;
-import org.springframework.http.server.reactive.observation.ServerRequestObservationContext;
-import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
-import org.springframework.mock.http.server.reactive.MockServerHttpResponse;
-import org.springframework.web.reactive.HandlerMapping;
-import org.springframework.web.util.pattern.PathPatternParser;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Tests for {@link ServerRequestObservationConventionAdapter}.
- *
- * @author Brian Clozel
- */
-@SuppressWarnings("removal")
-@Deprecated(since = "3.0.0", forRemoval = true)
-class ServerRequestObservationConventionAdapterTests {
-
-	private static final String TEST_METRIC_NAME = "test.metric.name";
-
-	private final ServerRequestObservationConventionAdapter convention = new ServerRequestObservationConventionAdapter(
-			TEST_METRIC_NAME, new DefaultWebFluxTagsProvider());
-
-	@Test
-	void shouldUseConfiguredName() {
-		assertThat(this.convention.getName()).isEqualTo(TEST_METRIC_NAME);
-	}
-
-	@Test
-	void shouldPushTagsAsLowCardinalityKeyValues() {
-		MockServerHttpRequest request = MockServerHttpRequest.get("/resource/test").build();
-		MockServerHttpResponse response = new MockServerHttpResponse();
-		ServerRequestObservationContext context = new ServerRequestObservationContext(request, response,
-				Map.of(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE,
-						PathPatternParser.defaultInstance.parse("/resource/{name}")));
-		assertThat(this.convention.getLowCardinalityKeyValues(context)).contains(KeyValue.of("status", "200"),
-				KeyValue.of("outcome", "SUCCESS"), KeyValue.of("uri", "/resource/{name}"),
-				KeyValue.of("method", "GET"));
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/WebFluxObservationAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/WebFluxObservationAutoConfigurationTests.java
index 88609e23f686..b5e05e99ea0a 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/WebFluxObservationAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/WebFluxObservationAutoConfigurationTests.java
@@ -16,38 +16,23 @@
 
 package org.springframework.boot.actuate.autoconfigure.observation.web.reactive;
 
-import java.util.List;
+import java.time.Duration;
+import java.time.temporal.ChronoUnit;
 
 import io.micrometer.core.instrument.MeterRegistry;
-import io.micrometer.core.instrument.Tag;
-import io.micrometer.core.instrument.Tags;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
-import reactor.core.publisher.Mono;
 
 import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration;
 import org.springframework.boot.actuate.autoconfigure.metrics.test.MetricsRun;
 import org.springframework.boot.actuate.autoconfigure.metrics.web.TestController;
 import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
-import org.springframework.boot.actuate.metrics.web.reactive.server.DefaultWebFluxTagsProvider;
-import org.springframework.boot.actuate.metrics.web.reactive.server.WebFluxTagsContributor;
-import org.springframework.boot.actuate.metrics.web.reactive.server.WebFluxTagsProvider;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration;
 import org.springframework.boot.test.context.assertj.AssertableReactiveWebApplicationContext;
 import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
 import org.springframework.boot.test.system.CapturedOutput;
 import org.springframework.boot.test.system.OutputCaptureExtension;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.core.Ordered;
-import org.springframework.core.annotation.Order;
-import org.springframework.http.server.reactive.observation.DefaultServerRequestObservationConvention;
-import org.springframework.test.web.reactive.server.WebTestClient;
-import org.springframework.web.filter.reactive.ServerHttpObservationFilter;
-import org.springframework.web.server.ServerWebExchange;
-import org.springframework.web.server.WebFilter;
-import org.springframework.web.server.WebFilterChain;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -59,7 +44,6 @@
  * @author Madhura Bhave
  */
 @ExtendWith(OutputCaptureExtension.class)
-@SuppressWarnings("removal")
 class WebFluxObservationAutoConfigurationTests {
 
 	private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner()
@@ -67,53 +51,6 @@ class WebFluxObservationAutoConfigurationTests {
 		.withConfiguration(
 				AutoConfigurations.of(ObservationAutoConfiguration.class, WebFluxObservationAutoConfiguration.class));
 
-	@Test
-	void shouldProvideWebFluxObservationFilter() {
-		this.contextRunner.run((context) -> assertThat(context).hasSingleBean(ServerHttpObservationFilter.class));
-	}
-
-	@Test
-	void shouldProvideWebFluxObservationFilterOrdered() {
-		this.contextRunner.withBean(FirstWebFilter.class).withBean(ThirdWebFilter.class).run((context) -> {
-			List<WebFilter> webFilters = context.getBeanProvider(WebFilter.class).orderedStream().toList();
-			assertThat(webFilters.get(0)).isInstanceOf(FirstWebFilter.class);
-			assertThat(webFilters.get(1)).isInstanceOf(ServerHttpObservationFilter.class);
-			assertThat(webFilters.get(2)).isInstanceOf(ThirdWebFilter.class);
-		});
-	}
-
-	@Test
-	void shouldUseConventionAdapterWhenCustomTagsProvider() {
-		this.contextRunner.withUserConfiguration(CustomTagsProviderConfiguration.class).run((context) -> {
-			assertThat(context).hasSingleBean(ServerHttpObservationFilter.class);
-			assertThat(context).hasSingleBean(WebFluxTagsProvider.class);
-			assertThat(context).getBean(ServerHttpObservationFilter.class)
-				.extracting("observationConvention")
-				.isInstanceOf(ServerRequestObservationConventionAdapter.class);
-		});
-	}
-
-	@Test
-	void shouldUseConventionAdapterWhenCustomTagsContributor() {
-		this.contextRunner.withUserConfiguration(CustomTagsContributorConfiguration.class).run((context) -> {
-			assertThat(context).hasSingleBean(ServerHttpObservationFilter.class);
-			assertThat(context).hasSingleBean(WebFluxTagsContributor.class);
-			assertThat(context).getBean(ServerHttpObservationFilter.class)
-				.extracting("observationConvention")
-				.isInstanceOf(ServerRequestObservationConventionAdapter.class);
-		});
-	}
-
-	@Test
-	void shouldUseCustomConventionWhenAvailable() {
-		this.contextRunner.withUserConfiguration(CustomConventionConfiguration.class).run((context) -> {
-			assertThat(context).hasSingleBean(ServerHttpObservationFilter.class);
-			assertThat(context).getBean(ServerHttpObservationFilter.class)
-				.extracting("observationConvention")
-				.isInstanceOf(CustomConvention.class);
-		});
-	}
-
 	@Test
 	void afterMaxUrisReachedFurtherUrisAreDenied(CapturedOutput output) {
 		this.contextRunner.withUserConfiguration(TestController.class)
@@ -127,21 +64,6 @@ void afterMaxUrisReachedFurtherUrisAreDenied(CapturedOutput output) {
 			});
 	}
 
-	@Test
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	void afterMaxUrisReachedFurtherUrisAreDeniedWhenUsingCustomMetricName(CapturedOutput output) {
-		this.contextRunner.withUserConfiguration(TestController.class)
-			.withConfiguration(AutoConfigurations.of(MetricsAutoConfiguration.class, ObservationAutoConfiguration.class,
-					WebFluxAutoConfiguration.class))
-			.withPropertyValues("management.metrics.web.server.max-uri-tags=2",
-					"management.metrics.web.server.request.metric-name=my.http.server.requests")
-			.run((context) -> {
-				MeterRegistry registry = getInitializedMeterRegistry(context);
-				assertThat(registry.get("my.http.server.requests").meters()).hasSizeLessThanOrEqualTo(2);
-				assertThat(output).contains("Reached the maximum number of URI tags for 'my.http.server.requests'");
-			});
-	}
-
 	@Test
 	void afterMaxUrisReachedFurtherUrisAreDeniedWhenUsingCustomObservationName(CapturedOutput output) {
 		this.contextRunner.withUserConfiguration(TestController.class)
@@ -150,7 +72,7 @@ void afterMaxUrisReachedFurtherUrisAreDeniedWhenUsingCustomObservationName(Captu
 			.withPropertyValues("management.metrics.web.server.max-uri-tags=2",
 					"management.observations.http.server.requests.name=my.http.server.requests")
 			.run((context) -> {
-				MeterRegistry registry = getInitializedMeterRegistry(context);
+				MeterRegistry registry = getInitializedMeterRegistry(context, "my.http.server.requests");
 				assertThat(registry.get("my.http.server.requests").meters()).hasSizeLessThanOrEqualTo(2);
 				assertThat(output).contains("Reached the maximum number of URI tags for 'my.http.server.requests'");
 			});
@@ -169,84 +91,17 @@ void shouldNotDenyNorLogIfMaxUrisIsNotReached(CapturedOutput output) {
 			});
 	}
 
-	private MeterRegistry getInitializedMeterRegistry(AssertableReactiveWebApplicationContext context)
-			throws Exception {
-		return getInitializedMeterRegistry(context, "/test0", "/test1", "/test2");
+	private MeterRegistry getInitializedMeterRegistry(AssertableReactiveWebApplicationContext context) {
+		return getInitializedMeterRegistry(context, "http.server.requests");
 	}
 
-	private MeterRegistry getInitializedMeterRegistry(AssertableReactiveWebApplicationContext context, String... urls)
-			throws Exception {
-		assertThat(context).hasSingleBean(ServerHttpObservationFilter.class);
-		WebTestClient client = WebTestClient.bindToApplicationContext(context).build();
-		for (String url : urls) {
-			client.get().uri(url).exchange().expectStatus().isOk();
-		}
-		return context.getBean(MeterRegistry.class);
-	}
-
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	@Configuration(proxyBeanMethods = false)
-	static class CustomTagsProviderConfiguration {
-
-		@Bean
-		WebFluxTagsProvider tagsProvider() {
-			return new DefaultWebFluxTagsProvider();
-		}
-
-	}
-
-	@Configuration(proxyBeanMethods = false)
-	static class CustomTagsContributorConfiguration {
-
-		@Bean
-		WebFluxTagsContributor tagsContributor() {
-			return new CustomTagsContributor();
-		}
-
-	}
-
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	static class CustomTagsContributor implements WebFluxTagsContributor {
-
-		@Override
-		public Iterable<Tag> httpRequestTags(ServerWebExchange exchange, Throwable ex) {
-			return Tags.of("custom", "testvalue");
-		}
-
-	}
-
-	@Configuration(proxyBeanMethods = false)
-	static class CustomConventionConfiguration {
-
-		@Bean
-		CustomConvention customConvention() {
-			return new CustomConvention();
-		}
-
-	}
-
-	static class CustomConvention extends DefaultServerRequestObservationConvention {
-
-	}
-
-	@Order(Ordered.HIGHEST_PRECEDENCE)
-	static class FirstWebFilter implements WebFilter {
-
-		@Override
-		public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
-			return chain.filter(exchange);
-		}
-
-	}
-
-	@Order(Ordered.HIGHEST_PRECEDENCE + 2)
-	static class ThirdWebFilter implements WebFilter {
-
-		@Override
-		public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
-			return chain.filter(exchange);
-		}
-
+	private MeterRegistry getInitializedMeterRegistry(AssertableReactiveWebApplicationContext context,
+			String metricName) {
+		MeterRegistry meterRegistry = context.getBean(MeterRegistry.class);
+		meterRegistry.timer(metricName, "uri", "/test0").record(Duration.of(500, ChronoUnit.SECONDS));
+		meterRegistry.timer(metricName, "uri", "/test1").record(Duration.of(500, ChronoUnit.SECONDS));
+		meterRegistry.timer(metricName, "uri", "/test2").record(Duration.of(500, ChronoUnit.SECONDS));
+		return meterRegistry;
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/ServerRequestObservationConventionAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/ServerRequestObservationConventionAdapterTests.java
deleted file mode 100644
index 9705829af336..000000000000
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/ServerRequestObservationConventionAdapterTests.java
+++ /dev/null
@@ -1,111 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.autoconfigure.observation.web.servlet;
-
-import java.util.Collections;
-import java.util.List;
-
-import io.micrometer.common.KeyValue;
-import io.micrometer.core.instrument.Tag;
-import io.micrometer.core.instrument.Tags;
-import io.micrometer.observation.Observation;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.boot.actuate.metrics.web.servlet.DefaultWebMvcTagsProvider;
-import org.springframework.boot.actuate.metrics.web.servlet.WebMvcTagsContributor;
-import org.springframework.http.server.observation.ServerRequestObservationContext;
-import org.springframework.mock.web.MockHttpServletRequest;
-import org.springframework.mock.web.MockHttpServletResponse;
-import org.springframework.web.servlet.HandlerMapping;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Tests for {@link ServerRequestObservationConventionAdapter}
- *
- * @author Brian Clozel
- */
-@SuppressWarnings("removal")
-@Deprecated(since = "3.0.0", forRemoval = true)
-class ServerRequestObservationConventionAdapterTests {
-
-	private static final String TEST_METRIC_NAME = "test.metric.name";
-
-	private final ServerRequestObservationConventionAdapter convention = new ServerRequestObservationConventionAdapter(
-			TEST_METRIC_NAME, new DefaultWebMvcTagsProvider(), Collections.emptyList());
-
-	private final MockHttpServletRequest request = new MockHttpServletRequest("GET", "/resource/test");
-
-	private final MockHttpServletResponse response = new MockHttpServletResponse();
-
-	private final ServerRequestObservationContext context = new ServerRequestObservationContext(this.request,
-			this.response);
-
-	@Test
-	void customNameIsUsed() {
-		assertThat(this.convention.getName()).isEqualTo(TEST_METRIC_NAME);
-	}
-
-	@Test
-	void onlySupportServerRequestObservationContext() {
-		assertThat(this.convention.supportsContext(this.context)).isTrue();
-		assertThat(this.convention.supportsContext(new OtherContext())).isFalse();
-	}
-
-	@Test
-	void pushTagsAsLowCardinalityKeyValues() {
-		this.request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/resource/{name}");
-		this.context.setPathPattern("/resource/{name}");
-		assertThat(this.convention.getLowCardinalityKeyValues(this.context)).contains(KeyValue.of("status", "200"),
-				KeyValue.of("outcome", "SUCCESS"), KeyValue.of("uri", "/resource/{name}"),
-				KeyValue.of("method", "GET"));
-	}
-
-	@Test
-	void doesNotPushAnyHighCardinalityKeyValue() {
-		assertThat(this.convention.getHighCardinalityKeyValues(this.context)).isEmpty();
-	}
-
-	@Test
-	void pushTagsFromContributors() {
-		ServerRequestObservationConventionAdapter convention = new ServerRequestObservationConventionAdapter(
-				TEST_METRIC_NAME, null, List.of(new CustomWebMvcContributor()));
-		assertThat(convention.getLowCardinalityKeyValues(this.context)).contains(KeyValue.of("custom", "value"));
-	}
-
-	static class OtherContext extends Observation.Context {
-
-	}
-
-	static class CustomWebMvcContributor implements WebMvcTagsContributor {
-
-		@Override
-		public Iterable<Tag> getTags(HttpServletRequest request, HttpServletResponse response, Object handler,
-				Throwable exception) {
-			return Tags.of("custom", "value");
-		}
-
-		@Override
-		public Iterable<Tag> getLongRequestTags(HttpServletRequest request, Object handler) {
-			return Collections.emptyList();
-		}
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/WebMvcObservationAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/WebMvcObservationAutoConfigurationTests.java
index a8fe9bf9c9f1..3fd1a2b61bef 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/WebMvcObservationAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/WebMvcObservationAutoConfigurationTests.java
@@ -16,16 +16,12 @@
 
 package org.springframework.boot.actuate.autoconfigure.observation.web.servlet;
 
-import java.util.Collections;
 import java.util.EnumSet;
 
 import io.micrometer.core.instrument.MeterRegistry;
-import io.micrometer.core.instrument.Tag;
 import io.micrometer.observation.tck.TestObservationRegistry;
 import jakarta.servlet.DispatcherType;
 import jakarta.servlet.Filter;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 
@@ -33,9 +29,6 @@
 import org.springframework.boot.actuate.autoconfigure.metrics.test.MetricsRun;
 import org.springframework.boot.actuate.autoconfigure.metrics.web.TestController;
 import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
-import org.springframework.boot.actuate.metrics.web.servlet.DefaultWebMvcTagsProvider;
-import org.springframework.boot.actuate.metrics.web.servlet.WebMvcTagsContributor;
-import org.springframework.boot.actuate.metrics.web.servlet.WebMvcTagsProvider;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
 import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext;
@@ -66,7 +59,6 @@
  * @author Chanhyeong LEE
  */
 @ExtendWith(OutputCaptureExtension.class)
-@SuppressWarnings("removal")
 class WebMvcObservationAutoConfigurationTests {
 
 	private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner()
@@ -84,21 +76,12 @@ void backsOffWhenMeterRegistryIsMissing() {
 	@Test
 	void definesFilterWhenRegistryIsPresent() {
 		this.contextRunner.run((context) -> {
-			assertThat(context).doesNotHaveBean(DefaultWebMvcTagsProvider.class);
 			assertThat(context).hasSingleBean(FilterRegistrationBean.class);
 			assertThat(context.getBean(FilterRegistrationBean.class).getFilter())
 				.isInstanceOf(ServerHttpObservationFilter.class);
 		});
 	}
 
-	@Test
-	void adapterConventionWhenTagsProviderPresent() {
-		this.contextRunner.withUserConfiguration(TagsProviderConfiguration.class)
-			.run((context) -> assertThat(context.getBean(FilterRegistrationBean.class).getFilter())
-				.extracting("observationConvention")
-				.isInstanceOf(ServerRequestObservationConventionAdapter.class));
-	}
-
 	@Test
 	void customConventionWhenPresent() {
 		this.contextRunner.withUserConfiguration(CustomConventionConfiguration.class)
@@ -159,21 +142,6 @@ void afterMaxUrisReachedFurtherUrisAreDenied(CapturedOutput output) {
 			});
 	}
 
-	@Test
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	void afterMaxUrisReachedFurtherUrisAreDeniedWhenUsingCustomMetricName(CapturedOutput output) {
-		this.contextRunner.withUserConfiguration(TestController.class)
-			.withConfiguration(AutoConfigurations.of(MetricsAutoConfiguration.class, ObservationAutoConfiguration.class,
-					WebMvcAutoConfiguration.class))
-			.withPropertyValues("management.metrics.web.server.max-uri-tags=2",
-					"management.metrics.web.server.request.metric-name=my.http.server.requests")
-			.run((context) -> {
-				MeterRegistry registry = getInitializedMeterRegistry(context);
-				assertThat(registry.get("my.http.server.requests").meters()).hasSizeLessThanOrEqualTo(2);
-				assertThat(output).contains("Reached the maximum number of URI tags for 'my.http.server.requests'");
-			});
-	}
-
 	@Test
 	void afterMaxUrisReachedFurtherUrisAreDeniedWhenUsingCustomObservationName(CapturedOutput output) {
 		this.contextRunner.withUserConfiguration(TestController.class)
@@ -201,14 +169,6 @@ void shouldNotDenyNorLogIfMaxUrisIsNotReached(CapturedOutput output) {
 			});
 	}
 
-	@Test
-	void whenTagContributorsAreDefinedThenTagsProviderUsesThem() {
-		this.contextRunner.withUserConfiguration(TagsContributorsConfiguration.class)
-			.run((context) -> assertThat(context.getBean(FilterRegistrationBean.class).getFilter())
-				.extracting("observationConvention")
-				.isInstanceOf(ServerRequestObservationConventionAdapter.class));
-	}
-
 	private MeterRegistry getInitializedMeterRegistry(AssertableWebApplicationContext context) throws Exception {
 		return getInitializedMeterRegistry(context, "/test0", "/test1", "/test2");
 	}
@@ -225,47 +185,6 @@ private MeterRegistry getInitializedMeterRegistry(AssertableWebApplicationContex
 		return context.getBean(MeterRegistry.class);
 	}
 
-	@Configuration(proxyBeanMethods = false)
-	static class TagsProviderConfiguration {
-
-		@Bean
-		TestWebMvcTagsProvider tagsProvider() {
-			return new TestWebMvcTagsProvider();
-		}
-
-	}
-
-	@Configuration(proxyBeanMethods = false)
-	static class TagsContributorsConfiguration {
-
-		@Bean
-		WebMvcTagsContributor tagContributorOne() {
-			return mock(WebMvcTagsContributor.class);
-		}
-
-		@Bean
-		WebMvcTagsContributor tagContributorTwo() {
-			return mock(WebMvcTagsContributor.class);
-		}
-
-	}
-
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	private static final class TestWebMvcTagsProvider implements WebMvcTagsProvider {
-
-		@Override
-		public Iterable<Tag> getTags(HttpServletRequest request, HttpServletResponse response, Object handler,
-				Throwable exception) {
-			return Collections.emptyList();
-		}
-
-		@Override
-		public Iterable<Tag> getLongRequestTags(HttpServletRequest request, Object handler) {
-			return Collections.emptyList();
-		}
-
-	}
-
 	@Configuration(proxyBeanMethods = false)
 	static class TestServerHttpObservationFilterRegistrationConfiguration {
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryAutoConfigurationTests.java
new file mode 100644
index 000000000000..167be648bf18
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryAutoConfigurationTests.java
@@ -0,0 +1,157 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.opentelemetry;
+
+import io.opentelemetry.api.OpenTelemetry;
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.context.propagation.ContextPropagators;
+import io.opentelemetry.sdk.OpenTelemetrySdk;
+import io.opentelemetry.sdk.logs.SdkLoggerProvider;
+import io.opentelemetry.sdk.metrics.SdkMeterProvider;
+import io.opentelemetry.sdk.resources.Resource;
+import io.opentelemetry.sdk.trace.SdkTracerProvider;
+import io.opentelemetry.semconv.ResourceAttributes;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.context.annotation.ImportCandidates;
+import org.springframework.boot.test.context.FilteredClassLoader;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.entry;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link OpenTelemetryAutoConfiguration}.
+ *
+ * @author Moritz Halbritter
+ */
+class OpenTelemetryAutoConfigurationTests {
+
+	private final ApplicationContextRunner runner = new ApplicationContextRunner()
+		.withConfiguration(AutoConfigurations.of(OpenTelemetryAutoConfiguration.class));
+
+	@Test
+	void isRegisteredInAutoConfigurationImports() {
+		assertThat(ImportCandidates.load(AutoConfiguration.class, null).getCandidates())
+			.contains(OpenTelemetryAutoConfiguration.class.getName());
+	}
+
+	@Test
+	void shouldProvideBeans() {
+		this.runner.run((context) -> {
+			assertThat(context).hasSingleBean(OpenTelemetrySdk.class);
+			assertThat(context).hasSingleBean(Resource.class);
+		});
+	}
+
+	@Test
+	void shouldBackOffIfOpenTelemetryIsNotOnClasspath() {
+		this.runner.withClassLoader(new FilteredClassLoader("io.opentelemetry")).run((context) -> {
+			assertThat(context).doesNotHaveBean(OpenTelemetrySdk.class);
+			assertThat(context).doesNotHaveBean(Resource.class);
+		});
+	}
+
+	@Test
+	void backsOffOnUserSuppliedBeans() {
+		this.runner.withUserConfiguration(UserConfiguration.class).run((context) -> {
+			assertThat(context).hasSingleBean(OpenTelemetry.class);
+			assertThat(context).hasBean("customOpenTelemetry");
+			assertThat(context).hasSingleBean(Resource.class);
+			assertThat(context).hasBean("customResource");
+		});
+	}
+
+	@Test
+	void shouldApplySpringApplicationNameToResource() {
+		this.runner.withPropertyValues("spring.application.name=my-application").run((context) -> {
+			Resource resource = context.getBean(Resource.class);
+			assertThat(resource.getAttributes().asMap())
+				.contains(entry(ResourceAttributes.SERVICE_NAME, "my-application"));
+		});
+	}
+
+	@Test
+	void shouldFallbackToDefaultApplicationNameIfSpringApplicationNameIsNotSet() {
+		this.runner.run((context) -> {
+			Resource resource = context.getBean(Resource.class);
+			assertThat(resource.getAttributes().asMap())
+				.contains(entry(ResourceAttributes.SERVICE_NAME, "application"));
+		});
+	}
+
+	@Test
+	void shouldApplyResourceAttributesFromProperties() {
+		this.runner.withPropertyValues("management.opentelemetry.resource-attributes.region=us-west").run((context) -> {
+			Resource resource = context.getBean(Resource.class);
+			assertThat(resource.getAttributes().asMap()).contains(entry(AttributeKey.stringKey("region"), "us-west"));
+		});
+	}
+
+	@Test
+	void shouldRegisterSdkTracerProviderIfAvailable() {
+		this.runner.withBean(SdkTracerProvider.class, () -> SdkTracerProvider.builder().build()).run((context) -> {
+			OpenTelemetry openTelemetry = context.getBean(OpenTelemetry.class);
+			assertThat(openTelemetry.getTracerProvider()).isNotNull();
+		});
+	}
+
+	@Test
+	void shouldRegisterContextPropagatorsIfAvailable() {
+		this.runner.withBean(ContextPropagators.class, ContextPropagators::noop).run((context) -> {
+			OpenTelemetry openTelemetry = context.getBean(OpenTelemetry.class);
+			assertThat(openTelemetry.getPropagators()).isNotNull();
+		});
+	}
+
+	@Test
+	void shouldRegisterSdkLoggerProviderIfAvailable() {
+		this.runner.withBean(SdkLoggerProvider.class, () -> SdkLoggerProvider.builder().build()).run((context) -> {
+			OpenTelemetry openTelemetry = context.getBean(OpenTelemetry.class);
+			assertThat(openTelemetry.getLogsBridge()).isNotNull();
+		});
+	}
+
+	@Test
+	void shouldRegisterSdkMeterProviderIfAvailable() {
+		this.runner.withBean(SdkMeterProvider.class, () -> SdkMeterProvider.builder().build()).run((context) -> {
+			OpenTelemetry openTelemetry = context.getBean(OpenTelemetry.class);
+			assertThat(openTelemetry.getMeterProvider()).isNotNull();
+		});
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	private static class UserConfiguration {
+
+		@Bean
+		OpenTelemetry customOpenTelemetry() {
+			return mock(OpenTelemetry.class);
+		}
+
+		@Bean
+		Resource customResource() {
+			return Resource.getDefault();
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryPropertiesTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryPropertiesTests.java
new file mode 100644
index 000000000000..fd754322f303
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryPropertiesTests.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.opentelemetry;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
+import org.springframework.context.annotation.Configuration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.entry;
+
+/**
+ * Tests for {@link OpenTelemetryProperties}.
+ *
+ * @author Moritz Halbritter
+ */
+class OpenTelemetryPropertiesTests {
+
+	private final ApplicationContextRunner runner = new ApplicationContextRunner().withPropertyValues(
+			"management.opentelemetry.resource-attributes.a=alpha",
+			"management.opentelemetry.resource-attributes.b=beta");
+
+	@Test
+	@ClassPathExclusions("opentelemetry-sdk-*")
+	void shouldNotDependOnOpenTelemetrySdk() {
+		this.runner.withUserConfiguration(TestConfiguration.class).run((context) -> {
+			OpenTelemetryProperties properties = context.getBean(OpenTelemetryProperties.class);
+			assertThat(properties.getResourceAttributes()).containsOnly(entry("a", "alpha"), entry("b", "beta"));
+		});
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	@EnableConfigurationProperties(OpenTelemetryProperties.class)
+	private static class TestConfiguration {
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/r2dbc/R2dbcObservationAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/r2dbc/R2dbcObservationAutoConfigurationTests.java
new file mode 100644
index 000000000000..7d3615dc2f94
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/r2dbc/R2dbcObservationAutoConfigurationTests.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.r2dbc;
+
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicReference;
+
+import io.micrometer.observation.Observation.Context;
+import io.micrometer.observation.ObservationHandler;
+import io.micrometer.observation.ObservationRegistry;
+import io.r2dbc.spi.ConnectionFactory;
+import org.awaitility.Awaitility;
+import org.hamcrest.Matchers;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.context.annotation.ImportCandidates;
+import org.springframework.boot.r2dbc.ConnectionFactoryBuilder;
+import org.springframework.boot.r2dbc.ConnectionFactoryDecorator;
+import org.springframework.boot.test.context.FilteredClassLoader;
+import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link R2dbcObservationAutoConfiguration}.
+ *
+ * @author Moritz Halbritter
+ */
+class R2dbcObservationAutoConfigurationTests {
+
+	private final ApplicationContextRunner runnerWithoutObservationRegistry = new ApplicationContextRunner()
+		.withConfiguration(AutoConfigurations.of(R2dbcObservationAutoConfiguration.class));
+
+	private final ApplicationContextRunner runner = this.runnerWithoutObservationRegistry
+		.withBean(ObservationRegistry.class, ObservationRegistry::create);
+
+	@Test
+	void shouldBeRegisteredInAutoConfigurationImports() {
+		assertThat(ImportCandidates.load(AutoConfiguration.class, null).getCandidates())
+			.contains(R2dbcObservationAutoConfiguration.class.getName());
+	}
+
+	@Test
+	void shouldSupplyConnectionFactoryDecorator() {
+		this.runner.run((context) -> assertThat(context).hasSingleBean(ConnectionFactoryDecorator.class));
+	}
+
+	@Test
+	void shouldNotSupplyBeansIfR2dbcSpiIsNotOnClasspath() {
+		this.runner.withClassLoader(new FilteredClassLoader("io.r2dbc.spi"))
+			.run((context) -> assertThat(context).doesNotHaveBean(ConnectionFactoryDecorator.class));
+	}
+
+	@Test
+	void shouldNotSupplyBeansIfR2dbcProxyIsNotOnClasspath() {
+		this.runner.withClassLoader(new FilteredClassLoader("io.r2dbc.proxy"))
+			.run((context) -> assertThat(context).doesNotHaveBean(ConnectionFactoryDecorator.class));
+	}
+
+	@Test
+	void shouldNotSupplyBeansIfObservationRegistryIsNotPresent() {
+		this.runnerWithoutObservationRegistry
+			.run((context) -> assertThat(context).doesNotHaveBean(ConnectionFactoryDecorator.class));
+	}
+
+	@Test
+	void decoratorShouldReportObservations() {
+		this.runner.run((context) -> {
+			CapturingObservationHandler handler = registerCapturingObservationHandler(context);
+			ConnectionFactoryDecorator decorator = context.getBean(ConnectionFactoryDecorator.class);
+			assertThat(decorator).isNotNull();
+			ConnectionFactory connectionFactory = ConnectionFactoryBuilder
+				.withUrl("r2dbc:h2:mem:///" + UUID.randomUUID())
+				.build();
+			ConnectionFactory decorated = decorator.decorate(connectionFactory);
+			Mono.from(decorated.create())
+				.flatMap((c) -> Mono.from(c.createStatement("SELECT 1;").execute())
+					.flatMap((ignore) -> Mono.from(c.close())))
+				.block();
+			assertThat(handler.awaitContext().getName()).as("context.getName()").isEqualTo("r2dbc.query");
+		});
+	}
+
+	private static CapturingObservationHandler registerCapturingObservationHandler(
+			AssertableApplicationContext context) {
+		ObservationRegistry observationRegistry = context.getBean(ObservationRegistry.class);
+		assertThat(observationRegistry).isNotNull();
+		CapturingObservationHandler handler = new CapturingObservationHandler();
+		observationRegistry.observationConfig().observationHandler(handler);
+		return handler;
+	}
+
+	private static class CapturingObservationHandler implements ObservationHandler<Context> {
+
+		private final AtomicReference<Context> context = new AtomicReference<>();
+
+		@Override
+		public boolean supportsContext(Context context) {
+			return true;
+		}
+
+		@Override
+		public void onStart(Context context) {
+			this.context.set(context);
+		}
+
+		Context awaitContext() {
+			return Awaitility.await().untilAtomic(this.context, Matchers.notNullValue());
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/scheduling/ScheduledTasksObservabilityAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/scheduling/ScheduledTasksObservabilityAutoConfigurationTests.java
new file mode 100644
index 000000000000..60ef5d14c5cc
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/scheduling/ScheduledTasksObservabilityAutoConfigurationTests.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.scheduling;
+
+import java.util.List;
+
+import io.micrometer.observation.ObservationRegistry;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
+import org.springframework.boot.actuate.autoconfigure.scheduling.ScheduledTasksObservabilityAutoConfiguration.ObservabilitySchedulingConfigurer;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.context.annotation.ImportCandidates;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.scheduling.config.ScheduledTaskRegistrar;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ScheduledTasksObservabilityAutoConfiguration}.
+ *
+ * @author Moritz Halbritter
+ */
+class ScheduledTasksObservabilityAutoConfigurationTests {
+
+	private final ApplicationContextRunner runner = new ApplicationContextRunner().withConfiguration(AutoConfigurations
+		.of(ObservationAutoConfiguration.class, ScheduledTasksObservabilityAutoConfiguration.class));
+
+	@Test
+	void shouldProvideObservabilitySchedulingConfigurer() {
+		this.runner.run((context) -> assertThat(context).hasSingleBean(ObservabilitySchedulingConfigurer.class));
+	}
+
+	@Test
+	void observabilitySchedulingConfigurerShouldConfigureObservationRegistry() {
+		ObservationRegistry observationRegistry = ObservationRegistry.create();
+		ObservabilitySchedulingConfigurer configurer = new ObservabilitySchedulingConfigurer(observationRegistry);
+		ScheduledTaskRegistrar registrar = new ScheduledTaskRegistrar();
+		configurer.configureTasks(registrar);
+		assertThat(registrar.getObservationRegistry()).isEqualTo(observationRegistry);
+	}
+
+	@Test
+	void isRegisteredInAutoConfigurationsFile() {
+		List<String> configurations = ImportCandidates.load(AutoConfiguration.class, null).getCandidates();
+		assertThat(configurations).contains(ScheduledTasksObservabilityAutoConfiguration.class.getName());
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/reactive/ReactiveManagementWebSecurityAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/reactive/ReactiveManagementWebSecurityAutoConfigurationTests.java
index fbfe9f23efe9..e41082b893a4 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/reactive/ReactiveManagementWebSecurityAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/reactive/ReactiveManagementWebSecurityAutoConfigurationTests.java
@@ -33,7 +33,6 @@
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.security.oauth2.resource.reactive.ReactiveOAuth2ResourceServerAutoConfiguration;
 import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration;
-import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration;
 import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration;
 import org.springframework.boot.test.context.assertj.AssertableReactiveWebApplicationContext;
 import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
@@ -48,6 +47,8 @@
 import org.springframework.mock.http.server.reactive.MockServerHttpResponse;
 import org.springframework.security.authentication.ReactiveAuthenticationManager;
 import org.springframework.security.config.web.server.ServerHttpSecurity;
+import org.springframework.security.core.userdetails.MapReactiveUserDetailsService;
+import org.springframework.security.core.userdetails.User;
 import org.springframework.security.web.server.SecurityWebFilterChain;
 import org.springframework.security.web.server.WebFilterChainProxy;
 import org.springframework.web.server.ServerWebExchange;
@@ -70,8 +71,8 @@ class ReactiveManagementWebSecurityAutoConfigurationTests {
 				HealthEndpointAutoConfiguration.class, InfoEndpointAutoConfiguration.class,
 				WebFluxAutoConfiguration.class, EnvironmentEndpointAutoConfiguration.class,
 				EndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class,
-				ReactiveSecurityAutoConfiguration.class, ReactiveUserDetailsServiceAutoConfiguration.class,
-				ReactiveManagementWebSecurityAutoConfiguration.class));
+				ReactiveSecurityAutoConfiguration.class, ReactiveManagementWebSecurityAutoConfiguration.class))
+		.withUserConfiguration(UserDetailsServiceConfiguration.class);
 
 	@Test
 	void permitAllForHealth() {
@@ -155,6 +156,17 @@ protected ServerWebExchange createExchange(ServerHttpRequest request, ServerHttp
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class UserDetailsServiceConfiguration {
+
+		@Bean
+		MapReactiveUserDetailsService userDetailsService() {
+			return new MapReactiveUserDetailsService(
+					User.withUsername("alice").password("secret").roles("admin").build());
+		}
+
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class CustomSecurityConfiguration {
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/servlet/AbstractEndpointRequestIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/servlet/AbstractEndpointRequestIntegrationTests.java
index 35236a3c5696..9417437d92f7 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/servlet/AbstractEndpointRequestIntegrationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/servlet/AbstractEndpointRequestIntegrationTests.java
@@ -37,7 +37,6 @@
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
 import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;
-import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
 import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext;
 import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
 import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext;
@@ -45,6 +44,8 @@
 import org.springframework.context.annotation.Configuration;
 import org.springframework.http.HttpStatus;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.provisioning.InMemoryUserDetailsManager;
 import org.springframework.security.web.SecurityFilterChain;
 import org.springframework.test.web.reactive.server.WebTestClient;
 
@@ -100,8 +101,8 @@ protected final WebApplicationContextRunner getContextRunner() {
 		return createContextRunner().withPropertyValues("management.endpoints.web.exposure.include=*")
 			.withUserConfiguration(BaseConfiguration.class, SecurityConfiguration.class)
 			.withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class, SecurityAutoConfiguration.class,
-					UserDetailsServiceAutoConfiguration.class, EndpointAutoConfiguration.class,
-					WebEndpointAutoConfiguration.class, ManagementContextAutoConfiguration.class));
+					EndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class,
+					ManagementContextAutoConfiguration.class));
 
 	}
 
@@ -189,6 +190,12 @@ public EndpointServlet get() {
 	@Configuration(proxyBeanMethods = false)
 	static class SecurityConfiguration {
 
+		@Bean
+		InMemoryUserDetailsManager userDetailsManager() {
+			return new InMemoryUserDetailsManager(
+					User.withUsername("user").password("{noop}password").roles("admin").build());
+		}
+
 		@Bean
 		SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
 			http.authorizeHttpRequests((requests) -> {
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/BaggagePropagationIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/BaggagePropagationIntegrationTests.java
index 6a243e92284e..77c997cd196c 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/BaggagePropagationIntegrationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/BaggagePropagationIntegrationTests.java
@@ -29,6 +29,7 @@
 import org.junit.jupiter.params.provider.EnumSource;
 import org.slf4j.MDC;
 
+import org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryAutoConfiguration;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.context.ApplicationContext;
@@ -151,8 +152,9 @@ public ApplicationContextRunner get() {
 		OTEL_DEFAULT {
 			@Override
 			public ApplicationContextRunner get() {
-				return new ApplicationContextRunner()
-					.withConfiguration(AutoConfigurations.of(OpenTelemetryAutoConfiguration.class))
+				return new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(
+						OpenTelemetryAutoConfiguration.class,
+						org.springframework.boot.actuate.autoconfigure.tracing.OpenTelemetryAutoConfiguration.class))
 					.withPropertyValues("management.tracing.baggage.remote-fields=x-vcap-request-id,country-code,bp",
 							"management.tracing.baggage.correlation.fields=country-code,bp");
 			}
@@ -172,8 +174,9 @@ public ApplicationContextRunner get() {
 		OTEL_W3C {
 			@Override
 			public ApplicationContextRunner get() {
-				return new ApplicationContextRunner()
-					.withConfiguration(AutoConfigurations.of(OpenTelemetryAutoConfiguration.class))
+				return new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(
+						OpenTelemetryAutoConfiguration.class,
+						org.springframework.boot.actuate.autoconfigure.tracing.OpenTelemetryAutoConfiguration.class))
 					.withPropertyValues("management.tracing.propagation.type=W3C",
 							"management.tracing.baggage.remote-fields=x-vcap-request-id,country-code,bp",
 							"management.tracing.baggage.correlation.fields=country-code,bp");
@@ -205,8 +208,9 @@ public ApplicationContextRunner get() {
 		OTEL_B3 {
 			@Override
 			public ApplicationContextRunner get() {
-				return new ApplicationContextRunner()
-					.withConfiguration(AutoConfigurations.of(OpenTelemetryAutoConfiguration.class))
+				return new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(
+						OpenTelemetryAutoConfiguration.class,
+						org.springframework.boot.actuate.autoconfigure.tracing.OpenTelemetryAutoConfiguration.class))
 					.withPropertyValues("management.tracing.propagation.type=B3",
 							"management.tracing.baggage.remote-fields=x-vcap-request-id,country-code,bp",
 							"management.tracing.baggage.correlation.fields=country-code,bp");
@@ -216,8 +220,9 @@ public ApplicationContextRunner get() {
 		OTEL_B3_MULTI {
 			@Override
 			public ApplicationContextRunner get() {
-				return new ApplicationContextRunner()
-					.withConfiguration(AutoConfigurations.of(OpenTelemetryAutoConfiguration.class))
+				return new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(
+						OpenTelemetryAutoConfiguration.class,
+						org.springframework.boot.actuate.autoconfigure.tracing.OpenTelemetryAutoConfiguration.class))
 					.withPropertyValues("management.tracing.propagation.type=B3_MULTI",
 							"management.tracing.baggage.remote-fields=x-vcap-request-id,country-code,bp",
 							"management.tracing.baggage.correlation.fields=country-code,bp");
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/BraveAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/BraveAutoConfigurationTests.java
index 0f27c7b5c3b2..1637f2901289 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/BraveAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/BraveAutoConfigurationTests.java
@@ -17,7 +17,9 @@
 package org.springframework.boot.actuate.autoconfigure.tracing;
 
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.stream.Stream;
 
 import brave.Span;
@@ -31,6 +33,7 @@
 import brave.propagation.CurrentTraceContext.ScopeDecorator;
 import brave.propagation.Propagation;
 import brave.propagation.Propagation.Factory;
+import brave.propagation.TraceContext;
 import brave.sampler.Sampler;
 import io.micrometer.tracing.brave.bridge.BraveBaggageManager;
 import io.micrometer.tracing.brave.bridge.BraveSpanCustomizer;
@@ -54,7 +57,7 @@
 import org.springframework.core.annotation.Order;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThatException;
 import static org.mockito.Mockito.mock;
 
 /**
@@ -153,9 +156,28 @@ void shouldSupplyB3PropagationFactoryViaProperty() {
 	}
 
 	@Test
-	void shouldNotSupplyBeansIfTracingIsDisabled() {
-		this.contextRunner.withPropertyValues("management.tracing.enabled=false")
-			.run((context) -> assertThat(context).doesNotHaveBean(BraveAutoConfiguration.class));
+	void shouldUseB3SingleWithParentWhenPropagationTypeIsB3() {
+		this.contextRunner
+			.withPropertyValues("management.tracing.propagation.type=B3", "management.tracing.sampling.probability=1.0")
+			.run((context) -> {
+				Propagation<String> propagation = context.getBean(Factory.class).get();
+				Tracer tracer = context.getBean(Tracing.class).tracer();
+				Span child;
+				Span parent = tracer.nextSpan().name("parent");
+				try (Tracer.SpanInScope ignored = tracer.withSpanInScope(parent.start())) {
+					child = tracer.nextSpan().name("child");
+					child.start().finish();
+				}
+				finally {
+					parent.finish();
+				}
+
+				Map<String, String> map = new HashMap<>();
+				TraceContext childContext = child.context();
+				propagation.injector(this::injectToMap).inject(childContext, map);
+				assertThat(map).containsExactly(Map.entry("b3", "%s-%s-1-%s".formatted(childContext.traceIdString(),
+						childContext.spanIdString(), childContext.parentIdString())));
+			});
 	}
 
 	@Test
@@ -263,30 +285,33 @@ void shouldFailIfSupportJoinedSpansIsEnabledAndW3cIsChosenAsType() {
 		this.contextRunner
 			.withPropertyValues("management.tracing.propagation.type=W3C",
 					"management.tracing.brave.span-joining-supported=true")
-			.run((context) -> assertThatThrownBy(() -> context.getBean(Tracing.class)).rootCause()
+			.run((context) -> assertThatException().isThrownBy(() -> context.getBean(Tracing.class))
+				.havingRootCause()
 				.isExactlyInstanceOf(IncompatibleConfigurationException.class)
-				.hasMessage(
-						"The following configuration properties have incompatible values: [management.tracing.propagation.type, management.tracing.brave.span-joining-supported]"));
+				.withMessage("The following configuration properties have incompatible values: "
+						+ "[management.tracing.propagation.type, management.tracing.brave.span-joining-supported]"));
 	}
 
 	@Test
 	void shouldFailIfSupportJoinedSpansIsEnabledAndW3cIsChosenAsConsume() {
 		this.contextRunner.withPropertyValues("management.tracing.propagation.produce=B3",
 				"management.tracing.propagation.consume=W3C", "management.tracing.brave.span-joining-supported=true")
-			.run((context) -> assertThatThrownBy(() -> context.getBean(Tracing.class)).rootCause()
+			.run((context) -> assertThatException().isThrownBy(() -> context.getBean(Tracing.class))
+				.havingRootCause()
 				.isExactlyInstanceOf(IncompatibleConfigurationException.class)
-				.hasMessage(
-						"The following configuration properties have incompatible values: [management.tracing.propagation.consume, management.tracing.brave.span-joining-supported]"));
+				.withMessage("The following configuration properties have incompatible values: "
+						+ "[management.tracing.propagation.consume, management.tracing.brave.span-joining-supported]"));
 	}
 
 	@Test
 	void shouldFailIfSupportJoinedSpansIsEnabledAndW3cIsChosenAsProduce() {
 		this.contextRunner.withPropertyValues("management.tracing.propagation.consume=B3",
 				"management.tracing.propagation.produce=W3C", "management.tracing.brave.span-joining-supported=true")
-			.run((context) -> assertThatThrownBy(() -> context.getBean(Tracing.class)).rootCause()
+			.run((context) -> assertThatException().isThrownBy(() -> context.getBean(Tracing.class))
+				.havingRootCause()
 				.isExactlyInstanceOf(IncompatibleConfigurationException.class)
-				.hasMessage(
-						"The following configuration properties have incompatible values: [management.tracing.propagation.produce, management.tracing.brave.span-joining-supported]"));
+				.withMessage("The following configuration properties have incompatible values: "
+						+ "[management.tracing.propagation.produce, management.tracing.brave.span-joining-supported]"));
 	}
 
 	@Test
@@ -319,6 +344,10 @@ void compositeSpanHandlerUsesFilterPredicateAndReportersInOrder() {
 		});
 	}
 
+	private void injectToMap(Map<String, String> map, String key, String value) {
+		map.put(key, value);
+	}
+
 	private List<Factory> getInjectors(Factory factory) {
 		assertThat(factory).as("factory").isNotNull();
 		if (factory instanceof CompositePropagationFactory compositePropagationFactory) {
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/CompositePropagationFactoryTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/CompositePropagationFactoryTests.java
index a232104de4ce..5c756514684c 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/CompositePropagationFactoryTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/CompositePropagationFactoryTests.java
@@ -65,7 +65,7 @@ void requires128BitTraceId() {
 	}
 
 	@Nested
-	static class CompostePropagationTests {
+	class CompositePropagationTests {
 
 		@Test
 		void keys() {
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/CompositeTextMapPropagatorTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/CompositeTextMapPropagatorTests.java
index dc5ccbc6c975..64268433c0af 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/CompositeTextMapPropagatorTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/CompositeTextMapPropagatorTests.java
@@ -22,22 +22,28 @@
 import java.util.List;
 import java.util.Map;
 
+import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator;
 import io.opentelemetry.context.Context;
 import io.opentelemetry.context.ContextKey;
 import io.opentelemetry.context.propagation.TextMapGetter;
 import io.opentelemetry.context.propagation.TextMapPropagator;
 import io.opentelemetry.context.propagation.TextMapSetter;
+import io.opentelemetry.extension.trace.propagation.B3Propagator;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.InOrder;
 import org.mockito.Mockito;
 
+import org.springframework.boot.actuate.autoconfigure.tracing.TracingProperties.Propagation;
+import org.springframework.boot.actuate.autoconfigure.tracing.TracingProperties.Propagation.PropagationType;
+
 import static org.assertj.core.api.Assertions.assertThat;
 
 /**
  * Tests for {@link CompositeTextMapPropagator}.
  *
  * @author Moritz Halbritter
+ * @author Scott Frederick
  */
 class CompositeTextMapPropagatorTests {
 
@@ -91,6 +97,17 @@ void extractWithBaggagePropagator() {
 		assertThat(c).isEqualTo("c-value");
 	}
 
+	@Test
+	void createMapsInjectorsAndExtractors() {
+		Propagation properties = new Propagation();
+		properties.setProduce(List.of(PropagationType.W3C));
+		properties.setConsume(List.of(PropagationType.B3));
+		CompositeTextMapPropagator propagator = (CompositeTextMapPropagator) CompositeTextMapPropagator
+			.create(properties, null);
+		assertThat(propagator.getInjectors()).hasExactlyElementsOfTypes(W3CTraceContextPropagator.class);
+		assertThat(propagator.getExtractors()).hasExactlyElementsOfTypes(B3Propagator.class);
+	}
+
 	private DummyTextMapPropagator field(String field) {
 		return new DummyTextMapPropagator(field, this.contextKeyRegistry);
 	}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/LocalBaggageFieldsTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/LocalBaggageFieldsTests.java
new file mode 100644
index 000000000000..6f6e99d87d35
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/LocalBaggageFieldsTests.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.tracing;
+
+import brave.baggage.BaggageField;
+import brave.baggage.BaggagePropagation;
+import brave.baggage.BaggagePropagation.FactoryBuilder;
+import brave.baggage.BaggagePropagationConfig;
+import brave.propagation.Propagation;
+import brave.propagation.Propagation.Factory;
+import brave.propagation.Propagation.KeyFactory;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link LocalBaggageFields}.
+ *
+ * @author Moritz Halbritter
+ */
+class LocalBaggageFieldsTests {
+
+	@Test
+	void extractFromBuilder() {
+		FactoryBuilder builder = createBuilder();
+		builder.add(BaggagePropagationConfig.SingleBaggageField.remote(BaggageField.create("remote-field-1")));
+		builder.add(BaggagePropagationConfig.SingleBaggageField.remote(BaggageField.create("remote-field-2")));
+		builder.add(BaggagePropagationConfig.SingleBaggageField.local(BaggageField.create("local-field-1")));
+		builder.add(BaggagePropagationConfig.SingleBaggageField.local(BaggageField.create("local-field-2")));
+		LocalBaggageFields fields = LocalBaggageFields.extractFrom(builder);
+		assertThat(fields.asList()).containsExactlyInAnyOrder("local-field-1", "local-field-2");
+	}
+
+	@Test
+	void empty() {
+		assertThat(LocalBaggageFields.empty().asList()).isEmpty();
+	}
+
+	@SuppressWarnings("deprecation")
+	private static FactoryBuilder createBuilder() {
+		return BaggagePropagation.newFactoryBuilder(new Factory() {
+			@Override
+			public <K> Propagation<K> create(KeyFactory<K> keyFactory) {
+				return null;
+			}
+		});
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/LogCorrelationEnvironmentPostProcessorTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/LogCorrelationEnvironmentPostProcessorTests.java
new file mode 100644
index 000000000000..3cf84a10db6f
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/LogCorrelationEnvironmentPostProcessorTests.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.tracing;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.logging.LoggingSystem;
+import org.springframework.boot.test.util.TestPropertyValues;
+import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
+import org.springframework.core.env.ConfigurableEnvironment;
+import org.springframework.core.env.EnumerablePropertySource;
+import org.springframework.core.env.PropertySource;
+import org.springframework.core.env.StandardEnvironment;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link LogCorrelationEnvironmentPostProcessor}.
+ *
+ * @author Jonatan Ivanov
+ * @author Phillip Webb
+ */
+class LogCorrelationEnvironmentPostProcessorTests {
+
+	private final ConfigurableEnvironment environment = new StandardEnvironment();
+
+	private final SpringApplication application = new SpringApplication();
+
+	private final LogCorrelationEnvironmentPostProcessor postProcessor = new LogCorrelationEnvironmentPostProcessor();
+
+	@Test
+	void getExpectCorrelationIdPropertyWhenMicrometerTracingPresentReturnsTrue() {
+		this.postProcessor.postProcessEnvironment(this.environment, this.application);
+		assertThat(this.environment.getProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, Boolean.class, false))
+			.isTrue();
+	}
+
+	@Test
+	@ClassPathExclusions("micrometer-tracing-*.jar")
+	void getExpectCorrelationIdPropertyWhenMicrometerTracingMissingReturnsFalse() {
+		this.postProcessor.postProcessEnvironment(this.environment, this.application);
+		assertThat(this.environment.getProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, Boolean.class, false))
+			.isFalse();
+	}
+
+	@Test
+	void getExpectCorrelationIdPropertyWhenTracingDisabledReturnsFalse() {
+		TestPropertyValues.of("management.tracing.enabled=false").applyTo(this.environment);
+		this.postProcessor.postProcessEnvironment(this.environment, this.application);
+		assertThat(this.environment.getProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, Boolean.class, false))
+			.isFalse();
+	}
+
+	@Test
+	void postProcessEnvironmentAddsEnumerablePropertySource() {
+		this.postProcessor.postProcessEnvironment(this.environment, this.application);
+		PropertySource<?> propertySource = this.environment.getPropertySources().get("logCorrelation");
+		assertThat(propertySource).isInstanceOf(EnumerablePropertySource.class);
+		assertThat(((EnumerablePropertySource<?>) propertySource).getPropertyNames())
+			.containsExactly(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/MicrometerTracingAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/MicrometerTracingAutoConfigurationTests.java
index 6f3c4a373d06..9c6c61f6f012 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/MicrometerTracingAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/MicrometerTracingAutoConfigurationTests.java
@@ -19,11 +19,18 @@
 import java.util.List;
 
 import io.micrometer.tracing.Tracer;
+import io.micrometer.tracing.annotation.DefaultNewSpanParser;
+import io.micrometer.tracing.annotation.ImperativeMethodInvocationProcessor;
+import io.micrometer.tracing.annotation.MethodInvocationProcessor;
+import io.micrometer.tracing.annotation.NewSpanParser;
+import io.micrometer.tracing.annotation.SpanAspect;
+import io.micrometer.tracing.annotation.SpanTagAnnotationHandler;
 import io.micrometer.tracing.handler.DefaultTracingObservationHandler;
 import io.micrometer.tracing.handler.PropagatingReceiverTracingObservationHandler;
 import io.micrometer.tracing.handler.PropagatingSenderTracingObservationHandler;
 import io.micrometer.tracing.handler.TracingObservationHandler;
 import io.micrometer.tracing.propagation.Propagator;
+import org.aspectj.weaver.Advice;
 import org.junit.jupiter.api.Test;
 
 import org.springframework.boot.autoconfigure.AutoConfigurations;
@@ -39,6 +46,7 @@
  * Tests for {@link MicrometerTracingAutoConfiguration}.
  *
  * @author Moritz Halbritter
+ * @author Jonatan Ivanov
  */
 class MicrometerTracingAutoConfigurationTests {
 
@@ -52,6 +60,9 @@ void shouldSupplyBeans() {
 				assertThat(context).hasSingleBean(DefaultTracingObservationHandler.class);
 				assertThat(context).hasSingleBean(PropagatingReceiverTracingObservationHandler.class);
 				assertThat(context).hasSingleBean(PropagatingSenderTracingObservationHandler.class);
+				assertThat(context).hasSingleBean(DefaultNewSpanParser.class);
+				assertThat(context).hasSingleBean(ImperativeMethodInvocationProcessor.class);
+				assertThat(context).hasSingleBean(SpanAspect.class);
 			});
 	}
 
@@ -75,14 +86,21 @@ void shouldSupplyBeansInCorrectOrder() {
 
 	@Test
 	void shouldBackOffOnCustomBeans() {
-		this.contextRunner.withUserConfiguration(CustomConfiguration.class).run((context) -> {
-			assertThat(context).hasBean("customDefaultTracingObservationHandler");
-			assertThat(context).hasSingleBean(DefaultTracingObservationHandler.class);
-			assertThat(context).hasBean("customPropagatingReceiverTracingObservationHandler");
-			assertThat(context).hasSingleBean(PropagatingReceiverTracingObservationHandler.class);
-			assertThat(context).hasBean("customPropagatingSenderTracingObservationHandler");
-			assertThat(context).hasSingleBean(PropagatingSenderTracingObservationHandler.class);
-		});
+		this.contextRunner.withUserConfiguration(TracerConfiguration.class, CustomConfiguration.class)
+			.run((context) -> {
+				assertThat(context).hasBean("customDefaultTracingObservationHandler");
+				assertThat(context).hasSingleBean(DefaultTracingObservationHandler.class);
+				assertThat(context).hasBean("customPropagatingReceiverTracingObservationHandler");
+				assertThat(context).hasSingleBean(PropagatingReceiverTracingObservationHandler.class);
+				assertThat(context).hasBean("customPropagatingSenderTracingObservationHandler");
+				assertThat(context).hasSingleBean(PropagatingSenderTracingObservationHandler.class);
+				assertThat(context).hasBean("customDefaultNewSpanParser");
+				assertThat(context).hasSingleBean(DefaultNewSpanParser.class);
+				assertThat(context).hasBean("customImperativeMethodInvocationProcessor");
+				assertThat(context).hasSingleBean(ImperativeMethodInvocationProcessor.class);
+				assertThat(context).hasBean("customSpanAspect");
+				assertThat(context).hasSingleBean(SpanAspect.class);
+			});
 	}
 
 	@Test
@@ -91,6 +109,9 @@ void shouldNotSupplyBeansIfMicrometerIsMissing() {
 			assertThat(context).doesNotHaveBean(DefaultTracingObservationHandler.class);
 			assertThat(context).doesNotHaveBean(PropagatingReceiverTracingObservationHandler.class);
 			assertThat(context).doesNotHaveBean(PropagatingSenderTracingObservationHandler.class);
+			assertThat(context).doesNotHaveBean(DefaultNewSpanParser.class);
+			assertThat(context).doesNotHaveBean(ImperativeMethodInvocationProcessor.class);
+			assertThat(context).doesNotHaveBean(SpanAspect.class);
 		});
 	}
 
@@ -100,25 +121,43 @@ void shouldNotSupplyBeansIfTracerIsMissing() {
 			assertThat(context).doesNotHaveBean(DefaultTracingObservationHandler.class);
 			assertThat(context).doesNotHaveBean(PropagatingReceiverTracingObservationHandler.class);
 			assertThat(context).doesNotHaveBean(PropagatingSenderTracingObservationHandler.class);
+			assertThat(context).doesNotHaveBean(DefaultNewSpanParser.class);
+			assertThat(context).doesNotHaveBean(ImperativeMethodInvocationProcessor.class);
+			assertThat(context).doesNotHaveBean(SpanAspect.class);
 		});
 	}
 
+	@Test
+	void shouldNotSupplyBeansIfAspectjIsMissing() {
+		this.contextRunner.withUserConfiguration(TracerConfiguration.class)
+			.withClassLoader(new FilteredClassLoader(Advice.class))
+			.run((context) -> {
+				assertThat(context).doesNotHaveBean(DefaultNewSpanParser.class);
+				assertThat(context).doesNotHaveBean(ImperativeMethodInvocationProcessor.class);
+				assertThat(context).doesNotHaveBean(SpanAspect.class);
+			});
+	}
+
 	@Test
 	void shouldNotSupplyBeansIfPropagatorIsMissing() {
 		this.contextRunner.withUserConfiguration(TracerConfiguration.class).run((context) -> {
 			assertThat(context).doesNotHaveBean(PropagatingSenderTracingObservationHandler.class);
 			assertThat(context).doesNotHaveBean(PropagatingReceiverTracingObservationHandler.class);
+
+			assertThat(context).hasSingleBean(DefaultNewSpanParser.class);
+			assertThat(context).hasSingleBean(ImperativeMethodInvocationProcessor.class);
+			assertThat(context).hasSingleBean(SpanAspect.class);
 		});
 	}
 
 	@Test
-	void shouldNotSupplyBeansIfTracingIsDisabled() {
-		this.contextRunner.withUserConfiguration(TracerConfiguration.class, PropagatorConfiguration.class)
-			.withPropertyValues("management.tracing.enabled=false")
+	void shouldConfigureSpanTagAnnotationHandler() {
+		this.contextRunner.withUserConfiguration(TracerConfiguration.class, SpanTagAnnotationHandlerConfiguration.class)
 			.run((context) -> {
-				assertThat(context).doesNotHaveBean(DefaultTracingObservationHandler.class);
-				assertThat(context).doesNotHaveBean(PropagatingReceiverTracingObservationHandler.class);
-				assertThat(context).doesNotHaveBean(PropagatingSenderTracingObservationHandler.class);
+				assertThat(context).hasSingleBean(DefaultNewSpanParser.class);
+				assertThat(context).hasSingleBean(SpanAspect.class);
+				assertThat(context.getBean(ImperativeMethodInvocationProcessor.class)).hasFieldOrPropertyWithValue(
+						"spanTagAnnotationHandler", context.getBean(SpanTagAnnotationHandler.class));
 			});
 	}
 
@@ -160,6 +199,32 @@ PropagatingSenderTracingObservationHandler<?> customPropagatingSenderTracingObse
 			return mock(PropagatingSenderTracingObservationHandler.class);
 		}
 
+		@Bean
+		DefaultNewSpanParser customDefaultNewSpanParser() {
+			return new DefaultNewSpanParser();
+		}
+
+		@Bean
+		ImperativeMethodInvocationProcessor customImperativeMethodInvocationProcessor(NewSpanParser newSpanParser,
+				Tracer tracer) {
+			return new ImperativeMethodInvocationProcessor(newSpanParser, tracer);
+		}
+
+		@Bean
+		SpanAspect customSpanAspect(MethodInvocationProcessor methodInvocationProcessor) {
+			return new SpanAspect(methodInvocationProcessor);
+		}
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	private static class SpanTagAnnotationHandlerConfiguration {
+
+		@Bean
+		SpanTagAnnotationHandler spanTagAnnotationHandler() {
+			return new SpanTagAnnotationHandler((aClass) -> null, (aClass) -> null);
+		}
+
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/OpenTelemetryAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/OpenTelemetryAutoConfigurationTests.java
index e1ab7b43ca08..64bb18571381 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/OpenTelemetryAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/OpenTelemetryAutoConfigurationTests.java
@@ -24,7 +24,6 @@
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
-import java.util.stream.Stream;
 
 import io.micrometer.tracing.SpanCustomizer;
 import io.micrometer.tracing.otel.bridge.OtelCurrentTraceContext;
@@ -35,9 +34,9 @@
 import io.micrometer.tracing.otel.bridge.Slf4JBaggageEventListener;
 import io.micrometer.tracing.otel.bridge.Slf4JEventListener;
 import io.micrometer.tracing.otel.propagation.BaggageTextMapPropagator;
-import io.opentelemetry.api.OpenTelemetry;
 import io.opentelemetry.api.common.AttributeKey;
 import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.metrics.MeterProvider;
 import io.opentelemetry.api.trace.Tracer;
 import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator;
 import io.opentelemetry.context.propagation.ContextPropagators;
@@ -51,10 +50,11 @@
 import io.opentelemetry.sdk.trace.data.SpanData;
 import io.opentelemetry.sdk.trace.export.SpanExporter;
 import io.opentelemetry.sdk.trace.samplers.Sampler;
-import io.opentelemetry.semconv.resource.attributes.ResourceAttributes;
+import io.opentelemetry.semconv.ResourceAttributes;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.Mockito;
 
 import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
@@ -66,6 +66,9 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
 import static org.mockito.Mockito.mock;
 
 /**
@@ -78,7 +81,9 @@
 class OpenTelemetryAutoConfigurationTests {
 
 	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
-		.withConfiguration(AutoConfigurations.of(OpenTelemetryAutoConfiguration.class));
+		.withConfiguration(AutoConfigurations.of(
+				org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryAutoConfiguration.class,
+				OpenTelemetryAutoConfiguration.class));
 
 	@Test
 	void shouldSupplyBeans() {
@@ -86,7 +91,6 @@ void shouldSupplyBeans() {
 			assertThat(context).hasSingleBean(OtelTracer.class);
 			assertThat(context).hasSingleBean(EventPublisher.class);
 			assertThat(context).hasSingleBean(OtelCurrentTraceContext.class);
-			assertThat(context).hasSingleBean(OpenTelemetry.class);
 			assertThat(context).hasSingleBean(SdkTracerProvider.class);
 			assertThat(context).hasSingleBean(ContextPropagators.class);
 			assertThat(context).hasSingleBean(Sampler.class);
@@ -97,6 +101,8 @@ void shouldSupplyBeans() {
 			assertThat(context).hasSingleBean(OtelPropagator.class);
 			assertThat(context).hasSingleBean(TextMapPropagator.class);
 			assertThat(context).hasSingleBean(OtelSpanCustomizer.class);
+			assertThat(context).hasSingleBean(SpanProcessors.class);
+			assertThat(context).hasSingleBean(SpanExporters.class);
 		});
 	}
 
@@ -116,7 +122,6 @@ void shouldNotSupplyBeansIfDependencyIsMissing(String packageName) {
 			assertThat(context).doesNotHaveBean(OtelTracer.class);
 			assertThat(context).doesNotHaveBean(EventPublisher.class);
 			assertThat(context).doesNotHaveBean(OtelCurrentTraceContext.class);
-			assertThat(context).doesNotHaveBean(OpenTelemetry.class);
 			assertThat(context).doesNotHaveBean(SdkTracerProvider.class);
 			assertThat(context).doesNotHaveBean(ContextPropagators.class);
 			assertThat(context).doesNotHaveBean(Sampler.class);
@@ -127,6 +132,8 @@ void shouldNotSupplyBeansIfDependencyIsMissing(String packageName) {
 			assertThat(context).doesNotHaveBean(OtelPropagator.class);
 			assertThat(context).doesNotHaveBean(TextMapPropagator.class);
 			assertThat(context).doesNotHaveBean(OtelSpanCustomizer.class);
+			assertThat(context).doesNotHaveBean(SpanProcessors.class);
+			assertThat(context).doesNotHaveBean(SpanExporters.class);
 		});
 	}
 
@@ -139,8 +146,6 @@ void shouldBackOffOnCustomBeans() {
 			assertThat(context).hasSingleBean(EventPublisher.class);
 			assertThat(context).hasBean("customOtelCurrentTraceContext");
 			assertThat(context).hasSingleBean(OtelCurrentTraceContext.class);
-			assertThat(context).hasBean("customOpenTelemetry");
-			assertThat(context).hasSingleBean(OpenTelemetry.class);
 			assertThat(context).hasBean("customSdkTracerProvider");
 			assertThat(context).hasSingleBean(SdkTracerProvider.class);
 			assertThat(context).hasBean("customContextPropagators");
@@ -157,6 +162,10 @@ void shouldBackOffOnCustomBeans() {
 			assertThat(context).hasSingleBean(OtelPropagator.class);
 			assertThat(context).hasBean("customSpanCustomizer");
 			assertThat(context).hasSingleBean(SpanCustomizer.class);
+			assertThat(context).hasBean("customSpanProcessors");
+			assertThat(context).hasSingleBean(SpanProcessors.class);
+			assertThat(context).hasBean("customSpanExporters");
+			assertThat(context).hasSingleBean(SpanExporters.class);
 		});
 	}
 
@@ -182,9 +191,22 @@ void shouldSetupDefaultResourceAttributes() {
 
 	@Test
 	void shouldAllowMultipleSpanProcessors() {
-		this.contextRunner.withUserConfiguration(CustomConfiguration.class).run((context) -> {
+		this.contextRunner.withUserConfiguration(AdditionalSpanProcessorConfiguration.class).run((context) -> {
 			assertThat(context.getBeansOfType(SpanProcessor.class)).hasSize(2);
 			assertThat(context).hasBean("customSpanProcessor");
+			SpanProcessors spanProcessors = context.getBean(SpanProcessors.class);
+			assertThat(spanProcessors).hasSize(2);
+		});
+	}
+
+	@Test
+	void shouldAllowMultipleSpanExporters() {
+		this.contextRunner.withUserConfiguration(MultipleSpanExporterConfiguration.class).run((context) -> {
+			assertThat(context.getBeansOfType(SpanExporter.class)).hasSize(2);
+			assertThat(context).hasBean("spanExporter1");
+			assertThat(context).hasBean("spanExporter2");
+			SpanExporters spanExporters = context.getBean(SpanExporters.class);
+			assertThat(spanExporters).hasSize(2);
 		});
 	}
 
@@ -212,8 +234,8 @@ void shouldNotSupplySlf4JBaggageEventListenerWhenBaggageDisabled() {
 	void shouldSupplyB3PropagationIfPropagationPropertySet() {
 		this.contextRunner.withPropertyValues("management.tracing.propagation.type=B3").run((context) -> {
 			TextMapPropagator propagator = context.getBean(TextMapPropagator.class);
-			Stream<Class<?>> injectors = getInjectors(propagator).stream().map(Object::getClass);
-			assertThat(injectors).containsExactly(B3Propagator.class, BaggageTextMapPropagator.class);
+			List<TextMapPropagator> injectors = getInjectors(propagator);
+			assertThat(injectors).hasExactlyElementsOfTypes(B3Propagator.class, BaggageTextMapPropagator.class);
 		});
 	}
 
@@ -223,8 +245,8 @@ void shouldSupplyB3PropagationIfPropagationPropertySetAndBaggageDisabled() {
 			.withPropertyValues("management.tracing.propagation.type=B3", "management.tracing.baggage.enabled=false")
 			.run((context) -> {
 				TextMapPropagator propagator = context.getBean(TextMapPropagator.class);
-				Stream<Class<?>> injectors = getInjectors(propagator).stream().map(Object::getClass);
-				assertThat(injectors).containsExactly(B3Propagator.class);
+				List<TextMapPropagator> injectors = getInjectors(propagator);
+				assertThat(injectors).hasExactlyElementsOfTypes(B3Propagator.class);
 			});
 	}
 
@@ -245,8 +267,26 @@ void shouldSupplyW3CPropagationWithBaggageByDefault() {
 	void shouldSupplyW3CPropagationWithoutBaggageWhenDisabled() {
 		this.contextRunner.withPropertyValues("management.tracing.baggage.enabled=false").run((context) -> {
 			TextMapPropagator propagator = context.getBean(TextMapPropagator.class);
-			Stream<Class<?>> injectors = getInjectors(propagator).stream().map(Object::getClass);
-			assertThat(injectors).containsExactly(W3CTraceContextPropagator.class);
+			List<TextMapPropagator> injectors = getInjectors(propagator);
+			assertThat(injectors).hasExactlyElementsOfTypes(W3CTraceContextPropagator.class);
+		});
+	}
+
+	@Test
+	void shouldCustomizeSdkTracerProvider() {
+		this.contextRunner.withUserConfiguration(SdkTracerProviderCustomizationConfiguration.class).run((context) -> {
+			SdkTracerProvider tracerProvider = context.getBean(SdkTracerProvider.class);
+			assertThat(tracerProvider.getSpanLimits().getMaxNumberOfEvents()).isEqualTo(42);
+			assertThat(tracerProvider.getSampler()).isEqualTo(Sampler.alwaysOn());
+		});
+	}
+
+	@Test
+	void defaultSpanProcessorShouldUseMeterProviderIfAvailable() {
+		this.contextRunner.withUserConfiguration(MeterProviderConfiguration.class).run((context) -> {
+			MeterProvider meterProvider = context.getBean(MeterProvider.class);
+			assertThat(Mockito.mockingDetails(meterProvider).isMock()).isTrue();
+			then(meterProvider).should().meterBuilder(anyString());
 		});
 	}
 
@@ -259,18 +299,57 @@ private List<TextMapPropagator> getInjectors(TextMapPropagator propagator) {
 		throw new AssertionError("Unreachable");
 	}
 
-	@Test
-	void shouldCustomizeSdkTracerProvider() {
-		this.contextRunner.withUserConfiguration(SdkTracerProviderCustomizationConfiguration.class).run((context) -> {
-			SdkTracerProvider tracerProvider = context.getBean(SdkTracerProvider.class);
-			assertThat(tracerProvider.getSpanLimits().getMaxNumberOfEvents()).isEqualTo(42);
-			assertThat(tracerProvider.getSampler()).isEqualTo(Sampler.alwaysOn());
-		});
+	@Configuration(proxyBeanMethods = false)
+	private static class MeterProviderConfiguration {
+
+		@Bean
+		MeterProvider meterProvider() {
+			MeterProvider mock = mock(MeterProvider.class);
+			given(mock.meterBuilder(anyString()))
+				.willAnswer((invocation) -> MeterProvider.noop().meterBuilder(invocation.getArgument(0, String.class)));
+			return mock;
+		}
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	private static class AdditionalSpanProcessorConfiguration {
+
+		@Bean
+		SpanProcessor customSpanProcessor() {
+			return mock(SpanProcessor.class);
+		}
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	private static class MultipleSpanExporterConfiguration {
+
+		@Bean
+		SpanExporter spanExporter1() {
+			return new DummySpanExporter();
+		}
+
+		@Bean
+		SpanExporter spanExporter2() {
+			return new DummySpanExporter();
+		}
+
 	}
 
 	@Configuration(proxyBeanMethods = false)
 	private static class CustomConfiguration {
 
+		@Bean
+		SpanProcessors customSpanProcessors() {
+			return SpanProcessors.of(mock(SpanProcessor.class));
+		}
+
+		@Bean
+		SpanExporters customSpanExporters() {
+			return SpanExporters.of(new DummySpanExporter());
+		}
+
 		@Bean
 		io.micrometer.tracing.Tracer customMicrometerTracer() {
 			return mock(io.micrometer.tracing.Tracer.class);
@@ -286,11 +365,6 @@ OtelCurrentTraceContext customOtelCurrentTraceContext() {
 			return mock(OtelCurrentTraceContext.class);
 		}
 
-		@Bean
-		OpenTelemetry customOpenTelemetry() {
-			return mock(OpenTelemetry.class);
-		}
-
 		@Bean
 		SdkTracerProvider customSdkTracerProvider() {
 			return SdkTracerProvider.builder().build();
@@ -366,6 +440,25 @@ SdkTracerProviderBuilderCustomizer sdkTracerProviderBuilderCustomizerTwo() {
 
 	}
 
+	private static class DummySpanExporter implements SpanExporter {
+
+		@Override
+		public CompletableResultCode export(Collection<SpanData> spans) {
+			return CompletableResultCode.ofSuccess();
+		}
+
+		@Override
+		public CompletableResultCode flush() {
+			return CompletableResultCode.ofSuccess();
+		}
+
+		@Override
+		public CompletableResultCode shutdown() {
+			return CompletableResultCode.ofSuccess();
+		}
+
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	private static class InMemoryRecordingSpanExporterConfiguration {
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanExportersTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanExportersTests.java
new file mode 100644
index 000000000000..d15f2d1aceb6
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanExportersTests.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.tracing;
+
+import java.util.List;
+
+import io.opentelemetry.sdk.trace.export.SpanExporter;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link SpanExporters}.
+ *
+ * @author Moritz Halbritter
+ */
+class SpanExportersTests {
+
+	@Test
+	void ofList() {
+		SpanExporter spanExporter1 = mock(SpanExporter.class);
+		SpanExporter spanExporter2 = mock(SpanExporter.class);
+		SpanExporters spanExporters = SpanExporters.of(List.of(spanExporter1, spanExporter2));
+		assertThat(spanExporters).containsExactly(spanExporter1, spanExporter2);
+		assertThat(spanExporters.list()).containsExactly(spanExporter1, spanExporter2);
+	}
+
+	@Test
+	void ofArray() {
+		SpanExporter spanExporter1 = mock(SpanExporter.class);
+		SpanExporter spanExporter2 = mock(SpanExporter.class);
+		SpanExporters spanExporters = SpanExporters.of(spanExporter1, spanExporter2);
+		assertThat(spanExporters).containsExactly(spanExporter1, spanExporter2);
+		assertThat(spanExporters.list()).containsExactly(spanExporter1, spanExporter2);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanProcessorsTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanProcessorsTests.java
new file mode 100644
index 000000000000..8a5fa76868de
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanProcessorsTests.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.autoconfigure.tracing;
+
+import java.util.List;
+
+import io.opentelemetry.sdk.trace.SpanProcessor;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link SpanProcessors}.
+ *
+ * @author Moritz Halbritter
+ */
+class SpanProcessorsTests {
+
+	@Test
+	void ofList() {
+		SpanProcessor spanProcessor1 = mock(SpanProcessor.class);
+		SpanProcessor spanProcessor2 = mock(SpanProcessor.class);
+		SpanProcessors spanProcessors = SpanProcessors.of(List.of(spanProcessor1, spanProcessor2));
+		assertThat(spanProcessors).containsExactly(spanProcessor1, spanProcessor2);
+		assertThat(spanProcessors.list()).containsExactly(spanProcessor1, spanProcessor2);
+	}
+
+	@Test
+	void ofArray() {
+		SpanProcessor spanProcessor1 = mock(SpanProcessor.class);
+		SpanProcessor spanProcessor2 = mock(SpanProcessor.class);
+		SpanProcessors spanProcessors = SpanProcessors.of(spanProcessor1, spanProcessor2);
+		assertThat(spanProcessors).containsExactly(spanProcessor1, spanProcessor2);
+		assertThat(spanProcessors.list()).containsExactly(spanProcessor1, spanProcessor2);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationIntegrationTests.java
index 47fbcc824a3c..0fcc77811f95 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationIntegrationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationIntegrationTests.java
@@ -34,8 +34,8 @@
 import org.junit.jupiter.api.Test;
 
 import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration;
+import org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryAutoConfiguration;
 import org.springframework.boot.actuate.autoconfigure.tracing.MicrometerTracingAutoConfiguration;
-import org.springframework.boot.actuate.autoconfigure.tracing.OpenTelemetryAutoConfiguration;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 
@@ -50,9 +50,10 @@ class OtlpAutoConfigurationIntegrationTests {
 
 	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
 		.withPropertyValues("management.tracing.sampling.probability=1.0")
-		.withConfiguration(
-				AutoConfigurations.of(ObservationAutoConfiguration.class, MicrometerTracingAutoConfiguration.class,
-						OpenTelemetryAutoConfiguration.class, OtlpAutoConfiguration.class));
+		.withConfiguration(AutoConfigurations.of(ObservationAutoConfiguration.class,
+				MicrometerTracingAutoConfiguration.class, OpenTelemetryAutoConfiguration.class,
+				org.springframework.boot.actuate.autoconfigure.tracing.OpenTelemetryAutoConfiguration.class,
+				OtlpAutoConfiguration.class));
 
 	private final MockWebServer mockWebServer = new MockWebServer();
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationTests.java
index 5d75c06b5112..9ca5956e2254 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationTests.java
@@ -19,8 +19,10 @@
 import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter;
 import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter;
 import io.opentelemetry.sdk.trace.export.SpanExporter;
+import okhttp3.HttpUrl;
 import org.junit.jupiter.api.Test;
 
+import org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpTracingConfigurations.ConnectionDetails.PropertiesOtlpTracingConnectionDetails;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.test.context.FilteredClassLoader;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
@@ -33,16 +35,27 @@
  * Tests for {@link OtlpAutoConfiguration}.
  *
  * @author Jonatan Ivanov
+ * @author Moritz Halbritter
+ * @author EddĂș MelĂ©ndez
  */
 class OtlpAutoConfigurationTests {
 
 	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
 		.withConfiguration(AutoConfigurations.of(OtlpAutoConfiguration.class));
 
+	private final ApplicationContextRunner tracingDisabledContextRunner = this.contextRunner
+		.withPropertyValues("management.tracing.enabled=false");
+
+	@Test
+	void shouldNotSupplyBeansIfPropertyIsNotSet() {
+		this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean(OtlpHttpSpanExporter.class));
+	}
+
 	@Test
 	void shouldSupplyBeans() {
-		this.contextRunner.run((context) -> assertThat(context).hasSingleBean(OtlpHttpSpanExporter.class)
-			.hasSingleBean(SpanExporter.class));
+		this.contextRunner.withPropertyValues("management.otlp.tracing.endpoint=http://localhost:4318/v1/traces")
+			.run((context) -> assertThat(context).hasSingleBean(OtlpHttpSpanExporter.class)
+				.hasSingleBean(SpanExporter.class));
 	}
 
 	@Test
@@ -89,6 +102,30 @@ void shouldBackOffWhenCustomGrpcExporterIsDefined() {
 				.hasSingleBean(SpanExporter.class));
 	}
 
+	@Test
+	void shouldNotSupplyOtlpHttpSpanExporterIfTracingIsDisabled() {
+		this.tracingDisabledContextRunner
+			.withPropertyValues("management.otlp.tracing.endpoint=http://localhost:4318/v1/traces")
+			.run((context) -> assertThat(context).doesNotHaveBean(OtlpHttpSpanExporter.class));
+	}
+
+	@Test
+	void definesPropertiesBasedConnectionDetailsByDefault() {
+		this.contextRunner.withPropertyValues("management.otlp.tracing.endpoint=http://localhost:4318/v1/traces")
+			.run((context) -> assertThat(context).hasSingleBean(PropertiesOtlpTracingConnectionDetails.class));
+	}
+
+	@Test
+	void testConnectionFactoryWithOverridesWhenUsingCustomConnectionDetails() {
+		this.contextRunner.withUserConfiguration(ConnectionDetailsConfiguration.class).run((context) -> {
+			assertThat(context).hasSingleBean(OtlpTracingConnectionDetails.class)
+				.doesNotHaveBean(PropertiesOtlpTracingConnectionDetails.class);
+			OtlpHttpSpanExporter otlpHttpSpanExporter = context.getBean(OtlpHttpSpanExporter.class);
+			assertThat(otlpHttpSpanExporter).extracting("delegate.httpSender.url")
+				.isEqualTo(HttpUrl.get("http://localhost:12345/v1/traces"));
+		});
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	private static class CustomHttpExporterConfiguration {
 
@@ -109,4 +146,14 @@ OtlpGrpcSpanExporter customOtlpGrpcSpanExporter() {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class ConnectionDetailsConfiguration {
+
+		@Bean
+		OtlpTracingConnectionDetails otlpTracingConnectionDetails() {
+			return () -> "http://localhost:12345/v1/traces";
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/prometheus/PrometheusExemplarsAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/prometheus/PrometheusExemplarsAutoConfigurationTests.java
index 2d2f9d35a530..8f1a8ba5d98e 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/prometheus/PrometheusExemplarsAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/prometheus/PrometheusExemplarsAutoConfigurationTests.java
@@ -16,6 +16,10 @@
 
 package org.springframework.boot.actuate.autoconfigure.tracing.prometheus;
 
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
 import io.micrometer.observation.Observation;
 import io.micrometer.observation.ObservationRegistry;
 import io.micrometer.prometheus.PrometheusMeterRegistry;
@@ -33,6 +37,7 @@
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.util.StringUtils;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.Mockito.mock;
@@ -40,10 +45,16 @@
 /**
  * Tests for {@link PrometheusExemplarsAutoConfiguration}.
  *
- * * @author Jonatan Ivanov
+ * @author Jonatan Ivanov
  */
 class PrometheusExemplarsAutoConfigurationTests {
 
+	private static final Pattern BUCKET_TRACE_INFO_PATTERN = Pattern.compile(
+			"^test_observation_seconds_bucket\\{error=\"none\",le=\".+\"} 1.0 # \\{span_id=\"(\\p{XDigit}+)\",trace_id=\"(\\p{XDigit}+)\"} .+$");
+
+	private static final Pattern COUNTER_TRACE_INFO_PATTERN = Pattern.compile(
+			"^test_observation_seconds_count\\{error=\"none\"} 1.0 # \\{span_id=\"(\\p{XDigit}+)\",trace_id=\"(\\p{XDigit}+)\"} .+$");
+
 	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
 		.withPropertyValues("management.tracing.sampling.probability=1.0",
 				"management.metrics.distribution.percentiles-histogram.all=true")
@@ -52,12 +63,6 @@ class PrometheusExemplarsAutoConfigurationTests {
 				AutoConfigurations.of(PrometheusExemplarsAutoConfiguration.class, ObservationAutoConfiguration.class,
 						BraveAutoConfiguration.class, MicrometerTracingAutoConfiguration.class));
 
-	@Test
-	void shouldNotSupplyBeansIfTracingIsDisabled() {
-		this.contextRunner.withPropertyValues("management.tracing.enabled=false")
-			.run((context) -> assertThat(context).doesNotHaveBean(SpanContextSupplier.class));
-	}
-
 	@Test
 	void shouldNotSupplyBeansIfPrometheusSupportIsMissing() {
 		this.contextRunner.withClassLoader(new FilteredClassLoader("io.prometheus.client.exemplars"))
@@ -86,9 +91,27 @@ void prometheusOpenMetricsOutputShouldContainExemplars() {
 			Observation.start("test.observation", observationRegistry).stop();
 			PrometheusMeterRegistry prometheusMeterRegistry = context.getBean(PrometheusMeterRegistry.class);
 			String openMetricsOutput = prometheusMeterRegistry.scrape(TextFormat.CONTENT_TYPE_OPENMETRICS_100);
-			assertThat(openMetricsOutput).contains("test_observation_seconds_bucket")
-				.containsOnlyOnce("trace_id=")
-				.containsOnlyOnce("span_id=");
+
+			assertThat(openMetricsOutput).contains("test_observation_seconds_bucket");
+			assertThat(openMetricsOutput).containsOnlyOnce("test_observation_seconds_count");
+			assertThat(StringUtils.countOccurrencesOf(openMetricsOutput, "span_id")).isEqualTo(2);
+			assertThat(StringUtils.countOccurrencesOf(openMetricsOutput, "trace_id")).isEqualTo(2);
+
+			Optional<TraceInfo> bucketTraceInfo = openMetricsOutput.lines()
+				.filter((line) -> line.contains("test_observation_seconds_bucket") && line.contains("span_id"))
+				.map(BUCKET_TRACE_INFO_PATTERN::matcher)
+				.flatMap(Matcher::results)
+				.map((matchResult) -> new TraceInfo(matchResult.group(2), matchResult.group(1)))
+				.findFirst();
+
+			Optional<TraceInfo> counterTraceInfo = openMetricsOutput.lines()
+				.filter((line) -> line.contains("test_observation_seconds_count") && line.contains("span_id"))
+				.map(COUNTER_TRACE_INFO_PATTERN::matcher)
+				.flatMap(Matcher::results)
+				.map((matchResult) -> new TraceInfo(matchResult.group(2), matchResult.group(1)))
+				.findFirst();
+
+			assertThat(bucketTraceInfo).isNotEmpty().contains(counterTraceInfo.orElse(null));
 		});
 	}
 
@@ -104,4 +127,7 @@ SpanContextSupplier customSpanContextSupplier() {
 
 	}
 
+	private record TraceInfo(String traceId, String spanId) {
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/wavefront/WavefrontTracingAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/wavefront/WavefrontTracingAutoConfigurationTests.java
index a75d692772e6..8295ed6930f6 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/wavefront/WavefrontTracingAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/wavefront/WavefrontTracingAutoConfigurationTests.java
@@ -47,6 +47,9 @@ class WavefrontTracingAutoConfigurationTests {
 	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withConfiguration(
 			AutoConfigurations.of(WavefrontAutoConfiguration.class, WavefrontTracingAutoConfiguration.class));
 
+	private final ApplicationContextRunner tracingDisabledContextRunner = this.contextRunner
+		.withPropertyValues("management.tracing.enabled=false");
+
 	@Test
 	void shouldSupplyBeans() {
 		this.contextRunner.withUserConfiguration(WavefrontSenderConfiguration.class).run((context) -> {
@@ -83,14 +86,11 @@ void shouldNotSupplyBeansIfMicrometerReporterWavefrontIsMissing() {
 
 	@Test
 	void shouldNotSupplyBeansIfTracingIsDisabled() {
-		this.contextRunner.withPropertyValues("management.tracing.enabled=false")
-			.withUserConfiguration(WavefrontSenderConfiguration.class)
-			.run((context) -> {
-				assertThat(context).doesNotHaveBean(WavefrontSpanHandler.class);
-				assertThat(context).doesNotHaveBean(SpanMetrics.class);
-				assertThat(context).doesNotHaveBean(WavefrontBraveSpanHandler.class);
-				assertThat(context).doesNotHaveBean(WavefrontOtelSpanExporter.class);
-			});
+		this.tracingDisabledContextRunner.withUserConfiguration(WavefrontSenderConfiguration.class).run((context) -> {
+			assertThat(context).doesNotHaveBean(WavefrontSpanHandler.class);
+			assertThat(context).doesNotHaveBean(WavefrontBraveSpanHandler.class);
+			assertThat(context).doesNotHaveBean(WavefrontOtelSpanExporter.class);
+		});
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinAutoConfigurationTests.java
index c647bfbfe2ac..1da2e7668a87 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinAutoConfigurationTests.java
@@ -59,12 +59,6 @@ void shouldBackOffOnCustomBeans() {
 		});
 	}
 
-	@Test
-	void shouldNotSupplyBeansIfTracingIsDisabled() {
-		this.contextRunner.withPropertyValues("management.tracing.enabled=false")
-			.run((context) -> assertThat(context).doesNotHaveBean(BytesEncoder.class));
-	}
-
 	@Test
 	void definesPropertiesBasedConnectionDetailsByDefault() {
 		this.contextRunner.run((context) -> assertThat(context).hasSingleBean(PropertiesZipkinConnectionDetails.class));
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurationsBraveConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurationsBraveConfigurationTests.java
index 735cd00d0c6b..9b488aebe404 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurationsBraveConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurationsBraveConfigurationTests.java
@@ -42,6 +42,9 @@ class ZipkinConfigurationsBraveConfigurationTests {
 	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
 		.withConfiguration(AutoConfigurations.of(BraveConfiguration.class));
 
+	private final ApplicationContextRunner tracingDisabledContextRunner = this.contextRunner
+		.withPropertyValues("management.tracing.enabled=false");
+
 	@Test
 	void shouldSupplyBeans() {
 		this.contextRunner.withUserConfiguration(ReporterConfiguration.class)
@@ -79,6 +82,12 @@ void shouldSupplyZipkinSpanHandlerWithCustomSpanHandler() {
 			});
 	}
 
+	@Test
+	void shouldNotSupplyZipkinSpanHandlerIfTracingIsDisabled() {
+		this.tracingDisabledContextRunner.withUserConfiguration(ReporterConfiguration.class)
+			.run((context) -> assertThat(context).doesNotHaveBean(ZipkinSpanHandler.class));
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	private static class ReporterConfiguration {
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurationsOpenTelemetryConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurationsOpenTelemetryConfigurationTests.java
index c3ef5f99c371..5c2a9059c558 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurationsOpenTelemetryConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurationsOpenTelemetryConfigurationTests.java
@@ -43,6 +43,9 @@ class ZipkinConfigurationsOpenTelemetryConfigurationTests {
 	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
 		.withConfiguration(AutoConfigurations.of(BaseConfiguration.class, OpenTelemetryConfiguration.class));
 
+	private final ApplicationContextRunner tracingDisabledContextRunner = this.contextRunner
+		.withPropertyValues("management.tracing.enabled=false");
+
 	@Test
 	void shouldSupplyBeans() {
 		this.contextRunner.withUserConfiguration(SenderConfiguration.class)
@@ -70,6 +73,12 @@ void shouldBackOffOnCustomBeans() {
 		});
 	}
 
+	@Test
+	void shouldNotSupplyZipkinSpanExporterIfTracingIsDisabled() {
+		this.tracingDisabledContextRunner.withUserConfiguration(SenderConfiguration.class)
+			.run((context) -> assertThat(context).doesNotHaveBean(ZipkinSpanExporter.class));
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	private static class SenderConfiguration {
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinHttpSenderTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinHttpSenderTests.java
index 970a8c300950..15fd231e4a1c 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinHttpSenderTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinHttpSenderTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -25,6 +25,7 @@
 import java.util.concurrent.atomic.AtomicReference;
 
 import org.awaitility.Awaitility;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import zipkin2.Callback;
@@ -32,7 +33,7 @@
 import zipkin2.reporter.Sender;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
 
 /**
  * Abstract base test class which is used for testing the different implementations of the
@@ -42,19 +43,25 @@
  */
 abstract class ZipkinHttpSenderTests {
 
-	protected Sender sut;
+	protected Sender sender;
 
-	abstract Sender createSut();
+	abstract Sender createSender();
 
 	@BeforeEach
-	void setUp() {
-		this.sut = createSut();
+	void beforeEach() throws Exception {
+		this.sender = createSender();
+	}
+
+	@AfterEach
+	void afterEach() throws IOException {
+		this.sender.close();
 	}
 
 	@Test
 	void sendSpansShouldThrowIfCloseWasCalled() throws IOException {
-		this.sut.close();
-		assertThatThrownBy(() -> this.sut.sendSpans(Collections.emptyList())).isInstanceOf(ClosedSenderException.class);
+		this.sender.close();
+		assertThatExceptionOfType(ClosedSenderException.class)
+			.isThrownBy(() -> this.sender.sendSpans(Collections.emptyList()));
 	}
 
 	protected void makeRequest(List<byte[]> encodedSpans, boolean async) throws IOException {
@@ -68,8 +75,12 @@ protected void makeRequest(List<byte[]> encodedSpans, boolean async) throws IOEx
 	}
 
 	protected CallbackResult makeAsyncRequest(List<byte[]> encodedSpans) {
+		return makeAsyncRequest(this.sender, encodedSpans);
+	}
+
+	protected CallbackResult makeAsyncRequest(Sender sender, List<byte[]> encodedSpans) {
 		AtomicReference<CallbackResult> callbackResult = new AtomicReference<>();
-		this.sut.sendSpans(encodedSpans).enqueue(new Callback<>() {
+		sender.sendSpans(encodedSpans).enqueue(new Callback<>() {
 			@Override
 			public void onSuccess(Void value) {
 				callbackResult.set(new CallbackResult(true, null));
@@ -84,7 +95,11 @@ public void onError(Throwable t) {
 	}
 
 	protected void makeSyncRequest(List<byte[]> encodedSpans) throws IOException {
-		this.sut.sendSpans(encodedSpans).execute();
+		makeSyncRequest(this.sender, encodedSpans);
+	}
+
+	protected void makeSyncRequest(Sender sender, List<byte[]> encodedSpans) throws IOException {
+		sender.sendSpans(encodedSpans).execute();
 	}
 
 	protected byte[] toByteArray(String input) {
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinRestTemplateSenderTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinRestTemplateSenderTests.java
index ec945ac3da38..ad30c2e5eb52 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinRestTemplateSenderTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinRestTemplateSenderTests.java
@@ -34,7 +34,7 @@
 import org.springframework.web.client.RestTemplate;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThatException;
 import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
 import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
 import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
@@ -54,14 +54,16 @@ class ZipkinRestTemplateSenderTests extends ZipkinHttpSenderTests {
 	private MockRestServiceServer mockServer;
 
 	@Override
-	Sender createSut() {
+	Sender createSender() {
 		RestTemplate restTemplate = new RestTemplate();
 		this.mockServer = MockRestServiceServer.createServer(restTemplate);
 		return new ZipkinRestTemplateSender(ZIPKIN_URL, restTemplate);
 	}
 
 	@AfterEach
-	void tearDown() {
+	@Override
+	void afterEach() throws IOException {
+		super.afterEach();
 		this.mockServer.verify();
 	}
 
@@ -71,7 +73,7 @@ void checkShouldSendEmptySpanList() {
 			.andExpect(method(HttpMethod.POST))
 			.andExpect(content().string("[]"))
 			.andRespond(withStatus(HttpStatus.ACCEPTED));
-		assertThat(this.sut.check()).isEqualTo(CheckResult.OK);
+		assertThat(this.sender.check()).isEqualTo(CheckResult.OK);
 	}
 
 	@Test
@@ -79,7 +81,7 @@ void checkShouldNotRaiseException() {
 		this.mockServer.expect(requestTo(ZIPKIN_URL))
 			.andExpect(method(HttpMethod.POST))
 			.andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR));
-		CheckResult result = this.sut.check();
+		CheckResult result = this.sender.check();
 		assertThat(result.ok()).isFalse();
 		assertThat(result.error()).hasMessageContaining("500 Internal Server Error");
 	}
@@ -107,8 +109,8 @@ void sendSpansShouldHandleHttpFailures(boolean async) {
 			assertThat(callbackResult.error()).isNotNull().hasMessageContaining("500 Internal Server Error");
 		}
 		else {
-			assertThatThrownBy(() -> makeSyncRequest(Collections.emptyList()))
-				.hasMessageContaining("500 Internal Server Error");
+			assertThatException().isThrownBy(() -> makeSyncRequest(Collections.emptyList()))
+				.withMessageContaining("500 Internal Server Error");
 		}
 	}
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinWebClientSenderTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinWebClientSenderTests.java
index dc19d987cab7..70b8402eaa8e 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinWebClientSenderTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinWebClientSenderTests.java
@@ -17,16 +17,21 @@
 package org.springframework.boot.actuate.autoconfigure.tracing.zipkin;
 
 import java.io.IOException;
+import java.time.Duration;
 import java.util.Base64;
 import java.util.Collections;
 import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 import java.util.function.Consumer;
 
 import okhttp3.mockwebserver.MockResponse;
 import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.QueueDispatcher;
 import okhttp3.mockwebserver.RecordedRequest;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
@@ -36,7 +41,7 @@
 import org.springframework.web.reactive.function.client.WebClient;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThatException;
 
 /**
  * Tests for {@link ZipkinWebClientSender}.
@@ -45,33 +50,48 @@
  */
 class ZipkinWebClientSenderTests extends ZipkinHttpSenderTests {
 
+	private static ClearableDispatcher dispatcher;
+
 	private static MockWebServer mockBackEnd;
 
 	private static String ZIPKIN_URL;
 
 	@BeforeAll
 	static void beforeAll() throws IOException {
+		dispatcher = new ClearableDispatcher();
 		mockBackEnd = new MockWebServer();
+		mockBackEnd.setDispatcher(dispatcher);
 		mockBackEnd.start();
-		ZIPKIN_URL = "http://localhost:%s/api/v2/spans".formatted(mockBackEnd.getPort());
+		ZIPKIN_URL = mockBackEnd.url("/api/v2/spans").toString();
 	}
 
 	@AfterAll
-	static void tearDown() throws IOException {
+	static void afterAll() throws IOException {
 		mockBackEnd.shutdown();
 	}
 
 	@Override
-	Sender createSut() {
+	@BeforeEach
+	void beforeEach() throws Exception {
+		super.beforeEach();
+		clearResponses();
+		clearRequests();
+	}
+
+	@Override
+	Sender createSender() {
+		return createSender(Duration.ofSeconds(10));
+	}
+
+	Sender createSender(Duration timeout) {
 		WebClient webClient = WebClient.builder().build();
-		return new ZipkinWebClientSender(ZIPKIN_URL, webClient);
+		return new ZipkinWebClientSender(ZIPKIN_URL, webClient, timeout);
 	}
 
 	@Test
 	void checkShouldSendEmptySpanList() throws InterruptedException {
 		mockBackEnd.enqueue(new MockResponse());
-		assertThat(this.sut.check()).isEqualTo(CheckResult.OK);
-
+		assertThat(this.sender.check()).isEqualTo(CheckResult.OK);
 		requestAssertions((request) -> {
 			assertThat(request.getMethod()).isEqualTo("POST");
 			assertThat(request.getBody().readUtf8()).isEqualTo("[]");
@@ -81,10 +101,9 @@ void checkShouldSendEmptySpanList() throws InterruptedException {
 	@Test
 	void checkShouldNotRaiseException() throws InterruptedException {
 		mockBackEnd.enqueue(new MockResponse().setResponseCode(500));
-		CheckResult result = this.sut.check();
+		CheckResult result = this.sender.check();
 		assertThat(result.ok()).isFalse();
 		assertThat(result.error()).hasMessageContaining("500 Internal Server Error");
-
 		requestAssertions((request) -> assertThat(request.getMethod()).isEqualTo("POST"));
 	}
 
@@ -94,7 +113,6 @@ void sendSpansShouldSendSpansToZipkin(boolean async) throws IOException, Interru
 		mockBackEnd.enqueue(new MockResponse());
 		List<byte[]> encodedSpans = List.of(toByteArray("span1"), toByteArray("span2"));
 		makeRequest(encodedSpans, async);
-
 		requestAssertions((request) -> {
 			assertThat(request.getMethod()).isEqualTo("POST");
 			assertThat(request.getHeader("Content-Type")).isEqualTo("application/json");
@@ -112,10 +130,9 @@ void sendSpansShouldHandleHttpFailures(boolean async) throws InterruptedExceptio
 			assertThat(callbackResult.error()).isNotNull().hasMessageContaining("500 Internal Server Error");
 		}
 		else {
-			assertThatThrownBy(() -> makeSyncRequest(Collections.emptyList()))
-				.hasMessageContaining("500 Internal Server Error");
+			assertThatException().isThrownBy(() -> makeSyncRequest(Collections.emptyList()))
+				.withMessageContaining("500 Internal Server Error");
 		}
-
 		requestAssertions((request) -> assertThat(request.getMethod()).isEqualTo("POST"));
 	}
 
@@ -126,18 +143,31 @@ void sendSpansShouldCompressData(boolean async) throws IOException, InterruptedE
 		// This is gzip compressed 10000 times 'a'
 		byte[] compressed = Base64.getDecoder()
 			.decode("H4sIAAAAAAAA/+3BMQ0AAAwDIKFLj/k3UR8NcA8AAAAAAAAAAAADUsAZfeASJwAA");
-
 		mockBackEnd.enqueue(new MockResponse());
-
 		makeRequest(List.of(toByteArray(uncompressed)), async);
-
 		requestAssertions((request) -> {
 			assertThat(request.getMethod()).isEqualTo("POST");
 			assertThat(request.getHeader("Content-Type")).isEqualTo("application/json");
 			assertThat(request.getHeader("Content-Encoding")).isEqualTo("gzip");
 			assertThat(request.getBody().readByteArray()).isEqualTo(compressed);
 		});
+	}
 
+	@ParameterizedTest
+	@ValueSource(booleans = { true, false })
+	void shouldTimeout(boolean async) {
+		Sender sender = createSender(Duration.ofMillis(1));
+		MockResponse response = new MockResponse().setResponseCode(200).setHeadersDelay(100, TimeUnit.MILLISECONDS);
+		mockBackEnd.enqueue(response);
+		if (async) {
+			CallbackResult callbackResult = makeAsyncRequest(sender, Collections.emptyList());
+			assertThat(callbackResult.success()).isFalse();
+			assertThat(callbackResult.error()).isInstanceOf(TimeoutException.class);
+		}
+		else {
+			assertThatException().isThrownBy(() -> makeSyncRequest(sender, Collections.emptyList()))
+				.withCauseInstanceOf(TimeoutException.class);
+		}
 	}
 
 	private void requestAssertions(Consumer<RecordedRequest> assertions) throws InterruptedException {
@@ -145,4 +175,24 @@ private void requestAssertions(Consumer<RecordedRequest> assertions) throws Inte
 		assertThat(request).satisfies(assertions);
 	}
 
+	private static void clearRequests() throws InterruptedException {
+		RecordedRequest request;
+		do {
+			request = mockBackEnd.takeRequest(0, TimeUnit.SECONDS);
+		}
+		while (request != null);
+	}
+
+	private static void clearResponses() {
+		dispatcher.clear();
+	}
+
+	private static class ClearableDispatcher extends QueueDispatcher {
+
+		void clear() {
+			getResponseQueue().clear();
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontPropertiesMetricsExportTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontPropertiesMetricsExportTests.java
index f467aa7c374a..172939080993 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontPropertiesMetricsExportTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontPropertiesMetricsExportTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -39,6 +39,9 @@ void defaultValuesAreConsistent() {
 		assertThat(properties.getReadTimeout()).isEqualTo(config.readTimeout());
 		assertThat(properties.getStep()).isEqualTo(config.step());
 		assertThat(properties.isEnabled()).isEqualTo(config.enabled());
+		assertThat(properties.isReportMinuteDistribution()).isEqualTo(config.reportMinuteDistribution());
+		assertThat(properties.isReportHourDistribution()).isEqualTo(config.reportHourDistribution());
+		assertThat(properties.isReportDayDistribution()).isEqualTo(config.reportDayDistribution());
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontPropertiesTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontPropertiesTests.java
index 02d1ce3b77cc..b9aaca914390 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontPropertiesTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontPropertiesTests.java
@@ -18,12 +18,16 @@
 
 import java.net.URI;
 
+import com.wavefront.sdk.common.clients.service.token.TokenService.Type;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
 
+import org.springframework.boot.actuate.autoconfigure.wavefront.WavefrontProperties.TokenType;
 import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
 
 /**
  * Tests for {@link WavefrontProperties}.
@@ -34,21 +38,54 @@ class WavefrontPropertiesTests {
 
 	@Test
 	void apiTokenIsOptionalWhenUsingProxy() {
-		WavefrontProperties sut = new WavefrontProperties();
-		sut.setUri(URI.create("proxy://localhost:2878"));
-		sut.setApiToken(null);
-		assertThat(sut.getApiTokenOrThrow()).isNull();
-		assertThat(sut.getEffectiveUri()).isEqualTo(URI.create("http://localhost:2878"));
+		WavefrontProperties properties = new WavefrontProperties();
+		properties.setUri(URI.create("proxy://localhost:2878"));
+		properties.setApiToken(null);
+		assertThat(properties.getApiTokenOrThrow()).isNull();
+		assertThat(properties.getEffectiveUri()).isEqualTo(URI.create("http://localhost:2878"));
 	}
 
 	@Test
 	void apiTokenIsMandatoryWhenNotUsingProxy() {
-		WavefrontProperties sut = new WavefrontProperties();
-		sut.setUri(URI.create("http://localhost:2878"));
-		sut.setApiToken(null);
-		assertThat(sut.getEffectiveUri()).isEqualTo(URI.create("http://localhost:2878"));
-		assertThatThrownBy(sut::getApiTokenOrThrow).isInstanceOf(InvalidConfigurationPropertyValueException.class)
-			.hasMessageContaining("management.wavefront.api-token");
+		WavefrontProperties properties = new WavefrontProperties();
+		properties.setUri(URI.create("http://localhost:2878"));
+		properties.setApiToken(null);
+		assertThat(properties.getEffectiveUri()).isEqualTo(URI.create("http://localhost:2878"));
+		assertThatExceptionOfType(InvalidConfigurationPropertyValueException.class)
+			.isThrownBy(properties::getApiTokenOrThrow)
+			.withMessageContaining("management.wavefront.api-token");
+	}
+
+	@Test
+	void shouldNotFailIfTokenTypeIsSetToNoToken() {
+		WavefrontProperties properties = new WavefrontProperties();
+		properties.setUri(URI.create("http://localhost:2878"));
+		properties.setApiTokenType(TokenType.NO_TOKEN);
+		properties.setApiToken(null);
+		assertThat(properties.getApiTokenOrThrow()).isNull();
+	}
+
+	@Test
+	void wavefrontApiTokenTypeWhenUsingProxy() {
+		WavefrontProperties properties = new WavefrontProperties();
+		properties.setUri(URI.create("proxy://localhost:2878"));
+		assertThat(properties.getWavefrontApiTokenType()).isEqualTo(Type.NO_TOKEN);
+	}
+
+	@Test
+	void wavefrontApiTokenTypeWhenNotUsingProxy() {
+		WavefrontProperties properties = new WavefrontProperties();
+		properties.setUri(URI.create("http://localhost:2878"));
+		assertThat(properties.getWavefrontApiTokenType()).isEqualTo(Type.WAVEFRONT_API_TOKEN);
+	}
+
+	@ParameterizedTest
+	@EnumSource(TokenType.class)
+	void wavefrontApiTokenMapping(TokenType from) {
+		WavefrontProperties properties = new WavefrontProperties();
+		properties.setApiTokenType(from);
+		Type expected = Type.valueOf(from.name());
+		assertThat(properties.getWavefrontApiTokenType()).isEqualTo(expected);
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontSenderConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontSenderConfigurationTests.java
index a75203e4fedf..512fd8311741 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontSenderConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontSenderConfigurationTests.java
@@ -19,6 +19,9 @@
 import java.util.concurrent.LinkedBlockingQueue;
 
 import com.wavefront.sdk.common.WavefrontSender;
+import com.wavefront.sdk.common.clients.service.token.CSPTokenService;
+import com.wavefront.sdk.common.clients.service.token.NoopProxyTokenService;
+import com.wavefront.sdk.common.clients.service.token.WavefrontTokenService;
 import org.assertj.core.api.InstanceOfAssertFactories;
 import org.junit.jupiter.api.Test;
 
@@ -42,6 +45,17 @@ class WavefrontSenderConfigurationTests {
 	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
 		.withConfiguration(AutoConfigurations.of(WavefrontSenderConfiguration.class));
 
+	private final ApplicationContextRunner tracingDisabledContextRunner = this.contextRunner
+		.withPropertyValues("management.tracing.enabled=false");
+
+	private final ApplicationContextRunner metricsDisabledContextRunner = this.contextRunner.withPropertyValues(
+			"management.defaults.metrics.export.enabled=false", "management.simple.metrics.export.enabled=true");
+
+	// Both metrics and tracing are disabled
+	private final ApplicationContextRunner observabilityDisabledContextRunner = this.contextRunner.withPropertyValues(
+			"management.tracing.enabled=false", "management.defaults.metrics.export.enabled=false",
+			"management.simple.metrics.export.enabled=true");
+
 	@Test
 	void shouldNotFailIfWavefrontIsMissing() {
 		this.contextRunner.withClassLoader(new FilteredClassLoader("com.wavefront"))
@@ -83,12 +97,71 @@ void configureWavefrontSender() {
 			});
 	}
 
+	@Test
+	void shouldNotSupplyWavefrontSenderIfObservabilityIsDisabled() {
+		this.observabilityDisabledContextRunner.withPropertyValues("management.wavefront.api-token=abcde")
+			.run((context) -> assertThat(context).doesNotHaveBean(WavefrontSender.class));
+	}
+
+	@Test
+	void shouldSupplyWavefrontSenderIfOnlyTracingIsDisabled() {
+		this.tracingDisabledContextRunner.withPropertyValues("management.wavefront.api-token=abcde")
+			.run((context) -> assertThat(context).hasSingleBean(WavefrontSender.class));
+	}
+
+	@Test
+	void shouldSupplyWavefrontSenderIfOnlyMetricsAreDisabled() {
+		this.metricsDisabledContextRunner.withPropertyValues("management.wavefront.api-token=abcde")
+			.run((context) -> assertThat(context).hasSingleBean(WavefrontSender.class));
+	}
+
 	@Test
 	void allowsWavefrontSenderToBeCustomized() {
 		this.contextRunner.withUserConfiguration(CustomSenderConfiguration.class)
 			.run((context) -> assertThat(context).hasSingleBean(WavefrontSender.class).hasBean("customSender"));
 	}
 
+	@Test
+	void shouldApplyTokenTypeWavefrontApiToken() {
+		this.contextRunner
+			.withPropertyValues("management.wavefront.api-token-type=WAVEFRONT_API_TOKEN",
+					"management.wavefront.api-token=abcde")
+			.run((context) -> {
+				WavefrontSender sender = context.getBean(WavefrontSender.class);
+				assertThat(sender).extracting("tokenService").isInstanceOf(WavefrontTokenService.class);
+			});
+	}
+
+	@Test
+	void shouldApplyTokenTypeCspApiToken() {
+		this.contextRunner
+			.withPropertyValues("management.wavefront.api-token-type=CSP_API_TOKEN",
+					"management.wavefront.api-token=abcde")
+			.run((context) -> {
+				WavefrontSender sender = context.getBean(WavefrontSender.class);
+				assertThat(sender).extracting("tokenService").isInstanceOf(CSPTokenService.class);
+			});
+	}
+
+	@Test
+	void shouldApplyTokenTypeCspClientCredentials() {
+		this.contextRunner
+			.withPropertyValues("management.wavefront.api-token-type=CSP_CLIENT_CREDENTIALS",
+					"management.wavefront.api-token=clientid=cid,clientsecret=csec")
+			.run((context) -> {
+				WavefrontSender sender = context.getBean(WavefrontSender.class);
+				assertThat(sender).extracting("tokenService").isInstanceOf(CSPTokenService.class);
+			});
+	}
+
+	@Test
+	void shouldApplyTokenTypeNoToken() {
+		this.contextRunner.withPropertyValues("management.wavefront.api-token-type=NO_TOKEN").run((context) -> {
+			WavefrontSender sender = context.getBean(WavefrontSender.class);
+			assertThat(sender).extracting("tokenService").isInstanceOf(NoopProxyTokenService.class);
+		});
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class CustomSenderConfiguration {
 
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/web/server/ManagementContextAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/web/server/ManagementContextAutoConfigurationTests.java
index 1d4e7c731983..52fca8b17ae8 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/web/server/ManagementContextAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/web/server/ManagementContextAutoConfigurationTests.java
@@ -55,6 +55,21 @@ void childManagementContextShouldStartForEmbeddedServer(CapturedOutput output) {
 			.run((context) -> assertThat(output).satisfies(numberOfOccurrences("Tomcat started on port", 2)));
 	}
 
+	@Test
+	void childManagementContextShouldRestartWhenParentIsStoppedThenStarted(CapturedOutput output) {
+		WebApplicationContextRunner contextRunner = new WebApplicationContextRunner(
+				AnnotationConfigServletWebServerApplicationContext::new)
+			.withConfiguration(AutoConfigurations.of(ManagementContextAutoConfiguration.class,
+					ServletWebServerFactoryAutoConfiguration.class, ServletManagementContextAutoConfiguration.class,
+					WebEndpointAutoConfiguration.class, EndpointAutoConfiguration.class));
+		contextRunner.withPropertyValues("server.port=0", "management.server.port=0").run((context) -> {
+			assertThat(output).satisfies(numberOfOccurrences("Tomcat started on port", 2));
+			context.getSourceApplicationContext().stop();
+			context.getSourceApplicationContext().start();
+			assertThat(output).satisfies(numberOfOccurrences("Tomcat started on port", 4));
+		});
+	}
+
 	@Test
 	void givenSamePortManagementServerWhenManagementServerAddressIsConfiguredThenContextRefreshFails() {
 		WebApplicationContextRunner contextRunner = new WebApplicationContextRunner(
diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/resources/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/sample.log b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/resources/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/sample.log
index 9b8f1ab7eced..b1f92c2d2c35 100644
--- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/resources/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/sample.log
+++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/resources/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/sample.log
@@ -9,7 +9,7 @@
 2017-08-08 17:12:30.910  INFO 19866 --- [           main] s.f.SampleWebFreeMarkerApplication       : Starting SampleWebFreeMarkerApplication with PID 19866
 2017-08-08 17:12:30.913  INFO 19866 --- [           main] s.f.SampleWebFreeMarkerApplication       : No active profile set, falling back to default profiles: default
 2017-08-08 17:12:30.952  INFO 19866 --- [           main] ConfigServletWebServerApplicationContext : Refreshing org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext@76b10754: startup date [Tue Aug 08 17:12:30 BST 2017]; root of context hierarchy
-2017-08-08 17:12:31.878  INFO 19866 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
+2017-08-08 17:12:31.878  INFO 19866 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port 8080 (http)
 2017-08-08 17:12:31.889  INFO 19866 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
 2017-08-08 17:12:31.890  INFO 19866 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet Engine: Apache Tomcat/8.5.16
 2017-08-08 17:12:31.978  INFO 19866 --- [ost-startStop-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
@@ -27,5 +27,5 @@
 2017-08-08 17:12:32.471  INFO 19866 --- [           main] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped URL path [/**/favicon.ico] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
 2017-08-08 17:12:32.600  INFO 19866 --- [           main] o.s.w.s.v.f.FreeMarkerConfigurer         : ClassTemplateLoader for Spring macros added to FreeMarker configuration
 2017-08-08 17:12:32.681  INFO 19866 --- [           main] o.s.j.e.a.AnnotationMBeanExporter        : Registering beans for JMX exposure on startup
-2017-08-08 17:12:32.744  INFO 19866 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http)
+2017-08-08 17:12:32.744  INFO 19866 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port 8080 (http)
 2017-08-08 17:12:32.750  INFO 19866 --- [           main] s.f.SampleWebFreeMarkerApplication       : Started SampleWebFreeMarkerApplication in 2.172 seconds (JVM running for 2.479)
diff --git a/spring-boot-project/spring-boot-actuator/build.gradle b/spring-boot-project/spring-boot-actuator/build.gradle
index c4e393059178..37325cfd7e19 100644
--- a/spring-boot-project/spring-boot-actuator/build.gradle
+++ b/spring-boot-project/spring-boot-actuator/build.gradle
@@ -22,7 +22,7 @@ dependencies {
 	optional("com.zaxxer:HikariCP")
 	optional("io.lettuce:lettuce-core")
 	optional("io.micrometer:micrometer-observation")
-	optional("io.micrometer:micrometer-core")
+	optional("io.micrometer:micrometer-jakarta9")
 	optional("io.micrometer:micrometer-tracing")
 	optional("io.micrometer:micrometer-registry-prometheus")
 	optional("io.prometheus:simpleclient_pushgateway") {
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/AbstractReactiveHealthIndicator.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/AbstractReactiveHealthIndicator.java
index 4e9269af24a6..0b6e2c792693 100644
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/AbstractReactiveHealthIndicator.java
+++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/AbstractReactiveHealthIndicator.java
@@ -99,7 +99,7 @@ private Mono<Health> handleFailure(Throwable ex) {
 	}
 
 	/**
-	 * Actual health check logic. If an error occurs in the pipeline it will be handled
+	 * Actual health check logic. If an error occurs in the pipeline, it will be handled
 	 * automatically.
 	 * @param builder the {@link Health.Builder} to report health status and details
 	 * @return a {@link Mono} that provides the {@link Health}
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/influx/InfluxDbHealthIndicator.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/influx/InfluxDbHealthIndicator.java
index 4896ff6094e2..f58586fb925c 100644
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/influx/InfluxDbHealthIndicator.java
+++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/influx/InfluxDbHealthIndicator.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -29,7 +29,11 @@
  *
  * @author EddĂș MelĂ©ndez
  * @since 2.0.0
+ * @deprecated since 3.2.0 for removal in 3.4.0 in favor of the
+ * <a href="https://github.com/influxdata/influxdb-client-java">new client</a> and its own
+ * Spring Boot integration.
  */
+@Deprecated(since = "3.2.0", forRemoval = true)
 public class InfluxDbHealthIndicator extends AbstractHealthIndicator {
 
 	private final InfluxDB influxDb;
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/jdbc/DataSourceHealthIndicator.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/jdbc/DataSourceHealthIndicator.java
index b92c7398a67d..caef14b9bcbd 100644
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/jdbc/DataSourceHealthIndicator.java
+++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/jdbc/DataSourceHealthIndicator.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -101,7 +101,7 @@ protected void doHealthCheck(Health.Builder builder) throws Exception {
 		}
 	}
 
-	private void doDataSourceHealthCheck(Health.Builder builder) throws Exception {
+	private void doDataSourceHealthCheck(Health.Builder builder) {
 		builder.up().withDetail("database", getProduct());
 		String validationQuery = this.query;
 		if (StringUtils.hasText(validationQuery)) {
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/mail/MailHealthIndicator.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/mail/MailHealthIndicator.java
index afd67045f7fe..99bc8a5eac4b 100644
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/mail/MailHealthIndicator.java
+++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/mail/MailHealthIndicator.java
@@ -20,11 +20,13 @@
 import org.springframework.boot.actuate.health.Health.Builder;
 import org.springframework.boot.actuate.health.HealthIndicator;
 import org.springframework.mail.javamail.JavaMailSenderImpl;
+import org.springframework.util.StringUtils;
 
 /**
  * {@link HealthIndicator} for configured smtp server(s).
  *
  * @author Johannes Edmeier
+ * @author Scott Frederick
  * @since 2.0.0
  */
 public class MailHealthIndicator extends AbstractHealthIndicator {
@@ -38,9 +40,15 @@ public MailHealthIndicator(JavaMailSenderImpl mailSender) {
 
 	@Override
 	protected void doHealthCheck(Builder builder) throws Exception {
+		String host = this.mailSender.getHost();
 		int port = this.mailSender.getPort();
-		builder.withDetail("location", (port != JavaMailSenderImpl.DEFAULT_PORT)
-				? this.mailSender.getHost() + ":" + this.mailSender.getPort() : this.mailSender.getHost());
+		StringBuilder location = new StringBuilder((host != null) ? host : "");
+		if (port != JavaMailSenderImpl.DEFAULT_PORT) {
+			location.append(":").append(port);
+		}
+		if (StringUtils.hasLength(location)) {
+			builder.withDetail("location", location.toString());
+		}
 		this.mailSender.testConnection();
 		builder.up();
 	}
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusPushGatewayManager.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusPushGatewayManager.java
index f0a3453e70e1..dc3ebe4d31ac 100644
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusPushGatewayManager.java
+++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusPushGatewayManager.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -141,7 +141,7 @@ private void shutdown(ShutdownOperation shutdownOperation) {
 		}
 		this.scheduled.cancel(false);
 		switch (shutdownOperation) {
-			case PUSH, POST -> post();
+			case POST -> post();
 			case PUT -> put();
 			case DELETE -> delete();
 		}
@@ -162,13 +162,6 @@ public enum ShutdownOperation {
 		 */
 		POST,
 
-		/**
-		 * Perform a POST before shutdown.
-		 * @deprecated since 3.0.0 for removal in 3.2.0 in favor of {@link #POST}.
-		 */
-		@Deprecated(since = "3.0.0", forRemoval = true)
-		PUSH,
-
 		/**
 		 * Perform a PUT before shutdown.
 		 */
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/jdbc/DataSourcePoolMetrics.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/jdbc/DataSourcePoolMetrics.java
index caf29f30ff68..f22023a1ef24 100644
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/jdbc/DataSourcePoolMetrics.java
+++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/jdbc/DataSourcePoolMetrics.java
@@ -110,12 +110,8 @@ <N extends Number> Function<DataSource, N> getValueFunction(Function<DataSourceP
 
 		@Override
 		public DataSourcePoolMetadata getDataSourcePoolMetadata(DataSource dataSource) {
-			DataSourcePoolMetadata metadata = cache.get(dataSource);
-			if (metadata == null) {
-				metadata = this.metadataProvider.getDataSourcePoolMetadata(dataSource);
-				cache.put(dataSource, metadata);
-			}
-			return metadata;
+			return cache.computeIfAbsent(dataSource,
+					(key) -> this.metadataProvider.getDataSourcePoolMetadata(dataSource));
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/startup/StartupTimeMetricsListener.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/startup/StartupTimeMetricsListener.java
index 7f5a4df7c193..de992b8c8672 100644
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/startup/StartupTimeMetricsListener.java
+++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/startup/StartupTimeMetricsListener.java
@@ -103,12 +103,12 @@ public void onApplicationEvent(ApplicationEvent event) {
 	}
 
 	private void onApplicationStarted(ApplicationStartedEvent event) {
-		registerGauge(this.startedTimeMetricName, "Time taken (ms) to start the application", event.getTimeTaken(),
+		registerGauge(this.startedTimeMetricName, "Time taken to start the application", event.getTimeTaken(),
 				event.getSpringApplication());
 	}
 
 	private void onApplicationReady(ApplicationReadyEvent event) {
-		registerGauge(this.readyTimeMetricName, "Time taken (ms) for the application to be ready to service requests",
+		registerGauge(this.readyTimeMetricName, "Time taken for the application to be ready to service requests",
 				event.getTimeTaken(), event.getSpringApplication());
 	}
 
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/DefaultRestTemplateExchangeTagsProvider.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/DefaultRestTemplateExchangeTagsProvider.java
deleted file mode 100644
index ace3689e6862..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/DefaultRestTemplateExchangeTagsProvider.java
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.metrics.web.client;
-
-import java.util.Arrays;
-
-import io.micrometer.core.instrument.Tag;
-
-import org.springframework.http.HttpRequest;
-import org.springframework.http.client.ClientHttpResponse;
-import org.springframework.util.StringUtils;
-
-/**
- * Default implementation of {@link RestTemplateExchangeTagsProvider}.
- *
- * @author Jon Schneider
- * @author Nishant Raut
- * @since 2.0.0
- * @deprecated since 3.0.0 for removal in 3.2.0 in favor of
- * {@link org.springframework.http.client.observation.DefaultClientRequestObservationConvention}
- */
-@Deprecated(since = "3.0.0", forRemoval = true)
-@SuppressWarnings("removal")
-public class DefaultRestTemplateExchangeTagsProvider implements RestTemplateExchangeTagsProvider {
-
-	@Override
-	public Iterable<Tag> getTags(String urlTemplate, HttpRequest request, ClientHttpResponse response) {
-		Tag uriTag = (StringUtils.hasText(urlTemplate) ? RestTemplateExchangeTags.uri(urlTemplate)
-				: RestTemplateExchangeTags.uri(request));
-		return Arrays.asList(RestTemplateExchangeTags.method(request), uriTag,
-				RestTemplateExchangeTags.status(response), RestTemplateExchangeTags.clientName(request),
-				RestTemplateExchangeTags.outcome(response));
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/ObservationRestClientCustomizer.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/ObservationRestClientCustomizer.java
new file mode 100644
index 000000000000..904e94260340
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/ObservationRestClientCustomizer.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.metrics.web.client;
+
+import io.micrometer.observation.ObservationRegistry;
+
+import org.springframework.boot.web.client.RestClientCustomizer;
+import org.springframework.http.client.observation.ClientRequestObservationConvention;
+import org.springframework.util.Assert;
+import org.springframework.web.client.RestClient.Builder;
+
+/**
+ * {@link RestClientCustomizer} that configures the {@link Builder RestClient builder} to
+ * record request observations.
+ *
+ * @author Moritz Halbritter
+ * @since 3.2.0
+ */
+public class ObservationRestClientCustomizer implements RestClientCustomizer {
+
+	private final ObservationRegistry observationRegistry;
+
+	private final ClientRequestObservationConvention observationConvention;
+
+	/**
+	 * Create a new {@link ObservationRestClientCustomizer}.
+	 * @param observationRegistry the observation registry
+	 * @param observationConvention the observation convention
+	 */
+	public ObservationRestClientCustomizer(ObservationRegistry observationRegistry,
+			ClientRequestObservationConvention observationConvention) {
+		Assert.notNull(observationConvention, "ObservationConvention must not be null");
+		Assert.notNull(observationRegistry, "ObservationRegistry must not be null");
+		this.observationRegistry = observationRegistry;
+		this.observationConvention = observationConvention;
+	}
+
+	@Override
+	public void customize(Builder restClientBuilder) {
+		restClientBuilder.observationRegistry(this.observationRegistry);
+		restClientBuilder.observationConvention(this.observationConvention);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/RestTemplateExchangeTags.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/RestTemplateExchangeTags.java
deleted file mode 100644
index 5f17ee3d4f44..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/RestTemplateExchangeTags.java
+++ /dev/null
@@ -1,144 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.metrics.web.client;
-
-import java.io.IOException;
-import java.net.URI;
-import java.util.regex.Pattern;
-
-import io.micrometer.core.instrument.Tag;
-
-import org.springframework.boot.actuate.metrics.http.Outcome;
-import org.springframework.http.HttpRequest;
-import org.springframework.http.client.ClientHttpResponse;
-import org.springframework.http.client.observation.DefaultClientRequestObservationConvention;
-import org.springframework.util.StringUtils;
-import org.springframework.web.client.RestTemplate;
-
-/**
- * Factory methods for creating {@link Tag Tags} related to a request-response exchange
- * performed by a {@link RestTemplate}.
- *
- * @author Andy Wilkinson
- * @author Jon Schneider
- * @author Nishant Raut
- * @author Brian Clozel
- * @since 2.0.0
- * @deprecated since 3.0.0 for removal in 3.2.0 in favor of
- * {@link DefaultClientRequestObservationConvention}
- */
-@Deprecated(since = "3.0.0", forRemoval = true)
-public final class RestTemplateExchangeTags {
-
-	private static final Pattern STRIP_URI_PATTERN = Pattern.compile("^https?://[^/]+/");
-
-	private RestTemplateExchangeTags() {
-	}
-
-	/**
-	 * Creates a {@code method} {@code Tag} for the {@link HttpRequest#getMethod() method}
-	 * of the given {@code request}.
-	 * @param request the request
-	 * @return the method tag
-	 */
-	public static Tag method(HttpRequest request) {
-		return Tag.of("method", request.getMethod().name());
-	}
-
-	/**
-	 * Creates a {@code uri} {@code Tag} for the URI of the given {@code request}.
-	 * @param request the request
-	 * @return the uri tag
-	 */
-	public static Tag uri(HttpRequest request) {
-		return Tag.of("uri", ensureLeadingSlash(stripUri(request.getURI().toString())));
-	}
-
-	/**
-	 * Creates a {@code uri} {@code Tag} from the given {@code uriTemplate}.
-	 * @param uriTemplate the template
-	 * @return the uri tag
-	 */
-	public static Tag uri(String uriTemplate) {
-		String uri = (StringUtils.hasText(uriTemplate) ? uriTemplate : "none");
-		return Tag.of("uri", ensureLeadingSlash(stripUri(uri)));
-	}
-
-	private static String stripUri(String uri) {
-		return STRIP_URI_PATTERN.matcher(uri).replaceAll("");
-	}
-
-	private static String ensureLeadingSlash(String url) {
-		return (url == null || url.startsWith("/")) ? url : "/" + url;
-	}
-
-	/**
-	 * Creates a {@code status} {@code Tag} derived from the
-	 * {@link ClientHttpResponse#getStatusCode() status} of the given {@code response}.
-	 * @param response the response
-	 * @return the status tag
-	 */
-	public static Tag status(ClientHttpResponse response) {
-		return Tag.of("status", getStatusMessage(response));
-	}
-
-	private static String getStatusMessage(ClientHttpResponse response) {
-		try {
-			if (response == null) {
-				return "CLIENT_ERROR";
-			}
-			return String.valueOf(response.getStatusCode().value());
-		}
-		catch (IOException ex) {
-			return "IO_ERROR";
-		}
-	}
-
-	/**
-	 * Create a {@code client.name} {@code Tag} derived from the {@link URI#getHost host}
-	 * of the {@link HttpRequest#getURI() URI} of the given {@code request}.
-	 * @param request the request
-	 * @return the client.name tag
-	 */
-	public static Tag clientName(HttpRequest request) {
-		String host = request.getURI().getHost();
-		if (host == null) {
-			host = "none";
-		}
-		return Tag.of("client.name", host);
-	}
-
-	/**
-	 * Creates an {@code outcome} {@code Tag} derived from the
-	 * {@link ClientHttpResponse#getStatusCode() status} of the given {@code response}.
-	 * @param response the response
-	 * @return the outcome tag
-	 * @since 2.2.0
-	 */
-	public static Tag outcome(ClientHttpResponse response) {
-		try {
-			if (response != null) {
-				return Outcome.forStatus(response.getStatusCode().value()).asTag();
-			}
-		}
-		catch (IOException ex) {
-			// Continue
-		}
-		return Outcome.UNKNOWN.asTag();
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/RestTemplateExchangeTagsProvider.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/RestTemplateExchangeTagsProvider.java
deleted file mode 100644
index ea3c05360ce3..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/RestTemplateExchangeTagsProvider.java
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.metrics.web.client;
-
-import io.micrometer.core.instrument.Tag;
-
-import org.springframework.http.HttpRequest;
-import org.springframework.http.client.ClientHttpResponse;
-import org.springframework.http.client.observation.ClientRequestObservationConvention;
-import org.springframework.web.client.RestTemplate;
-
-/**
- * Provides {@link Tag Tags} for an exchange performed by a {@link RestTemplate}.
- *
- * @author Jon Schneider
- * @author Andy Wilkinson
- * @since 2.0.0
- * @deprecated since 3.0.0 for removal in 3.2.0 in favor of
- * {@link ClientRequestObservationConvention}
- */
-@FunctionalInterface
-@Deprecated(since = "3.0.0", forRemoval = true)
-public interface RestTemplateExchangeTagsProvider {
-
-	/**
-	 * Provides the tags to be associated with metrics that are recorded for the given
-	 * {@code request} and {@code response} exchange.
-	 * @param urlTemplate the source URl template, if available
-	 * @param request the request
-	 * @param response the response (may be {@code null} if the exchange failed)
-	 * @return the tags
-	 */
-	Iterable<Tag> getTags(String urlTemplate, HttpRequest request, ClientHttpResponse response);
-
-}
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/client/DefaultWebClientExchangeTagsProvider.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/client/DefaultWebClientExchangeTagsProvider.java
deleted file mode 100644
index aeae3222d5ab..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/client/DefaultWebClientExchangeTagsProvider.java
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.metrics.web.reactive.client;
-
-import java.util.Arrays;
-
-import io.micrometer.core.instrument.Tag;
-
-import org.springframework.web.reactive.function.client.ClientRequest;
-import org.springframework.web.reactive.function.client.ClientResponse;
-
-/**
- * Default implementation of {@link WebClientExchangeTagsProvider}.
- *
- * @author Brian Clozel
- * @author Nishant Raut
- * @since 2.1.0
- * @deprecated since 3.0.0 for removal in 3.2.0 in favor of
- * {@link org.springframework.web.reactive.function.client.ClientRequestObservationConvention}
- */
-@Deprecated(since = "3.0.0", forRemoval = true)
-@SuppressWarnings("removal")
-public class DefaultWebClientExchangeTagsProvider implements WebClientExchangeTagsProvider {
-
-	@Override
-	public Iterable<Tag> tags(ClientRequest request, ClientResponse response, Throwable throwable) {
-		Tag method = WebClientExchangeTags.method(request);
-		Tag uri = WebClientExchangeTags.uri(request);
-		Tag clientName = WebClientExchangeTags.clientName(request);
-		Tag status = WebClientExchangeTags.status(response, throwable);
-		Tag outcome = WebClientExchangeTags.outcome(response);
-		return Arrays.asList(method, uri, clientName, status, outcome);
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/client/WebClientExchangeTags.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/client/WebClientExchangeTags.java
deleted file mode 100644
index c916188b6846..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/client/WebClientExchangeTags.java
+++ /dev/null
@@ -1,127 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.metrics.web.reactive.client;
-
-import java.io.IOException;
-import java.util.regex.Pattern;
-
-import io.micrometer.core.instrument.Tag;
-
-import org.springframework.boot.actuate.metrics.http.Outcome;
-import org.springframework.http.client.reactive.ClientHttpRequest;
-import org.springframework.web.reactive.function.client.ClientRequest;
-import org.springframework.web.reactive.function.client.ClientResponse;
-import org.springframework.web.reactive.function.client.WebClient;
-
-/**
- * Factory methods for creating {@link Tag Tags} related to a request-response exchange
- * performed by a {@link WebClient}.
- *
- * @author Brian Clozel
- * @author Nishant Raut
- * @since 2.1.0
- * @deprecated since 3.0.0 for removal in 3.2.0 in favor of
- * {@link org.springframework.web.reactive.function.client.DefaultClientRequestObservationConvention}
- */
-@Deprecated(since = "3.0.0", forRemoval = true)
-public final class WebClientExchangeTags {
-
-	private static final String URI_TEMPLATE_ATTRIBUTE = WebClient.class.getName() + ".uriTemplate";
-
-	private static final Tag IO_ERROR = Tag.of("status", "IO_ERROR");
-
-	private static final Tag CLIENT_ERROR = Tag.of("status", "CLIENT_ERROR");
-
-	private static final Pattern PATTERN_BEFORE_PATH = Pattern.compile("^https?://[^/]+/");
-
-	private static final Tag CLIENT_NAME_NONE = Tag.of("client.name", "none");
-
-	private WebClientExchangeTags() {
-	}
-
-	/**
-	 * Creates a {@code method} {@code Tag} for the {@link ClientHttpRequest#getMethod()
-	 * method} of the given {@code request}.
-	 * @param request the request
-	 * @return the method tag
-	 */
-	public static Tag method(ClientRequest request) {
-		return Tag.of("method", request.method().name());
-	}
-
-	/**
-	 * Creates a {@code uri} {@code Tag} for the URI path of the given {@code request}.
-	 * @param request the request
-	 * @return the uri tag
-	 */
-	public static Tag uri(ClientRequest request) {
-		String uri = (String) request.attribute(URI_TEMPLATE_ATTRIBUTE).orElseGet(() -> request.url().toString());
-		return Tag.of("uri", extractPath(uri));
-	}
-
-	private static String extractPath(String url) {
-		String path = PATTERN_BEFORE_PATH.matcher(url).replaceFirst("");
-		return (path.startsWith("/") ? path : "/" + path);
-	}
-
-	/**
-	 * Creates a {@code status} {@code Tag} derived from the
-	 * {@link ClientResponse#statusCode()} of the given {@code response} if available, the
-	 * thrown exception otherwise, or considers the request as Cancelled as a last resort.
-	 * @param response the response
-	 * @param throwable the exception
-	 * @return the status tag
-	 * @since 2.3.0
-	 */
-	public static Tag status(ClientResponse response, Throwable throwable) {
-		if (response != null) {
-			return Tag.of("status", String.valueOf(response.statusCode().value()));
-		}
-		if (throwable != null) {
-			return (throwable instanceof IOException) ? IO_ERROR : CLIENT_ERROR;
-		}
-		return CLIENT_ERROR;
-	}
-
-	/**
-	 * Create a {@code client.name} {@code Tag} derived from the
-	 * {@link java.net.URI#getHost host} of the {@link ClientRequest#url() URL} of the
-	 * given {@code request}.
-	 * @param request the request
-	 * @return the client.name tag
-	 */
-	public static Tag clientName(ClientRequest request) {
-		String host = request.url().getHost();
-		if (host == null) {
-			return CLIENT_NAME_NONE;
-		}
-		return Tag.of("client.name", host);
-	}
-
-	/**
-	 * Creates an {@code outcome} {@code Tag} derived from the
-	 * {@link ClientResponse#statusCode() status} of the given {@code response}.
-	 * @param response the response
-	 * @return the outcome tag
-	 * @since 2.2.0
-	 */
-	public static Tag outcome(ClientResponse response) {
-		Outcome outcome = (response != null) ? Outcome.forStatus(response.statusCode().value()) : Outcome.UNKNOWN;
-		return outcome.asTag();
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/client/WebClientExchangeTagsProvider.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/client/WebClientExchangeTagsProvider.java
deleted file mode 100644
index 7d522e48d2b3..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/client/WebClientExchangeTagsProvider.java
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.metrics.web.reactive.client;
-
-import io.micrometer.core.instrument.Tag;
-
-import org.springframework.web.reactive.function.client.ClientRequest;
-import org.springframework.web.reactive.function.client.ClientResponse;
-
-/**
- * {@link Tag Tags} provider for an exchange performed by a
- * {@link org.springframework.web.reactive.function.client.WebClient}.
- *
- * @author Brian Clozel
- * @since 2.1.0
- * @deprecated since 3.0.0 for removal in 3.2.0 in favor of
- * {@link org.springframework.web.reactive.function.client.ClientRequestObservationConvention}
- */
-@FunctionalInterface
-@Deprecated(since = "3.0.0", forRemoval = true)
-public interface WebClientExchangeTagsProvider {
-
-	/**
-	 * Provide tags to be associated with metrics for the client exchange.
-	 * @param request the client request
-	 * @param response the server response (may be {@code null})
-	 * @param throwable the exception (may be {@code null})
-	 * @return tags to associate with metrics for the request and response exchange
-	 */
-	Iterable<Tag> tags(ClientRequest request, ClientResponse response, Throwable throwable);
-
-}
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/DefaultWebFluxTagsProvider.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/DefaultWebFluxTagsProvider.java
deleted file mode 100644
index ef319dc065ad..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/DefaultWebFluxTagsProvider.java
+++ /dev/null
@@ -1,89 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.metrics.web.reactive.server;
-
-import java.util.Collections;
-import java.util.List;
-
-import io.micrometer.core.instrument.Tag;
-import io.micrometer.core.instrument.Tags;
-
-import org.springframework.web.server.ServerWebExchange;
-
-/**
- * Default implementation of {@link WebFluxTagsProvider}.
- *
- * @author Jon Schneider
- * @author Andy Wilkinson
- * @since 2.0.0
- * @deprecated since 3.0.0 for removal in 3.2.0 in favor of
- * {@link org.springframework.http.server.reactive.observation.ServerRequestObservationConvention}
- */
-@Deprecated(since = "3.0.0", forRemoval = true)
-@SuppressWarnings("removal")
-public class DefaultWebFluxTagsProvider implements WebFluxTagsProvider {
-
-	private final boolean ignoreTrailingSlash;
-
-	private final List<WebFluxTagsContributor> contributors;
-
-	public DefaultWebFluxTagsProvider() {
-		this(false);
-	}
-
-	/**
-	 * Creates a new {@link DefaultWebFluxTagsProvider} that will provide tags from the
-	 * given {@code contributors} in addition to its own.
-	 * @param contributors the contributors that will provide additional tags
-	 * @since 2.3.0
-	 */
-	public DefaultWebFluxTagsProvider(List<WebFluxTagsContributor> contributors) {
-		this(false, contributors);
-	}
-
-	public DefaultWebFluxTagsProvider(boolean ignoreTrailingSlash) {
-		this(ignoreTrailingSlash, Collections.emptyList());
-	}
-
-	/**
-	 * Creates a new {@link DefaultWebFluxTagsProvider} that will provide tags from the
-	 * given {@code contributors} in addition to its own.
-	 * @param ignoreTrailingSlash whether trailing slashes should be ignored when
-	 * determining the {@code uri} tag.
-	 * @param contributors the contributors that will provide additional tags
-	 * @since 2.3.0
-	 */
-	public DefaultWebFluxTagsProvider(boolean ignoreTrailingSlash, List<WebFluxTagsContributor> contributors) {
-		this.ignoreTrailingSlash = ignoreTrailingSlash;
-		this.contributors = contributors;
-	}
-
-	@Override
-	public Iterable<Tag> httpRequestTags(ServerWebExchange exchange, Throwable exception) {
-		Tags tags = Tags.empty();
-		tags = tags.and(WebFluxTags.method(exchange));
-		tags = tags.and(WebFluxTags.uri(exchange, this.ignoreTrailingSlash));
-		tags = tags.and(WebFluxTags.exception(exception));
-		tags = tags.and(WebFluxTags.status(exchange));
-		tags = tags.and(WebFluxTags.outcome(exchange, exception));
-		for (WebFluxTagsContributor contributor : this.contributors) {
-			tags = tags.and(contributor.httpRequestTags(exchange, exception));
-		}
-		return tags;
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTags.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTags.java
deleted file mode 100644
index ecada43258a4..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTags.java
+++ /dev/null
@@ -1,191 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.metrics.web.reactive.server;
-
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.Set;
-import java.util.regex.Pattern;
-
-import io.micrometer.core.instrument.Tag;
-
-import org.springframework.boot.actuate.metrics.http.Outcome;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.HttpStatusCode;
-import org.springframework.util.StringUtils;
-import org.springframework.web.reactive.HandlerMapping;
-import org.springframework.web.server.ServerWebExchange;
-import org.springframework.web.util.pattern.PathPattern;
-
-/**
- * Factory methods for {@link Tag Tags} associated with a request-response exchange that
- * is handled by WebFlux.
- *
- * @author Jon Schneider
- * @author Andy Wilkinson
- * @author Michael McFadyen
- * @author Brian Clozel
- * @since 2.0.0
- * @deprecated since 3.0.0 for removal in 3.2.0 in favor of
- * {@link org.springframework.http.server.reactive.observation.ServerRequestObservationConvention}
- */
-@Deprecated(since = "3.0.0", forRemoval = true)
-public final class WebFluxTags {
-
-	private static final Tag URI_NOT_FOUND = Tag.of("uri", "NOT_FOUND");
-
-	private static final Tag URI_REDIRECTION = Tag.of("uri", "REDIRECTION");
-
-	private static final Tag URI_ROOT = Tag.of("uri", "root");
-
-	private static final Tag URI_UNKNOWN = Tag.of("uri", "UNKNOWN");
-
-	private static final Tag EXCEPTION_NONE = Tag.of("exception", "None");
-
-	private static final Pattern FORWARD_SLASHES_PATTERN = Pattern.compile("//+");
-
-	private static final Set<String> DISCONNECTED_CLIENT_EXCEPTIONS = new HashSet<>(
-			Arrays.asList("AbortedException", "ClientAbortException", "EOFException", "EofException"));
-
-	private WebFluxTags() {
-	}
-
-	/**
-	 * Creates a {@code method} tag based on the
-	 * {@link org.springframework.http.server.reactive.ServerHttpRequest#getMethod()
-	 * method} of the {@link ServerWebExchange#getRequest()} request of the given
-	 * {@code exchange}.
-	 * @param exchange the exchange
-	 * @return the method tag whose value is a capitalized method (e.g. GET).
-	 */
-	public static Tag method(ServerWebExchange exchange) {
-		return Tag.of("method", exchange.getRequest().getMethod().name());
-	}
-
-	/**
-	 * Creates a {@code status} tag based on the response status of the given
-	 * {@code exchange}.
-	 * @param exchange the exchange
-	 * @return the status tag derived from the response status
-	 */
-	public static Tag status(ServerWebExchange exchange) {
-		HttpStatusCode status = exchange.getResponse().getStatusCode();
-		if (status == null) {
-			status = HttpStatus.OK;
-		}
-		return Tag.of("status", String.valueOf(status.value()));
-	}
-
-	/**
-	 * Creates a {@code uri} tag based on the URI of the given {@code exchange}. Uses the
-	 * {@link HandlerMapping#BEST_MATCHING_PATTERN_ATTRIBUTE} best matching pattern if
-	 * available. Falling back to {@code REDIRECTION} for 3xx responses, {@code NOT_FOUND}
-	 * for 404 responses, {@code root} for requests with no path info, and {@code UNKNOWN}
-	 * for all other requests.
-	 * @param exchange the exchange
-	 * @return the uri tag derived from the exchange
-	 */
-	public static Tag uri(ServerWebExchange exchange) {
-		return uri(exchange, false);
-	}
-
-	/**
-	 * Creates a {@code uri} tag based on the URI of the given {@code exchange}. Uses the
-	 * {@link HandlerMapping#BEST_MATCHING_PATTERN_ATTRIBUTE} best matching pattern if
-	 * available. Falling back to {@code REDIRECTION} for 3xx responses, {@code NOT_FOUND}
-	 * for 404 responses, {@code root} for requests with no path info, and {@code UNKNOWN}
-	 * for all other requests.
-	 * @param exchange the exchange
-	 * @param ignoreTrailingSlash whether to ignore the trailing slash
-	 * @return the uri tag derived from the exchange
-	 */
-	public static Tag uri(ServerWebExchange exchange, boolean ignoreTrailingSlash) {
-		PathPattern pathPattern = exchange.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
-		if (pathPattern != null) {
-			String patternString = pathPattern.getPatternString();
-			if (ignoreTrailingSlash && patternString.length() > 1) {
-				patternString = removeTrailingSlash(patternString);
-			}
-			if (patternString.isEmpty()) {
-				return URI_ROOT;
-			}
-			return Tag.of("uri", patternString);
-		}
-		HttpStatusCode status = exchange.getResponse().getStatusCode();
-		if (status != null) {
-			if (status.is3xxRedirection()) {
-				return URI_REDIRECTION;
-			}
-			if (status == HttpStatus.NOT_FOUND) {
-				return URI_NOT_FOUND;
-			}
-		}
-		String path = getPathInfo(exchange);
-		if (path.isEmpty()) {
-			return URI_ROOT;
-		}
-		return URI_UNKNOWN;
-	}
-
-	private static String getPathInfo(ServerWebExchange exchange) {
-		String path = exchange.getRequest().getPath().value();
-		String uri = StringUtils.hasText(path) ? path : "/";
-		String singleSlashes = FORWARD_SLASHES_PATTERN.matcher(uri).replaceAll("/");
-		return removeTrailingSlash(singleSlashes);
-	}
-
-	private static String removeTrailingSlash(String text) {
-		if (!StringUtils.hasLength(text)) {
-			return text;
-		}
-		return text.endsWith("/") ? text.substring(0, text.length() - 1) : text;
-	}
-
-	/**
-	 * Creates an {@code exception} tag based on the {@link Class#getSimpleName() simple
-	 * name} of the class of the given {@code exception}.
-	 * @param exception the exception, may be {@code null}
-	 * @return the exception tag derived from the exception
-	 */
-	public static Tag exception(Throwable exception) {
-		if (exception != null) {
-			String simpleName = exception.getClass().getSimpleName();
-			return Tag.of("exception", StringUtils.hasText(simpleName) ? simpleName : exception.getClass().getName());
-		}
-		return EXCEPTION_NONE;
-	}
-
-	/**
-	 * Creates an {@code outcome} tag based on the response status of the given
-	 * {@code exchange} and the exception thrown during request processing.
-	 * @param exchange the exchange
-	 * @param exception the termination signal sent by the publisher
-	 * @return the outcome tag derived from the response status
-	 * @since 2.5.0
-	 */
-	public static Tag outcome(ServerWebExchange exchange, Throwable exception) {
-		if (exception != null) {
-			if (DISCONNECTED_CLIENT_EXCEPTIONS.contains(exception.getClass().getSimpleName())) {
-				return Outcome.UNKNOWN.asTag();
-			}
-		}
-		HttpStatusCode statusCode = exchange.getResponse().getStatusCode();
-		Outcome outcome = (statusCode != null) ? Outcome.forStatus(statusCode.value()) : Outcome.SUCCESS;
-		return outcome.asTag();
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTagsContributor.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTagsContributor.java
deleted file mode 100644
index 6bd9958dfca3..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTagsContributor.java
+++ /dev/null
@@ -1,44 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.metrics.web.reactive.server;
-
-import io.micrometer.core.instrument.Tag;
-
-import org.springframework.web.server.ServerWebExchange;
-
-/**
- * A contributor of {@link Tag Tags} for WebFlux-based request handling. Typically used by
- * a {@link WebFluxTagsProvider} to provide tags in addition to its defaults.
- *
- * @author Andy Wilkinson
- * @since 2.3.0
- * @deprecated since 3.0.0 for removal in 3.2.0 in favor of
- * {@link org.springframework.http.server.reactive.observation.ServerRequestObservationConvention}
- */
-@FunctionalInterface
-@Deprecated(since = "3.0.0", forRemoval = true)
-public interface WebFluxTagsContributor {
-
-	/**
-	 * Provides tags to be associated with metrics for the given {@code exchange}.
-	 * @param exchange the exchange
-	 * @param ex the current exception (may be {@code null})
-	 * @return tags to associate with metrics for the request and response exchange
-	 */
-	Iterable<Tag> httpRequestTags(ServerWebExchange exchange, Throwable ex);
-
-}
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTagsProvider.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTagsProvider.java
deleted file mode 100644
index 081c9598cc89..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTagsProvider.java
+++ /dev/null
@@ -1,44 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.metrics.web.reactive.server;
-
-import io.micrometer.core.instrument.Tag;
-
-import org.springframework.web.server.ServerWebExchange;
-
-/**
- * Provides {@link Tag Tags} for WebFlux-based request handling.
- *
- * @author Jon Schneider
- * @author Andy Wilkinson
- * @since 2.0.0
- * @deprecated since 3.0.0 for removal in 3.2.0 in favor of
- * {@link org.springframework.http.server.reactive.observation.ServerRequestObservationConvention}
- */
-@FunctionalInterface
-@Deprecated(since = "3.0.0", forRemoval = true)
-public interface WebFluxTagsProvider {
-
-	/**
-	 * Provides tags to be associated with metrics for the given {@code exchange}.
-	 * @param exchange the exchange
-	 * @param ex the current exception (may be {@code null})
-	 * @return tags to associate with metrics for the request and response exchange
-	 */
-	Iterable<Tag> httpRequestTags(ServerWebExchange exchange, Throwable ex);
-
-}
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/package-info.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/package-info.java
deleted file mode 100644
index 1167af5ca302..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/package-info.java
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * Actuator support for WebFlux metrics.
- */
-package org.springframework.boot.actuate.metrics.web.reactive.server;
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/DefaultWebMvcTagsProvider.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/DefaultWebMvcTagsProvider.java
deleted file mode 100644
index ac50670ca326..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/DefaultWebMvcTagsProvider.java
+++ /dev/null
@@ -1,94 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.metrics.web.servlet;
-
-import java.util.Collections;
-import java.util.List;
-
-import io.micrometer.core.instrument.Tag;
-import io.micrometer.core.instrument.Tags;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-
-/**
- * Default implementation of {@link WebMvcTagsProvider}.
- *
- * @author Jon Schneider
- * @since 2.0.0
- * @deprecated since 3.0.0 for removal in 3.2.0 in favor of
- * {@link org.springframework.http.server.observation.ServerRequestObservationConvention}
- */
-@Deprecated(since = "3.0.0", forRemoval = true)
-@SuppressWarnings("removal")
-public class DefaultWebMvcTagsProvider implements WebMvcTagsProvider {
-
-	private final boolean ignoreTrailingSlash;
-
-	private final List<WebMvcTagsContributor> contributors;
-
-	public DefaultWebMvcTagsProvider() {
-		this(false);
-	}
-
-	/**
-	 * Creates a new {@link DefaultWebMvcTagsProvider} that will provide tags from the
-	 * given {@code contributors} in addition to its own.
-	 * @param contributors the contributors that will provide additional tags
-	 * @since 2.3.0
-	 */
-	public DefaultWebMvcTagsProvider(List<WebMvcTagsContributor> contributors) {
-		this(false, contributors);
-	}
-
-	public DefaultWebMvcTagsProvider(boolean ignoreTrailingSlash) {
-		this(ignoreTrailingSlash, Collections.emptyList());
-	}
-
-	/**
-	 * Creates a new {@link DefaultWebMvcTagsProvider} that will provide tags from the
-	 * given {@code contributors} in addition to its own.
-	 * @param ignoreTrailingSlash whether trailing slashes should be ignored when
-	 * determining the {@code uri} tag.
-	 * @param contributors the contributors that will provide additional tags
-	 * @since 2.3.0
-	 */
-	public DefaultWebMvcTagsProvider(boolean ignoreTrailingSlash, List<WebMvcTagsContributor> contributors) {
-		this.ignoreTrailingSlash = ignoreTrailingSlash;
-		this.contributors = contributors;
-	}
-
-	@Override
-	public Iterable<Tag> getTags(HttpServletRequest request, HttpServletResponse response, Object handler,
-			Throwable exception) {
-		Tags tags = Tags.of(WebMvcTags.method(request), WebMvcTags.uri(request, response, this.ignoreTrailingSlash),
-				WebMvcTags.exception(exception), WebMvcTags.status(response), WebMvcTags.outcome(response));
-		for (WebMvcTagsContributor contributor : this.contributors) {
-			tags = tags.and(contributor.getTags(request, response, handler, exception));
-		}
-		return tags;
-	}
-
-	@Override
-	public Iterable<Tag> getLongRequestTags(HttpServletRequest request, Object handler) {
-		Tags tags = Tags.of(WebMvcTags.method(request), WebMvcTags.uri(request, null, this.ignoreTrailingSlash));
-		for (WebMvcTagsContributor contributor : this.contributors) {
-			tags = tags.and(contributor.getLongRequestTags(request, handler));
-		}
-		return tags;
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/WebMvcTags.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/WebMvcTags.java
deleted file mode 100644
index db5c1f0e49c5..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/WebMvcTags.java
+++ /dev/null
@@ -1,193 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.metrics.web.servlet;
-
-import java.util.regex.Pattern;
-
-import io.micrometer.core.instrument.Tag;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-
-import org.springframework.boot.actuate.metrics.http.Outcome;
-import org.springframework.http.HttpStatus;
-import org.springframework.util.StringUtils;
-import org.springframework.web.servlet.HandlerMapping;
-import org.springframework.web.util.pattern.PathPattern;
-
-/**
- * Factory methods for {@link Tag Tags} associated with a request-response exchange that
- * is handled by Spring MVC.
- *
- * @author Jon Schneider
- * @author Andy Wilkinson
- * @author Brian Clozel
- * @author Michael McFadyen
- * @since 2.0.0
- * @deprecated since 3.0.0 for removal in 3.2.0 in favor of
- * {@link org.springframework.http.server.observation.ServerRequestObservationConvention}
- */
-@Deprecated(since = "3.0.0", forRemoval = true)
-public final class WebMvcTags {
-
-	private static final String DATA_REST_PATH_PATTERN_ATTRIBUTE = "org.springframework.data.rest.webmvc.RepositoryRestHandlerMapping.EFFECTIVE_REPOSITORY_RESOURCE_LOOKUP_PATH";
-
-	private static final Tag URI_NOT_FOUND = Tag.of("uri", "NOT_FOUND");
-
-	private static final Tag URI_REDIRECTION = Tag.of("uri", "REDIRECTION");
-
-	private static final Tag URI_ROOT = Tag.of("uri", "root");
-
-	private static final Tag URI_UNKNOWN = Tag.of("uri", "UNKNOWN");
-
-	private static final Tag EXCEPTION_NONE = Tag.of("exception", "None");
-
-	private static final Tag STATUS_UNKNOWN = Tag.of("status", "UNKNOWN");
-
-	private static final Tag METHOD_UNKNOWN = Tag.of("method", "UNKNOWN");
-
-	private static final Pattern TRAILING_SLASH_PATTERN = Pattern.compile("/$");
-
-	private static final Pattern MULTIPLE_SLASH_PATTERN = Pattern.compile("//+");
-
-	private WebMvcTags() {
-	}
-
-	/**
-	 * Creates a {@code method} tag based on the {@link HttpServletRequest#getMethod()
-	 * method} of the given {@code request}.
-	 * @param request the request
-	 * @return the method tag whose value is a capitalized method (e.g. GET).
-	 */
-	public static Tag method(HttpServletRequest request) {
-		return (request != null) ? Tag.of("method", request.getMethod()) : METHOD_UNKNOWN;
-	}
-
-	/**
-	 * Creates a {@code status} tag based on the status of the given {@code response}.
-	 * @param response the HTTP response
-	 * @return the status tag derived from the status of the response
-	 */
-	public static Tag status(HttpServletResponse response) {
-		return (response != null) ? Tag.of("status", Integer.toString(response.getStatus())) : STATUS_UNKNOWN;
-	}
-
-	/**
-	 * Creates a {@code uri} tag based on the URI of the given {@code request}. Uses the
-	 * {@link HandlerMapping#BEST_MATCHING_PATTERN_ATTRIBUTE} best matching pattern if
-	 * available. Falling back to {@code REDIRECTION} for 3xx responses, {@code NOT_FOUND}
-	 * for 404 responses, {@code root} for requests with no path info, and {@code UNKNOWN}
-	 * for all other requests.
-	 * @param request the request
-	 * @param response the response
-	 * @return the uri tag derived from the request
-	 */
-	public static Tag uri(HttpServletRequest request, HttpServletResponse response) {
-		return uri(request, response, false);
-	}
-
-	/**
-	 * Creates a {@code uri} tag based on the URI of the given {@code request}. Uses the
-	 * {@link HandlerMapping#BEST_MATCHING_PATTERN_ATTRIBUTE} best matching pattern if
-	 * available. Falling back to {@code REDIRECTION} for 3xx responses, {@code NOT_FOUND}
-	 * for 404 responses, {@code root} for requests with no path info, and {@code UNKNOWN}
-	 * for all other requests.
-	 * @param request the request
-	 * @param response the response
-	 * @param ignoreTrailingSlash whether to ignore the trailing slash
-	 * @return the uri tag derived from the request
-	 */
-	public static Tag uri(HttpServletRequest request, HttpServletResponse response, boolean ignoreTrailingSlash) {
-		if (request != null) {
-			String pattern = getMatchingPattern(request);
-			if (pattern != null) {
-				if (ignoreTrailingSlash && pattern.length() > 1) {
-					pattern = TRAILING_SLASH_PATTERN.matcher(pattern).replaceAll("");
-				}
-				if (pattern.isEmpty()) {
-					return URI_ROOT;
-				}
-				return Tag.of("uri", pattern);
-			}
-			if (response != null) {
-				HttpStatus status = extractStatus(response);
-				if (status != null) {
-					if (status.is3xxRedirection()) {
-						return URI_REDIRECTION;
-					}
-					if (status == HttpStatus.NOT_FOUND) {
-						return URI_NOT_FOUND;
-					}
-				}
-			}
-			String pathInfo = getPathInfo(request);
-			if (pathInfo.isEmpty()) {
-				return URI_ROOT;
-			}
-		}
-		return URI_UNKNOWN;
-	}
-
-	private static HttpStatus extractStatus(HttpServletResponse response) {
-		try {
-			return HttpStatus.valueOf(response.getStatus());
-		}
-		catch (IllegalArgumentException ex) {
-			return null;
-		}
-	}
-
-	private static String getMatchingPattern(HttpServletRequest request) {
-		PathPattern dataRestPathPattern = (PathPattern) request.getAttribute(DATA_REST_PATH_PATTERN_ATTRIBUTE);
-		if (dataRestPathPattern != null) {
-			return dataRestPathPattern.getPatternString();
-		}
-		return (String) request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
-	}
-
-	private static String getPathInfo(HttpServletRequest request) {
-		String pathInfo = request.getPathInfo();
-		String uri = StringUtils.hasText(pathInfo) ? pathInfo : "/";
-		uri = MULTIPLE_SLASH_PATTERN.matcher(uri).replaceAll("/");
-		return TRAILING_SLASH_PATTERN.matcher(uri).replaceAll("");
-	}
-
-	/**
-	 * Creates an {@code exception} tag based on the {@link Class#getSimpleName() simple
-	 * name} of the class of the given {@code exception}.
-	 * @param exception the exception, may be {@code null}
-	 * @return the exception tag derived from the exception
-	 */
-	public static Tag exception(Throwable exception) {
-		if (exception != null) {
-			String simpleName = exception.getClass().getSimpleName();
-			return Tag.of("exception", StringUtils.hasText(simpleName) ? simpleName : exception.getClass().getName());
-		}
-		return EXCEPTION_NONE;
-	}
-
-	/**
-	 * Creates an {@code outcome} tag based on the status of the given {@code response}.
-	 * @param response the HTTP response
-	 * @return the outcome tag derived from the status of the response
-	 * @since 2.1.0
-	 */
-	public static Tag outcome(HttpServletResponse response) {
-		Outcome outcome = (response != null) ? Outcome.forStatus(response.getStatus()) : Outcome.UNKNOWN;
-		return outcome.asTag();
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/WebMvcTagsContributor.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/WebMvcTagsContributor.java
deleted file mode 100644
index f27b2115af67..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/WebMvcTagsContributor.java
+++ /dev/null
@@ -1,58 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.metrics.web.servlet;
-
-import io.micrometer.core.instrument.LongTaskTimer;
-import io.micrometer.core.instrument.Tag;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-
-/**
- * A contributor of {@link Tag Tags} for Spring MVC-based request handling. Typically used
- * by a {@link WebMvcTagsProvider} to provide tags in addition to its defaults.
- *
- * @author Andy Wilkinson
- * @since 2.3.0
- * @deprecated since 3.0.0 for removal in 3.2.0 in favor of
- * {@link org.springframework.http.server.observation.ServerRequestObservationConvention}
- */
-@Deprecated(since = "3.0.0", forRemoval = true)
-public interface WebMvcTagsContributor {
-
-	/**
-	 * Provides tags to be associated with metrics for the given {@code request} and
-	 * {@code response} exchange.
-	 * @param request the request
-	 * @param response the response
-	 * @param handler the handler for the request or {@code null} if the handler is
-	 * unknown
-	 * @param exception the current exception, if any
-	 * @return tags to associate with metrics for the request and response exchange
-	 */
-	Iterable<Tag> getTags(HttpServletRequest request, HttpServletResponse response, Object handler,
-			Throwable exception);
-
-	/**
-	 * Provides tags to be used by {@link LongTaskTimer long task timers}.
-	 * @param request the HTTP request
-	 * @param handler the handler for the request or {@code null} if the handler is
-	 * unknown
-	 * @return tags to associate with metrics recorded for the request
-	 */
-	Iterable<Tag> getLongRequestTags(HttpServletRequest request, Object handler);
-
-}
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/WebMvcTagsProvider.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/WebMvcTagsProvider.java
deleted file mode 100644
index 09206f727b1b..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/WebMvcTagsProvider.java
+++ /dev/null
@@ -1,58 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.metrics.web.servlet;
-
-import io.micrometer.core.instrument.LongTaskTimer;
-import io.micrometer.core.instrument.Tag;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-
-/**
- * Provides {@link Tag Tags} for Spring MVC-based request handling.
- *
- * @author Jon Schneider
- * @author Andy Wilkinson
- * @since 2.0.0
- * @deprecated since 3.0.0 for removal in 3.2.0 in favor of
- * {@link org.springframework.http.server.observation.ServerRequestObservationConvention}
- */
-@Deprecated(since = "3.0.0", forRemoval = true)
-public interface WebMvcTagsProvider {
-
-	/**
-	 * Provides tags to be associated with metrics for the given {@code request} and
-	 * {@code response} exchange.
-	 * @param request the request
-	 * @param response the response
-	 * @param handler the handler for the request or {@code null} if the handler is
-	 * unknown
-	 * @param exception the current exception, if any
-	 * @return tags to associate with metrics for the request and response exchange
-	 */
-	Iterable<Tag> getTags(HttpServletRequest request, HttpServletResponse response, Object handler,
-			Throwable exception);
-
-	/**
-	 * Provides tags to be used by {@link LongTaskTimer long task timers}.
-	 * @param request the HTTP request
-	 * @param handler the handler for the request or {@code null} if the handler is
-	 * unknown
-	 * @return tags to associate with metrics recorded for the request
-	 */
-	Iterable<Tag> getLongRequestTags(HttpServletRequest request, Object handler);
-
-}
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/package-info.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/package-info.java
deleted file mode 100644
index 22bbf429f87a..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/package-info.java
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * Actuator support for Spring MVC metrics.
- */
-package org.springframework.boot.actuate.metrics.web.servlet;
diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/context/ShutdownEndpointTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/context/ShutdownEndpointTests.java
index 45b37c3e6fb3..018dd3a9059c 100644
--- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/context/ShutdownEndpointTests.java
+++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/context/ShutdownEndpointTests.java
@@ -60,7 +60,7 @@ void shutdown() {
 				Thread.currentThread().setContextClassLoader(previousTccl);
 			}
 			assertThat(result.getMessage()).startsWith("Shutting down");
-			assertThat(((ConfigurableApplicationContext) context).isActive()).isTrue();
+			assertThat(context.isActive()).isTrue();
 			assertThat(config.latch.await(10, TimeUnit.SECONDS)).isTrue();
 			assertThat(config.threadContextClassLoader).isEqualTo(getClass().getClassLoader());
 		});
diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoke/convert/ConversionServiceParameterValueMapperTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoke/convert/ConversionServiceParameterValueMapperTests.java
index 3ba4df37a969..c2c1bc8d6ca2 100644
--- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoke/convert/ConversionServiceParameterValueMapperTests.java
+++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoke/convert/ConversionServiceParameterValueMapperTests.java
@@ -30,6 +30,7 @@
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.BDDMockito.given;
 import static org.mockito.BDDMockito.then;
 import static org.mockito.Mockito.mock;
@@ -55,7 +56,7 @@ void mapParameterShouldDelegateToConversionService() {
 	void mapParameterWhenConversionServiceFailsShouldThrowParameterMappingException() {
 		ConversionService conversionService = mock(ConversionService.class);
 		RuntimeException error = new RuntimeException();
-		given(conversionService.convert(any(), any())).willThrow(error);
+		given(conversionService.convert(any(Object.class), eq(Integer.class))).willThrow(error);
 		ConversionServiceParameterValueMapper mapper = new ConversionServiceParameterValueMapper(conversionService);
 		assertThatExceptionOfType(ParameterMappingException.class)
 			.isThrownBy(() -> mapper.mapParameterValue(new TestOperationParameter(Integer.class), "123"))
diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/DefaultWebMvcTagsProviderTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/DefaultWebMvcTagsProviderTests.java
deleted file mode 100644
index 4bdcb94ce427..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/DefaultWebMvcTagsProviderTests.java
+++ /dev/null
@@ -1,120 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.endpoint.web.servlet;
-
-import java.util.Arrays;
-import java.util.List;
-import java.util.Map;
-import java.util.function.Function;
-import java.util.stream.Collectors;
-import java.util.stream.StreamSupport;
-
-import io.micrometer.core.instrument.Tag;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.boot.actuate.metrics.web.servlet.DefaultWebMvcTagsProvider;
-import org.springframework.boot.actuate.metrics.web.servlet.WebMvcTagsContributor;
-import org.springframework.mock.web.MockHttpServletRequest;
-import org.springframework.web.servlet.HandlerMapping;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Tests for {@link DefaultWebMvcTagsProvider}.
- *
- * @author Andy Wilkinson
- */
-@SuppressWarnings("removal")
-@Deprecated(since = "3.0.0", forRemoval = true)
-class DefaultWebMvcTagsProviderTests {
-
-	@Test
-	void whenTagsAreProvidedThenDefaultTagsArePresent() {
-		Map<String, Tag> tags = asMap(new DefaultWebMvcTagsProvider().getTags(null, null, null, null));
-		assertThat(tags).containsOnlyKeys("exception", "method", "outcome", "status", "uri");
-	}
-
-	@Test
-	void givenSomeContributorsWhenTagsAreProvidedThenDefaultTagsAndContributedTagsArePresent() {
-		Map<String, Tag> tags = asMap(
-				new DefaultWebMvcTagsProvider(Arrays.asList(new TestWebMvcTagsContributor("alpha"),
-						new TestWebMvcTagsContributor("bravo", "charlie")))
-					.getTags(null, null, null, null));
-		assertThat(tags).containsOnlyKeys("exception", "method", "outcome", "status", "uri", "alpha", "bravo",
-				"charlie");
-	}
-
-	@Test
-	void whenLongRequestTagsAreProvidedThenDefaultTagsArePresent() {
-		Map<String, Tag> tags = asMap(new DefaultWebMvcTagsProvider().getLongRequestTags(null, null));
-		assertThat(tags).containsOnlyKeys("method", "uri");
-	}
-
-	@Test
-	void givenSomeContributorsWhenLongRequestTagsAreProvidedThenDefaultTagsAndContributedTagsArePresent() {
-		Map<String, Tag> tags = asMap(
-				new DefaultWebMvcTagsProvider(Arrays.asList(new TestWebMvcTagsContributor("alpha"),
-						new TestWebMvcTagsContributor("bravo", "charlie")))
-					.getLongRequestTags(null, null));
-		assertThat(tags).containsOnlyKeys("method", "uri", "alpha", "bravo", "charlie");
-	}
-
-	@Test
-	void trailingSlashIsIncludedByDefault() {
-		MockHttpServletRequest request = new MockHttpServletRequest("GET", "/the/uri/");
-		request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "{one}/{two}/");
-		Map<String, Tag> tags = asMap(new DefaultWebMvcTagsProvider().getTags(request, null, null, null));
-		assertThat(tags.get("uri").getValue()).isEqualTo("{one}/{two}/");
-	}
-
-	@Test
-	void trailingSlashCanBeIgnored() {
-		MockHttpServletRequest request = new MockHttpServletRequest("GET", "/the/uri/");
-		request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "{one}/{two}/");
-		Map<String, Tag> tags = asMap(new DefaultWebMvcTagsProvider(true).getTags(request, null, null, null));
-		assertThat(tags.get("uri").getValue()).isEqualTo("{one}/{two}");
-	}
-
-	private Map<String, Tag> asMap(Iterable<Tag> tags) {
-		return StreamSupport.stream(tags.spliterator(), false)
-			.collect(Collectors.toMap(Tag::getKey, Function.identity()));
-	}
-
-	private static final class TestWebMvcTagsContributor implements WebMvcTagsContributor {
-
-		private final List<String> tagNames;
-
-		private TestWebMvcTagsContributor(String... tagNames) {
-			this.tagNames = Arrays.asList(tagNames);
-		}
-
-		@Override
-		public Iterable<Tag> getTags(HttpServletRequest request, HttpServletResponse response, Object handler,
-				Throwable exception) {
-			return this.tagNames.stream().map((name) -> Tag.of(name, "value")).toList();
-		}
-
-		@Override
-		public Iterable<Tag> getLongRequestTags(HttpServletRequest request, Object handler) {
-			return this.tagNames.stream().map((name) -> Tag.of(name, "value")).toList();
-		}
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/WebMvcTagsTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/WebMvcTagsTests.java
deleted file mode 100644
index 7097ee9dfd79..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/WebMvcTagsTests.java
+++ /dev/null
@@ -1,184 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.endpoint.web.servlet;
-
-import io.micrometer.core.instrument.Tag;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.boot.actuate.metrics.web.servlet.WebMvcTags;
-import org.springframework.mock.web.MockHttpServletRequest;
-import org.springframework.mock.web.MockHttpServletResponse;
-import org.springframework.web.servlet.HandlerMapping;
-import org.springframework.web.util.pattern.PathPatternParser;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Tests for {@link WebMvcTags}.
- *
- * @author Andy Wilkinson
- * @author Brian Clozel
- * @author Michael McFadyen
- */
-@SuppressWarnings("removal")
-@Deprecated(since = "3.0.0", forRemoval = true)
-class WebMvcTagsTests {
-
-	private final MockHttpServletRequest request = new MockHttpServletRequest();
-
-	private final MockHttpServletResponse response = new MockHttpServletResponse();
-
-	@Test
-	void uriTagIsDataRestsEffectiveRepositoryLookupPathWhenAvailable() {
-		this.request.setAttribute(
-				"org.springframework.data.rest.webmvc.RepositoryRestHandlerMapping.EFFECTIVE_REPOSITORY_RESOURCE_LOOKUP_PATH",
-				new PathPatternParser().parse("/api/cities"));
-		this.request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/api/{repository}");
-		Tag tag = WebMvcTags.uri(this.request, this.response);
-		assertThat(tag.getValue()).isEqualTo("/api/cities");
-	}
-
-	@Test
-	void uriTagValueIsBestMatchingPatternWhenAvailable() {
-		this.request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/spring/");
-		this.response.setStatus(301);
-		Tag tag = WebMvcTags.uri(this.request, this.response);
-		assertThat(tag.getValue()).isEqualTo("/spring/");
-	}
-
-	@Test
-	void uriTagValueIsRootWhenBestMatchingPatternIsEmpty() {
-		this.request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "");
-		this.response.setStatus(301);
-		Tag tag = WebMvcTags.uri(this.request, this.response);
-		assertThat(tag.getValue()).isEqualTo("root");
-	}
-
-	@Test
-	void uriTagValueWithBestMatchingPatternAndIgnoreTrailingSlashRemoveTrailingSlash() {
-		this.request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/spring/");
-		Tag tag = WebMvcTags.uri(this.request, this.response, true);
-		assertThat(tag.getValue()).isEqualTo("/spring");
-	}
-
-	@Test
-	void uriTagValueWithBestMatchingPatternAndIgnoreTrailingSlashKeepSingleSlash() {
-		this.request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/");
-		Tag tag = WebMvcTags.uri(this.request, this.response, true);
-		assertThat(tag.getValue()).isEqualTo("/");
-	}
-
-	@Test
-	void uriTagValueIsRootWhenRequestHasNoPatternOrPathInfo() {
-		assertThat(WebMvcTags.uri(this.request, null).getValue()).isEqualTo("root");
-	}
-
-	@Test
-	void uriTagValueIsRootWhenRequestHasNoPatternAndSlashPathInfo() {
-		this.request.setPathInfo("/");
-		assertThat(WebMvcTags.uri(this.request, null).getValue()).isEqualTo("root");
-	}
-
-	@Test
-	void uriTagValueIsUnknownWhenRequestHasNoPatternAndNonRootPathInfo() {
-		this.request.setPathInfo("/example");
-		assertThat(WebMvcTags.uri(this.request, null).getValue()).isEqualTo("UNKNOWN");
-	}
-
-	@Test
-	void uriTagValueIsRedirectionWhenResponseStatusIs3xx() {
-		this.response.setStatus(301);
-		Tag tag = WebMvcTags.uri(this.request, this.response);
-		assertThat(tag.getValue()).isEqualTo("REDIRECTION");
-	}
-
-	@Test
-	void uriTagValueIsNotFoundWhenResponseStatusIs404() {
-		this.response.setStatus(404);
-		Tag tag = WebMvcTags.uri(this.request, this.response);
-		assertThat(tag.getValue()).isEqualTo("NOT_FOUND");
-	}
-
-	@Test
-	void uriTagToleratesCustomResponseStatus() {
-		this.response.setStatus(601);
-		Tag tag = WebMvcTags.uri(this.request, this.response);
-		assertThat(tag.getValue()).isEqualTo("root");
-	}
-
-	@Test
-	void uriTagIsUnknownWhenRequestIsNull() {
-		Tag tag = WebMvcTags.uri(null, null);
-		assertThat(tag.getValue()).isEqualTo("UNKNOWN");
-	}
-
-	@Test
-	void outcomeTagIsUnknownWhenResponseIsNull() {
-		Tag tag = WebMvcTags.outcome(null);
-		assertThat(tag.getValue()).isEqualTo("UNKNOWN");
-	}
-
-	@Test
-	void outcomeTagIsInformationalWhenResponseIs1xx() {
-		this.response.setStatus(100);
-		Tag tag = WebMvcTags.outcome(this.response);
-		assertThat(tag.getValue()).isEqualTo("INFORMATIONAL");
-	}
-
-	@Test
-	void outcomeTagIsSuccessWhenResponseIs2xx() {
-		this.response.setStatus(200);
-		Tag tag = WebMvcTags.outcome(this.response);
-		assertThat(tag.getValue()).isEqualTo("SUCCESS");
-	}
-
-	@Test
-	void outcomeTagIsRedirectionWhenResponseIs3xx() {
-		this.response.setStatus(301);
-		Tag tag = WebMvcTags.outcome(this.response);
-		assertThat(tag.getValue()).isEqualTo("REDIRECTION");
-	}
-
-	@Test
-	void outcomeTagIsClientErrorWhenResponseIs4xx() {
-		this.response.setStatus(400);
-		Tag tag = WebMvcTags.outcome(this.response);
-		assertThat(tag.getValue()).isEqualTo("CLIENT_ERROR");
-	}
-
-	@Test
-	void outcomeTagIsClientErrorWhenResponseIsNonStandardInClientSeries() {
-		this.response.setStatus(490);
-		Tag tag = WebMvcTags.outcome(this.response);
-		assertThat(tag.getValue()).isEqualTo("CLIENT_ERROR");
-	}
-
-	@Test
-	void outcomeTagIsServerErrorWhenResponseIs5xx() {
-		this.response.setStatus(500);
-		Tag tag = WebMvcTags.outcome(this.response);
-		assertThat(tag.getValue()).isEqualTo("SERVER_ERROR");
-	}
-
-	@Test
-	void outcomeTagIsUnknownWhenResponseStatusIsInUnknownSeries() {
-		this.response.setStatus(701);
-		Tag tag = WebMvcTags.outcome(this.response);
-		assertThat(tag.getValue()).isEqualTo("UNKNOWN");
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/influx/InfluxDbHealthIndicatorTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/influx/InfluxDbHealthIndicatorTests.java
index 367b1ec99113..f874582108fd 100644
--- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/influx/InfluxDbHealthIndicatorTests.java
+++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/influx/InfluxDbHealthIndicatorTests.java
@@ -36,6 +36,8 @@
  *
  * @author EddĂș MelĂ©ndez
  */
+@SuppressWarnings("removal")
+@Deprecated(since = "3.2.0", forRemoval = true)
 class InfluxDbHealthIndicatorTests {
 
 	@Test
diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/mail/MailHealthIndicatorTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/mail/MailHealthIndicatorTests.java
index 6ee4bdc7a3cf..9abfbedd88da 100644
--- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/mail/MailHealthIndicatorTests.java
+++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/mail/MailHealthIndicatorTests.java
@@ -43,6 +43,7 @@
  *
  * @author Johannes Edmeier
  * @author Stephane Nicoll
+ * @author Scott Frederick
  */
 class MailHealthIndicatorTests {
 
@@ -60,6 +61,52 @@ void setup() {
 		this.indicator = new MailHealthIndicator(this.mailSender);
 	}
 
+	@Test
+	void smtpOnDefaultHostAndPortIsUp() {
+		given(this.mailSender.getHost()).willReturn(null);
+		given(this.mailSender.getPort()).willReturn(-1);
+		given(this.mailSender.getProtocol()).willReturn("success");
+		Health health = this.indicator.health();
+		assertThat(health.getStatus()).isEqualTo(Status.UP);
+		assertThat(health.getDetails()).doesNotContainKey("location");
+	}
+
+	@Test
+	void smtpOnDefaultHostAndPortIsDown() throws MessagingException {
+		given(this.mailSender.getHost()).willReturn(null);
+		given(this.mailSender.getPort()).willReturn(-1);
+		willThrow(new MessagingException("A test exception")).given(this.mailSender).testConnection();
+		Health health = this.indicator.health();
+		assertThat(health.getStatus()).isEqualTo(Status.DOWN);
+		assertThat(health.getDetails()).doesNotContainKey("location");
+		Object errorMessage = health.getDetails().get("error");
+		assertThat(errorMessage).isNotNull();
+		assertThat(errorMessage.toString()).contains("A test exception");
+	}
+
+	@Test
+	void smtpOnDefaultHostAndCustomPortIsUp() {
+		given(this.mailSender.getHost()).willReturn(null);
+		given(this.mailSender.getPort()).willReturn(1234);
+		given(this.mailSender.getProtocol()).willReturn("success");
+		Health health = this.indicator.health();
+		assertThat(health.getStatus()).isEqualTo(Status.UP);
+		assertThat(health.getDetails().get("location")).isEqualTo(":1234");
+	}
+
+	@Test
+	void smtpOnDefaultHostAndCustomPortIsDown() throws MessagingException {
+		given(this.mailSender.getHost()).willReturn(null);
+		given(this.mailSender.getPort()).willReturn(1234);
+		willThrow(new MessagingException("A test exception")).given(this.mailSender).testConnection();
+		Health health = this.indicator.health();
+		assertThat(health.getStatus()).isEqualTo(Status.DOWN);
+		assertThat(health.getDetails().get("location")).isEqualTo(":1234");
+		Object errorMessage = health.getDetails().get("error");
+		assertThat(errorMessage).isNotNull();
+		assertThat(errorMessage.toString()).contains("A test exception");
+	}
+
 	@Test
 	void smtpOnDefaultPortIsUp() {
 		given(this.mailSender.getPort()).willReturn(-1);
@@ -78,7 +125,7 @@ void smtpOnDefaultPortIsDown() throws MessagingException {
 		assertThat(health.getDetails()).containsEntry("location", "smtp.acme.org");
 		Object errorMessage = health.getDetails().get("error");
 		assertThat(errorMessage).isNotNull();
-		assertThat(errorMessage.toString().contains("A test exception")).isTrue();
+		assertThat(errorMessage.toString()).contains("A test exception");
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusPushGatewayManagerTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusPushGatewayManagerTests.java
index ca8d61d3108c..6960ea722752 100644
--- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusPushGatewayManagerTests.java
+++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusPushGatewayManagerTests.java
@@ -140,18 +140,6 @@ void shutdownWhenDoesNotOwnSchedulerDoesNotShutdownScheduler() {
 		then(otherScheduler).should(never()).shutdown();
 	}
 
-	@Test
-	@SuppressWarnings("removal")
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	void shutdownWhenShutdownOperationIsPushPerformsPushAddOnShutdown() throws Exception {
-		givenScheduleAtFixedRateWithReturnFuture();
-		PrometheusPushGatewayManager manager = new PrometheusPushGatewayManager(this.pushGateway, this.registry,
-				this.scheduler, this.pushRate, "job", this.groupingKey, ShutdownOperation.PUSH);
-		manager.shutdown();
-		then(this.future).should().cancel(false);
-		then(this.pushGateway).should().pushAdd(this.registry, "job", this.groupingKey);
-	}
-
 	@Test
 	void shutdownWhenShutdownOperationIsPostPerformsPushAddOnShutdown() throws Exception {
 		givenScheduleAtFixedRateWithReturnFuture();
diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/r2dbc/ConnectionPoolMetricsTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/r2dbc/ConnectionPoolMetricsTests.java
index bb0f1055ca7b..c833dd7f91dc 100644
--- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/r2dbc/ConnectionPoolMetricsTests.java
+++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/r2dbc/ConnectionPoolMetricsTests.java
@@ -72,6 +72,7 @@ void connectionFactoryIsInstrumented() {
 		ConnectionPoolMetrics metrics = new ConnectionPoolMetrics(connectionPool, "test-pool",
 				Tags.of(testTag, regionTag));
 		metrics.bindTo(registry);
+		connectionPool.warmup().as(StepVerifier::create).expectNext(3).expectComplete().verify(Duration.ofSeconds(30));
 		// acquire two connections
 		connectionPool.create()
 			.as(StepVerifier::create)
diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/client/ObservationRestClientCustomizerTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/client/ObservationRestClientCustomizerTests.java
new file mode 100644
index 000000000000..b945b4d7f596
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/client/ObservationRestClientCustomizerTests.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.actuate.metrics.web.client;
+
+import io.micrometer.observation.ObservationRegistry;
+import io.micrometer.observation.tck.TestObservationRegistry;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.client.observation.DefaultClientRequestObservationConvention;
+import org.springframework.web.client.RestClient;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ObservationRestClientCustomizer}.
+ *
+ * @author Brian Clozel
+ * @author Moritz Halbritter
+ */
+class ObservationRestClientCustomizerTests {
+
+	private static final String TEST_METRIC_NAME = "http.test.metric.name";
+
+	private final ObservationRegistry observationRegistry = TestObservationRegistry.create();
+
+	private final RestClient.Builder restClientBuilder = RestClient.builder();
+
+	private final ObservationRestClientCustomizer customizer = new ObservationRestClientCustomizer(
+			this.observationRegistry, new DefaultClientRequestObservationConvention(TEST_METRIC_NAME));
+
+	@Test
+	void shouldCustomizeObservationConfiguration() {
+		this.customizer.customize(this.restClientBuilder);
+		assertThat(this.restClientBuilder).hasFieldOrPropertyWithValue("observationRegistry", this.observationRegistry);
+		assertThat(this.restClientBuilder).extracting("observationConvention")
+			.isInstanceOf(DefaultClientRequestObservationConvention.class)
+			.hasFieldOrPropertyWithValue("name", TEST_METRIC_NAME);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/client/RestTemplateExchangeTagsTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/client/RestTemplateExchangeTagsTests.java
deleted file mode 100644
index 7dd9a2322b37..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/client/RestTemplateExchangeTagsTests.java
+++ /dev/null
@@ -1,118 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.metrics.web.client;
-
-import java.io.IOException;
-import java.net.URI;
-
-import io.micrometer.core.instrument.Tag;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.http.HttpStatus;
-import org.springframework.http.HttpStatusCode;
-import org.springframework.http.client.ClientHttpRequest;
-import org.springframework.http.client.ClientHttpResponse;
-import org.springframework.mock.http.client.MockClientHttpResponse;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.BDDMockito.given;
-import static org.mockito.Mockito.mock;
-
-/**
- * Tests for {@link RestTemplateExchangeTags}.
- *
- * @author Nishant Raut
- * @author Brian Clozel
- */
-@SuppressWarnings({ "removal" })
-@Deprecated(since = "3.0.0", forRemoval = true)
-class RestTemplateExchangeTagsTests {
-
-	@Test
-	void outcomeTagIsUnknownWhenResponseIsNull() {
-		Tag tag = RestTemplateExchangeTags.outcome(null);
-		assertThat(tag.getValue()).isEqualTo("UNKNOWN");
-	}
-
-	@Test
-	void outcomeTagIsInformationalWhenResponseIs1xx() {
-		ClientHttpResponse response = new MockClientHttpResponse("foo".getBytes(), HttpStatus.CONTINUE);
-		Tag tag = RestTemplateExchangeTags.outcome(response);
-		assertThat(tag.getValue()).isEqualTo("INFORMATIONAL");
-	}
-
-	@Test
-	void outcomeTagIsSuccessWhenResponseIs2xx() {
-		ClientHttpResponse response = new MockClientHttpResponse("foo".getBytes(), HttpStatus.OK);
-		Tag tag = RestTemplateExchangeTags.outcome(response);
-		assertThat(tag.getValue()).isEqualTo("SUCCESS");
-	}
-
-	@Test
-	void outcomeTagIsRedirectionWhenResponseIs3xx() {
-		ClientHttpResponse response = new MockClientHttpResponse("foo".getBytes(), HttpStatus.MOVED_PERMANENTLY);
-		Tag tag = RestTemplateExchangeTags.outcome(response);
-		assertThat(tag.getValue()).isEqualTo("REDIRECTION");
-	}
-
-	@Test
-	void outcomeTagIsClientErrorWhenResponseIs4xx() {
-		ClientHttpResponse response = new MockClientHttpResponse("foo".getBytes(), HttpStatus.BAD_REQUEST);
-		Tag tag = RestTemplateExchangeTags.outcome(response);
-		assertThat(tag.getValue()).isEqualTo("CLIENT_ERROR");
-	}
-
-	@Test
-	void outcomeTagIsServerErrorWhenResponseIs5xx() {
-		ClientHttpResponse response = new MockClientHttpResponse("foo".getBytes(), HttpStatus.BAD_GATEWAY);
-		Tag tag = RestTemplateExchangeTags.outcome(response);
-		assertThat(tag.getValue()).isEqualTo("SERVER_ERROR");
-	}
-
-	@Test
-	void outcomeTagIsUnknownWhenResponseThrowsIOException() throws Exception {
-		ClientHttpResponse response = mock(ClientHttpResponse.class);
-		given(response.getStatusCode()).willThrow(IOException.class);
-		Tag tag = RestTemplateExchangeTags.outcome(response);
-		assertThat(tag.getValue()).isEqualTo("UNKNOWN");
-	}
-
-	@Test
-	void outcomeTagIsClientErrorWhenResponseIsNonStandardInClientSeries() throws IOException {
-		ClientHttpResponse response = mock(ClientHttpResponse.class);
-		given(response.getStatusCode()).willReturn(HttpStatusCode.valueOf(490));
-		Tag tag = RestTemplateExchangeTags.outcome(response);
-		assertThat(tag.getValue()).isEqualTo("CLIENT_ERROR");
-	}
-
-	@Test
-	void outcomeTagIsUnknownWhenResponseStatusIsInUnknownSeries() throws IOException {
-		ClientHttpResponse response = mock(ClientHttpResponse.class);
-		given(response.getStatusCode()).willReturn(HttpStatusCode.valueOf(701));
-		Tag tag = RestTemplateExchangeTags.outcome(response);
-		assertThat(tag.getValue()).isEqualTo("UNKNOWN");
-	}
-
-	@Test
-	void clientNameTagIsHostOfRequestUri() {
-		ClientHttpRequest request = mock(ClientHttpRequest.class);
-		given(request.getURI()).willReturn(URI.create("https://example.org"));
-		Tag tag = RestTemplateExchangeTags.clientName(request);
-		assertThat(tag).isEqualTo(Tag.of("client.name", "example.org"));
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/client/DefaultWebClientExchangeTagsProviderTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/client/DefaultWebClientExchangeTagsProviderTests.java
deleted file mode 100644
index c04846e71696..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/client/DefaultWebClientExchangeTagsProviderTests.java
+++ /dev/null
@@ -1,100 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.metrics.web.reactive.client;
-
-import java.io.IOException;
-import java.net.URI;
-
-import io.micrometer.core.instrument.Tag;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.http.HttpMethod;
-import org.springframework.http.HttpStatus;
-import org.springframework.web.reactive.function.client.ClientRequest;
-import org.springframework.web.reactive.function.client.ClientResponse;
-import org.springframework.web.reactive.function.client.WebClient;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.BDDMockito.given;
-import static org.mockito.Mockito.mock;
-
-/**
- * Tests for {@link DefaultWebClientExchangeTagsProvider}
- *
- * @author Brian Clozel
- * @author Nishant Raut
- */
-@SuppressWarnings({ "deprecation", "removal" })
-class DefaultWebClientExchangeTagsProviderTests {
-
-	private static final String URI_TEMPLATE_ATTRIBUTE = WebClient.class.getName() + ".uriTemplate";
-
-	private final WebClientExchangeTagsProvider tagsProvider = new DefaultWebClientExchangeTagsProvider();
-
-	private ClientRequest request;
-
-	private ClientResponse response;
-
-	@BeforeEach
-	void setup() {
-		this.request = ClientRequest.create(HttpMethod.GET, URI.create("https://example.org/projects/spring-boot"))
-			.attribute(URI_TEMPLATE_ATTRIBUTE, "https://example.org/projects/{project}")
-			.build();
-		this.response = mock(ClientResponse.class);
-		given(this.response.statusCode()).willReturn(HttpStatus.OK);
-	}
-
-	@Test
-	void tagsShouldBePopulated() {
-		Iterable<Tag> tags = this.tagsProvider.tags(this.request, this.response, null);
-		assertThat(tags).containsExactlyInAnyOrder(Tag.of("method", "GET"), Tag.of("uri", "/projects/{project}"),
-				Tag.of("client.name", "example.org"), Tag.of("status", "200"), Tag.of("outcome", "SUCCESS"));
-	}
-
-	@Test
-	void tagsWhenNoUriTemplateShouldProvideUriPath() {
-		ClientRequest request = ClientRequest
-			.create(HttpMethod.GET, URI.create("https://example.org/projects/spring-boot"))
-			.build();
-		Iterable<Tag> tags = this.tagsProvider.tags(request, this.response, null);
-		assertThat(tags).containsExactlyInAnyOrder(Tag.of("method", "GET"), Tag.of("uri", "/projects/spring-boot"),
-				Tag.of("client.name", "example.org"), Tag.of("status", "200"), Tag.of("outcome", "SUCCESS"));
-	}
-
-	@Test
-	void tagsWhenIoExceptionShouldReturnIoErrorStatus() {
-		Iterable<Tag> tags = this.tagsProvider.tags(this.request, null, new IOException());
-		assertThat(tags).containsExactlyInAnyOrder(Tag.of("method", "GET"), Tag.of("uri", "/projects/{project}"),
-				Tag.of("client.name", "example.org"), Tag.of("status", "IO_ERROR"), Tag.of("outcome", "UNKNOWN"));
-	}
-
-	@Test
-	void tagsWhenExceptionShouldReturnClientErrorStatus() {
-		Iterable<Tag> tags = this.tagsProvider.tags(this.request, null, new IllegalArgumentException());
-		assertThat(tags).containsExactlyInAnyOrder(Tag.of("method", "GET"), Tag.of("uri", "/projects/{project}"),
-				Tag.of("client.name", "example.org"), Tag.of("status", "CLIENT_ERROR"), Tag.of("outcome", "UNKNOWN"));
-	}
-
-	@Test
-	void tagsWhenCancelledRequestShouldReturnClientErrorStatus() {
-		Iterable<Tag> tags = this.tagsProvider.tags(this.request, null, null);
-		assertThat(tags).containsExactlyInAnyOrder(Tag.of("method", "GET"), Tag.of("uri", "/projects/{project}"),
-				Tag.of("client.name", "example.org"), Tag.of("status", "CLIENT_ERROR"), Tag.of("outcome", "UNKNOWN"));
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/client/WebClientExchangeTagsTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/client/WebClientExchangeTagsTests.java
deleted file mode 100644
index fbc3860fb4bc..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/client/WebClientExchangeTagsTests.java
+++ /dev/null
@@ -1,182 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.metrics.web.reactive.client;
-
-import java.io.IOException;
-import java.net.URI;
-
-import io.micrometer.core.instrument.Tag;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.http.HttpMethod;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.HttpStatusCode;
-import org.springframework.web.reactive.function.client.ClientRequest;
-import org.springframework.web.reactive.function.client.ClientResponse;
-import org.springframework.web.reactive.function.client.WebClient;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.BDDMockito.given;
-import static org.mockito.Mockito.mock;
-
-/**
- * Tests for {@link WebClientExchangeTags}.
- *
- * @author Brian Clozel
- * @author Nishant Raut
- */
-@SuppressWarnings({ "deprecation", "removal" })
-class WebClientExchangeTagsTests {
-
-	private static final String URI_TEMPLATE_ATTRIBUTE = WebClient.class.getName() + ".uriTemplate";
-
-	private ClientRequest request;
-
-	private ClientResponse response;
-
-	@BeforeEach
-	void setup() {
-		this.request = ClientRequest.create(HttpMethod.GET, URI.create("https://example.org/projects/spring-boot"))
-			.attribute(URI_TEMPLATE_ATTRIBUTE, "https://example.org/projects/{project}")
-			.build();
-		this.response = mock(ClientResponse.class);
-	}
-
-	@Test
-	void method() {
-		assertThat(WebClientExchangeTags.method(this.request)).isEqualTo(Tag.of("method", "GET"));
-	}
-
-	@Test
-	void uriWhenAbsoluteTemplateIsAvailableShouldReturnTemplate() {
-		assertThat(WebClientExchangeTags.uri(this.request)).isEqualTo(Tag.of("uri", "/projects/{project}"));
-	}
-
-	@Test
-	void uriWhenRelativeTemplateIsAvailableShouldReturnTemplate() {
-		this.request = ClientRequest.create(HttpMethod.GET, URI.create("https://example.org/projects/spring-boot"))
-			.attribute(URI_TEMPLATE_ATTRIBUTE, "/projects/{project}")
-			.build();
-		assertThat(WebClientExchangeTags.uri(this.request)).isEqualTo(Tag.of("uri", "/projects/{project}"));
-	}
-
-	@Test
-	void uriWhenTemplateIsMissingShouldReturnPath() {
-		this.request = ClientRequest.create(HttpMethod.GET, URI.create("https://example.org/projects/spring-boot"))
-			.build();
-		assertThat(WebClientExchangeTags.uri(this.request)).isEqualTo(Tag.of("uri", "/projects/spring-boot"));
-	}
-
-	@Test
-	void uriWhenTemplateIsMissingShouldReturnPathWithQueryParams() {
-		this.request = ClientRequest
-			.create(HttpMethod.GET, URI.create("https://example.org/projects/spring-boot?section=docs"))
-			.build();
-		assertThat(WebClientExchangeTags.uri(this.request))
-			.isEqualTo(Tag.of("uri", "/projects/spring-boot?section=docs"));
-	}
-
-	@Test
-	void clientName() {
-		assertThat(WebClientExchangeTags.clientName(this.request)).isEqualTo(Tag.of("client.name", "example.org"));
-	}
-
-	@Test
-	void status() {
-		given(this.response.statusCode()).willReturn(HttpStatus.OK);
-		assertThat(WebClientExchangeTags.status(this.response, null)).isEqualTo(Tag.of("status", "200"));
-	}
-
-	@Test
-	void statusWhenIOException() {
-		assertThat(WebClientExchangeTags.status(null, new IOException())).isEqualTo(Tag.of("status", "IO_ERROR"));
-	}
-
-	@Test
-	void statusWhenClientException() {
-		assertThat(WebClientExchangeTags.status(null, new IllegalArgumentException()))
-			.isEqualTo(Tag.of("status", "CLIENT_ERROR"));
-	}
-
-	@Test
-	void statusWhenNonStandard() {
-		given(this.response.statusCode()).willReturn(HttpStatusCode.valueOf(490));
-		assertThat(WebClientExchangeTags.status(this.response, null)).isEqualTo(Tag.of("status", "490"));
-	}
-
-	@Test
-	void statusWhenCancelled() {
-		assertThat(WebClientExchangeTags.status(null, null)).isEqualTo(Tag.of("status", "CLIENT_ERROR"));
-	}
-
-	@Test
-	void outcomeTagIsUnknownWhenResponseIsNull() {
-		Tag tag = WebClientExchangeTags.outcome(null);
-		assertThat(tag.getValue()).isEqualTo("UNKNOWN");
-	}
-
-	@Test
-	void outcomeTagIsInformationalWhenResponseIs1xx() {
-		given(this.response.statusCode()).willReturn(HttpStatus.CONTINUE);
-		Tag tag = WebClientExchangeTags.outcome(this.response);
-		assertThat(tag.getValue()).isEqualTo("INFORMATIONAL");
-	}
-
-	@Test
-	void outcomeTagIsSuccessWhenResponseIs2xx() {
-		given(this.response.statusCode()).willReturn(HttpStatus.OK);
-		Tag tag = WebClientExchangeTags.outcome(this.response);
-		assertThat(tag.getValue()).isEqualTo("SUCCESS");
-	}
-
-	@Test
-	void outcomeTagIsRedirectionWhenResponseIs3xx() {
-		given(this.response.statusCode()).willReturn(HttpStatus.MOVED_PERMANENTLY);
-		Tag tag = WebClientExchangeTags.outcome(this.response);
-		assertThat(tag.getValue()).isEqualTo("REDIRECTION");
-	}
-
-	@Test
-	void outcomeTagIsClientErrorWhenResponseIs4xx() {
-		given(this.response.statusCode()).willReturn(HttpStatus.BAD_REQUEST);
-		Tag tag = WebClientExchangeTags.outcome(this.response);
-		assertThat(tag.getValue()).isEqualTo("CLIENT_ERROR");
-	}
-
-	@Test
-	void outcomeTagIsServerErrorWhenResponseIs5xx() {
-		given(this.response.statusCode()).willReturn(HttpStatus.BAD_GATEWAY);
-		Tag tag = WebClientExchangeTags.outcome(this.response);
-		assertThat(tag.getValue()).isEqualTo("SERVER_ERROR");
-	}
-
-	@Test
-	void outcomeTagIsClientErrorWhenResponseIsNonStandardInClientSeries() {
-		given(this.response.statusCode()).willReturn(HttpStatusCode.valueOf(490));
-		Tag tag = WebClientExchangeTags.outcome(this.response);
-		assertThat(tag.getValue()).isEqualTo("CLIENT_ERROR");
-	}
-
-	@Test
-	void outcomeTagIsUnknownWhenResponseStatusIsInUnknownSeries() {
-		given(this.response.statusCode()).willReturn(HttpStatusCode.valueOf(701));
-		Tag tag = WebClientExchangeTags.outcome(this.response);
-		assertThat(tag.getValue()).isEqualTo("UNKNOWN");
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/server/DefaultWebFluxTagsProviderTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/server/DefaultWebFluxTagsProviderTests.java
deleted file mode 100644
index 39240b2fd341..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/server/DefaultWebFluxTagsProviderTests.java
+++ /dev/null
@@ -1,82 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.metrics.web.reactive.server;
-
-import java.util.Arrays;
-import java.util.List;
-import java.util.Map;
-import java.util.function.Function;
-import java.util.stream.Collectors;
-import java.util.stream.StreamSupport;
-
-import io.micrometer.core.instrument.Tag;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
-import org.springframework.mock.web.server.MockServerWebExchange;
-import org.springframework.web.server.ServerWebExchange;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Tests for {@link DefaultWebFluxTagsProvider}.
- *
- * @author Andy Wilkinson
- */
-@SuppressWarnings("removal")
-@Deprecated(since = "3.0.0", forRemoval = true)
-class DefaultWebFluxTagsProviderTests {
-
-	@Test
-	void whenTagsAreProvidedThenDefaultTagsArePresent() {
-		ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/test"));
-		Map<String, Tag> tags = asMap(new DefaultWebFluxTagsProvider().httpRequestTags(exchange, null));
-		assertThat(tags).containsOnlyKeys("exception", "method", "outcome", "status", "uri");
-	}
-
-	@Test
-	void givenSomeContributorsWhenTagsAreProvidedThenDefaultTagsAndContributedTagsArePresent() {
-		ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/test"));
-		Map<String, Tag> tags = asMap(
-				new DefaultWebFluxTagsProvider(Arrays.asList(new TestWebFluxTagsContributor("alpha"),
-						new TestWebFluxTagsContributor("bravo", "charlie")))
-					.httpRequestTags(exchange, null));
-		assertThat(tags).containsOnlyKeys("exception", "method", "outcome", "status", "uri", "alpha", "bravo",
-				"charlie");
-	}
-
-	private Map<String, Tag> asMap(Iterable<Tag> tags) {
-		return StreamSupport.stream(tags.spliterator(), false)
-			.collect(Collectors.toMap(Tag::getKey, Function.identity()));
-	}
-
-	private static final class TestWebFluxTagsContributor implements WebFluxTagsContributor {
-
-		private final List<String> tagNames;
-
-		private TestWebFluxTagsContributor(String... tagNames) {
-			this.tagNames = Arrays.asList(tagNames);
-		}
-
-		@Override
-		public Iterable<Tag> httpRequestTags(ServerWebExchange exchange, Throwable ex) {
-			return this.tagNames.stream().map((name) -> Tag.of(name, "value")).toList();
-		}
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTagsTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTagsTests.java
deleted file mode 100644
index 03d7a1030258..000000000000
--- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTagsTests.java
+++ /dev/null
@@ -1,220 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.actuate.metrics.web.reactive.server;
-
-import java.io.EOFException;
-
-import io.micrometer.core.instrument.Tag;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.http.HttpMethod;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.server.reactive.ServerHttpRequest;
-import org.springframework.http.server.reactive.ServerHttpResponse;
-import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
-import org.springframework.mock.web.server.MockServerWebExchange;
-import org.springframework.web.reactive.HandlerMapping;
-import org.springframework.web.server.ServerWebExchange;
-import org.springframework.web.util.pattern.PathPatternParser;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.BDDMockito.given;
-import static org.mockito.Mockito.mock;
-
-/**
- * Tests for {@link WebFluxTags}.
- *
- * @author Brian Clozel
- * @author Michael McFadyen
- * @author Madhura Bhave
- * @author Stephane Nicoll
- */
-@SuppressWarnings("removal")
-@Deprecated(since = "3.0.0", forRemoval = true)
-class WebFluxTagsTests {
-
-	private MockServerWebExchange exchange;
-
-	private final PathPatternParser parser = new PathPatternParser();
-
-	@BeforeEach
-	void setup() {
-		this.exchange = MockServerWebExchange.from(MockServerHttpRequest.get(""));
-	}
-
-	@Test
-	void uriTagValueIsBestMatchingPatternWhenAvailable() {
-		this.exchange.getAttributes()
-			.put(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, this.parser.parse("/spring/"));
-		this.exchange.getResponse().setStatusCode(HttpStatus.MOVED_PERMANENTLY);
-		Tag tag = WebFluxTags.uri(this.exchange);
-		assertThat(tag.getValue()).isEqualTo("/spring/");
-	}
-
-	@Test
-	void uriTagValueIsRootWhenBestMatchingPatternIsEmpty() {
-		this.exchange.getAttributes().put(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, this.parser.parse(""));
-		this.exchange.getResponse().setStatusCode(HttpStatus.MOVED_PERMANENTLY);
-		Tag tag = WebFluxTags.uri(this.exchange);
-		assertThat(tag.getValue()).isEqualTo("root");
-	}
-
-	@Test
-	void uriTagValueWithBestMatchingPatternAndIgnoreTrailingSlashRemoveTrailingSlash() {
-		this.exchange.getAttributes()
-			.put(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, this.parser.parse("/spring/"));
-		Tag tag = WebFluxTags.uri(this.exchange, true);
-		assertThat(tag.getValue()).isEqualTo("/spring");
-	}
-
-	@Test
-	void uriTagValueWithBestMatchingPatternAndIgnoreTrailingSlashKeepSingleSlash() {
-		this.exchange.getAttributes().put(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, this.parser.parse("/"));
-		Tag tag = WebFluxTags.uri(this.exchange, true);
-		assertThat(tag.getValue()).isEqualTo("/");
-	}
-
-	@Test
-	void uriTagValueIsRedirectionWhenResponseStatusIs3xx() {
-		this.exchange.getResponse().setStatusCode(HttpStatus.MOVED_PERMANENTLY);
-		Tag tag = WebFluxTags.uri(this.exchange);
-		assertThat(tag.getValue()).isEqualTo("REDIRECTION");
-	}
-
-	@Test
-	void uriTagValueIsNotFoundWhenResponseStatusIs404() {
-		this.exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND);
-		Tag tag = WebFluxTags.uri(this.exchange);
-		assertThat(tag.getValue()).isEqualTo("NOT_FOUND");
-	}
-
-	@Test
-	void uriTagToleratesCustomResponseStatus() {
-		this.exchange.getResponse().setRawStatusCode(601);
-		Tag tag = WebFluxTags.uri(this.exchange);
-		assertThat(tag.getValue()).isEqualTo("root");
-	}
-
-	@Test
-	void uriTagValueIsRootWhenRequestHasNoPatternOrPathInfo() {
-		Tag tag = WebFluxTags.uri(this.exchange);
-		assertThat(tag.getValue()).isEqualTo("root");
-	}
-
-	@Test
-	void uriTagValueIsRootWhenRequestHasNoPatternAndSlashPathInfo() {
-		MockServerHttpRequest request = MockServerHttpRequest.get("/").build();
-		ServerWebExchange exchange = MockServerWebExchange.from(request);
-		Tag tag = WebFluxTags.uri(exchange);
-		assertThat(tag.getValue()).isEqualTo("root");
-	}
-
-	@Test
-	void uriTagValueIsUnknownWhenRequestHasNoPatternAndNonRootPathInfo() {
-		MockServerHttpRequest request = MockServerHttpRequest.get("/example").build();
-		ServerWebExchange exchange = MockServerWebExchange.from(request);
-		Tag tag = WebFluxTags.uri(exchange);
-		assertThat(tag.getValue()).isEqualTo("UNKNOWN");
-	}
-
-	@Test
-	void methodTagToleratesNonStandardHttpMethods() {
-		ServerWebExchange exchange = mock(ServerWebExchange.class);
-		ServerHttpRequest request = mock(ServerHttpRequest.class);
-		given(exchange.getRequest()).willReturn(request);
-		given(request.getMethod()).willReturn(HttpMethod.valueOf("CUSTOM"));
-		Tag tag = WebFluxTags.method(exchange);
-		assertThat(tag.getValue()).isEqualTo("CUSTOM");
-	}
-
-	@Test
-	void outcomeTagIsSuccessWhenResponseStatusIsNull() {
-		this.exchange.getResponse().setStatusCode(null);
-		Tag tag = WebFluxTags.outcome(this.exchange, null);
-		assertThat(tag.getValue()).isEqualTo("SUCCESS");
-	}
-
-	@Test
-	void outcomeTagIsSuccessWhenResponseStatusIsAvailableFromUnderlyingServer() {
-		ServerWebExchange exchange = mock(ServerWebExchange.class);
-		ServerHttpRequest request = mock(ServerHttpRequest.class);
-		ServerHttpResponse response = mock(ServerHttpResponse.class);
-		given(response.getStatusCode()).willReturn(HttpStatus.OK);
-		given(response.getStatusCode().value()).willReturn(null);
-		given(exchange.getRequest()).willReturn(request);
-		given(exchange.getResponse()).willReturn(response);
-		Tag tag = WebFluxTags.outcome(exchange, null);
-		assertThat(tag.getValue()).isEqualTo("SUCCESS");
-	}
-
-	@Test
-	void outcomeTagIsInformationalWhenResponseIs1xx() {
-		this.exchange.getResponse().setStatusCode(HttpStatus.CONTINUE);
-		Tag tag = WebFluxTags.outcome(this.exchange, null);
-		assertThat(tag.getValue()).isEqualTo("INFORMATIONAL");
-	}
-
-	@Test
-	void outcomeTagIsSuccessWhenResponseIs2xx() {
-		this.exchange.getResponse().setStatusCode(HttpStatus.OK);
-		Tag tag = WebFluxTags.outcome(this.exchange, null);
-		assertThat(tag.getValue()).isEqualTo("SUCCESS");
-	}
-
-	@Test
-	void outcomeTagIsRedirectionWhenResponseIs3xx() {
-		this.exchange.getResponse().setStatusCode(HttpStatus.MOVED_PERMANENTLY);
-		Tag tag = WebFluxTags.outcome(this.exchange, null);
-		assertThat(tag.getValue()).isEqualTo("REDIRECTION");
-	}
-
-	@Test
-	void outcomeTagIsClientErrorWhenResponseIs4xx() {
-		this.exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST);
-		Tag tag = WebFluxTags.outcome(this.exchange, null);
-		assertThat(tag.getValue()).isEqualTo("CLIENT_ERROR");
-	}
-
-	@Test
-	void outcomeTagIsServerErrorWhenResponseIs5xx() {
-		this.exchange.getResponse().setStatusCode(HttpStatus.BAD_GATEWAY);
-		Tag tag = WebFluxTags.outcome(this.exchange, null);
-		assertThat(tag.getValue()).isEqualTo("SERVER_ERROR");
-	}
-
-	@Test
-	void outcomeTagIsClientErrorWhenResponseIsNonStandardInClientSeries() {
-		this.exchange.getResponse().setRawStatusCode(490);
-		Tag tag = WebFluxTags.outcome(this.exchange, null);
-		assertThat(tag.getValue()).isEqualTo("CLIENT_ERROR");
-	}
-
-	@Test
-	void outcomeTagIsUnknownWhenResponseStatusIsInUnknownSeries() {
-		this.exchange.getResponse().setRawStatusCode(701);
-		Tag tag = WebFluxTags.outcome(this.exchange, null);
-		assertThat(tag.getValue()).isEqualTo("UNKNOWN");
-	}
-
-	@Test
-	void outcomeTagIsUnknownWhenExceptionIsDisconnectedClient() {
-		Tag tag = WebFluxTags.outcome(this.exchange, new EOFException("broken pipe"));
-		assertThat(tag.getValue()).isEqualTo("UNKNOWN");
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-autoconfigure/build.gradle b/spring-boot-project/spring-boot-autoconfigure/build.gradle
index db2a24fb5066..0841bd8b7076 100644
--- a/spring-boot-project/spring-boot-autoconfigure/build.gradle
+++ b/spring-boot-project/spring-boot-autoconfigure/build.gradle
@@ -29,6 +29,7 @@ dependencies {
 	optional("com.nimbusds:oauth2-oidc-sdk")
 	optional("com.oracle.database.jdbc:ojdbc8")
 	optional("com.oracle.database.jdbc:ucp")
+	optional("com.querydsl:querydsl-core")
 	optional("com.samskivert:jmustache")
 	optional("io.lettuce:lettuce-core")
 	optional("io.projectreactor.netty:reactor-netty-http")
@@ -78,20 +79,10 @@ dependencies {
 	optional("nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect")
 	optional("org.aspectj:aspectjweaver")
 	optional("org.cache2k:cache2k-spring")
-	optional("org.eclipse.jetty:jetty-webapp") {
-		exclude(group: "org.eclipse.jetty", module: "jetty-jndi")
-		exclude(group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-servlet-api")
-	}
+	optional("org.eclipse.jetty.ee10:jetty-ee10-webapp")
 	optional("org.eclipse.jetty:jetty-reactive-httpclient")
-	optional("org.eclipse.jetty.websocket:websocket-jakarta-server") {
-		exclude(group: "org.eclipse.jetty", module: "jetty-jndi")
-		exclude(group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-servlet-api")
-		exclude(group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-websocket-api")
-	}
-	optional("org.eclipse.jetty.websocket:websocket-jetty-server") {
-		exclude(group: "org.eclipse.jetty", module: "jetty-jndi")
-		exclude(group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-servlet-api")
-	}
+	optional("org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jakarta-server")
+	optional("org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jetty-server")
 	optional("org.ehcache:ehcache") {
 		artifact {
 			classifier = 'jakarta'
@@ -104,6 +95,7 @@ dependencies {
 		exclude group: "commons-logging", module: "commons-logging"
 	}
 	optional("org.flywaydb:flyway-core")
+	optional("org.flywaydb:flyway-database-oracle")
 	optional("org.flywaydb:flyway-sqlserver")
 	optional("org.freemarker:freemarker")
 	optional("org.glassfish.jersey.containers:jersey-container-servlet-core")
@@ -141,11 +133,12 @@ dependencies {
 	optional("org.opensaml:opensaml-saml-api:4.0.1")
 	optional("org.opensaml:opensaml-saml-impl:4.0.1")
 	optional("org.quartz-scheduler:quartz")
-	optional("org.springframework:spring-jdbc")
 	optional("org.springframework.integration:spring-integration-core")
 	optional("org.springframework.integration:spring-integration-jdbc")
 	optional("org.springframework.integration:spring-integration-jmx")
 	optional("org.springframework.integration:spring-integration-rsocket")
+	optional("org.springframework:spring-aspects")
+	optional("org.springframework:spring-jdbc")
 	optional("org.springframework:spring-jms")
 	optional("org.springframework:spring-orm")
 	optional("org.springframework:spring-tx")
@@ -177,6 +170,8 @@ dependencies {
 	optional("org.springframework.data:spring-data-redis")
 	optional("org.springframework.graphql:spring-graphql")
 	optional("org.springframework.hateoas:spring-hateoas")
+	optional("org.springframework.pulsar:spring-pulsar")
+	optional("org.springframework.pulsar:spring-pulsar-reactive")
 	optional("org.springframework.security:spring-security-acl")
 	optional("org.springframework.security:spring-security-config")
 	optional("org.springframework.security:spring-security-data") {
@@ -222,9 +217,9 @@ dependencies {
 	testImplementation("com.ibm.db2:jcc")
 	testImplementation("com.jayway.jsonpath:json-path")
 	testImplementation("com.mysql:mysql-connector-j")
-	testImplementation("com.querydsl:querydsl-core")
 	testImplementation("com.squareup.okhttp3:mockwebserver")
 	testImplementation("com.sun.xml.messaging.saaj:saaj-impl")
+	testImplementation("io.micrometer:context-propagation")
 	testImplementation("io.projectreactor:reactor-test")
 	testImplementation("io.r2dbc:r2dbc-h2")
 	testImplementation("jakarta.json:jakarta.json-api")
@@ -245,7 +240,10 @@ dependencies {
 	testImplementation("org.springframework:spring-test")
 	testImplementation("org.springframework:spring-core-test")
 	testImplementation("org.springframework.graphql:spring-graphql-test")
-	testImplementation("org.springframework.kafka:spring-kafka-test")
+	testImplementation("org.springframework.kafka:spring-kafka-test") {
+		exclude group: "commons-logging", module: "commons-logging"
+	}
+	testImplementation("org.springframework.pulsar:spring-pulsar-cache-provider-caffeine")
 	testImplementation("org.springframework.security:spring-security-test")
 	testImplementation("org.testcontainers:cassandra")
 	testImplementation("org.testcontainers:couchbase")
@@ -253,6 +251,7 @@ dependencies {
 	testImplementation("org.testcontainers:junit-jupiter")
 	testImplementation("org.testcontainers:mongodb")
 	testImplementation("org.testcontainers:neo4j")
+	testImplementation("org.testcontainers:pulsar")
 	testImplementation("org.testcontainers:testcontainers")
 	testImplementation("org.yaml:snakeyaml")
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/BackgroundPreinitializer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/BackgroundPreinitializer.java
index 7fc2382303ef..d64b0763fae6 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/BackgroundPreinitializer.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/BackgroundPreinitializer.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -17,11 +17,14 @@
 package org.springframework.boot.autoconfigure;
 
 import java.nio.charset.StandardCharsets;
+import java.time.ZoneId;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 import jakarta.validation.Configuration;
 import jakarta.validation.Validation;
+import org.apache.catalina.authenticator.NonLoginAuthenticator;
+import org.apache.tomcat.util.http.Rfc6265CookieProcessor;
 
 import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent;
 import org.springframework.boot.context.event.ApplicationFailedEvent;
@@ -107,6 +110,8 @@ public void run() {
 						runSafely(new JacksonInitializer());
 					}
 					runSafely(new CharsetInitializer());
+					runSafely(new TomcatInitializer());
+					runSafely(new JdkInitializer());
 					preinitializationComplete.countDown();
 				}
 
@@ -189,4 +194,23 @@ public void run() {
 
 	}
 
+	private static class TomcatInitializer implements Runnable {
+
+		@Override
+		public void run() {
+			new Rfc6265CookieProcessor();
+			new NonLoginAuthenticator();
+		}
+
+	}
+
+	private static class JdkInitializer implements Runnable {
+
+		@Override
+		public void run() {
+			ZoneId.systemDefault();
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ImportAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ImportAutoConfiguration.java
index b31722ca1766..ce5fdfd3ce75 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ImportAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ImportAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -61,7 +61,9 @@
 	/**
 	 * The auto-configuration classes that should be imported. When empty, the classes are
 	 * specified using a file in {@code META-INF/spring} where the file name is the
-	 * fully-qualified name of the annotated class, suffixed with '.imports'.
+	 * fully-qualified name of the annotated class, suffixed with {@code .imports}. An
+	 * entry in the file may be prefixed with {@code optional:} to indicate that the
+	 * imported class should be ignored if it is not on the classpath.
 	 * @return the classes to import
 	 */
 	@AliasFor("value")
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ImportAutoConfigurationImportSelector.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ImportAutoConfigurationImportSelector.java
index bd6eb6b30131..f19ff33322b7 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ImportAutoConfigurationImportSelector.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ImportAutoConfigurationImportSelector.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -25,6 +25,7 @@
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 
 import org.springframework.boot.context.annotation.DeterminableImports;
@@ -32,6 +33,7 @@
 import org.springframework.core.annotation.AnnotatedElementUtils;
 import org.springframework.core.annotation.AnnotationAttributes;
 import org.springframework.core.annotation.AnnotationUtils;
+import org.springframework.core.io.ClassPathResource;
 import org.springframework.core.type.AnnotationMetadata;
 import org.springframework.util.ClassUtils;
 import org.springframework.util.LinkedMultiValueMap;
@@ -49,6 +51,8 @@
  */
 class ImportAutoConfigurationImportSelector extends AutoConfigurationImportSelector implements DeterminableImports {
 
+	private static final String OPTIONAL_PREFIX = "optional:";
+
 	private static final Set<String> ANNOTATION_NAMES;
 
 	static {
@@ -92,7 +96,20 @@ private Collection<String> getConfigurationsForAnnotation(Class<?> source, Annot
 		if (classes.length > 0) {
 			return Arrays.asList(classes);
 		}
-		return loadFactoryNames(source);
+		return loadFactoryNames(source).stream().map(this::mapFactoryName).filter(Objects::nonNull).toList();
+	}
+
+	private String mapFactoryName(String name) {
+		if (!name.startsWith(OPTIONAL_PREFIX)) {
+			return name;
+		}
+		name = name.substring(OPTIONAL_PREFIX.length());
+		return (!present(name)) ? null : name;
+	}
+
+	private boolean present(String className) {
+		String resourcePath = ClassUtils.convertClassNameToResourcePath(className) + ".class";
+		return new ClassPathResource(resourcePath).exists();
 	}
 
 	protected Collection<String> loadFactoryNames(Class<?> source) {
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/AbstractRabbitListenerContainerFactoryConfigurer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/AbstractRabbitListenerContainerFactoryConfigurer.java
index feab224f2ca7..bcfe04a6748f 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/AbstractRabbitListenerContainerFactoryConfigurer.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/AbstractRabbitListenerContainerFactoryConfigurer.java
@@ -17,6 +17,7 @@
 package org.springframework.boot.autoconfigure.amqp;
 
 import java.util.List;
+import java.util.concurrent.Executor;
 
 import org.springframework.amqp.rabbit.config.AbstractRabbitListenerContainerFactory;
 import org.springframework.amqp.rabbit.config.RetryInterceptorBuilder;
@@ -47,6 +48,8 @@ public abstract class AbstractRabbitListenerContainerFactoryConfigurer<T extends
 
 	private final RabbitProperties rabbitProperties;
 
+	private Executor taskExecutor;
+
 	/**
 	 * Creates a new configurer that will use the given {@code rabbitProperties}.
 	 * @param rabbitProperties properties to use
@@ -81,6 +84,15 @@ protected void setRetryTemplateCustomizers(List<RabbitRetryTemplateCustomizer> r
 		this.retryTemplateCustomizers = retryTemplateCustomizers;
 	}
 
+	/**
+	 * Set the task executor to use.
+	 * @param taskExecutor the task executor
+	 * @since 3.2.0
+	 */
+	public void setTaskExecutor(Executor taskExecutor) {
+		this.taskExecutor = taskExecutor;
+	}
+
 	protected final RabbitProperties getRabbitProperties() {
 		return this.rabbitProperties;
 	}
@@ -118,6 +130,10 @@ protected void configure(T factory, ConnectionFactory connectionFactory,
 		}
 		factory.setMissingQueuesFatal(configuration.isMissingQueuesFatal());
 		factory.setDeBatchingEnabled(configuration.isDeBatchingEnabled());
+		factory.setForceStop(configuration.isForceStop());
+		if (this.taskExecutor != null) {
+			factory.setTaskExecutor(this.taskExecutor);
+		}
 		ListenerRetry retryConfig = configuration.getRetry();
 		if (retryConfig.isEnabled()) {
 			RetryInterceptorBuilder<?, ?> builder = (retryConfig.isStateless()) ? RetryInterceptorBuilder.stateless()
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/PropertiesRabbitConnectionDetails.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/PropertiesRabbitConnectionDetails.java
index 3e4c485d446c..1ac95b513fd5 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/PropertiesRabbitConnectionDetails.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/PropertiesRabbitConnectionDetails.java
@@ -53,8 +53,10 @@ public String getVirtualHost() {
 	public List<Address> getAddresses() {
 		List<Address> addresses = new ArrayList<>();
 		for (String address : this.properties.determineAddresses().split(",")) {
-			String[] components = address.split(":");
-			addresses.add(new Address(components[0], Integer.parseInt(components[1])));
+			int portSeparatorIndex = address.lastIndexOf(':');
+			String host = address.substring(0, portSeparatorIndex);
+			String port = address.substring(portSeparatorIndex + 1);
+			addresses.add(new Address(host, Integer.parseInt(port)));
 		}
 		return addresses;
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitAnnotationDrivenConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitAnnotationDrivenConfiguration.java
index 51decebff512..0a5331e144b0 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitAnnotationDrivenConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitAnnotationDrivenConfiguration.java
@@ -30,14 +30,18 @@
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnThreading;
+import org.springframework.boot.autoconfigure.thread.Threading;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.core.task.VirtualThreadTaskExecutor;
 
 /**
  * Configuration for Spring AMQP annotation driven endpoints.
  *
  * @author Stephane Nicoll
  * @author Josh Thornhill
+ * @author Moritz Halbritter
  */
 @Configuration(proxyBeanMethods = false)
 @ConditionalOnClass(EnableRabbit.class)
@@ -62,12 +66,17 @@ class RabbitAnnotationDrivenConfiguration {
 
 	@Bean
 	@ConditionalOnMissingBean
+	@ConditionalOnThreading(Threading.PLATFORM)
 	SimpleRabbitListenerContainerFactoryConfigurer simpleRabbitListenerContainerFactoryConfigurer() {
-		SimpleRabbitListenerContainerFactoryConfigurer configurer = new SimpleRabbitListenerContainerFactoryConfigurer(
-				this.properties);
-		configurer.setMessageConverter(this.messageConverter.getIfUnique());
-		configurer.setMessageRecoverer(this.messageRecoverer.getIfUnique());
-		configurer.setRetryTemplateCustomizers(this.retryTemplateCustomizers.orderedStream().toList());
+		return simpleListenerConfigurer();
+	}
+
+	@Bean(name = "simpleRabbitListenerContainerFactoryConfigurer")
+	@ConditionalOnMissingBean
+	@ConditionalOnThreading(Threading.VIRTUAL)
+	SimpleRabbitListenerContainerFactoryConfigurer simpleRabbitListenerContainerFactoryConfigurerVirtualThreads() {
+		SimpleRabbitListenerContainerFactoryConfigurer configurer = simpleListenerConfigurer();
+		configurer.setTaskExecutor(new VirtualThreadTaskExecutor());
 		return configurer;
 	}
 
@@ -86,12 +95,17 @@ SimpleRabbitListenerContainerFactory simpleRabbitListenerContainerFactory(
 
 	@Bean
 	@ConditionalOnMissingBean
+	@ConditionalOnThreading(Threading.PLATFORM)
 	DirectRabbitListenerContainerFactoryConfigurer directRabbitListenerContainerFactoryConfigurer() {
-		DirectRabbitListenerContainerFactoryConfigurer configurer = new DirectRabbitListenerContainerFactoryConfigurer(
-				this.properties);
-		configurer.setMessageConverter(this.messageConverter.getIfUnique());
-		configurer.setMessageRecoverer(this.messageRecoverer.getIfUnique());
-		configurer.setRetryTemplateCustomizers(this.retryTemplateCustomizers.orderedStream().toList());
+		return directListenerConfigurer();
+	}
+
+	@Bean(name = "directRabbitListenerContainerFactoryConfigurer")
+	@ConditionalOnMissingBean
+	@ConditionalOnThreading(Threading.VIRTUAL)
+	DirectRabbitListenerContainerFactoryConfigurer directRabbitListenerContainerFactoryConfigurerVirtualThreads() {
+		DirectRabbitListenerContainerFactoryConfigurer configurer = directListenerConfigurer();
+		configurer.setTaskExecutor(new VirtualThreadTaskExecutor());
 		return configurer;
 	}
 
@@ -107,6 +121,24 @@ DirectRabbitListenerContainerFactory directRabbitListenerContainerFactory(
 		return factory;
 	}
 
+	private SimpleRabbitListenerContainerFactoryConfigurer simpleListenerConfigurer() {
+		SimpleRabbitListenerContainerFactoryConfigurer configurer = new SimpleRabbitListenerContainerFactoryConfigurer(
+				this.properties);
+		configurer.setMessageConverter(this.messageConverter.getIfUnique());
+		configurer.setMessageRecoverer(this.messageRecoverer.getIfUnique());
+		configurer.setRetryTemplateCustomizers(this.retryTemplateCustomizers.orderedStream().toList());
+		return configurer;
+	}
+
+	private DirectRabbitListenerContainerFactoryConfigurer directListenerConfigurer() {
+		DirectRabbitListenerContainerFactoryConfigurer configurer = new DirectRabbitListenerContainerFactoryConfigurer(
+				this.properties);
+		configurer.setMessageConverter(this.messageConverter.getIfUnique());
+		configurer.setMessageRecoverer(this.messageRecoverer.getIfUnique());
+		configurer.setRetryTemplateCustomizers(this.retryTemplateCustomizers.orderedStream().toList());
+		return configurer;
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	@EnableRabbit
 	@ConditionalOnMissingBean(name = RabbitListenerConfigUtils.RABBIT_LISTENER_ANNOTATION_PROCESSOR_BEAN_NAME)
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitAutoConfiguration.java
index bd9fb0daa0ea..4c56a677c830 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitAutoConfiguration.java
@@ -38,6 +38,7 @@
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.ssl.SslBundles;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Import;
@@ -69,6 +70,7 @@
  * @author Chris Bono
  * @author Moritz Halbritter
  * @author Andy Wilkinson
+ * @author Scott Frederick
  * @since 1.0.0
  */
 @AutoConfiguration
@@ -82,8 +84,7 @@ protected static class RabbitConnectionFactoryCreator {
 
 		private final RabbitProperties properties;
 
-		protected RabbitConnectionFactoryCreator(RabbitProperties properties,
-				ObjectProvider<RabbitConnectionDetails> connectionDetails) {
+		protected RabbitConnectionFactoryCreator(RabbitProperties properties) {
 			this.properties = properties;
 		}
 
@@ -97,9 +98,10 @@ RabbitConnectionDetails rabbitConnectionDetails() {
 		@ConditionalOnMissingBean
 		RabbitConnectionFactoryBeanConfigurer rabbitConnectionFactoryBeanConfigurer(ResourceLoader resourceLoader,
 				RabbitConnectionDetails connectionDetails, ObjectProvider<CredentialsProvider> credentialsProvider,
-				ObjectProvider<CredentialsRefreshService> credentialsRefreshService) {
+				ObjectProvider<CredentialsRefreshService> credentialsRefreshService,
+				ObjectProvider<SslBundles> sslBundles) {
 			RabbitConnectionFactoryBeanConfigurer configurer = new RabbitConnectionFactoryBeanConfigurer(resourceLoader,
-					this.properties, connectionDetails);
+					this.properties, connectionDetails, sslBundles.getIfAvailable());
 			configurer.setCredentialsProvider(credentialsProvider.getIfUnique());
 			configurer.setCredentialsRefreshService(credentialsRefreshService.getIfUnique());
 			return configurer;
@@ -122,7 +124,7 @@ CachingConnectionFactory rabbitConnectionFactory(
 				CachingConnectionFactoryConfigurer rabbitCachingConnectionFactoryConfigurer,
 				ObjectProvider<ConnectionFactoryCustomizer> connectionFactoryCustomizers) throws Exception {
 
-			RabbitConnectionFactoryBean connectionFactoryBean = new RabbitConnectionFactoryBean();
+			RabbitConnectionFactoryBean connectionFactoryBean = new SslBundleRabbitConnectionFactoryBean();
 			rabbitConnectionFactoryBeanConfigurer.configure(connectionFactoryBean);
 			connectionFactoryBean.afterPropertiesSet();
 			com.rabbitmq.client.ConnectionFactory connectionFactory = connectionFactoryBean.getObject();
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitConnectionFactoryBeanConfigurer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitConnectionFactoryBeanConfigurer.java
index f54e91ace5c2..2f59e2d8faed 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitConnectionFactoryBeanConfigurer.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitConnectionFactoryBeanConfigurer.java
@@ -24,8 +24,11 @@
 import org.springframework.amqp.rabbit.connection.RabbitConnectionFactoryBean;
 import org.springframework.boot.autoconfigure.amqp.RabbitConnectionDetails.Address;
 import org.springframework.boot.context.properties.PropertyMapper;
+import org.springframework.boot.ssl.SslBundle;
+import org.springframework.boot.ssl.SslBundles;
 import org.springframework.core.io.ResourceLoader;
 import org.springframework.util.Assert;
+import org.springframework.util.unit.DataSize;
 
 /**
  * Configures {@link RabbitConnectionFactoryBean} with sensible defaults.
@@ -34,6 +37,7 @@
  * @author Moritz Halbritter
  * @author Andy Wilkinson
  * @author Phillip Webb
+ * @author Scott Frederick
  * @since 2.6.0
  */
 public class RabbitConnectionFactoryBeanConfigurer {
@@ -44,6 +48,8 @@ public class RabbitConnectionFactoryBeanConfigurer {
 
 	private final RabbitConnectionDetails connectionDetails;
 
+	private final SslBundles sslBundles;
+
 	private CredentialsProvider credentialsProvider;
 
 	private CredentialsRefreshService credentialsRefreshService;
@@ -64,17 +70,33 @@ public RabbitConnectionFactoryBeanConfigurer(ResourceLoader resourceLoader, Rabb
 	 * priority over the properties.
 	 * @param resourceLoader the resource loader
 	 * @param properties the properties
-	 * @param connectionDetails the connection details.
+	 * @param connectionDetails the connection details
 	 * @since 3.1.0
 	 */
 	public RabbitConnectionFactoryBeanConfigurer(ResourceLoader resourceLoader, RabbitProperties properties,
 			RabbitConnectionDetails connectionDetails) {
+		this(resourceLoader, properties, connectionDetails, null);
+	}
+
+	/**
+	 * Creates a new configurer that will use the given {@code resourceLoader},
+	 * {@code properties}, {@code connectionDetails}, and {@code sslBundles}. The
+	 * connection details have priority over the properties.
+	 * @param resourceLoader the resource loader
+	 * @param properties the properties
+	 * @param connectionDetails the connection details
+	 * @param sslBundles the SSL bundles
+	 * @since 3.2.0
+	 */
+	public RabbitConnectionFactoryBeanConfigurer(ResourceLoader resourceLoader, RabbitProperties properties,
+			RabbitConnectionDetails connectionDetails, SslBundles sslBundles) {
 		Assert.notNull(resourceLoader, "ResourceLoader must not be null");
 		Assert.notNull(properties, "Properties must not be null");
 		Assert.notNull(connectionDetails, "ConnectionDetails must not be null");
 		this.resourceLoader = resourceLoader;
 		this.rabbitProperties = properties;
 		this.connectionDetails = connectionDetails;
+		this.sslBundles = sslBundles;
 	}
 
 	public void setCredentialsProvider(CredentialsProvider credentialsProvider) {
@@ -110,15 +132,23 @@ public void configure(RabbitConnectionFactoryBean factory) {
 		RabbitProperties.Ssl ssl = this.rabbitProperties.getSsl();
 		if (ssl.determineEnabled()) {
 			factory.setUseSSL(true);
-			map.from(ssl::getAlgorithm).whenNonNull().to(factory::setSslAlgorithm);
-			map.from(ssl::getKeyStoreType).to(factory::setKeyStoreType);
-			map.from(ssl::getKeyStore).to(factory::setKeyStore);
-			map.from(ssl::getKeyStorePassword).to(factory::setKeyStorePassphrase);
-			map.from(ssl::getKeyStoreAlgorithm).whenNonNull().to(factory::setKeyStoreAlgorithm);
-			map.from(ssl::getTrustStoreType).to(factory::setTrustStoreType);
-			map.from(ssl::getTrustStore).to(factory::setTrustStore);
-			map.from(ssl::getTrustStorePassword).to(factory::setTrustStorePassphrase);
-			map.from(ssl::getTrustStoreAlgorithm).whenNonNull().to(factory::setTrustStoreAlgorithm);
+			if (ssl.getBundle() != null) {
+				SslBundle bundle = this.sslBundles.getBundle(ssl.getBundle());
+				if (factory instanceof SslBundleRabbitConnectionFactoryBean sslFactory) {
+					sslFactory.setSslBundle(bundle);
+				}
+			}
+			else {
+				map.from(ssl::getAlgorithm).whenNonNull().to(factory::setSslAlgorithm);
+				map.from(ssl::getKeyStoreType).to(factory::setKeyStoreType);
+				map.from(ssl::getKeyStore).to(factory::setKeyStore);
+				map.from(ssl::getKeyStorePassword).to(factory::setKeyStorePassphrase);
+				map.from(ssl::getKeyStoreAlgorithm).whenNonNull().to(factory::setKeyStoreAlgorithm);
+				map.from(ssl::getTrustStoreType).to(factory::setTrustStoreType);
+				map.from(ssl::getTrustStore).to(factory::setTrustStore);
+				map.from(ssl::getTrustStorePassword).to(factory::setTrustStorePassphrase);
+				map.from(ssl::getTrustStoreAlgorithm).whenNonNull().to(factory::setTrustStoreAlgorithm);
+			}
 			map.from(ssl::isValidateServerCertificate)
 				.to((validate) -> factory.setSkipServerCertificateValidation(!validate));
 			map.from(ssl::getVerifyHostname).to(factory::setEnableHostnameVerification);
@@ -133,6 +163,10 @@ public void configure(RabbitConnectionFactoryBean factory) {
 			.to(factory::setChannelRpcTimeout);
 		map.from(this.credentialsProvider).whenNonNull().to(factory::setCredentialsProvider);
 		map.from(this.credentialsRefreshService).whenNonNull().to(factory::setCredentialsRefreshService);
+		map.from(this.rabbitProperties.getMaxInboundMessageBodySize())
+			.whenNonNull()
+			.asInt(DataSize::toBytes)
+			.to(factory::setMaxInboundMessageBodySize);
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitProperties.java
index 60e076ca56a3..4279fb30df0f 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitProperties.java
@@ -31,6 +31,7 @@
 import org.springframework.boot.convert.DurationUnit;
 import org.springframework.util.CollectionUtils;
 import org.springframework.util.StringUtils;
+import org.springframework.util.unit.DataSize;
 
 /**
  * Configuration properties for Rabbit.
@@ -45,6 +46,7 @@
  * @author Franjo Zilic
  * @author EddĂș MelĂ©ndez
  * @author Rafael Carvalho
+ * @author Scott Frederick
  * @since 1.0.0
  */
 @ConfigurationProperties(prefix = "spring.rabbitmq")
@@ -130,6 +132,11 @@ public class RabbitProperties {
 	 */
 	private Duration channelRpcTimeout = Duration.ofMinutes(10);
 
+	/**
+	 * Maximum size of the body of inbound (received) messages.
+	 */
+	private DataSize maxInboundMessageBodySize = DataSize.ofMegabytes(64);
+
 	/**
 	 * Cache configuration.
 	 */
@@ -360,6 +367,14 @@ public void setChannelRpcTimeout(Duration channelRpcTimeout) {
 		this.channelRpcTimeout = channelRpcTimeout;
 	}
 
+	public DataSize getMaxInboundMessageBodySize() {
+		return this.maxInboundMessageBodySize;
+	}
+
+	public void setMaxInboundMessageBodySize(DataSize maxInboundMessageBodySize) {
+		this.maxInboundMessageBodySize = maxInboundMessageBodySize;
+	}
+
 	public Cache getCache() {
 		return this.cache;
 	}
@@ -386,6 +401,11 @@ public class Ssl {
 		 */
 		private Boolean enabled;
 
+		/**
+		 * SSL bundle name.
+		 */
+		private String bundle;
+
 		/**
 		 * Path to the key store that holds the SSL certificate.
 		 */
@@ -453,7 +473,7 @@ public Boolean getEnabled() {
 		 * @see #getEnabled() ()
 		 */
 		public boolean determineEnabled() {
-			boolean defaultEnabled = Optional.ofNullable(getEnabled()).orElse(false);
+			boolean defaultEnabled = Optional.ofNullable(getEnabled()).orElse(false) || this.bundle != null;
 			if (CollectionUtils.isEmpty(RabbitProperties.this.parsedAddresses)) {
 				return defaultEnabled;
 			}
@@ -465,6 +485,14 @@ public void setEnabled(Boolean enabled) {
 			this.enabled = enabled;
 		}
 
+		public String getBundle() {
+			return this.bundle;
+		}
+
+		public void setBundle(String bundle) {
+			this.bundle = bundle;
+		}
+
 		public String getKeyStore() {
 			return this.keyStore;
 		}
@@ -734,6 +762,12 @@ public abstract static class AmqpContainer extends BaseContainer {
 		 */
 		private boolean deBatchingEnabled = true;
 
+		/**
+		 * Whether the container (when stopped) should stop immediately after processing
+		 * the current message or stop after processing all pre-fetched messages.
+		 */
+		private boolean forceStop;
+
 		/**
 		 * Optional properties for a retry interceptor.
 		 */
@@ -781,6 +815,14 @@ public void setDeBatchingEnabled(boolean deBatchingEnabled) {
 			this.deBatchingEnabled = deBatchingEnabled;
 		}
 
+		public boolean isForceStop() {
+			return this.forceStop;
+		}
+
+		public void setForceStop(boolean forceStop) {
+			this.forceStop = forceStop;
+		}
+
 		public ListenerRetry getRetry() {
 			return this.retry;
 		}
@@ -1192,6 +1234,12 @@ public static final class Stream {
 		 */
 		private int port = DEFAULT_STREAM_PORT;
 
+		/**
+		 * Virtual host of a RabbitMQ instance with the Stream plugin enabled. When not
+		 * set, spring.rabbitmq.virtual-host is used.
+		 */
+		private String virtualHost;
+
 		/**
 		 * Login user to authenticate to the broker. When not set,
 		 * spring.rabbitmq.username is used.
@@ -1225,6 +1273,14 @@ public void setPort(int port) {
 			this.port = port;
 		}
 
+		public String getVirtualHost() {
+			return this.virtualHost;
+		}
+
+		public void setVirtualHost(String virtualHost) {
+			this.virtualHost = virtualHost;
+		}
+
 		public String getUsername() {
 			return this.username;
 		}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitStreamConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitStreamConfiguration.java
index 6547cfdc4e9c..569cdb2bf664 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitStreamConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitStreamConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -102,6 +102,10 @@ static EnvironmentBuilder configure(EnvironmentBuilder builder, RabbitProperties
 		PropertyMapper map = PropertyMapper.get();
 		map.from(stream.getHost()).to(builder::host);
 		map.from(stream.getPort()).to(builder::port);
+		map.from(stream.getVirtualHost())
+			.as(withFallback(properties::getVirtualHost))
+			.whenNonNull()
+			.to(builder::virtualHost);
 		map.from(stream.getUsername()).as(withFallback(properties::getUsername)).whenNonNull().to(builder::username);
 		map.from(stream.getPassword()).as(withFallback(properties::getPassword)).whenNonNull().to(builder::password);
 		return builder;
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/SslBundleRabbitConnectionFactoryBean.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/SslBundleRabbitConnectionFactoryBean.java
new file mode 100644
index 000000000000..526a187dd428
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/SslBundleRabbitConnectionFactoryBean.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.amqp;
+
+import org.springframework.amqp.rabbit.connection.RabbitConnectionFactoryBean;
+import org.springframework.boot.ssl.SslBundle;
+
+/**
+ * A {@link RabbitConnectionFactoryBean} that can be configured with custom SSL trust
+ * material from an {@link SslBundle}.
+ *
+ * @author Scott Frederick
+ */
+class SslBundleRabbitConnectionFactoryBean extends RabbitConnectionFactoryBean {
+
+	private SslBundle sslBundle;
+
+	private boolean enableHostnameVerification;
+
+	@Override
+	protected void setUpSSL() {
+		if (this.sslBundle != null) {
+			this.connectionFactory.useSslProtocol(this.sslBundle.createSslContext());
+			if (this.enableHostnameVerification) {
+				this.connectionFactory.enableHostnameVerification();
+			}
+		}
+		else {
+			super.setUpSSL();
+		}
+	}
+
+	void setSslBundle(SslBundle sslBundle) {
+		this.sslBundle = sslBundle;
+	}
+
+	@Override
+	public void setEnableHostnameVerification(boolean enable) {
+		this.enableHostnameVerification = enable;
+		super.setEnableHostnameVerification(enable);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/batch/BatchAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/batch/BatchAutoConfiguration.java
index 3c050a0856bc..e74e377465b8 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/batch/BatchAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/batch/BatchAutoConfiguration.java
@@ -20,14 +20,10 @@
 
 import javax.sql.DataSource;
 
-import org.springframework.batch.core.configuration.ListableJobLocator;
 import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing;
 import org.springframework.batch.core.configuration.support.DefaultBatchConfiguration;
-import org.springframework.batch.core.converter.JobParametersConverter;
 import org.springframework.batch.core.explore.JobExplorer;
 import org.springframework.batch.core.launch.JobLauncher;
-import org.springframework.batch.core.launch.JobOperator;
-import org.springframework.batch.core.launch.support.SimpleJobOperator;
 import org.springframework.batch.core.repository.JobRepository;
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.boot.ExitCodeGenerator;
@@ -95,20 +91,6 @@ public JobExecutionExitCodeGenerator jobExecutionExitCodeGenerator() {
 		return new JobExecutionExitCodeGenerator();
 	}
 
-	@Bean
-	@ConditionalOnMissingBean(JobOperator.class)
-	public SimpleJobOperator jobOperator(ObjectProvider<JobParametersConverter> jobParametersConverter,
-			JobExplorer jobExplorer, JobLauncher jobLauncher, ListableJobLocator jobRegistry,
-			JobRepository jobRepository) throws Exception {
-		SimpleJobOperator factory = new SimpleJobOperator();
-		factory.setJobExplorer(jobExplorer);
-		factory.setJobLauncher(jobLauncher);
-		factory.setJobRegistry(jobRegistry);
-		factory.setJobRepository(jobRepository);
-		jobParametersConverter.ifAvailable(factory::setJobParametersConverter);
-		return factory;
-	}
-
 	@Configuration(proxyBeanMethods = false)
 	static class SpringBootBatchConfiguration extends DefaultBatchConfiguration {
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/batch/JobLauncherApplicationRunner.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/batch/JobLauncherApplicationRunner.java
index 7be6c237a2a9..a343346eb3e6 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/batch/JobLauncherApplicationRunner.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/batch/JobLauncherApplicationRunner.java
@@ -19,12 +19,10 @@
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Properties;
 
-import jakarta.annotation.PostConstruct;
 import org.apache.commons.logging.Log;
 import org.apache.commons.logging.LogFactory;
 
@@ -41,11 +39,11 @@
 import org.springframework.batch.core.converter.JobParametersConverter;
 import org.springframework.batch.core.explore.JobExplorer;
 import org.springframework.batch.core.launch.JobLauncher;
-import org.springframework.batch.core.launch.NoSuchJobException;
 import org.springframework.batch.core.repository.JobExecutionAlreadyRunningException;
 import org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException;
 import org.springframework.batch.core.repository.JobRepository;
 import org.springframework.batch.core.repository.JobRestartException;
+import org.springframework.beans.factory.InitializingBean;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.ApplicationArguments;
 import org.springframework.boot.ApplicationRunner;
@@ -65,9 +63,11 @@
  * @author Jean-Pierre Bergamin
  * @author Mahmoud Ben Hassine
  * @author Stephane Nicoll
+ * @author Akshay Dubey
  * @since 2.3.0
  */
-public class JobLauncherApplicationRunner implements ApplicationRunner, Ordered, ApplicationEventPublisherAware {
+public class JobLauncherApplicationRunner
+		implements ApplicationRunner, InitializingBean, Ordered, ApplicationEventPublisherAware {
 
 	/**
 	 * The default order for the command line runner.
@@ -110,13 +110,21 @@ public JobLauncherApplicationRunner(JobLauncher jobLauncher, JobExplorer jobExpl
 		this.jobRepository = jobRepository;
 	}
 
-	@PostConstruct
-	public void validate() {
-		if (this.jobs.size() > 1 && !StringUtils.hasText(this.jobName)) {
-			throw new IllegalArgumentException("Job name must be specified in case of multiple jobs");
+	@Override
+	public void afterPropertiesSet() {
+		Assert.isTrue(this.jobs.size() <= 1 || StringUtils.hasText(this.jobName),
+				"Job name must be specified in case of multiple jobs");
+		if (StringUtils.hasText(this.jobName)) {
+			Assert.isTrue(isLocalJob(this.jobName) || isRegisteredJob(this.jobName),
+					() -> "No job found with name '" + this.jobName + "'");
 		}
 	}
 
+	@Deprecated(since = "3.0.10", forRemoval = true)
+	public void validate() {
+		afterPropertiesSet();
+	}
+
 	public void setOrder(int order) {
 		this.order = order;
 	}
@@ -167,6 +175,14 @@ protected void launchJobFromProperties(Properties properties) throws JobExecutio
 		executeRegisteredJobs(jobParameters);
 	}
 
+	private boolean isLocalJob(String jobName) {
+		return this.jobs.stream().anyMatch((job) -> job.getName().equals(jobName));
+	}
+
+	private boolean isRegisteredJob(String jobName) {
+		return this.jobRegistry != null && this.jobRegistry.getJobNames().contains(jobName);
+	}
+
 	private void executeLocalJobs(JobParameters jobParameters) throws JobExecutionException {
 		for (Job job : this.jobs) {
 			if (StringUtils.hasText(this.jobName)) {
@@ -181,14 +197,9 @@ private void executeLocalJobs(JobParameters jobParameters) throws JobExecutionEx
 
 	private void executeRegisteredJobs(JobParameters jobParameters) throws JobExecutionException {
 		if (this.jobRegistry != null && StringUtils.hasText(this.jobName)) {
-			try {
+			if (!isLocalJob(this.jobName)) {
 				Job job = this.jobRegistry.getJob(this.jobName);
-				if (!this.jobs.contains(job)) {
-					execute(job, jobParameters);
-				}
-			}
-			catch (NoSuchJobException ex) {
-				logger.debug(LogMessage.format("No job found in registry for job name: %s", this.jobName));
+				execute(job, jobParameters);
 			}
 		}
 	}
@@ -218,7 +229,8 @@ private JobParameters getNextJobParameters(Job job, JobParameters jobParameters)
 	private JobParameters getNextJobParametersForExisting(Job job, JobParameters jobParameters) {
 		JobExecution lastExecution = this.jobRepository.getLastJobExecution(job.getName(), jobParameters);
 		if (isStoppedOrFailed(lastExecution) && job.isRestartable()) {
-			JobParameters previousIdentifyingParameters = getGetIdentifying(lastExecution.getJobParameters());
+			JobParameters previousIdentifyingParameters = new JobParameters(
+					lastExecution.getJobParameters().getIdentifyingParameters());
 			return merge(previousIdentifyingParameters, jobParameters);
 		}
 		return jobParameters;
@@ -229,16 +241,6 @@ private boolean isStoppedOrFailed(JobExecution execution) {
 		return (status == BatchStatus.STOPPED || status == BatchStatus.FAILED);
 	}
 
-	private JobParameters getGetIdentifying(JobParameters parameters) {
-		HashMap<String, JobParameter<?>> nonIdentifying = new LinkedHashMap<>(parameters.getParameters().size());
-		parameters.getParameters().forEach((key, value) -> {
-			if (value.isIdentifying()) {
-				nonIdentifying.put(key, value);
-			}
-		});
-		return new JobParameters(nonIdentifying);
-	}
-
 	private JobParameters merge(JobParameters parameters, JobParameters additionals) {
 		Map<String, JobParameter<?>> merged = new LinkedHashMap<>();
 		merged.putAll(parameters.getParameters());
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/codec/CodecProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/codec/CodecProperties.java
index 692b8bb817c7..3686d39c369e 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/codec/CodecProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/codec/CodecProperties.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -20,7 +20,7 @@
 import org.springframework.util.unit.DataSize;
 
 /**
- * {@link ConfigurationProperties properties} for reactive codecs.
+ * {@link ConfigurationProperties Properties} for reactive codecs.
  *
  * @author Brian Clozel
  * @since 2.2.1
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/ConditionEvaluationReport.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/ConditionEvaluationReport.java
index cb987b1af3a9..7712bc2847ed 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/ConditionEvaluationReport.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/ConditionEvaluationReport.java
@@ -80,10 +80,7 @@ public void recordConditionEvaluation(String source, Condition condition, Condit
 		Assert.notNull(condition, "Condition must not be null");
 		Assert.notNull(outcome, "Outcome must not be null");
 		this.unconditionalClasses.remove(source);
-		if (!this.outcomes.containsKey(source)) {
-			this.outcomes.put(source, new ConditionAndOutcomes());
-		}
-		this.outcomes.get(source).add(condition, outcome);
+		this.outcomes.computeIfAbsent(source, (key) -> new ConditionAndOutcomes()).add(condition, outcome);
 		this.addedAncestorOutcomes = false;
 	}
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/ConditionalOnCheckpointRestore.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/ConditionalOnCheckpointRestore.java
new file mode 100644
index 000000000000..2505d4930ffc
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/ConditionalOnCheckpointRestore.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.condition;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.springframework.context.annotation.Conditional;
+
+/**
+ * {@link Conditional @Conditional} that only matches when coordinated restore at
+ * checkpoint is to be used.
+ *
+ * @author Andy Wilkinson
+ * @since 3.2.0
+ */
+@Target({ ElementType.TYPE, ElementType.METHOD })
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@ConditionalOnClass(name = "org.crac.Resource")
+public @interface ConditionalOnCheckpointRestore {
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/ConditionalOnThreading.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/ConditionalOnThreading.java
new file mode 100644
index 000000000000..18da6ceddbda
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/ConditionalOnThreading.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.condition;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.springframework.boot.autoconfigure.thread.Threading;
+import org.springframework.context.annotation.Conditional;
+
+/**
+ * {@link Conditional @Conditional} that matches when the specified threading is active.
+ *
+ * @author Moritz Halbritter
+ * @since 3.2.0
+ */
+@Target({ ElementType.TYPE, ElementType.METHOD })
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@Conditional(OnThreadingCondition.class)
+public @interface ConditionalOnThreading {
+
+	/**
+	 * The {@link Threading threading} that must be active.
+	 * @return the expected threading
+	 */
+	Threading value();
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/OnBeanCondition.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/OnBeanCondition.java
index 9bfb116b5fc9..1de90f0c917d 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/OnBeanCondition.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/OnBeanCondition.java
@@ -36,6 +36,7 @@
 import org.springframework.beans.factory.ListableBeanFactory;
 import org.springframework.beans.factory.config.BeanDefinition;
 import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.config.SingletonBeanRegistry;
 import org.springframework.boot.autoconfigure.AutoConfigurationMetadata;
 import org.springframework.boot.autoconfigure.condition.ConditionMessage.Style;
 import org.springframework.context.annotation.Bean;
@@ -279,7 +280,7 @@ private Class<? extends Annotation> resolveAnnotationType(ClassLoader classLoade
 
 	private Set<String> collectBeanNamesForAnnotation(ListableBeanFactory beanFactory,
 			Class<? extends Annotation> annotationType, boolean considerHierarchy, Set<String> result) {
-		result = addAll(result, beanFactory.getBeanNamesForAnnotation(annotationType));
+		result = addAll(result, getBeanNamesForAnnotation(beanFactory, annotationType));
 		if (considerHierarchy) {
 			BeanFactory parent = ((HierarchicalBeanFactory) beanFactory).getParentBeanFactory();
 			if (parent instanceof ListableBeanFactory listableBeanFactory) {
@@ -289,6 +290,30 @@ private Set<String> collectBeanNamesForAnnotation(ListableBeanFactory beanFactor
 		return result;
 	}
 
+	private String[] getBeanNamesForAnnotation(ListableBeanFactory beanFactory,
+			Class<? extends Annotation> annotationType) {
+		Set<String> foundBeanNames = new LinkedHashSet<>();
+		for (String beanName : beanFactory.getBeanDefinitionNames()) {
+			if (beanFactory instanceof ConfigurableListableBeanFactory configurableListableBeanFactory) {
+				BeanDefinition beanDefinition = configurableListableBeanFactory.getBeanDefinition(beanName);
+				if (beanDefinition != null && beanDefinition.isAbstract()) {
+					continue;
+				}
+			}
+			if (beanFactory.findAnnotationOnBean(beanName, annotationType, false) != null) {
+				foundBeanNames.add(beanName);
+			}
+		}
+		if (beanFactory instanceof SingletonBeanRegistry singletonBeanRegistry) {
+			for (String beanName : singletonBeanRegistry.getSingletonNames()) {
+				if (beanFactory.findAnnotationOnBean(beanName, annotationType) != null) {
+					foundBeanNames.add(beanName);
+				}
+			}
+		}
+		return foundBeanNames.toArray(String[]::new);
+	}
+
 	private boolean containsBean(ConfigurableListableBeanFactory beanFactory, String beanName,
 			boolean considerHierarchy) {
 		if (considerHierarchy) {
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/OnThreadingCondition.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/OnThreadingCondition.java
new file mode 100644
index 000000000000..7856a63431a6
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/OnThreadingCondition.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.condition;
+
+import java.util.Map;
+
+import org.springframework.boot.autoconfigure.thread.Threading;
+import org.springframework.context.annotation.Condition;
+import org.springframework.context.annotation.ConditionContext;
+import org.springframework.core.env.Environment;
+import org.springframework.core.type.AnnotatedTypeMetadata;
+
+/**
+ * {@link Condition} that checks for a required {@link Threading}.
+ *
+ * @author Moritz Halbritter
+ * @see ConditionalOnThreading
+ */
+class OnThreadingCondition extends SpringBootCondition {
+
+	@Override
+	public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
+		Map<String, Object> attributes = metadata.getAnnotationAttributes(ConditionalOnThreading.class.getName());
+		Threading threading = (Threading) attributes.get("value");
+		return getMatchOutcome(context.getEnvironment(), threading);
+	}
+
+	private ConditionOutcome getMatchOutcome(Environment environment, Threading threading) {
+		String name = threading.name();
+		ConditionMessage.Builder message = ConditionMessage.forCondition(ConditionalOnThreading.class);
+		if (threading.isActive(environment)) {
+			return ConditionOutcome.match(message.foundExactly(name));
+		}
+		return ConditionOutcome.noMatch(message.didNotFind(name).atAll());
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/context/MessageSourceAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/context/MessageSourceAutoConfiguration.java
index ed306db9882e..0ec92b6568b9 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/context/MessageSourceAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/context/MessageSourceAutoConfiguration.java
@@ -18,6 +18,8 @@
 
 import java.time.Duration;
 
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.AutoConfigureOrder;
 import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
@@ -26,6 +28,7 @@
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.autoconfigure.condition.SearchStrategy;
 import org.springframework.boot.autoconfigure.condition.SpringBootCondition;
+import org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration.MessageSourceRuntimeHints;
 import org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration.ResourceBundleCondition;
 import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -33,6 +36,7 @@
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.ConditionContext;
 import org.springframework.context.annotation.Conditional;
+import org.springframework.context.annotation.ImportRuntimeHints;
 import org.springframework.context.support.AbstractApplicationContext;
 import org.springframework.context.support.ResourceBundleMessageSource;
 import org.springframework.core.Ordered;
@@ -48,6 +52,7 @@
  * @author Dave Syer
  * @author Phillip Webb
  * @author EddĂș MelĂ©ndez
+ * @author Marc Becker
  * @since 1.5.0
  */
 @AutoConfiguration
@@ -55,6 +60,7 @@
 @AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
 @Conditional(ResourceBundleCondition.class)
 @EnableConfigurationProperties
+@ImportRuntimeHints(MessageSourceRuntimeHints.class)
 public class MessageSourceAutoConfiguration {
 
 	private static final Resource[] NO_RESOURCES = {};
@@ -125,4 +131,13 @@ private Resource[] getResources(ClassLoader classLoader, String name) {
 
 	}
 
+	static class MessageSourceRuntimeHints implements RuntimeHintsRegistrar {
+
+		@Override
+		public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
+			hints.resources().registerPattern("messages.properties").registerPattern("messages_*.properties");
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseProperties.java
index 6132c73bbd34..d1e93181c570 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseProperties.java
@@ -118,7 +118,7 @@ public static class Io {
 		 * Length of time an HTTP connection may remain idle before it is closed and
 		 * removed from the pool.
 		 */
-		private Duration idleHttpConnectionTimeout = Duration.ofMillis(4500);
+		private Duration idleHttpConnectionTimeout = Duration.ofSeconds(1);
 
 		public int getMinEndpoints() {
 			return this.minEndpoints;
@@ -180,7 +180,8 @@ public void setEnabled(Boolean enabled) {
 
 		@Deprecated(since = "3.1.0", forRemoval = true)
 		@DeprecatedConfigurationProperty(
-				reason = "SSL bundle support with spring.ssl.bundle and spring.couchbase.env.ssl.bundle should be used instead")
+				reason = "SSL bundle support with spring.ssl.bundle and spring.couchbase.env.ssl.bundle should be used instead",
+				since = "3.1.0")
 		public String getKeyStore() {
 			return this.keyStore;
 		}
@@ -192,7 +193,8 @@ public void setKeyStore(String keyStore) {
 
 		@Deprecated(since = "3.1.0", forRemoval = true)
 		@DeprecatedConfigurationProperty(
-				reason = "SSL bundle support with spring.ssl.bundle and spring.couchbase.env.ssl.bundle should be used instead")
+				reason = "SSL bundle support with spring.ssl.bundle and spring.couchbase.env.ssl.bundle should be used instead",
+				since = "3.1.0")
 		public String getKeyStorePassword() {
 			return this.keyStorePassword;
 		}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/couchbase/CouchbaseDataConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/couchbase/CouchbaseDataConfiguration.java
index 20826c607ba9..1b408f876620 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/couchbase/CouchbaseDataConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/couchbase/CouchbaseDataConfiguration.java
@@ -61,7 +61,7 @@ TranslationService couchbaseTranslationService() {
 	@ConditionalOnMissingBean(name = BeanNames.COUCHBASE_MAPPING_CONTEXT)
 	CouchbaseMappingContext couchbaseMappingContext(CouchbaseDataProperties properties,
 			ApplicationContext applicationContext, CouchbaseCustomConversions couchbaseCustomConversions)
-			throws Exception {
+			throws ClassNotFoundException {
 		CouchbaseMappingContext mappingContext = new CouchbaseMappingContext();
 		mappingContext.setInitialEntitySet(new EntityScanner(applicationContext).scan(Document.class));
 		mappingContext.setSimpleTypeHolder(couchbaseCustomConversions.getSimpleTypeHolder());
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/neo4j/Neo4jDataAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/neo4j/Neo4jDataAutoConfiguration.java
index dd72cf323df7..38cad56487c8 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/neo4j/Neo4jDataAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/neo4j/Neo4jDataAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -29,10 +29,12 @@
 import org.springframework.boot.autoconfigure.domain.EntityScanner;
 import org.springframework.boot.autoconfigure.neo4j.Neo4jAutoConfiguration;
 import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration;
+import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizationAutoConfiguration;
 import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizers;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.context.ApplicationContext;
 import org.springframework.context.annotation.Bean;
+import org.springframework.data.neo4j.aot.Neo4jManagedTypes;
 import org.springframework.data.neo4j.core.DatabaseSelectionProvider;
 import org.springframework.data.neo4j.core.Neo4jClient;
 import org.springframework.data.neo4j.core.Neo4jOperations;
@@ -57,7 +59,8 @@
  * @author Michael J. Simons
  * @since 1.4.0
  */
-@AutoConfiguration(before = TransactionAutoConfiguration.class, after = Neo4jAutoConfiguration.class)
+@AutoConfiguration(before = TransactionAutoConfiguration.class,
+		after = { Neo4jAutoConfiguration.class, TransactionManagerCustomizationAutoConfiguration.class })
 @ConditionalOnClass({ Driver.class, Neo4jTransactionManager.class, PlatformTransactionManager.class })
 @EnableConfigurationProperties(Neo4jDataProperties.class)
 @ConditionalOnBean(Driver.class)
@@ -71,12 +74,17 @@ public Neo4jConversions neo4jConversions() {
 
 	@Bean
 	@ConditionalOnMissingBean
-	public Neo4jMappingContext neo4jMappingContext(ApplicationContext applicationContext,
-			Neo4jConversions neo4jConversions) throws ClassNotFoundException {
+	Neo4jManagedTypes neo4jManagedTypes(ApplicationContext applicationContext) throws ClassNotFoundException {
 		Set<Class<?>> initialEntityClasses = new EntityScanner(applicationContext).scan(Node.class,
 				RelationshipProperties.class);
+		return Neo4jManagedTypes.fromIterable(initialEntityClasses);
+	}
+
+	@Bean
+	@ConditionalOnMissingBean
+	public Neo4jMappingContext neo4jMappingContext(Neo4jManagedTypes managedTypes, Neo4jConversions neo4jConversions) {
 		Neo4jMappingContext context = new Neo4jMappingContext(neo4jConversions);
-		context.setInitialEntitySet(initialEntityClasses);
+		context.setManagedTypes(managedTypes);
 		return context;
 	}
 
@@ -105,7 +113,7 @@ public Neo4jTemplate neo4jTemplate(Neo4jClient neo4jClient, Neo4jMappingContext
 	public Neo4jTransactionManager transactionManager(Driver driver, DatabaseSelectionProvider databaseNameProvider,
 			ObjectProvider<TransactionManagerCustomizers> optionalCustomizers) {
 		Neo4jTransactionManager transactionManager = new Neo4jTransactionManager(driver, databaseNameProvider);
-		optionalCustomizers.ifAvailable((customizer) -> customizer.customize(transactionManager));
+		optionalCustomizers.ifAvailable((customizer) -> customizer.customize((TransactionManager) transactionManager));
 		return transactionManager;
 	}
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/JedisConnectionConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/JedisConnectionConfiguration.java
index 80bcde71166a..c1c6fbb6e811 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/JedisConnectionConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/JedisConnectionConfiguration.java
@@ -26,12 +26,15 @@
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnThreading;
+import org.springframework.boot.autoconfigure.thread.Threading;
 import org.springframework.boot.context.properties.PropertyMapper;
 import org.springframework.boot.ssl.SslBundle;
 import org.springframework.boot.ssl.SslBundles;
 import org.springframework.boot.ssl.SslOptions;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.core.task.SimpleAsyncTaskExecutor;
 import org.springframework.data.redis.connection.RedisClusterConfiguration;
 import org.springframework.data.redis.connection.RedisConnectionFactory;
 import org.springframework.data.redis.connection.RedisSentinelConfiguration;
@@ -69,11 +72,23 @@ class JedisConnectionConfiguration extends RedisConnectionConfiguration {
 	}
 
 	@Bean
+	@ConditionalOnThreading(Threading.PLATFORM)
 	JedisConnectionFactory redisConnectionFactory(
 			ObjectProvider<JedisClientConfigurationBuilderCustomizer> builderCustomizers) {
 		return createJedisConnectionFactory(builderCustomizers);
 	}
 
+	@Bean
+	@ConditionalOnThreading(Threading.VIRTUAL)
+	JedisConnectionFactory redisConnectionFactoryVirtualThreads(
+			ObjectProvider<JedisClientConfigurationBuilderCustomizer> builderCustomizers) {
+		JedisConnectionFactory factory = createJedisConnectionFactory(builderCustomizers);
+		SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("redis-");
+		executor.setVirtualThreads(true);
+		factory.setExecutor(executor);
+		return factory;
+	}
+
 	private JedisConnectionFactory createJedisConnectionFactory(
 			ObjectProvider<JedisClientConfigurationBuilderCustomizer> builderCustomizers) {
 		JedisClientConfiguration clientConfiguration = getJedisClientConfiguration(builderCustomizers);
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/LettuceConnectionConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/LettuceConnectionConfiguration.java
index 0f88d9029684..4a765d1e2004 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/LettuceConnectionConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/LettuceConnectionConfiguration.java
@@ -33,13 +33,16 @@
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnThreading;
 import org.springframework.boot.autoconfigure.data.redis.RedisProperties.Lettuce.Cluster.Refresh;
 import org.springframework.boot.autoconfigure.data.redis.RedisProperties.Pool;
+import org.springframework.boot.autoconfigure.thread.Threading;
 import org.springframework.boot.ssl.SslBundle;
 import org.springframework.boot.ssl.SslBundles;
 import org.springframework.boot.ssl.SslOptions;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.core.task.SimpleAsyncTaskExecutor;
 import org.springframework.data.redis.connection.RedisClusterConfiguration;
 import org.springframework.data.redis.connection.RedisConnectionFactory;
 import org.springframework.data.redis.connection.RedisSentinelConfiguration;
@@ -83,9 +86,29 @@ DefaultClientResources lettuceClientResources(ObjectProvider<ClientResourcesBuil
 
 	@Bean
 	@ConditionalOnMissingBean(RedisConnectionFactory.class)
+	@ConditionalOnThreading(Threading.PLATFORM)
 	LettuceConnectionFactory redisConnectionFactory(
 			ObjectProvider<LettuceClientConfigurationBuilderCustomizer> builderCustomizers,
 			ClientResources clientResources) {
+		return createConnectionFactory(builderCustomizers, clientResources);
+	}
+
+	@Bean
+	@ConditionalOnMissingBean(RedisConnectionFactory.class)
+	@ConditionalOnThreading(Threading.VIRTUAL)
+	LettuceConnectionFactory redisConnectionFactoryVirtualThreads(
+			ObjectProvider<LettuceClientConfigurationBuilderCustomizer> builderCustomizers,
+			ClientResources clientResources) {
+		LettuceConnectionFactory factory = createConnectionFactory(builderCustomizers, clientResources);
+		SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("redis-");
+		executor.setVirtualThreads(true);
+		factory.setExecutor(executor);
+		return factory;
+	}
+
+	private LettuceConnectionFactory createConnectionFactory(
+			ObjectProvider<LettuceClientConfigurationBuilderCustomizer> builderCustomizers,
+			ClientResources clientResources) {
 		LettuceClientConfiguration clientConfig = getLettuceClientConfiguration(builderCustomizers, clientResources,
 				getProperties().getLettuce().getPool());
 		return createLettuceConnectionFactory(clientConfig);
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/RedisProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/RedisProperties.java
index c66e8fde7bc5..91a58ac42fe9 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/RedisProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/RedisProperties.java
@@ -42,7 +42,7 @@ public class RedisProperties {
 	private int database = 0;
 
 	/**
-	 * Connection URL. Overrides host, port, and password. User is ignored. Example:
+	 * Connection URL. Overrides host, port, username, and password. Example:
 	 * redis://user:password@example.com:6379
 	 */
 	private String url;
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/RedisReactiveAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/RedisReactiveAutoConfiguration.java
index 9f2cb6ee3cc3..1e1fefe8ef3b 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/RedisReactiveAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/RedisReactiveAutoConfiguration.java
@@ -28,8 +28,8 @@
 import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory;
 import org.springframework.data.redis.core.ReactiveRedisTemplate;
 import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
-import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer;
 import org.springframework.data.redis.serializer.RedisSerializationContext;
+import org.springframework.data.redis.serializer.RedisSerializer;
 
 /**
  * {@link EnableAutoConfiguration Auto-configuration} for Spring Data's reactive Redis
@@ -48,14 +48,13 @@ public class RedisReactiveAutoConfiguration {
 	@ConditionalOnBean(ReactiveRedisConnectionFactory.class)
 	public ReactiveRedisTemplate<Object, Object> reactiveRedisTemplate(
 			ReactiveRedisConnectionFactory reactiveRedisConnectionFactory, ResourceLoader resourceLoader) {
-		JdkSerializationRedisSerializer jdkSerializer = new JdkSerializationRedisSerializer(
-				resourceLoader.getClassLoader());
+		RedisSerializer<Object> javaSerializer = RedisSerializer.java(resourceLoader.getClassLoader());
 		RedisSerializationContext<Object, Object> serializationContext = RedisSerializationContext
 			.newSerializationContext()
-			.key(jdkSerializer)
-			.value(jdkSerializer)
-			.hashKey(jdkSerializer)
-			.hashValue(jdkSerializer)
+			.key(javaSerializer)
+			.value(javaSerializer)
+			.hashKey(javaSerializer)
+			.hashValue(javaSerializer)
 			.build();
 		return new ReactiveRedisTemplate<>(reactiveRedisConnectionFactory, serializationContext);
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchClientAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchClientAutoConfiguration.java
index 64b86ab0e627..f28d32e23997 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchClientAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchClientAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -17,12 +17,15 @@
 package org.springframework.boot.autoconfigure.elasticsearch;
 
 import co.elastic.clients.elasticsearch.ElasticsearchClient;
+import org.elasticsearch.client.RestClient;
 
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchClientConfigurations.ElasticsearchClientConfiguration;
 import org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchClientConfigurations.ElasticsearchTransportConfiguration;
+import org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchClientConfigurations.JsonpMapperConfiguration;
 import org.springframework.boot.autoconfigure.jsonb.JsonbAutoConfiguration;
 import org.springframework.context.annotation.Import;
 
@@ -33,8 +36,10 @@
  * @since 3.0.0
  */
 @AutoConfiguration(after = { JsonbAutoConfiguration.class, ElasticsearchRestClientAutoConfiguration.class })
+@ConditionalOnBean(RestClient.class)
 @ConditionalOnClass(ElasticsearchClient.class)
-@Import({ ElasticsearchTransportConfiguration.class, ElasticsearchClientConfiguration.class })
+@Import({ JsonpMapperConfiguration.class, ElasticsearchTransportConfiguration.class,
+		ElasticsearchClientConfiguration.class })
 public class ElasticsearchClientAutoConfiguration {
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchClientConfigurations.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchClientConfigurations.java
index 63ec135c01b1..de1fd52b0833 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchClientConfigurations.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchClientConfigurations.java
@@ -22,7 +22,7 @@
 import co.elastic.clients.json.jackson.JacksonJsonpMapper;
 import co.elastic.clients.json.jsonb.JsonbJsonpMapper;
 import co.elastic.clients.transport.ElasticsearchTransport;
-import co.elastic.clients.transport.TransportOptions;
+import co.elastic.clients.transport.rest_client.RestClientOptions;
 import co.elastic.clients.transport.rest_client.RestClientTransport;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import jakarta.json.bind.Jsonb;
@@ -44,6 +44,12 @@
  */
 class ElasticsearchClientConfigurations {
 
+	@Import({ JacksonJsonpMapperConfiguration.class, JsonbJsonpMapperConfiguration.class,
+			SimpleJsonpMapperConfiguration.class })
+	static class JsonpMapperConfiguration {
+
+	}
+
 	@ConditionalOnMissingBean(JsonpMapper.class)
 	@ConditionalOnClass(ObjectMapper.class)
 	@Configuration(proxyBeanMethods = false)
@@ -79,16 +85,13 @@ SimpleJsonpMapper simpleJsonpMapper() {
 
 	}
 
-	@Import({ JacksonJsonpMapperConfiguration.class, JsonbJsonpMapperConfiguration.class,
-			SimpleJsonpMapperConfiguration.class })
-	@ConditionalOnBean(RestClient.class)
 	@ConditionalOnMissingBean(ElasticsearchTransport.class)
 	static class ElasticsearchTransportConfiguration {
 
 		@Bean
 		RestClientTransport restClientTransport(RestClient restClient, JsonpMapper jsonMapper,
-				ObjectProvider<TransportOptions> transportOptions) {
-			return new RestClientTransport(restClient, jsonMapper, transportOptions.getIfAvailable());
+				ObjectProvider<RestClientOptions> restClientOptions) {
+			return new RestClientTransport(restClient, jsonMapper, restClientOptions.getIfAvailable());
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ReactiveElasticsearchClientAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ReactiveElasticsearchClientAutoConfiguration.java
index 25ceac118664..16c56ac08e13 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ReactiveElasticsearchClientAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ReactiveElasticsearchClientAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -17,6 +17,7 @@
 package org.springframework.boot.autoconfigure.elasticsearch;
 
 import co.elastic.clients.transport.ElasticsearchTransport;
+import org.elasticsearch.client.RestClient;
 import reactor.core.publisher.Mono;
 
 import org.springframework.boot.autoconfigure.AutoConfiguration;
@@ -37,9 +38,11 @@
  * @since 3.0.0
  */
 @AutoConfiguration(after = ElasticsearchClientAutoConfiguration.class)
+@ConditionalOnBean(RestClient.class)
 @ConditionalOnClass({ ReactiveElasticsearchClient.class, ElasticsearchTransport.class, Mono.class })
 @EnableConfigurationProperties(ElasticsearchProperties.class)
-@Import(ElasticsearchClientConfigurations.ElasticsearchTransportConfiguration.class)
+@Import({ ElasticsearchClientConfigurations.JsonpMapperConfiguration.class,
+		ElasticsearchClientConfigurations.ElasticsearchTransportConfiguration.class })
 public class ReactiveElasticsearchClientAutoConfiguration {
 
 	@Bean
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/FlywayAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/FlywayAutoConfiguration.java
index 046cd91f6086..a7e93b42157c 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/FlywayAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/FlywayAutoConfiguration.java
@@ -24,6 +24,8 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
 
 import javax.sql.DataSource;
 
@@ -32,6 +34,9 @@
 import org.flywaydb.core.api.callback.Callback;
 import org.flywaydb.core.api.configuration.FluentConfiguration;
 import org.flywaydb.core.api.migration.JavaMigration;
+import org.flywaydb.core.extensibility.ConfigurationExtension;
+import org.flywaydb.core.internal.database.postgresql.PostgreSQLConfigurationExtension;
+import org.flywaydb.database.oracle.OracleConfigurationExtension;
 import org.flywaydb.database.sqlserver.SQLServerConfigurationExtension;
 
 import org.springframework.aot.hint.RuntimeHints;
@@ -46,6 +51,9 @@
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration.FlywayAutoConfigurationRuntimeHints;
 import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration.FlywayDataSourceCondition;
+import org.springframework.boot.autoconfigure.flyway.FlywayProperties.Oracle;
+import org.springframework.boot.autoconfigure.flyway.FlywayProperties.Postgresql;
+import org.springframework.boot.autoconfigure.flyway.FlywayProperties.Sqlserver;
 import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
 import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails;
 import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
@@ -61,6 +69,8 @@
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Import;
 import org.springframework.context.annotation.ImportRuntimeHints;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
 import org.springframework.core.convert.TypeDescriptor;
 import org.springframework.core.convert.converter.GenericConverter;
 import org.springframework.core.io.ResourceLoader;
@@ -71,6 +81,7 @@
 import org.springframework.util.CollectionUtils;
 import org.springframework.util.ObjectUtils;
 import org.springframework.util.StringUtils;
+import org.springframework.util.function.SingletonSupplier;
 
 /**
  * {@link EnableAutoConfiguration Auto-configuration} for Flyway database migrations.
@@ -116,6 +127,12 @@ public FlywaySchemaManagementProvider flywayDefaultDdlModeProvider(ObjectProvide
 	@EnableConfigurationProperties(FlywayProperties.class)
 	public static class FlywayConfiguration {
 
+		private final FlywayProperties properties;
+
+		FlywayConfiguration(FlywayProperties properties) {
+			this.properties = properties;
+		}
+
 		@Bean
 		ResourceProviderCustomizer resourceProviderCustomizer() {
 			return new ResourceProviderCustomizer();
@@ -123,31 +140,38 @@ ResourceProviderCustomizer resourceProviderCustomizer() {
 
 		@Bean
 		@ConditionalOnMissingBean(FlywayConnectionDetails.class)
-		PropertiesFlywayConnectionDetails flywayConnectionDetails(FlywayProperties properties) {
-			return new PropertiesFlywayConnectionDetails(properties);
+		PropertiesFlywayConnectionDetails flywayConnectionDetails() {
+			return new PropertiesFlywayConnectionDetails(this.properties);
 		}
 
-		@Deprecated(since = "3.0.0", forRemoval = true)
-		public Flyway flyway(FlywayProperties properties, ResourceLoader resourceLoader,
-				ObjectProvider<DataSource> dataSource, ObjectProvider<DataSource> flywayDataSource,
-				ObjectProvider<FlywayConfigurationCustomizer> fluentConfigurationCustomizers,
-				ObjectProvider<JavaMigration> javaMigrations, ObjectProvider<Callback> callbacks) {
-			return flyway(properties, new PropertiesFlywayConnectionDetails(properties), resourceLoader, dataSource,
-					flywayDataSource, fluentConfigurationCustomizers, javaMigrations, callbacks,
-					new ResourceProviderCustomizer());
+		@Bean
+		@ConditionalOnClass(name = "org.flywaydb.database.sqlserver.SQLServerConfigurationExtension")
+		SqlServerFlywayConfigurationCustomizer sqlServerFlywayConfigurationCustomizer() {
+			return new SqlServerFlywayConfigurationCustomizer(this.properties);
+		}
+
+		@Bean
+		@ConditionalOnClass(name = "org.flywaydb.database.oracle.OracleConfigurationExtension")
+		OracleFlywayConfigurationCustomizer oracleFlywayConfigurationCustomizer() {
+			return new OracleFlywayConfigurationCustomizer(this.properties);
+		}
+
+		@Bean
+		@ConditionalOnClass(name = "org.flywaydb.core.internal.database.postgresql.PostgreSQLConfigurationExtension")
+		PostgresqlFlywayConfigurationCustomizer postgresqlFlywayConfigurationCustomizer() {
+			return new PostgresqlFlywayConfigurationCustomizer(this.properties);
 		}
 
 		@Bean
-		Flyway flyway(FlywayProperties properties, FlywayConnectionDetails connectionDetails,
-				ResourceLoader resourceLoader, ObjectProvider<DataSource> dataSource,
-				@FlywayDataSource ObjectProvider<DataSource> flywayDataSource,
+		Flyway flyway(FlywayConnectionDetails connectionDetails, ResourceLoader resourceLoader,
+				ObjectProvider<DataSource> dataSource, @FlywayDataSource ObjectProvider<DataSource> flywayDataSource,
 				ObjectProvider<FlywayConfigurationCustomizer> fluentConfigurationCustomizers,
 				ObjectProvider<JavaMigration> javaMigrations, ObjectProvider<Callback> callbacks,
 				ResourceProviderCustomizer resourceProviderCustomizer) {
 			FluentConfiguration configuration = new FluentConfiguration(resourceLoader.getClassLoader());
 			configureDataSource(configuration, flywayDataSource.getIfAvailable(), dataSource.getIfUnique(),
 					connectionDetails);
-			configureProperties(configuration, properties);
+			configureProperties(configuration, this.properties);
 			configureCallbacks(configuration, callbacks.orderedStream().toList());
 			configureJavaMigrations(configuration, javaMigrations.orderedStream().toList());
 			fluentConfigurationCustomizers.orderedStream().forEach((customizer) -> customizer.customize(configuration));
@@ -193,87 +217,114 @@ private void applyConnectionDetails(FlywayConnectionDetails connectionDetails, D
 			}
 		}
 
+		/**
+		 * Configure the given {@code configuration} using the given {@code properties}.
+		 * <p>
+		 * To maximize forwards- and backwards-compatibility method references are not
+		 * used.
+		 * @param configuration the configuration
+		 * @param properties the properties
+		 */
 		private void configureProperties(FluentConfiguration configuration, FlywayProperties properties) {
 			PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
 			String[] locations = new LocationResolver(configuration.getDataSource())
 				.resolveLocations(properties.getLocations())
 				.toArray(new String[0]);
-			map.from(properties.isFailOnMissingLocations()).to(configuration::failOnMissingLocations);
-			map.from(locations).to(configuration::locations);
-			map.from(properties.getEncoding()).to(configuration::encoding);
-			map.from(properties.getConnectRetries()).to(configuration::connectRetries);
+			configuration.locations(locations);
+			map.from(properties.isFailOnMissingLocations())
+				.to((failOnMissingLocations) -> configuration.failOnMissingLocations(failOnMissingLocations));
+			map.from(properties.getEncoding()).to((encoding) -> configuration.encoding(encoding));
+			map.from(properties.getConnectRetries())
+				.to((connectRetries) -> configuration.connectRetries(connectRetries));
 			map.from(properties.getConnectRetriesInterval())
 				.as(Duration::getSeconds)
 				.as(Long::intValue)
-				.to(configuration::connectRetriesInterval);
-			map.from(properties.getLockRetryCount()).to(configuration::lockRetryCount);
-			map.from(properties.getDefaultSchema()).to(configuration::defaultSchema);
-			map.from(properties.getSchemas()).as(StringUtils::toStringArray).to(configuration::schemas);
-			map.from(properties.isCreateSchemas()).to(configuration::createSchemas);
-			map.from(properties.getTable()).to(configuration::table);
-			map.from(properties.getTablespace()).to(configuration::tablespace);
-			map.from(properties.getBaselineDescription()).to(configuration::baselineDescription);
-			map.from(properties.getBaselineVersion()).to(configuration::baselineVersion);
-			map.from(properties.getInstalledBy()).to(configuration::installedBy);
-			map.from(properties.getPlaceholders()).to(configuration::placeholders);
-			map.from(properties.getPlaceholderPrefix()).to(configuration::placeholderPrefix);
-			map.from(properties.getPlaceholderSuffix()).to(configuration::placeholderSuffix);
-			map.from(properties.getPlaceholderSeparator()).to(configuration::placeholderSeparator);
-			map.from(properties.isPlaceholderReplacement()).to(configuration::placeholderReplacement);
-			map.from(properties.getSqlMigrationPrefix()).to(configuration::sqlMigrationPrefix);
+				.to((connectRetriesInterval) -> configuration.connectRetriesInterval(connectRetriesInterval));
+			map.from(properties.getLockRetryCount())
+				.to((lockRetryCount) -> configuration.lockRetryCount(lockRetryCount));
+			map.from(properties.getDefaultSchema()).to((schema) -> configuration.defaultSchema(schema));
+			map.from(properties.getSchemas())
+				.as(StringUtils::toStringArray)
+				.to((schemas) -> configuration.schemas(schemas));
+			map.from(properties.isCreateSchemas()).to((createSchemas) -> configuration.createSchemas(createSchemas));
+			map.from(properties.getTable()).to((table) -> configuration.table(table));
+			map.from(properties.getTablespace()).to((tablespace) -> configuration.tablespace(tablespace));
+			map.from(properties.getBaselineDescription())
+				.to((baselineDescription) -> configuration.baselineDescription(baselineDescription));
+			map.from(properties.getBaselineVersion())
+				.to((baselineVersion) -> configuration.baselineVersion(baselineVersion));
+			map.from(properties.getInstalledBy()).to((installedBy) -> configuration.installedBy(installedBy));
+			map.from(properties.getPlaceholders()).to((placeholders) -> configuration.placeholders(placeholders));
+			map.from(properties.getPlaceholderPrefix())
+				.to((placeholderPrefix) -> configuration.placeholderPrefix(placeholderPrefix));
+			map.from(properties.getPlaceholderSuffix())
+				.to((placeholderSuffix) -> configuration.placeholderSuffix(placeholderSuffix));
+			map.from(properties.getPlaceholderSeparator())
+				.to((placeHolderSeparator) -> configuration.placeholderSeparator(placeHolderSeparator));
+			map.from(properties.isPlaceholderReplacement())
+				.to((placeholderReplacement) -> configuration.placeholderReplacement(placeholderReplacement));
+			map.from(properties.getSqlMigrationPrefix())
+				.to((sqlMigrationPrefix) -> configuration.sqlMigrationPrefix(sqlMigrationPrefix));
 			map.from(properties.getSqlMigrationSuffixes())
 				.as(StringUtils::toStringArray)
-				.to(configuration::sqlMigrationSuffixes);
-			map.from(properties.getSqlMigrationSeparator()).to(configuration::sqlMigrationSeparator);
-			map.from(properties.getRepeatableSqlMigrationPrefix()).to(configuration::repeatableSqlMigrationPrefix);
-			map.from(properties.getTarget()).to(configuration::target);
-			map.from(properties.isBaselineOnMigrate()).to(configuration::baselineOnMigrate);
-			map.from(properties.isCleanDisabled()).to(configuration::cleanDisabled);
-			map.from(properties.isCleanOnValidationError()).to(configuration::cleanOnValidationError);
-			map.from(properties.isGroup()).to(configuration::group);
-			map.from(properties.isMixed()).to(configuration::mixed);
-			map.from(properties.isOutOfOrder()).to(configuration::outOfOrder);
-			map.from(properties.isSkipDefaultCallbacks()).to(configuration::skipDefaultCallbacks);
-			map.from(properties.isSkipDefaultResolvers()).to(configuration::skipDefaultResolvers);
-			map.from(properties.isValidateMigrationNaming()).to(configuration::validateMigrationNaming);
-			map.from(properties.isValidateOnMigrate()).to(configuration::validateOnMigrate);
+				.to((sqlMigrationSuffixes) -> configuration.sqlMigrationSuffixes(sqlMigrationSuffixes));
+			map.from(properties.getSqlMigrationSeparator())
+				.to((sqlMigrationSeparator) -> configuration.sqlMigrationSeparator(sqlMigrationSeparator));
+			map.from(properties.getRepeatableSqlMigrationPrefix())
+				.to((repeatableSqlMigrationPrefix) -> configuration
+					.repeatableSqlMigrationPrefix(repeatableSqlMigrationPrefix));
+			map.from(properties.getTarget()).to((target) -> configuration.target(target));
+			map.from(properties.isBaselineOnMigrate())
+				.to((baselineOnMigrate) -> configuration.baselineOnMigrate(baselineOnMigrate));
+			map.from(properties.isCleanDisabled()).to((cleanDisabled) -> configuration.cleanDisabled(cleanDisabled));
+			map.from(properties.isCleanOnValidationError())
+				.to((cleanOnValidationError) -> configuration.cleanOnValidationError(cleanOnValidationError));
+			map.from(properties.isGroup()).to((group) -> configuration.group(group));
+			map.from(properties.isMixed()).to((mixed) -> configuration.mixed(mixed));
+			map.from(properties.isOutOfOrder()).to((outOfOrder) -> configuration.outOfOrder(outOfOrder));
+			map.from(properties.isSkipDefaultCallbacks())
+				.to((skipDefaultCallbacks) -> configuration.skipDefaultCallbacks(skipDefaultCallbacks));
+			map.from(properties.isSkipDefaultResolvers())
+				.to((skipDefaultResolvers) -> configuration.skipDefaultResolvers(skipDefaultResolvers));
+			map.from(properties.isValidateMigrationNaming())
+				.to((validateMigrationNaming) -> configuration.validateMigrationNaming(validateMigrationNaming));
+			map.from(properties.isValidateOnMigrate())
+				.to((validateOnMigrate) -> configuration.validateOnMigrate(validateOnMigrate));
 			map.from(properties.getInitSqls())
 				.whenNot(CollectionUtils::isEmpty)
 				.as((initSqls) -> StringUtils.collectionToDelimitedString(initSqls, "\n"))
-				.to(configuration::initSql);
+				.to((initSql) -> configuration.initSql(initSql));
 			map.from(properties.getScriptPlaceholderPrefix())
 				.to((prefix) -> configuration.scriptPlaceholderPrefix(prefix));
 			map.from(properties.getScriptPlaceholderSuffix())
 				.to((suffix) -> configuration.scriptPlaceholderSuffix(suffix));
 			configureExecuteInTransaction(configuration, properties, map);
-			map.from(properties::getLoggers).to(configuration::loggers);
+			map.from(properties::getLoggers).to((loggers) -> configuration.loggers(loggers));
 			// Flyway Teams properties
-			map.from(properties.getBatch()).to(configuration::batch);
-			map.from(properties.getDryRunOutput()).to(configuration::dryRunOutput);
-			map.from(properties.getErrorOverrides()).to(configuration::errorOverrides);
-			map.from(properties.getLicenseKey()).to(configuration::licenseKey);
-			// No method references for Oracle props for compatibility with Flyway 9.20+
-			map.from(properties.getOracleSqlplus()).to((oracleSqlplus) -> configuration.oracleSqlplus(oracleSqlplus));
-			map.from(properties.getOracleSqlplusWarn())
-				.to((oracleSqlplusWarn) -> configuration.oracleSqlplusWarn(oracleSqlplusWarn));
-			map.from(properties.getOracleKerberosCacheFile())
-				.to((oracleKerberosCacheFile) -> configuration.oracleKerberosCacheFile(oracleKerberosCacheFile));
-			map.from(properties.getStream()).to(configuration::stream);
-			map.from(properties.getUndoSqlMigrationPrefix()).to(configuration::undoSqlMigrationPrefix);
-			map.from(properties.getCherryPick()).to(configuration::cherryPick);
-			map.from(properties.getJdbcProperties()).whenNot(Map::isEmpty).to(configuration::jdbcProperties);
-			map.from(properties.getKerberosConfigFile()).to(configuration::kerberosConfigFile);
-			map.from(properties.getOutputQueryResults()).to(configuration::outputQueryResults);
-			map.from(properties.getSqlServerKerberosLoginFile())
-				.whenNonNull()
-				.to((sqlServerKerberosLoginFile) -> configureSqlServerKerberosLoginFile(configuration,
-						sqlServerKerberosLoginFile));
-			map.from(properties.getSkipExecutingMigrations()).to(configuration::skipExecutingMigrations);
+			map.from(properties.getBatch()).to((batch) -> configuration.batch(batch));
+			map.from(properties.getDryRunOutput()).to((dryRunOutput) -> configuration.dryRunOutput(dryRunOutput));
+			map.from(properties.getErrorOverrides())
+				.to((errorOverrides) -> configuration.errorOverrides(errorOverrides));
+			map.from(properties.getLicenseKey()).to((licenseKey) -> configuration.licenseKey(licenseKey));
+			map.from(properties.getStream()).to((stream) -> configuration.stream(stream));
+			map.from(properties.getUndoSqlMigrationPrefix())
+				.to((undoSqlMigrationPrefix) -> configuration.undoSqlMigrationPrefix(undoSqlMigrationPrefix));
+			map.from(properties.getCherryPick()).to((cherryPick) -> configuration.cherryPick(cherryPick));
+			map.from(properties.getJdbcProperties())
+				.whenNot(Map::isEmpty)
+				.to((jdbcProperties) -> configuration.jdbcProperties(jdbcProperties));
+			map.from(properties.getKerberosConfigFile())
+				.to((configFile) -> configuration.kerberosConfigFile(configFile));
+			map.from(properties.getOutputQueryResults())
+				.to((outputQueryResults) -> configuration.outputQueryResults(outputQueryResults));
+			map.from(properties.getSkipExecutingMigrations())
+				.to((skipExecutingMigrations) -> configuration.skipExecutingMigrations(skipExecutingMigrations));
 			map.from(properties.getIgnoreMigrationPatterns())
 				.whenNot(List::isEmpty)
-				.as((patterns) -> patterns.toArray(new String[0]))
-				.to(configuration::ignoreMigrationPatterns);
-			map.from(properties.getDetectEncoding()).to(configuration::detectEncoding);
+				.to((ignoreMigrationPatterns) -> configuration
+					.ignoreMigrationPatterns(ignoreMigrationPatterns.toArray(new String[0])));
+			map.from(properties.getDetectEncoding())
+				.to((detectEncoding) -> configuration.detectEncoding(detectEncoding));
 		}
 
 		private void configureExecuteInTransaction(FluentConfiguration configuration, FlywayProperties properties,
@@ -286,14 +337,6 @@ private void configureExecuteInTransaction(FluentConfiguration configuration, Fl
 			}
 		}
 
-		private void configureSqlServerKerberosLoginFile(FluentConfiguration configuration,
-				String sqlServerKerberosLoginFile) {
-			SQLServerConfigurationExtension sqlServerConfigurationExtension = configuration.getPluginRegister()
-				.getPlugin(SQLServerConfigurationExtension.class);
-			Assert.state(sqlServerConfigurationExtension != null, "Flyway SQL Server extension missing");
-			sqlServerConfigurationExtension.setKerberosLoginFile(sqlServerKerberosLoginFile);
-		}
-
 		private void configureCallbacks(FluentConfiguration configuration, List<Callback> callbacks) {
 			if (!callbacks.isEmpty()) {
 				configuration.callbacks(callbacks.toArray(new Callback[0]));
@@ -455,4 +498,98 @@ public String getDriverClassName() {
 
 	}
 
+	@Order(Ordered.HIGHEST_PRECEDENCE)
+	static final class OracleFlywayConfigurationCustomizer implements FlywayConfigurationCustomizer {
+
+		private final FlywayProperties properties;
+
+		OracleFlywayConfigurationCustomizer(FlywayProperties properties) {
+			this.properties = properties;
+		}
+
+		@Override
+		public void customize(FluentConfiguration configuration) {
+			Extension<OracleConfigurationExtension> extension = new Extension<>(configuration,
+					OracleConfigurationExtension.class, "Oracle");
+			Oracle properties = this.properties.getOracle();
+			PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+			map.from(properties::getSqlplus).to(extension.via((ext, sqlplus) -> ext.setSqlplus(sqlplus)));
+			map.from(properties::getSqlplusWarn)
+				.to(extension.via((ext, sqlplusWarn) -> ext.setSqlplusWarn(sqlplusWarn)));
+			map.from(properties::getWalletLocation)
+				.to(extension.via((ext, walletLocation) -> ext.setWalletLocation(walletLocation)));
+			map.from(properties::getKerberosCacheFile)
+				.to(extension.via((ext, kerberosCacheFile) -> ext.setKerberosCacheFile(kerberosCacheFile)));
+		}
+
+	}
+
+	@Order(Ordered.HIGHEST_PRECEDENCE)
+	static final class PostgresqlFlywayConfigurationCustomizer implements FlywayConfigurationCustomizer {
+
+		private final FlywayProperties properties;
+
+		PostgresqlFlywayConfigurationCustomizer(FlywayProperties properties) {
+			this.properties = properties;
+		}
+
+		@Override
+		public void customize(FluentConfiguration configuration) {
+			Extension<PostgreSQLConfigurationExtension> extension = new Extension<>(configuration,
+					PostgreSQLConfigurationExtension.class, "PostgreSQL");
+			Postgresql properties = this.properties.getPostgresql();
+			PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+			map.from(properties::getTransactionalLock)
+				.to(extension.via((ext, transactionalLock) -> ext.setTransactionalLock(transactionalLock)));
+		}
+
+	}
+
+	@Order(Ordered.HIGHEST_PRECEDENCE)
+	static final class SqlServerFlywayConfigurationCustomizer implements FlywayConfigurationCustomizer {
+
+		private final FlywayProperties properties;
+
+		SqlServerFlywayConfigurationCustomizer(FlywayProperties properties) {
+			this.properties = properties;
+		}
+
+		@Override
+		public void customize(FluentConfiguration configuration) {
+			Extension<SQLServerConfigurationExtension> extension = new Extension<>(configuration,
+					SQLServerConfigurationExtension.class, "SQL Server");
+			Sqlserver properties = this.properties.getSqlserver();
+			PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+			map.from(properties::getKerberosLoginFile).to(extension.via(this::setKerberosLoginFile));
+		}
+
+		private void setKerberosLoginFile(SQLServerConfigurationExtension configuration, String file) {
+			configuration.getKerberos().getLogin().setFile(file);
+		}
+
+	}
+
+	/**
+	 * Helper class used to map properties to a {@link ConfigurationExtension}.
+	 *
+	 * @param <E> the extension type
+	 */
+	static class Extension<E extends ConfigurationExtension> {
+
+		private SingletonSupplier<E> extension;
+
+		Extension(FluentConfiguration configuration, Class<E> type, String name) {
+			this.extension = SingletonSupplier.of(() -> {
+				E extension = configuration.getPluginRegister().getPlugin(type);
+				Assert.notNull(extension, () -> "Flyway %s extension missing".formatted(name));
+				return extension;
+			});
+		}
+
+		<T> Consumer<T> via(BiConsumer<E, T> action) {
+			return (value) -> action.accept(this.extension.get(), value);
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/FlywayProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/FlywayProperties.java
index b629e7c425f8..4a6d7db6e0ca 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/FlywayProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/FlywayProperties.java
@@ -28,6 +28,7 @@
 import java.util.Map;
 
 import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.context.properties.DeprecatedConfigurationProperty;
 import org.springframework.boot.convert.DurationUnit;
 
 /**
@@ -295,17 +296,6 @@ public class FlywayProperties {
 	 */
 	private String licenseKey;
 
-	/**
-	 * Whether to enable support for Oracle SQL*Plus commands. Requires Flyway Teams.
-	 */
-	private Boolean oracleSqlplus;
-
-	/**
-	 * Whether to issue a warning rather than an error when a not-yet-supported Oracle
-	 * SQL*Plus statement is encountered. Requires Flyway Teams.
-	 */
-	private Boolean oracleSqlplusWarn;
-
 	/**
 	 * Whether to stream SQL migrations when executing them. Requires Flyway Teams.
 	 */
@@ -332,28 +322,12 @@ public class FlywayProperties {
 	 */
 	private String kerberosConfigFile;
 
-	/**
-	 * Path of the Oracle Kerberos cache file. Requires Flyway Teams.
-	 */
-	private String oracleKerberosCacheFile;
-
-	/**
-	 * Location of the Oracle Wallet, used to sign in to the database automatically.
-	 * Requires Flyway Teams.
-	 */
-	private String oracleWalletLocation;
-
 	/**
 	 * Whether Flyway should output a table with the results of queries when executing
 	 * migrations. Requires Flyway Teams.
 	 */
 	private Boolean outputQueryResults;
 
-	/**
-	 * Path to the SQL Server Kerberos login file. Requires Flyway Teams.
-	 */
-	private String sqlServerKerberosLoginFile;
-
 	/**
 	 * Whether Flyway should skip executing the contents of the migrations and only update
 	 * the schema history table. Requires Flyway teams.
@@ -372,6 +346,12 @@ public class FlywayProperties {
 	 */
 	private Boolean detectEncoding;
 
+	private final Oracle oracle = new Oracle();
+
+	private final Postgresql postgresql = new Postgresql();
+
+	private final Sqlserver sqlserver = new Sqlserver();
+
 	public boolean isEnabled() {
 		return this.enabled;
 	}
@@ -756,28 +736,37 @@ public void setLicenseKey(String licenseKey) {
 		this.licenseKey = licenseKey;
 	}
 
+	@DeprecatedConfigurationProperty(replacement = "spring.flyway.oracle.sqlplus", since = "3.2.0")
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	public Boolean getOracleSqlplus() {
-		return this.oracleSqlplus;
+		return getOracle().getSqlplus();
 	}
 
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	public void setOracleSqlplus(Boolean oracleSqlplus) {
-		this.oracleSqlplus = oracleSqlplus;
+		getOracle().setSqlplus(oracleSqlplus);
 	}
 
+	@DeprecatedConfigurationProperty(replacement = "spring.flyway.oracle.sqlplus-warn", since = "3.2.0")
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	public Boolean getOracleSqlplusWarn() {
-		return this.oracleSqlplusWarn;
+		return getOracle().getSqlplusWarn();
 	}
 
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	public void setOracleSqlplusWarn(Boolean oracleSqlplusWarn) {
-		this.oracleSqlplusWarn = oracleSqlplusWarn;
+		getOracle().setSqlplusWarn(oracleSqlplusWarn);
 	}
 
+	@DeprecatedConfigurationProperty(replacement = "spring.flyway.oracle.wallet-location", since = "3.2.0")
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	public String getOracleWalletLocation() {
-		return this.oracleWalletLocation;
+		return getOracle().getWalletLocation();
 	}
 
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	public void setOracleWalletLocation(String oracleWalletLocation) {
-		this.oracleWalletLocation = oracleWalletLocation;
+		getOracle().setWalletLocation(oracleWalletLocation);
 	}
 
 	public Boolean getStream() {
@@ -820,12 +809,15 @@ public void setKerberosConfigFile(String kerberosConfigFile) {
 		this.kerberosConfigFile = kerberosConfigFile;
 	}
 
+	@DeprecatedConfigurationProperty(replacement = "spring.flyway.oracle.kerberos-cache-file", since = "3.2.0")
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	public String getOracleKerberosCacheFile() {
-		return this.oracleKerberosCacheFile;
+		return getOracle().getKerberosCacheFile();
 	}
 
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	public void setOracleKerberosCacheFile(String oracleKerberosCacheFile) {
-		this.oracleKerberosCacheFile = oracleKerberosCacheFile;
+		getOracle().setKerberosCacheFile(oracleKerberosCacheFile);
 	}
 
 	public Boolean getOutputQueryResults() {
@@ -836,12 +828,15 @@ public void setOutputQueryResults(Boolean outputQueryResults) {
 		this.outputQueryResults = outputQueryResults;
 	}
 
+	@DeprecatedConfigurationProperty(replacement = "spring.flyway.sqlserver.kerberos-login-file")
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	public String getSqlServerKerberosLoginFile() {
-		return this.sqlServerKerberosLoginFile;
+		return getSqlserver().getKerberosLoginFile();
 	}
 
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	public void setSqlServerKerberosLoginFile(String sqlServerKerberosLoginFile) {
-		this.sqlServerKerberosLoginFile = sqlServerKerberosLoginFile;
+		getSqlserver().setKerberosLoginFile(sqlServerKerberosLoginFile);
 	}
 
 	public Boolean getSkipExecutingMigrations() {
@@ -868,4 +863,118 @@ public void setDetectEncoding(final Boolean detectEncoding) {
 		this.detectEncoding = detectEncoding;
 	}
 
+	public Oracle getOracle() {
+		return this.oracle;
+	}
+
+	public Postgresql getPostgresql() {
+		return this.postgresql;
+	}
+
+	public Sqlserver getSqlserver() {
+		return this.sqlserver;
+	}
+
+	/**
+	 * {@code OracleConfigurationExtension} properties.
+	 */
+	public static class Oracle {
+
+		/**
+		 * Whether to enable support for Oracle SQL*Plus commands. Requires Flyway Teams.
+		 */
+		private Boolean sqlplus;
+
+		/**
+		 * Whether to issue a warning rather than an error when a not-yet-supported Oracle
+		 * SQL*Plus statement is encountered. Requires Flyway Teams.
+		 */
+		private Boolean sqlplusWarn;
+
+		/**
+		 * Path of the Oracle Kerberos cache file. Requires Flyway Teams.
+		 */
+		private String kerberosCacheFile;
+
+		/**
+		 * Location of the Oracle Wallet, used to sign in to the database automatically.
+		 * Requires Flyway Teams.
+		 */
+		private String walletLocation;
+
+		public Boolean getSqlplus() {
+			return this.sqlplus;
+		}
+
+		public void setSqlplus(Boolean sqlplus) {
+			this.sqlplus = sqlplus;
+		}
+
+		public Boolean getSqlplusWarn() {
+			return this.sqlplusWarn;
+		}
+
+		public void setSqlplusWarn(Boolean sqlplusWarn) {
+			this.sqlplusWarn = sqlplusWarn;
+		}
+
+		public String getKerberosCacheFile() {
+			return this.kerberosCacheFile;
+		}
+
+		public void setKerberosCacheFile(String kerberosCacheFile) {
+			this.kerberosCacheFile = kerberosCacheFile;
+		}
+
+		public String getWalletLocation() {
+			return this.walletLocation;
+		}
+
+		public void setWalletLocation(String walletLocation) {
+			this.walletLocation = walletLocation;
+		}
+
+	}
+
+	/**
+	 * {@code PostgreSQLConfigurationExtension} properties.
+	 */
+	public static class Postgresql {
+
+		/**
+		 * Whether transactional advisory locks should be used. If set to false,
+		 * session-level locks are used instead.
+		 */
+		private Boolean transactionalLock;
+
+		public Boolean getTransactionalLock() {
+			return this.transactionalLock;
+		}
+
+		public void setTransactionalLock(Boolean transactionalLock) {
+			this.transactionalLock = transactionalLock;
+		}
+
+	}
+
+	/**
+	 * {@code SQLServerConfigurationExtension} properties.
+	 */
+	public static class Sqlserver {
+
+		/**
+		 * Path to the SQL Server Kerberos login file. Requires Flyway Teams.
+		 */
+		private String kerberosLoginFile;
+
+		public String getKerberosLoginFile() {
+			return this.kerberosLoginFile;
+		}
+
+		public void setKerberosLoginFile(String kerberosLoginFile) {
+			this.kerberosLoginFile = kerberosLoginFile;
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/ResourceProviderCustomizerBeanRegistrationAotProcessor.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/ResourceProviderCustomizerBeanRegistrationAotProcessor.java
index 919cfd3f4e68..6bdcccecd377 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/ResourceProviderCustomizerBeanRegistrationAotProcessor.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/ResourceProviderCustomizerBeanRegistrationAotProcessor.java
@@ -16,8 +16,6 @@
 
 package org.springframework.boot.autoconfigure.flyway;
 
-import java.lang.reflect.Executable;
-
 import javax.lang.model.element.Modifier;
 
 import org.springframework.aot.generate.GeneratedMethod;
@@ -58,8 +56,7 @@ protected AotContribution(BeanRegistrationCodeFragments delegate, RegisteredBean
 
 		@Override
 		public CodeBlock generateInstanceSupplierCode(GenerationContext generationContext,
-				BeanRegistrationCode beanRegistrationCode, Executable constructorOrFactoryMethod,
-				boolean allowDirectSupplierShortcut) {
+				BeanRegistrationCode beanRegistrationCode, boolean allowDirectSupplierShortcut) {
 			GeneratedMethod generatedMethod = beanRegistrationCode.getMethods().add("getInstance", (method) -> {
 				method.addJavadoc("Get the bean instance for '$L'.", this.registeredBean.getBeanName());
 				method.addModifiers(Modifier.PRIVATE, Modifier.STATIC);
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfiguration.java
index 37a2b97c409b..886e597a5220 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfiguration.java
@@ -21,6 +21,7 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.concurrent.Executor;
 
 import graphql.GraphQL;
 import graphql.execution.instrumentation.Instrumentation;
@@ -33,10 +34,12 @@
 import org.springframework.aot.hint.RuntimeHintsRegistrar;
 import org.springframework.beans.factory.ListableBeanFactory;
 import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.boot.convert.ApplicationConversionService;
 import org.springframework.context.annotation.Bean;
@@ -101,6 +104,9 @@ public GraphQlSource graphQlSource(ResourcePatternResolver resourcePatternResolv
 			.exceptionResolvers(exceptionResolvers.orderedStream().toList())
 			.subscriptionExceptionResolvers(subscriptionExceptionResolvers.orderedStream().toList())
 			.instrumentation(instrumentations.orderedStream().toList());
+		if (properties.getSchema().getInspection().isEnabled()) {
+			builder.inspectSchemaMappings(logger::info);
+		}
 		if (!properties.getSchema().getIntrospection().isEnabled()) {
 			builder.configureRuntimeWiring(this::enableIntrospection);
 		}
@@ -152,10 +158,12 @@ public ExecutionGraphQlService executionGraphQlService(GraphQlSource graphQlSour
 
 	@Bean
 	@ConditionalOnMissingBean
-	public AnnotatedControllerConfigurer annotatedControllerConfigurer() {
+	public AnnotatedControllerConfigurer annotatedControllerConfigurer(
+			@Qualifier(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME) ObjectProvider<Executor> executorProvider) {
 		AnnotatedControllerConfigurer controllerConfigurer = new AnnotatedControllerConfigurer();
 		controllerConfigurer
 			.addFormatterRegistrar((registry) -> ApplicationConversionService.addBeans(registry, this.beanFactory));
+		executorProvider.ifAvailable(controllerConfigurer::setExecutor);
 		return controllerConfigurer;
 	}
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java
index 1ea766a8be68..046155b1aa4f 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java
@@ -22,7 +22,7 @@
 import org.springframework.boot.context.properties.ConfigurationProperties;
 
 /**
- * {@link ConfigurationProperties properties} for Spring GraphQL.
+ * {@link ConfigurationProperties Properties} for Spring GraphQL.
  *
  * @author Brian Clozel
  * @since 2.7.0
@@ -79,6 +79,8 @@ public static class Schema {
 		 */
 		private String[] fileExtensions = new String[] { ".graphqls", ".gqls" };
 
+		private final Inspection inspection = new Inspection();
+
 		private final Introspection introspection = new Introspection();
 
 		private final Printer printer = new Printer();
@@ -105,6 +107,10 @@ private String[] appendSlashIfNecessary(String[] locations) {
 				.toArray(String[]::new);
 		}
 
+		public Inspection getInspection() {
+			return this.inspection;
+		}
+
 		public Introspection getIntrospection() {
 			return this.introspection;
 		}
@@ -113,6 +119,24 @@ public Printer getPrinter() {
 			return this.printer;
 		}
 
+		public static class Inspection {
+
+			/**
+			 * Whether schema should be compared to the application to detect missing
+			 * mappings.
+			 */
+			private boolean enabled = true;
+
+			public boolean isEnabled() {
+				return this.enabled;
+			}
+
+			public void setEnabled(boolean enabled) {
+				this.enabled = enabled;
+			}
+
+		}
+
 		public static class Introspection {
 
 			/**
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfiguration.java
index 52df8e11cb43..42d79c0f31c9 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,6 +16,7 @@
 
 package org.springframework.boot.autoconfigure.graphql.data;
 
+import java.util.Collections;
 import java.util.List;
 
 import graphql.GraphQL;
@@ -29,9 +30,9 @@
 import org.springframework.boot.autoconfigure.graphql.GraphQlSourceBuilderCustomizer;
 import org.springframework.context.annotation.Bean;
 import org.springframework.data.repository.query.QueryByExampleExecutor;
-import org.springframework.data.repository.query.ReactiveQueryByExampleExecutor;
 import org.springframework.graphql.data.query.QueryByExampleDataFetcher;
 import org.springframework.graphql.execution.GraphQlSource;
+import org.springframework.graphql.execution.RuntimeWiringConfigurer;
 
 /**
  * {@link EnableAutoConfiguration Auto-configuration} that creates a
@@ -49,10 +50,10 @@
 public class GraphQlQueryByExampleAutoConfiguration {
 
 	@Bean
-	public GraphQlSourceBuilderCustomizer queryByExampleRegistrar(ObjectProvider<QueryByExampleExecutor<?>> executors,
-			ObjectProvider<ReactiveQueryByExampleExecutor<?>> reactiveExecutors) {
-		return new GraphQlQuerydslSourceBuilderCustomizer<>(QueryByExampleDataFetcher::autoRegistrationConfigurer,
-				executors, reactiveExecutors);
+	public GraphQlSourceBuilderCustomizer queryByExampleRegistrar(ObjectProvider<QueryByExampleExecutor<?>> executors) {
+		RuntimeWiringConfigurer configurer = QueryByExampleDataFetcher
+			.autoRegistrationConfigurer(executors.orderedStream().toList(), Collections.emptyList());
+		return (builder) -> builder.configureRuntimeWiring(configurer);
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfiguration.java
index c32b21b7811f..97e2debd0a4b 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,8 +16,10 @@
 
 package org.springframework.boot.autoconfigure.graphql.data;
 
+import java.util.Collections;
 import java.util.List;
 
+import com.querydsl.core.Query;
 import graphql.GraphQL;
 
 import org.springframework.beans.factory.ObjectProvider;
@@ -29,9 +31,9 @@
 import org.springframework.boot.autoconfigure.graphql.GraphQlSourceBuilderCustomizer;
 import org.springframework.context.annotation.Bean;
 import org.springframework.data.querydsl.QuerydslPredicateExecutor;
-import org.springframework.data.querydsl.ReactiveQuerydslPredicateExecutor;
 import org.springframework.graphql.data.query.QuerydslDataFetcher;
 import org.springframework.graphql.execution.GraphQlSource;
+import org.springframework.graphql.execution.RuntimeWiringConfigurer;
 
 /**
  * {@link EnableAutoConfiguration Auto-configuration} that creates a
@@ -45,15 +47,15 @@
  * @see QuerydslDataFetcher#autoRegistrationConfigurer(List, List)
  */
 @AutoConfiguration(after = GraphQlAutoConfiguration.class)
-@ConditionalOnClass({ GraphQL.class, QuerydslDataFetcher.class, QuerydslPredicateExecutor.class })
+@ConditionalOnClass({ GraphQL.class, Query.class, QuerydslDataFetcher.class, QuerydslPredicateExecutor.class })
 @ConditionalOnBean(GraphQlSource.class)
 public class GraphQlQuerydslAutoConfiguration {
 
 	@Bean
-	public GraphQlSourceBuilderCustomizer querydslRegistrar(ObjectProvider<QuerydslPredicateExecutor<?>> executors,
-			ObjectProvider<ReactiveQuerydslPredicateExecutor<?>> reactiveExecutors) {
-		return new GraphQlQuerydslSourceBuilderCustomizer<>(QuerydslDataFetcher::autoRegistrationConfigurer, executors,
-				reactiveExecutors);
+	public GraphQlSourceBuilderCustomizer querydslRegistrar(ObjectProvider<QuerydslPredicateExecutor<?>> executors) {
+		RuntimeWiringConfigurer configurer = QuerydslDataFetcher
+			.autoRegistrationConfigurer(executors.orderedStream().toList(), Collections.emptyList());
+		return (builder) -> builder.configureRuntimeWiring(configurer);
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslSourceBuilderCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslSourceBuilderCustomizer.java
deleted file mode 100644
index ae0db51b20ab..000000000000
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslSourceBuilderCustomizer.java
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.autoconfigure.graphql.data;
-
-import java.util.Collections;
-import java.util.List;
-import java.util.function.BiFunction;
-
-import org.springframework.beans.factory.ObjectProvider;
-import org.springframework.boot.autoconfigure.graphql.GraphQlSourceBuilderCustomizer;
-import org.springframework.graphql.execution.GraphQlSource;
-import org.springframework.graphql.execution.RuntimeWiringConfigurer;
-
-/**
- * {@link GraphQlSourceBuilderCustomizer} to apply auto-configured QueryDSL
- * {@link RuntimeWiringConfigurer RuntimeWiringConfigurers}.
- *
- * @param <E> the executor type
- * @param <R> the reactive executor type
- * @author Phillip Webb
- * @author Rossen Stoyanchev
- * @author Brian Clozel
- */
-class GraphQlQuerydslSourceBuilderCustomizer<E, R> implements GraphQlSourceBuilderCustomizer {
-
-	private final BiFunction<List<E>, List<R>, RuntimeWiringConfigurer> wiringConfigurerFactory;
-
-	private final List<E> executors;
-
-	private final List<R> reactiveExecutors;
-
-	GraphQlQuerydslSourceBuilderCustomizer(
-			BiFunction<List<E>, List<R>, RuntimeWiringConfigurer> wiringConfigurerFactory, ObjectProvider<E> executors,
-			ObjectProvider<R> reactiveExecutors) {
-		this.wiringConfigurerFactory = wiringConfigurerFactory;
-		this.executors = asList(executors);
-		this.reactiveExecutors = asList(reactiveExecutors);
-	}
-
-	private static <T> List<T> asList(ObjectProvider<T> provider) {
-		return (provider != null) ? provider.orderedStream().toList() : Collections.emptyList();
-	}
-
-	@Override
-	public void customize(GraphQlSource.SchemaResourceBuilder builder) {
-		if (!this.executors.isEmpty() || !this.reactiveExecutors.isEmpty()) {
-			builder.configureRuntimeWiring(this.wiringConfigurerFactory.apply(this.executors, this.reactiveExecutors));
-		}
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQueryByExampleAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQueryByExampleAutoConfiguration.java
index f28b17801ef1..6e784108e9da 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQueryByExampleAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQueryByExampleAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,6 +16,7 @@
 
 package org.springframework.boot.autoconfigure.graphql.data;
 
+import java.util.Collections;
 import java.util.List;
 
 import graphql.GraphQL;
@@ -28,10 +29,10 @@
 import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration;
 import org.springframework.boot.autoconfigure.graphql.GraphQlSourceBuilderCustomizer;
 import org.springframework.context.annotation.Bean;
-import org.springframework.data.repository.query.QueryByExampleExecutor;
 import org.springframework.data.repository.query.ReactiveQueryByExampleExecutor;
 import org.springframework.graphql.data.query.QueryByExampleDataFetcher;
 import org.springframework.graphql.execution.GraphQlSource;
+import org.springframework.graphql.execution.RuntimeWiringConfigurer;
 
 /**
  * {@link EnableAutoConfiguration Auto-configuration} that creates a
@@ -51,8 +52,9 @@ public class GraphQlReactiveQueryByExampleAutoConfiguration {
 	@Bean
 	public GraphQlSourceBuilderCustomizer reactiveQueryByExampleRegistrar(
 			ObjectProvider<ReactiveQueryByExampleExecutor<?>> reactiveExecutors) {
-		return new GraphQlQuerydslSourceBuilderCustomizer<>(QueryByExampleDataFetcher::autoRegistrationConfigurer,
-				(ObjectProvider<QueryByExampleExecutor<?>>) null, reactiveExecutors);
+		RuntimeWiringConfigurer configurer = QueryByExampleDataFetcher
+			.autoRegistrationConfigurer(Collections.emptyList(), reactiveExecutors.orderedStream().toList());
+		return (builder) -> builder.configureRuntimeWiring(configurer);
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfiguration.java
index f12be0563ac7..14b81dcc7108 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,8 +16,10 @@
 
 package org.springframework.boot.autoconfigure.graphql.data;
 
+import java.util.Collections;
 import java.util.List;
 
+import com.querydsl.core.Query;
 import graphql.GraphQL;
 
 import org.springframework.beans.factory.ObjectProvider;
@@ -28,10 +30,10 @@
 import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration;
 import org.springframework.boot.autoconfigure.graphql.GraphQlSourceBuilderCustomizer;
 import org.springframework.context.annotation.Bean;
-import org.springframework.data.querydsl.QuerydslPredicateExecutor;
 import org.springframework.data.querydsl.ReactiveQuerydslPredicateExecutor;
 import org.springframework.graphql.data.query.QuerydslDataFetcher;
 import org.springframework.graphql.execution.GraphQlSource;
+import org.springframework.graphql.execution.RuntimeWiringConfigurer;
 
 /**
  * {@link EnableAutoConfiguration Auto-configuration} that creates a
@@ -45,15 +47,16 @@
  * @see QuerydslDataFetcher#autoRegistrationConfigurer(List, List)
  */
 @AutoConfiguration(after = GraphQlAutoConfiguration.class)
-@ConditionalOnClass({ GraphQL.class, QuerydslDataFetcher.class, ReactiveQuerydslPredicateExecutor.class })
+@ConditionalOnClass({ GraphQL.class, Query.class, QuerydslDataFetcher.class, ReactiveQuerydslPredicateExecutor.class })
 @ConditionalOnBean(GraphQlSource.class)
 public class GraphQlReactiveQuerydslAutoConfiguration {
 
 	@Bean
 	public GraphQlSourceBuilderCustomizer reactiveQuerydslRegistrar(
 			ObjectProvider<ReactiveQuerydslPredicateExecutor<?>> reactiveExecutors) {
-		return new GraphQlQuerydslSourceBuilderCustomizer<>(QuerydslDataFetcher::autoRegistrationConfigurer,
-				(ObjectProvider<QuerydslPredicateExecutor<?>>) null, reactiveExecutors);
+		RuntimeWiringConfigurer configurer = QuerydslDataFetcher.autoRegistrationConfigurer(Collections.emptyList(),
+				reactiveExecutors.orderedStream().toList());
+		return (builder) -> builder.configureRuntimeWiring(configurer);
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java
index 29d0c53c248f..4c82ba3b5126 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java
@@ -195,7 +195,7 @@ public HandlerMapping graphQlWebSocketMapping(GraphQlWebSocketHandler handler, G
 			mapping.setWebSocketUpgradeMatch(true);
 			mapping.setUrlMap(Collections.singletonMap(path,
 					handler.initWebSocketHttpRequestHandler(new DefaultHandshakeHandler())));
-			mapping.setOrder(2); // Ahead of HTTP endpoint ("routerFunctionMapping" bean)
+			mapping.setOrder(-2); // Ahead of HTTP endpoint ("routerFunctionMapping" bean)
 			return mapping;
 		}
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/hateoas/HateoasProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/hateoas/HateoasProperties.java
index e6ca03f24eee..a1a17215501e 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/hateoas/HateoasProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/hateoas/HateoasProperties.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -19,7 +19,7 @@
 import org.springframework.boot.context.properties.ConfigurationProperties;
 
 /**
- * {@link ConfigurationProperties properties} for Spring HATEOAS.
+ * {@link ConfigurationProperties Properties} for Spring HATEOAS.
  *
  * @author Phillip Webb
  * @author Andy Wilkinson
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbAutoConfiguration.java
index 5641d5182184..904541755a4b 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbAutoConfiguration.java
@@ -39,11 +39,16 @@
  * @author Andy Wilkinson
  * @author Phillip Webb
  * @since 2.0.0
+ * @deprecated since 3.2.0 for removal in 3.4.0 in favor of the
+ * <a href="https://github.com/influxdata/influxdb-client-java">new client</a> and its own
+ * Spring Boot integration.
  */
 @AutoConfiguration
 @ConditionalOnClass(InfluxDB.class)
 @EnableConfigurationProperties(InfluxDbProperties.class)
 @ConditionalOnProperty("spring.influx.url")
+@Deprecated(since = "3.2.0", forRemoval = true)
+@SuppressWarnings("removal")
 public class InfluxDbAutoConfiguration {
 
 	@Bean
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbCustomizer.java
index 9e46dd17fa3e..62f9b0df9998 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbCustomizer.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbCustomizer.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -24,8 +24,12 @@
  *
  * @author EddĂș MelĂ©ndez
  * @since 2.5.0
+ * @deprecated since 3.2.0 for removal in 3.4.0 in favor of the
+ * <a href="https://github.com/influxdata/influxdb-client-java">new client</a> and its own
+ * Spring Boot integration.
  */
 @FunctionalInterface
+@Deprecated(since = "3.2.0", forRemoval = true)
 public interface InfluxDbCustomizer {
 
 	/**
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbOkHttpClientBuilderProvider.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbOkHttpClientBuilderProvider.java
index 67dc383089de..14995dba425f 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbOkHttpClientBuilderProvider.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbOkHttpClientBuilderProvider.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -27,8 +27,12 @@
  *
  * @author Stephane Nicoll
  * @since 2.1.0
+ * @deprecated since 3.2.0 for removal in 3.4.0 in favor of the
+ * <a href="https://github.com/influxdata/influxdb-client-java">new client</a> and its own
+ * Spring Boot integration.
  */
 @FunctionalInterface
+@Deprecated(since = "3.2.0", forRemoval = true)
 public interface InfluxDbOkHttpClientBuilderProvider extends Supplier<OkHttpClient.Builder> {
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbProperties.java
index d8a4c07d5b68..145c490c2762 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbProperties.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -17,6 +17,7 @@
 package org.springframework.boot.autoconfigure.influx;
 
 import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.context.properties.DeprecatedConfigurationProperty;
 
 /**
  * Configuration properties for InfluxDB.
@@ -24,7 +25,11 @@
  * @author Sergey Kuptsov
  * @author Stephane Nicoll
  * @since 2.0.0
+ * @deprecated since 3.2.0 for removal in 3.4.0 in favor of the
+ * <a href="https://github.com/influxdata/influxdb-client-java">new InfluxDB Java
+ * client</a> and its own Spring Boot integration.
  */
+@Deprecated(since = "3.2.0", forRemoval = true)
 @ConfigurationProperties(prefix = "spring.influx")
 public class InfluxDbProperties {
 
@@ -43,6 +48,8 @@ public class InfluxDbProperties {
 	 */
 	private String password;
 
+	@DeprecatedConfigurationProperty(reason = "the new InfluxDb Java client provides Spring Boot integration",
+			since = "3.2.0")
 	public String getUrl() {
 		return this.url;
 	}
@@ -51,6 +58,8 @@ public void setUrl(String url) {
 		this.url = url;
 	}
 
+	@DeprecatedConfigurationProperty(reason = "the new InfluxDb Java client provides Spring Boot integration",
+			since = "3.2.0")
 	public String getUser() {
 		return this.user;
 	}
@@ -59,6 +68,8 @@ public void setUser(String user) {
 		this.user = user;
 	}
 
+	@DeprecatedConfigurationProperty(reason = "the new InfluxDb Java client provides Spring Boot integration",
+			since = "3.2.0")
 	public String getPassword() {
 		return this.password;
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/integration/IntegrationAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/integration/IntegrationAutoConfiguration.java
index 9ca2706b611e..71f20ef9e7a2 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/integration/IntegrationAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/integration/IntegrationAutoConfiguration.java
@@ -24,6 +24,7 @@
 import io.rsocket.transport.netty.server.TcpServerTransport;
 
 import org.springframework.beans.factory.BeanFactory;
+import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.AutoConfigureBefore;
 import org.springframework.boot.autoconfigure.condition.AnyNestedCondition;
@@ -43,6 +44,7 @@
 import org.springframework.boot.context.properties.PropertyMapper;
 import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException;
 import org.springframework.boot.task.TaskSchedulerBuilder;
+import org.springframework.boot.task.ThreadPoolTaskSchedulerBuilder;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Conditional;
 import org.springframework.context.annotation.Configuration;
@@ -168,11 +170,18 @@ private Trigger createPeriodicTrigger(Duration period, Duration initialDelay, bo
 	@Configuration(proxyBeanMethods = false)
 	@ConditionalOnBean(TaskSchedulerBuilder.class)
 	@ConditionalOnMissingBean(name = IntegrationContextUtils.TASK_SCHEDULER_BEAN_NAME)
+	@SuppressWarnings("removal")
 	protected static class IntegrationTaskSchedulerConfiguration {
 
 		@Bean(name = IntegrationContextUtils.TASK_SCHEDULER_BEAN_NAME)
-		public ThreadPoolTaskScheduler taskScheduler(TaskSchedulerBuilder builder) {
-			return builder.build();
+		public ThreadPoolTaskScheduler taskScheduler(TaskSchedulerBuilder taskSchedulerBuilder,
+				ObjectProvider<ThreadPoolTaskSchedulerBuilder> threadPoolTaskSchedulerBuilderProvider) {
+			ThreadPoolTaskSchedulerBuilder threadPoolTaskSchedulerBuilder = threadPoolTaskSchedulerBuilderProvider
+				.getIfUnique();
+			if (threadPoolTaskSchedulerBuilder != null) {
+				return threadPoolTaskSchedulerBuilder.build();
+			}
+			return taskSchedulerBuilder.build();
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfiguration.java
index a6aa97773ed3..5410786e4385 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfiguration.java
@@ -213,6 +213,8 @@ public void customize(Jackson2ObjectMapperBuilder builder) {
 				configureFeatures(builder, this.jacksonProperties.getMapper());
 				configureFeatures(builder, this.jacksonProperties.getParser());
 				configureFeatures(builder, this.jacksonProperties.getGenerator());
+				configureFeatures(builder, this.jacksonProperties.getDatatype().getEnum());
+				configureFeatures(builder, this.jacksonProperties.getDatatype().getJsonNode());
 				configureDateFormat(builder);
 				configurePropertyNamingStrategy(builder);
 				configureModules(builder);
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jackson/JacksonProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jackson/JacksonProperties.java
index 805604e98308..fe3a67e028a2 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jackson/JacksonProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jackson/JacksonProperties.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -29,6 +29,8 @@
 import com.fasterxml.jackson.databind.DeserializationFeature;
 import com.fasterxml.jackson.databind.MapperFeature;
 import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.databind.cfg.EnumFeature;
+import com.fasterxml.jackson.databind.cfg.JsonNodeFeature;
 
 import org.springframework.boot.context.properties.ConfigurationProperties;
 
@@ -38,6 +40,7 @@
  * @author Andy Wilkinson
  * @author Marcel Overdijk
  * @author Johannes Edmeier
+ * @author EddĂș MelĂ©ndez
  * @since 1.2.0
  */
 @ConfigurationProperties(prefix = "spring.jackson")
@@ -114,6 +117,8 @@ public class JacksonProperties {
 	 */
 	private Locale locale;
 
+	private final Datatype datatype = new Datatype();
+
 	public String getDateFormat() {
 		return this.dateFormat;
 	}
@@ -194,6 +199,10 @@ public void setLocale(Locale locale) {
 		this.locale = locale;
 	}
 
+	public Datatype getDatatype() {
+		return this.datatype;
+	}
+
 	public enum ConstructorDetectorStrategy {
 
 		/**
@@ -219,4 +228,26 @@ public enum ConstructorDetectorStrategy {
 
 	}
 
+	public static class Datatype {
+
+		/**
+		 * Jackson on/off features for enums.
+		 */
+		private final Map<EnumFeature, Boolean> enumFeatures = new EnumMap<>(EnumFeature.class);
+
+		/**
+		 * Jackson on/off features for JsonNodes.
+		 */
+		private final Map<JsonNodeFeature, Boolean> jsonNode = new EnumMap<>(JsonNodeFeature.class);
+
+		public Map<EnumFeature, Boolean> getEnum() {
+			return this.enumFeatures;
+		}
+
+		public Map<JsonNodeFeature, Boolean> getJsonNode() {
+			return this.jsonNode;
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceAutoConfiguration.java
index 5a41f5d0bdef..5ad8ff75ae83 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceAutoConfiguration.java
@@ -51,13 +51,14 @@
  * @author Phillip Webb
  * @author Stephane Nicoll
  * @author Kazuki Shimizu
+ * @author Olga Maciaszek-Sharma
  * @since 1.0.0
  */
 @AutoConfiguration(before = SqlInitializationAutoConfiguration.class)
 @ConditionalOnClass({ DataSource.class, EmbeddedDatabaseType.class })
 @ConditionalOnMissingBean(type = "io.r2dbc.spi.ConnectionFactory")
 @EnableConfigurationProperties(DataSourceProperties.class)
-@Import(DataSourcePoolMetadataProvidersConfiguration.class)
+@Import({ DataSourcePoolMetadataProvidersConfiguration.class, DataSourceCheckpointRestoreConfiguration.class })
 public class DataSourceAutoConfiguration {
 
 	@Configuration(proxyBeanMethods = false)
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceCheckpointRestoreConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceCheckpointRestoreConfiguration.java
new file mode 100644
index 000000000000..79d1a048b86d
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceCheckpointRestoreConfiguration.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.jdbc;
+
+import javax.sql.DataSource;
+
+import com.zaxxer.hikari.HikariDataSource;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnCheckpointRestore;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.jdbc.HikariCheckpointRestoreLifecycle;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Checkpoint-restore specific configuration.
+ *
+ * @author Olga Maciaszek-Sharma
+ */
+@Configuration(proxyBeanMethods = false)
+@ConditionalOnCheckpointRestore
+@ConditionalOnBean(DataSource.class)
+class DataSourceCheckpointRestoreConfiguration {
+
+	@Configuration(proxyBeanMethods = false)
+	@ConditionalOnClass(HikariDataSource.class)
+	static class Hikari {
+
+		@Bean
+		@ConditionalOnMissingBean
+		HikariCheckpointRestoreLifecycle hikariCheckpointRestoreLifecycle(DataSource dataSource) {
+			return new HikariCheckpointRestoreLifecycle(dataSource);
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceConfiguration.java
index 8dd321ee7cb4..1cebf37cd5e0 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceConfiguration.java
@@ -172,7 +172,6 @@ PoolDataSourceImpl dataSource(DataSourceProperties properties, JdbcConnectionDet
 				throws SQLException {
 			PoolDataSourceImpl dataSource = createDataSource(connectionDetails, PoolDataSourceImpl.class,
 					properties.getClassLoader());
-			dataSource.setValidateConnectionOnBorrow(true);
 			if (StringUtils.hasText(properties.getName())) {
 				dataSource.setConnectionPoolName(properties.getName());
 			}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceTransactionManagerAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceTransactionManagerAutoConfiguration.java
index e144521271d1..e674dff8451b 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceTransactionManagerAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceTransactionManagerAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -26,6 +26,7 @@
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate;
 import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration;
+import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizationAutoConfiguration;
 import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizers;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.context.annotation.Bean;
@@ -46,7 +47,8 @@
  * @author Kazuki Shimizu
  * @since 1.0.0
  */
-@AutoConfiguration(before = TransactionAutoConfiguration.class)
+@AutoConfiguration(before = TransactionAutoConfiguration.class,
+		after = TransactionManagerCustomizationAutoConfiguration.class)
 @ConditionalOnClass({ JdbcTemplate.class, TransactionManager.class })
 @AutoConfigureOrder(Ordered.LOWEST_PRECEDENCE)
 @EnableConfigurationProperties(DataSourceProperties.class)
@@ -61,7 +63,8 @@ static class JdbcTransactionManagerConfiguration {
 		DataSourceTransactionManager transactionManager(Environment environment, DataSource dataSource,
 				ObjectProvider<TransactionManagerCustomizers> transactionManagerCustomizers) {
 			DataSourceTransactionManager transactionManager = createTransactionManager(environment, dataSource);
-			transactionManagerCustomizers.ifAvailable((customizers) -> customizers.customize(transactionManager));
+			transactionManagerCustomizers
+				.ifAvailable((customizers) -> customizers.customize((TransactionManager) transactionManager));
 			return transactionManager;
 		}
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/JdbcClientAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/JdbcClientAutoConfiguration.java
new file mode 100644
index 000000000000..9b78ee8e9d09
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/JdbcClientAutoConfiguration.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.jdbc;
+
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate;
+import org.springframework.boot.sql.init.dependency.DatabaseInitializationDependencyConfigurer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.jdbc.core.simple.JdbcClient;
+
+/**
+ * {@link EnableAutoConfiguration Auto-configuration} for {@link JdbcClient}.
+ *
+ * @author Stephane Nicoll
+ * @since 3.2.0
+ */
+@AutoConfiguration(after = JdbcTemplateAutoConfiguration.class)
+@ConditionalOnSingleCandidate(NamedParameterJdbcTemplate.class)
+@ConditionalOnMissingBean(JdbcClient.class)
+@Import(DatabaseInitializationDependencyConfigurer.class)
+public class JdbcClientAutoConfiguration {
+
+	@Bean
+	JdbcClient jdbcClient(NamedParameterJdbcTemplate jdbcTemplate) {
+		return JdbcClient.create(jdbcTemplate);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/AcknowledgeMode.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/AcknowledgeMode.java
new file mode 100644
index 000000000000..f3c3240d7e2d
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/AcknowledgeMode.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.jms;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import jakarta.jms.Session;
+
+import org.springframework.jms.support.JmsAccessor;
+
+/**
+ * Acknowledge modes for a JMS Session. Supports the acknowledge modes defined by
+ * {@link jakarta.jms.Session} as well as other, non-standard modes.
+ *
+ * <p>
+ * Note that {@link jakarta.jms.Session#SESSION_TRANSACTED} is not defined. It should be
+ * handled through a call to {@link JmsAccessor#setSessionTransacted(boolean)}.
+ *
+ * @author Andy Wilkinson
+ * @since 3.2.0
+ */
+public final class AcknowledgeMode {
+
+	private static final Map<String, AcknowledgeMode> knownModes = new HashMap<>(3);
+
+	/**
+	 * Messages sent or received from the session are automatically acknowledged. This is
+	 * the simplest mode and enables once-only message delivery guarantee.
+	 */
+	public static final AcknowledgeMode AUTO = new AcknowledgeMode(Session.AUTO_ACKNOWLEDGE);
+
+	/**
+	 * Messages are acknowledged once the message listener implementation has called
+	 * {@link jakarta.jms.Message#acknowledge()}. This mode gives the application (rather
+	 * than the JMS provider) complete control over message acknowledgement.
+	 */
+	public static final AcknowledgeMode CLIENT = new AcknowledgeMode(Session.CLIENT_ACKNOWLEDGE);
+
+	/**
+	 * Similar to auto acknowledgment except that said acknowledgment is lazy. As a
+	 * consequence, the messages might be delivered more than once. This mode enables
+	 * at-least-once message delivery guarantee.
+	 */
+	public static final AcknowledgeMode DUPS_OK = new AcknowledgeMode(Session.DUPS_OK_ACKNOWLEDGE);
+
+	static {
+		knownModes.put("auto", AUTO);
+		knownModes.put("client", CLIENT);
+		knownModes.put("dupsok", DUPS_OK);
+	}
+
+	private final int mode;
+
+	private AcknowledgeMode(int mode) {
+		this.mode = mode;
+	}
+
+	public int getMode() {
+		return this.mode;
+	}
+
+	/**
+	 * Creates an {@code AcknowledgeMode} of the given {@code mode}. The mode may be
+	 * {@code auto}, {@code client}, {@code dupsok} or a non-standard acknowledge mode
+	 * that can be {@link Integer#parseInt parsed as an integer}.
+	 * @param mode the mode
+	 * @return the acknowledge mode
+	 */
+	public static AcknowledgeMode of(String mode) {
+		String canonicalMode = canonicalize(mode);
+		AcknowledgeMode knownMode = knownModes.get(canonicalMode);
+		try {
+			return (knownMode != null) ? knownMode : new AcknowledgeMode(Integer.parseInt(canonicalMode));
+		}
+		catch (NumberFormatException ex) {
+			throw new IllegalArgumentException("'" + mode
+					+ "' is neither a known acknowledge mode (auto, client, or dups_ok) nor an integer value");
+		}
+	}
+
+	private static String canonicalize(String input) {
+		StringBuilder canonicalName = new StringBuilder(input.length());
+		input.chars()
+			.filter(Character::isLetterOrDigit)
+			.map(Character::toLowerCase)
+			.forEach((c) -> canonicalName.append((char) c));
+		return canonicalName.toString();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/DefaultJmsListenerContainerFactoryConfigurer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/DefaultJmsListenerContainerFactoryConfigurer.java
index 931e8f653d98..2e5f1cc58a0f 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/DefaultJmsListenerContainerFactoryConfigurer.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/DefaultJmsListenerContainerFactoryConfigurer.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -21,6 +21,8 @@
 import jakarta.jms.ConnectionFactory;
 import jakarta.jms.ExceptionListener;
 
+import org.springframework.boot.autoconfigure.jms.JmsProperties.Listener.Session;
+import org.springframework.boot.context.properties.PropertyMapper;
 import org.springframework.jms.config.DefaultJmsListenerContainerFactory;
 import org.springframework.jms.support.converter.MessageConverter;
 import org.springframework.jms.support.destination.DestinationResolver;
@@ -32,6 +34,7 @@
  *
  * @author Stephane Nicoll
  * @author EddĂș MelĂ©ndez
+ * @author Vedran Pavic
  * @since 1.3.3
  */
 public final class DefaultJmsListenerContainerFactoryConfigurer {
@@ -101,34 +104,21 @@ public void configure(DefaultJmsListenerContainerFactory factory, ConnectionFact
 		Assert.notNull(connectionFactory, "ConnectionFactory must not be null");
 		factory.setConnectionFactory(connectionFactory);
 		factory.setPubSubDomain(this.jmsProperties.isPubSubDomain());
-		if (this.transactionManager != null) {
-			factory.setTransactionManager(this.transactionManager);
-		}
-		else {
+		JmsProperties.Listener listenerProperties = this.jmsProperties.getListener();
+		Session sessionProperties = listenerProperties.getSession();
+		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+		map.from(this.transactionManager).to(factory::setTransactionManager);
+		map.from(this.destinationResolver).to(factory::setDestinationResolver);
+		map.from(this.messageConverter).to(factory::setMessageConverter);
+		map.from(this.exceptionListener).to(factory::setExceptionListener);
+		map.from(sessionProperties.getAcknowledgeMode()::getMode).to(factory::setSessionAcknowledgeMode);
+		if (this.transactionManager == null && sessionProperties.getTransacted() == null) {
 			factory.setSessionTransacted(true);
 		}
-		if (this.destinationResolver != null) {
-			factory.setDestinationResolver(this.destinationResolver);
-		}
-		if (this.messageConverter != null) {
-			factory.setMessageConverter(this.messageConverter);
-		}
-		if (this.exceptionListener != null) {
-			factory.setExceptionListener(this.exceptionListener);
-		}
-		JmsProperties.Listener listener = this.jmsProperties.getListener();
-		factory.setAutoStartup(listener.isAutoStartup());
-		if (listener.getAcknowledgeMode() != null) {
-			factory.setSessionAcknowledgeMode(listener.getAcknowledgeMode().getMode());
-		}
-		String concurrency = listener.formatConcurrency();
-		if (concurrency != null) {
-			factory.setConcurrency(concurrency);
-		}
-		Duration receiveTimeout = listener.getReceiveTimeout();
-		if (receiveTimeout != null) {
-			factory.setReceiveTimeout(receiveTimeout.toMillis());
-		}
+		map.from(sessionProperties::getTransacted).to(factory::setSessionTransacted);
+		map.from(listenerProperties::isAutoStartup).to(factory::setAutoStartup);
+		map.from(listenerProperties::formatConcurrency).to(factory::setConcurrency);
+		map.from(listenerProperties::getReceiveTimeout).as(Duration::toMillis).to(factory::setReceiveTimeout);
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/JmsAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/JmsAutoConfiguration.java
index e256a0a63d47..2c2ba4b5a0b9 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/JmsAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/JmsAutoConfiguration.java
@@ -17,10 +17,15 @@
 package org.springframework.boot.autoconfigure.jms;
 
 import java.time.Duration;
+import java.util.List;
 
 import jakarta.jms.ConnectionFactory;
 import jakarta.jms.Message;
 
+import org.springframework.aot.hint.ExecutableMode;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+import org.springframework.aot.hint.TypeReference;
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
@@ -28,6 +33,7 @@
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate;
+import org.springframework.boot.autoconfigure.jms.JmsAutoConfiguration.JmsRuntimeHints;
 import org.springframework.boot.autoconfigure.jms.JmsProperties.DeliveryMode;
 import org.springframework.boot.autoconfigure.jms.JmsProperties.Template;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -35,6 +41,7 @@
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.ImportRuntimeHints;
 import org.springframework.jms.core.JmsMessageOperations;
 import org.springframework.jms.core.JmsMessagingTemplate;
 import org.springframework.jms.core.JmsOperations;
@@ -47,6 +54,7 @@
  *
  * @author Greg Turnquist
  * @author Stephane Nicoll
+ * @author Vedran Pavic
  * @since 1.0.0
  */
 @AutoConfiguration
@@ -54,6 +62,7 @@
 @ConditionalOnBean(ConnectionFactory.class)
 @EnableConfigurationProperties(JmsProperties.class)
 @Import(JmsAnnotationDrivenConfiguration.class)
+@ImportRuntimeHints(JmsRuntimeHints.class)
 public class JmsAutoConfiguration {
 
 	@Configuration(proxyBeanMethods = false)
@@ -87,20 +96,16 @@ public JmsTemplate jmsTemplate(ConnectionFactory connectionFactory) {
 		}
 
 		private void mapTemplateProperties(Template properties, JmsTemplate template) {
-			PropertyMapper map = PropertyMapper.get();
+			PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+			map.from(properties.getSession().getAcknowledgeMode()::getMode).to(template::setSessionAcknowledgeMode);
+			map.from(properties.getSession()::isTransacted).to(template::setSessionTransacted);
 			map.from(properties::getDefaultDestination).whenNonNull().to(template::setDefaultDestinationName);
 			map.from(properties::getDeliveryDelay).whenNonNull().as(Duration::toMillis).to(template::setDeliveryDelay);
 			map.from(properties::determineQosEnabled).to(template::setExplicitQosEnabled);
-			map.from(properties::getDeliveryMode)
-				.whenNonNull()
-				.as(DeliveryMode::getValue)
-				.to(template::setDeliveryMode);
+			map.from(properties::getDeliveryMode).as(DeliveryMode::getValue).to(template::setDeliveryMode);
 			map.from(properties::getPriority).whenNonNull().to(template::setPriority);
 			map.from(properties::getTimeToLive).whenNonNull().as(Duration::toMillis).to(template::setTimeToLive);
-			map.from(properties::getReceiveTimeout)
-				.whenNonNull()
-				.as(Duration::toMillis)
-				.to(template::setReceiveTimeout);
+			map.from(properties::getReceiveTimeout).as(Duration::toMillis).to(template::setReceiveTimeout);
 		}
 
 	}
@@ -126,4 +131,15 @@ private void mapTemplateProperties(Template properties, JmsMessagingTemplate mes
 
 	}
 
+	static class JmsRuntimeHints implements RuntimeHintsRegistrar {
+
+		@Override
+		public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
+			hints.reflection()
+				.registerType(TypeReference.of(AcknowledgeMode.class), (type) -> type.withMethod("of",
+						List.of(TypeReference.of(String.class)), ExecutableMode.INVOKE));
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/JmsProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/JmsProperties.java
index a4fdc8550366..08b7a6d5ee5e 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/JmsProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/JmsProperties.java
@@ -19,6 +19,7 @@
 import java.time.Duration;
 
 import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.context.properties.DeprecatedConfigurationProperty;
 
 /**
  * Configuration properties for JMS.
@@ -26,6 +27,7 @@
  * @author Greg Turnquist
  * @author Phillip Webb
  * @author Stephane Nicoll
+ * @author Vedran Pavic
  * @since 1.0.0
  */
 @ConfigurationProperties(prefix = "spring.jms")
@@ -140,15 +142,10 @@ public static class Listener {
 		private boolean autoStartup = true;
 
 		/**
-		 * Acknowledge mode of the container. By default, the listener is transacted with
-		 * automatic acknowledgment.
+		 * Minimum number of concurrent consumers. When max-concurrency is not specified
+		 * the minimum will also be used as the maximum.
 		 */
-		private AcknowledgeMode acknowledgeMode;
-
-		/**
-		 * Minimum number of concurrent consumers.
-		 */
-		private Integer concurrency;
+		private Integer minConcurrency;
 
 		/**
 		 * Maximum number of concurrent consumers.
@@ -162,6 +159,8 @@ public static class Listener {
 		 */
 		private Duration receiveTimeout = Duration.ofSeconds(1);
 
+		private final Session session = new Session();
+
 		public boolean isAutoStartup() {
 			return this.autoStartup;
 		}
@@ -170,20 +169,34 @@ public void setAutoStartup(boolean autoStartup) {
 			this.autoStartup = autoStartup;
 		}
 
+		@Deprecated(since = "3.2.0", forRemoval = true)
+		@DeprecatedConfigurationProperty(replacement = "spring.jms.listener.session.acknowledge-mode", since = "3.2.0")
 		public AcknowledgeMode getAcknowledgeMode() {
-			return this.acknowledgeMode;
+			return this.session.getAcknowledgeMode();
 		}
 
+		@Deprecated(since = "3.2.0", forRemoval = true)
 		public void setAcknowledgeMode(AcknowledgeMode acknowledgeMode) {
-			this.acknowledgeMode = acknowledgeMode;
+			this.session.setAcknowledgeMode(acknowledgeMode);
 		}
 
+		@DeprecatedConfigurationProperty(replacement = "spring.jms.listener.min-concurrency", since = "3.2.0")
+		@Deprecated(since = "3.2.0", forRemoval = true)
 		public Integer getConcurrency() {
-			return this.concurrency;
+			return this.minConcurrency;
 		}
 
+		@Deprecated(since = "3.2.0", forRemoval = true)
 		public void setConcurrency(Integer concurrency) {
-			this.concurrency = concurrency;
+			this.minConcurrency = concurrency;
+		}
+
+		public Integer getMinConcurrency() {
+			return this.minConcurrency;
+		}
+
+		public void setMinConcurrency(Integer minConcurrency) {
+			this.minConcurrency = minConcurrency;
 		}
 
 		public Integer getMaxConcurrency() {
@@ -195,11 +208,11 @@ public void setMaxConcurrency(Integer maxConcurrency) {
 		}
 
 		public String formatConcurrency() {
-			if (this.concurrency == null) {
+			if (this.minConcurrency == null) {
 				return (this.maxConcurrency != null) ? "1-" + this.maxConcurrency : null;
 			}
-			return ((this.maxConcurrency != null) ? this.concurrency + "-" + this.maxConcurrency
-					: String.valueOf(this.concurrency));
+			return this.minConcurrency + "-"
+					+ ((this.maxConcurrency != null) ? this.maxConcurrency : this.minConcurrency);
 		}
 
 		public Duration getReceiveTimeout() {
@@ -210,6 +223,41 @@ public void setReceiveTimeout(Duration receiveTimeout) {
 			this.receiveTimeout = receiveTimeout;
 		}
 
+		public Session getSession() {
+			return this.session;
+		}
+
+		public static class Session {
+
+			/**
+			 * Acknowledge mode of the listener container.
+			 */
+			private AcknowledgeMode acknowledgeMode = AcknowledgeMode.AUTO;
+
+			/**
+			 * Whether the listener container should use transacted JMS sessions. Defaults
+			 * to false in the presence of a JtaTransactionManager and true otherwise.
+			 */
+			private Boolean transacted;
+
+			public AcknowledgeMode getAcknowledgeMode() {
+				return this.acknowledgeMode;
+			}
+
+			public void setAcknowledgeMode(AcknowledgeMode acknowledgeMode) {
+				this.acknowledgeMode = acknowledgeMode;
+			}
+
+			public Boolean getTransacted() {
+				return this.transacted;
+			}
+
+			public void setTransacted(Boolean transacted) {
+				this.transacted = transacted;
+			}
+
+		}
+
 	}
 
 	public static class Template {
@@ -254,6 +302,8 @@ public static class Template {
 		 */
 		private Duration receiveTimeout;
 
+		private final Session session = new Session();
+
 		public String getDefaultDestination() {
 			return this.defaultDestination;
 		}
@@ -317,45 +367,38 @@ public void setReceiveTimeout(Duration receiveTimeout) {
 			this.receiveTimeout = receiveTimeout;
 		}
 
-	}
+		public Session getSession() {
+			return this.session;
+		}
 
-	/**
-	 * Translate the acknowledge modes defined on the {@link jakarta.jms.Session}.
-	 *
-	 * <p>
-	 * {@link jakarta.jms.Session#SESSION_TRANSACTED} is not defined as we take care of
-	 * this already through a call to {@code setSessionTransacted}.
-	 */
-	public enum AcknowledgeMode {
+		public static class Session {
 
-		/**
-		 * Messages sent or received from the session are automatically acknowledged. This
-		 * is the simplest mode and enables once-only message delivery guarantee.
-		 */
-		AUTO(1),
+			/**
+			 * Acknowledge mode used when creating sessions.
+			 */
+			private AcknowledgeMode acknowledgeMode = AcknowledgeMode.AUTO;
 
-		/**
-		 * Messages are acknowledged once the message listener implementation has called
-		 * {@link jakarta.jms.Message#acknowledge()}. This mode gives the application
-		 * (rather than the JMS provider) complete control over message acknowledgement.
-		 */
-		CLIENT(2),
+			/**
+			 * Whether to use transacted sessions.
+			 */
+			private boolean transacted = false;
 
-		/**
-		 * Similar to auto acknowledgment except that said acknowledgment is lazy. As a
-		 * consequence, the messages might be delivered more than once. This mode enables
-		 * at-least-once message delivery guarantee.
-		 */
-		DUPS_OK(3);
+			public AcknowledgeMode getAcknowledgeMode() {
+				return this.acknowledgeMode;
+			}
 
-		private final int mode;
+			public void setAcknowledgeMode(AcknowledgeMode acknowledgeMode) {
+				this.acknowledgeMode = acknowledgeMode;
+			}
 
-		AcknowledgeMode(int mode) {
-			this.mode = mode;
-		}
+			public boolean isTransacted() {
+				return this.transacted;
+			}
+
+			public void setTransacted(boolean transacted) {
+				this.transacted = transacted;
+			}
 
-		public int getMode() {
-			return this.mode;
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQAutoConfiguration.java
index da642a7f8689..44c3cebe3082 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQAutoConfiguration.java
@@ -27,6 +27,7 @@
 import org.springframework.boot.autoconfigure.jms.JmsProperties;
 import org.springframework.boot.autoconfigure.jms.JndiConnectionFactoryAutoConfiguration;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Import;
 
 /**
@@ -35,6 +36,7 @@
  *
  * @author Stephane Nicoll
  * @author Phillip Webb
+ * @author EddĂș MelĂ©ndez
  * @since 3.1.0
  */
 @AutoConfiguration(before = JmsAutoConfiguration.class, after = JndiConnectionFactoryAutoConfiguration.class)
@@ -44,4 +46,38 @@
 @Import({ ActiveMQXAConnectionFactoryConfiguration.class, ActiveMQConnectionFactoryConfiguration.class })
 public class ActiveMQAutoConfiguration {
 
+	@Bean
+	@ConditionalOnMissingBean(ActiveMQConnectionDetails.class)
+	ActiveMQConnectionDetails activemqConnectionDetails(ActiveMQProperties properties) {
+		return new PropertiesActiveMQConnectionDetails(properties);
+	}
+
+	/**
+	 * Adapts {@link ActiveMQProperties} to {@link ActiveMQConnectionDetails}.
+	 */
+	static class PropertiesActiveMQConnectionDetails implements ActiveMQConnectionDetails {
+
+		private final ActiveMQProperties properties;
+
+		PropertiesActiveMQConnectionDetails(ActiveMQProperties properties) {
+			this.properties = properties;
+		}
+
+		@Override
+		public String getBrokerUrl() {
+			return this.properties.determineBrokerUrl();
+		}
+
+		@Override
+		public String getUser() {
+			return this.properties.getUser();
+		}
+
+		@Override
+		public String getPassword() {
+			return this.properties.getPassword();
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQConnectionDetails.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQConnectionDetails.java
new file mode 100644
index 000000000000..9c095cfda901
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQConnectionDetails.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.jms.activemq;
+
+import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
+
+/**
+ * Details required to establish a connection to an ActiveMQ service.
+ *
+ * @author EddĂș MelĂ©ndez
+ * @author Stephane Nicoll
+ * @since 3.2.0
+ */
+public interface ActiveMQConnectionDetails extends ConnectionDetails {
+
+	/**
+	 * Broker URL to use.
+	 * @return the url of the broker
+	 */
+	String getBrokerUrl();
+
+	/**
+	 * Login user to authenticate to the broker.
+	 * @return the login user to authenticate to the broker or {@code null}
+	 */
+	String getUser();
+
+	/**
+	 * Login to authenticate against the broker.
+	 * @return the login to authenticate against the broker or {@code null}
+	 */
+	String getPassword();
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQConnectionFactoryConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQConnectionFactoryConfiguration.java
index a55337e58a2c..a4d242600e51 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQConnectionFactoryConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQConnectionFactoryConfiguration.java
@@ -39,6 +39,7 @@
  * @author Phillip Webb
  * @author Andy Wilkinson
  * @author Aurélien Leboulanger
+ * @author EddĂș MelĂ©ndez
  */
 @Configuration(proxyBeanMethods = false)
 @ConditionalOnMissingBean(ConnectionFactory.class)
@@ -52,13 +53,16 @@ static class SimpleConnectionFactoryConfiguration {
 		@Bean
 		@ConditionalOnProperty(prefix = "spring.jms.cache", name = "enabled", havingValue = "false")
 		ActiveMQConnectionFactory jmsConnectionFactory(ActiveMQProperties properties,
-				ObjectProvider<ActiveMQConnectionFactoryCustomizer> factoryCustomizers) {
-			return createJmsConnectionFactory(properties, factoryCustomizers);
+				ObjectProvider<ActiveMQConnectionFactoryCustomizer> factoryCustomizers,
+				ActiveMQConnectionDetails connectionDetails) {
+			return createJmsConnectionFactory(properties, factoryCustomizers, connectionDetails);
 		}
 
 		private static ActiveMQConnectionFactory createJmsConnectionFactory(ActiveMQProperties properties,
-				ObjectProvider<ActiveMQConnectionFactoryCustomizer> factoryCustomizers) {
-			return new ActiveMQConnectionFactoryFactory(properties, factoryCustomizers.orderedStream().toList())
+				ObjectProvider<ActiveMQConnectionFactoryCustomizer> factoryCustomizers,
+				ActiveMQConnectionDetails connectionDetails) {
+			return new ActiveMQConnectionFactoryFactory(properties, factoryCustomizers.orderedStream().toList(),
+					connectionDetails)
 				.createConnectionFactory(ActiveMQConnectionFactory.class);
 		}
 
@@ -70,10 +74,11 @@ static class CachingConnectionFactoryConfiguration {
 
 			@Bean
 			CachingConnectionFactory jmsConnectionFactory(JmsProperties jmsProperties, ActiveMQProperties properties,
-					ObjectProvider<ActiveMQConnectionFactoryCustomizer> factoryCustomizers) {
+					ObjectProvider<ActiveMQConnectionFactoryCustomizer> factoryCustomizers,
+					ActiveMQConnectionDetails connectionDetails) {
 				JmsProperties.Cache cacheProperties = jmsProperties.getCache();
 				CachingConnectionFactory connectionFactory = new CachingConnectionFactory(
-						createJmsConnectionFactory(properties, factoryCustomizers));
+						createJmsConnectionFactory(properties, factoryCustomizers, connectionDetails));
 				connectionFactory.setCacheConsumers(cacheProperties.isConsumers());
 				connectionFactory.setCacheProducers(cacheProperties.isProducers());
 				connectionFactory.setSessionCacheSize(cacheProperties.getSessionCacheSize());
@@ -91,9 +96,10 @@ static class PooledConnectionFactoryConfiguration {
 		@Bean(destroyMethod = "stop")
 		@ConditionalOnProperty(prefix = "spring.activemq.pool", name = "enabled", havingValue = "true")
 		JmsPoolConnectionFactory jmsConnectionFactory(ActiveMQProperties properties,
-				ObjectProvider<ActiveMQConnectionFactoryCustomizer> factoryCustomizers) {
+				ObjectProvider<ActiveMQConnectionFactoryCustomizer> factoryCustomizers,
+				ActiveMQConnectionDetails connectionDetails) {
 			ActiveMQConnectionFactory connectionFactory = new ActiveMQConnectionFactoryFactory(properties,
-					factoryCustomizers.orderedStream().toList())
+					factoryCustomizers.orderedStream().toList(), connectionDetails)
 				.createConnectionFactory(ActiveMQConnectionFactory.class);
 			return new JmsPoolConnectionFactoryFactory(properties.getPool())
 				.createPooledConnectionFactory(connectionFactory);
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQConnectionFactoryFactory.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQConnectionFactoryFactory.java
index b571860491f0..67768c0363ae 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQConnectionFactoryFactory.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQConnectionFactoryFactory.java
@@ -32,20 +32,22 @@
  *
  * @author Phillip Webb
  * @author Venil Noronha
+ * @author EddĂș MelĂ©ndez
  */
 class ActiveMQConnectionFactoryFactory {
 
-	private static final String DEFAULT_NETWORK_BROKER_URL = "tcp://localhost:61616";
-
 	private final ActiveMQProperties properties;
 
 	private final List<ActiveMQConnectionFactoryCustomizer> factoryCustomizers;
 
+	private final ActiveMQConnectionDetails connectionDetails;
+
 	ActiveMQConnectionFactoryFactory(ActiveMQProperties properties,
-			List<ActiveMQConnectionFactoryCustomizer> factoryCustomizers) {
+			List<ActiveMQConnectionFactoryCustomizer> factoryCustomizers, ActiveMQConnectionDetails connectionDetails) {
 		Assert.notNull(properties, "Properties must not be null");
 		this.properties = properties;
 		this.factoryCustomizers = (factoryCustomizers != null) ? factoryCustomizers : Collections.emptyList();
+		this.connectionDetails = connectionDetails;
 	}
 
 	<T extends ActiveMQConnectionFactory> T createConnectionFactory(Class<T> factoryClass) {
@@ -79,9 +81,9 @@ private <T extends ActiveMQConnectionFactory> T doCreateConnectionFactory(Class<
 
 	private <T extends ActiveMQConnectionFactory> T createConnectionFactoryInstance(Class<T> factoryClass)
 			throws InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException {
-		String brokerUrl = determineBrokerUrl();
-		String user = this.properties.getUser();
-		String password = this.properties.getPassword();
+		String brokerUrl = this.connectionDetails.getBrokerUrl();
+		String user = this.connectionDetails.getUser();
+		String password = this.connectionDetails.getPassword();
 		if (StringUtils.hasLength(user) && StringUtils.hasLength(password)) {
 			return factoryClass.getConstructor(String.class, String.class, String.class)
 				.newInstance(user, password, brokerUrl);
@@ -95,11 +97,4 @@ private void customize(ActiveMQConnectionFactory connectionFactory) {
 		}
 	}
 
-	String determineBrokerUrl() {
-		if (this.properties.getBrokerUrl() != null) {
-			return this.properties.getBrokerUrl();
-		}
-		return DEFAULT_NETWORK_BROKER_URL;
-	}
-
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQProperties.java
index 48b72e88935c..2877479a08e6 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQProperties.java
@@ -31,11 +31,14 @@
  * @author Stephane Nicoll
  * @author Aurélien Leboulanger
  * @author Venil Noronha
+ * @author EddĂș MelĂ©ndez
  * @since 3.1.0
  */
 @ConfigurationProperties(prefix = "spring.activemq")
 public class ActiveMQProperties {
 
+	private static final String DEFAULT_NETWORK_BROKER_URL = "tcp://localhost:61616";
+
 	/**
 	 * URL of the ActiveMQ broker. Auto-generated by default.
 	 */
@@ -128,6 +131,13 @@ public Packages getPackages() {
 		return this.packages;
 	}
 
+	String determineBrokerUrl() {
+		if (this.brokerUrl != null) {
+			return this.brokerUrl;
+		}
+		return DEFAULT_NETWORK_BROKER_URL;
+	}
+
 	public static class Packages {
 
 		/**
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQXAConnectionFactoryConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQXAConnectionFactoryConfiguration.java
index 4a7cbd214cea..6458c5824ae3 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQXAConnectionFactoryConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQXAConnectionFactoryConfiguration.java
@@ -36,6 +36,7 @@
  *
  * @author Phillip Webb
  * @author Aurélien Leboulanger
+ * @author EddĂș MelĂ©ndez
  */
 @Configuration(proxyBeanMethods = false)
 @ConditionalOnClass(TransactionManager.class)
@@ -46,10 +47,10 @@ class ActiveMQXAConnectionFactoryConfiguration {
 	@Primary
 	@Bean(name = { "jmsConnectionFactory", "xaJmsConnectionFactory" })
 	ConnectionFactory jmsConnectionFactory(ActiveMQProperties properties,
-			ObjectProvider<ActiveMQConnectionFactoryCustomizer> factoryCustomizers, XAConnectionFactoryWrapper wrapper)
-			throws Exception {
+			ObjectProvider<ActiveMQConnectionFactoryCustomizer> factoryCustomizers, XAConnectionFactoryWrapper wrapper,
+			ActiveMQConnectionDetails connectionDetails) throws Exception {
 		ActiveMQXAConnectionFactory connectionFactory = new ActiveMQConnectionFactoryFactory(properties,
-				factoryCustomizers.orderedStream().toList())
+				factoryCustomizers.orderedStream().toList(), connectionDetails)
 			.createConnectionFactory(ActiveMQXAConnectionFactory.class);
 		return wrapper.wrapConnectionFactory(connectionFactory);
 	}
@@ -58,8 +59,10 @@ ConnectionFactory jmsConnectionFactory(ActiveMQProperties properties,
 	@ConditionalOnProperty(prefix = "spring.activemq.pool", name = "enabled", havingValue = "false",
 			matchIfMissing = true)
 	ActiveMQConnectionFactory nonXaJmsConnectionFactory(ActiveMQProperties properties,
-			ObjectProvider<ActiveMQConnectionFactoryCustomizer> factoryCustomizers) {
-		return new ActiveMQConnectionFactoryFactory(properties, factoryCustomizers.orderedStream().toList())
+			ObjectProvider<ActiveMQConnectionFactoryCustomizer> factoryCustomizers,
+			ActiveMQConnectionDetails connectionDetails) {
+		return new ActiveMQConnectionFactoryFactory(properties, factoryCustomizers.orderedStream().toList(),
+				connectionDetails)
 			.createConnectionFactory(ActiveMQConnectionFactory.class);
 	}
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/artemis/ArtemisConnectionFactoryConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/artemis/ArtemisConnectionFactoryConfiguration.java
index 580383d865e9..98a305be8f38 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/artemis/ArtemisConnectionFactoryConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/artemis/ArtemisConnectionFactoryConfiguration.java
@@ -43,41 +43,40 @@
 class ArtemisConnectionFactoryConfiguration {
 
 	@Configuration(proxyBeanMethods = false)
-	@ConditionalOnClass(CachingConnectionFactory.class)
 	@ConditionalOnProperty(prefix = "spring.artemis.pool", name = "enabled", havingValue = "false",
 			matchIfMissing = true)
 	static class SimpleConnectionFactoryConfiguration {
 
-		private final ArtemisProperties properties;
-
-		private final ListableBeanFactory beanFactory;
+		@Bean(name = "jmsConnectionFactory")
+		@ConditionalOnProperty(prefix = "spring.jms.cache", name = "enabled", havingValue = "false")
+		ActiveMQConnectionFactory jmsConnectionFactory(ArtemisProperties properties, ListableBeanFactory beanFactory) {
+			return createJmsConnectionFactory(properties, beanFactory);
+		}
 
-		SimpleConnectionFactoryConfiguration(ArtemisProperties properties, ListableBeanFactory beanFactory) {
-			this.properties = properties;
-			this.beanFactory = beanFactory;
+		private static ActiveMQConnectionFactory createJmsConnectionFactory(ArtemisProperties properties,
+				ListableBeanFactory beanFactory) {
+			return new ArtemisConnectionFactoryFactory(beanFactory, properties)
+				.createConnectionFactory(ActiveMQConnectionFactory.class);
 		}
 
-		@Bean(name = "jmsConnectionFactory")
+		@Configuration(proxyBeanMethods = false)
+		@ConditionalOnClass(CachingConnectionFactory.class)
 		@ConditionalOnProperty(prefix = "spring.jms.cache", name = "enabled", havingValue = "true",
 				matchIfMissing = true)
-		CachingConnectionFactory cachingJmsConnectionFactory(JmsProperties jmsProperties) {
-			JmsProperties.Cache cacheProperties = jmsProperties.getCache();
-			CachingConnectionFactory connectionFactory = new CachingConnectionFactory(createConnectionFactory());
-			connectionFactory.setCacheConsumers(cacheProperties.isConsumers());
-			connectionFactory.setCacheProducers(cacheProperties.isProducers());
-			connectionFactory.setSessionCacheSize(cacheProperties.getSessionCacheSize());
-			return connectionFactory;
-		}
-
-		@Bean(name = "jmsConnectionFactory")
-		@ConditionalOnProperty(prefix = "spring.jms.cache", name = "enabled", havingValue = "false")
-		ActiveMQConnectionFactory jmsConnectionFactory() {
-			return createConnectionFactory();
-		}
+		static class CachingConnectionFactoryConfiguration {
+
+			@Bean(name = "jmsConnectionFactory")
+			CachingConnectionFactory cachingJmsConnectionFactory(JmsProperties jmsProperties,
+					ArtemisProperties properties, ListableBeanFactory beanFactory) {
+				JmsProperties.Cache cacheProperties = jmsProperties.getCache();
+				CachingConnectionFactory connectionFactory = new CachingConnectionFactory(
+						createJmsConnectionFactory(properties, beanFactory));
+				connectionFactory.setCacheConsumers(cacheProperties.isConsumers());
+				connectionFactory.setCacheProducers(cacheProperties.isProducers());
+				connectionFactory.setSessionCacheSize(cacheProperties.getSessionCacheSize());
+				return connectionFactory;
+			}
 
-		private ActiveMQConnectionFactory createConnectionFactory() {
-			return new ArtemisConnectionFactoryFactory(this.beanFactory, this.properties)
-				.createConnectionFactory(ActiveMQConnectionFactory.class);
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/artemis/ArtemisProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/artemis/ArtemisProperties.java
index fd2aa332110c..15325c1c2768 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/artemis/ArtemisProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/artemis/ArtemisProperties.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -44,7 +44,7 @@ public class ArtemisProperties {
 	private ArtemisMode mode;
 
 	/**
-	 * Artemis broker port.
+	 * Artemis broker url.
 	 */
 	private String brokerUrl;
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/SqlDialectLookup.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/SqlDialectLookup.java
index b91b72b68258..4213e39c50bb 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/SqlDialectLookup.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/SqlDialectLookup.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,7 +16,8 @@
 
 package org.springframework.boot.autoconfigure.jooq;
 
-import java.sql.DatabaseMetaData;
+import java.sql.Connection;
+import java.sql.SQLException;
 
 import javax.sql.DataSource;
 
@@ -25,14 +26,12 @@
 import org.jooq.SQLDialect;
 import org.jooq.tools.jdbc.JDBCUtils;
 
-import org.springframework.jdbc.support.JdbcUtils;
-import org.springframework.jdbc.support.MetaDataAccessException;
-
 /**
  * Utility to lookup well known {@link SQLDialect SQLDialects} from a {@link DataSource}.
  *
  * @author Michael Simons
  * @author Lukas Eder
+ * @author Ramil Saetov
  */
 final class SqlDialectLookup {
 
@@ -47,18 +46,12 @@ private SqlDialectLookup() {
 	 * @return the most suitable {@link SQLDialect}
 	 */
 	static SQLDialect getDialect(DataSource dataSource) {
-		if (dataSource == null) {
-			return SQLDialect.DEFAULT;
-		}
 		try {
-			String url = JdbcUtils.extractDatabaseMetaData(dataSource, DatabaseMetaData::getURL);
-			SQLDialect sqlDialect = JDBCUtils.dialect(url);
-			if (sqlDialect != null) {
-				return sqlDialect;
-			}
+			Connection connection = (dataSource != null) ? dataSource.getConnection() : null;
+			return JDBCUtils.dialect(connection);
 		}
-		catch (MetaDataAccessException ex) {
-			logger.warn("Unable to determine jdbc url from datasource", ex);
+		catch (SQLException ex) {
+			logger.warn("Unable to determine dialect from datasource", ex);
 		}
 		return SQLDialect.DEFAULT;
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/ConcurrentKafkaListenerContainerFactoryConfigurer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/ConcurrentKafkaListenerContainerFactoryConfigurer.java
index 8ea525a151b5..86cfd258ee93 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/ConcurrentKafkaListenerContainerFactoryConfigurer.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/ConcurrentKafkaListenerContainerFactoryConfigurer.java
@@ -17,9 +17,11 @@
 package org.springframework.boot.autoconfigure.kafka;
 
 import java.time.Duration;
+import java.util.function.Function;
 
 import org.springframework.boot.autoconfigure.kafka.KafkaProperties.Listener;
 import org.springframework.boot.context.properties.PropertyMapper;
+import org.springframework.core.task.SimpleAsyncTaskExecutor;
 import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
 import org.springframework.kafka.core.ConsumerFactory;
 import org.springframework.kafka.core.KafkaTemplate;
@@ -28,6 +30,7 @@
 import org.springframework.kafka.listener.CommonErrorHandler;
 import org.springframework.kafka.listener.ConsumerAwareRebalanceListener;
 import org.springframework.kafka.listener.ContainerProperties;
+import org.springframework.kafka.listener.MessageListenerContainer;
 import org.springframework.kafka.listener.RecordInterceptor;
 import org.springframework.kafka.listener.adapter.RecordFilterStrategy;
 import org.springframework.kafka.support.converter.BatchMessageConverter;
@@ -40,6 +43,7 @@
  * @author Gary Russell
  * @author EddĂș MelĂ©ndez
  * @author Thomas KÄsene
+ * @author Moritz Halbritter
  * @since 1.5.0
  */
 public class ConcurrentKafkaListenerContainerFactoryConfigurer {
@@ -66,6 +70,10 @@ public class ConcurrentKafkaListenerContainerFactoryConfigurer {
 
 	private BatchInterceptor<Object, Object> batchInterceptor;
 
+	private Function<MessageListenerContainer, String> threadNameSupplier;
+
+	private SimpleAsyncTaskExecutor listenerTaskExecutor;
+
 	/**
 	 * Set the {@link KafkaProperties} to use.
 	 * @param properties the properties
@@ -156,6 +164,22 @@ void setBatchInterceptor(BatchInterceptor<Object, Object> batchInterceptor) {
 		this.batchInterceptor = batchInterceptor;
 	}
 
+	/**
+	 * Set the thread name supplier to use.
+	 * @param threadNameSupplier the thread name supplier to use
+	 */
+	void setThreadNameSupplier(Function<MessageListenerContainer, String> threadNameSupplier) {
+		this.threadNameSupplier = threadNameSupplier;
+	}
+
+	/**
+	 * Set the executor for threads that poll the consumer.
+	 * @param listenerTaskExecutor task executor
+	 */
+	void setListenerTaskExecutor(SimpleAsyncTaskExecutor listenerTaskExecutor) {
+		this.listenerTaskExecutor = listenerTaskExecutor;
+	}
+
 	/**
 	 * Configure the specified Kafka listener container factory. The factory can be
 	 * further tuned and default settings can be overridden.
@@ -186,6 +210,8 @@ private void configureListenerFactory(ConcurrentKafkaListenerContainerFactory<Ob
 		map.from(this.afterRollbackProcessor).to(factory::setAfterRollbackProcessor);
 		map.from(this.recordInterceptor).to(factory::setRecordInterceptor);
 		map.from(this.batchInterceptor).to(factory::setBatchInterceptor);
+		map.from(this.threadNameSupplier).to(factory::setThreadNameSupplier);
+		map.from(properties::getChangeConsumerThreadName).to(factory::setChangeConsumerThreadName);
 	}
 
 	private void configureContainer(ContainerProperties container) {
@@ -210,8 +236,10 @@ private void configureContainer(ContainerProperties container) {
 		map.from(properties::getLogContainerConfig).to(container::setLogContainerConfig);
 		map.from(properties::isMissingTopicsFatal).to(container::setMissingTopicsFatal);
 		map.from(properties::isImmediateStop).to(container::setStopImmediate);
+		map.from(properties::isObservationEnabled).to(container::setObservationEnabled);
 		map.from(this.transactionManager).to(container::setTransactionManager);
 		map.from(this.rebalanceListener).to(container::setConsumerRebalanceListener);
+		map.from(this.listenerTaskExecutor).to(container::setListenerTaskExecutor);
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaAnnotationDrivenConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaAnnotationDrivenConfiguration.java
index ba3a3c824447..abbc834f466f 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaAnnotationDrivenConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaAnnotationDrivenConfiguration.java
@@ -16,11 +16,17 @@
 
 package org.springframework.boot.autoconfigure.kafka;
 
+import java.util.function.Function;
+
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnThreading;
+import org.springframework.boot.autoconfigure.thread.Threading;
+import org.springframework.boot.ssl.SslBundles;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.core.task.SimpleAsyncTaskExecutor;
 import org.springframework.kafka.annotation.EnableKafka;
 import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
 import org.springframework.kafka.config.ContainerCustomizer;
@@ -33,6 +39,7 @@
 import org.springframework.kafka.listener.CommonErrorHandler;
 import org.springframework.kafka.listener.ConcurrentMessageListenerContainer;
 import org.springframework.kafka.listener.ConsumerAwareRebalanceListener;
+import org.springframework.kafka.listener.MessageListenerContainer;
 import org.springframework.kafka.listener.RecordInterceptor;
 import org.springframework.kafka.listener.adapter.RecordFilterStrategy;
 import org.springframework.kafka.support.converter.BatchMessageConverter;
@@ -46,6 +53,9 @@
  * @author Gary Russell
  * @author EddĂș MelĂ©ndez
  * @author Thomas KÄsene
+ * @author Moritz Halbritter
+ * @author Andy Wilkinson
+ * @author Scott Frederick
  */
 @Configuration(proxyBeanMethods = false)
 @ConditionalOnClass(EnableKafka.class)
@@ -73,6 +83,8 @@ class KafkaAnnotationDrivenConfiguration {
 
 	private final BatchInterceptor<Object, Object> batchInterceptor;
 
+	private final Function<MessageListenerContainer, String> threadNameSupplier;
+
 	KafkaAnnotationDrivenConfiguration(KafkaProperties properties,
 			ObjectProvider<RecordMessageConverter> recordMessageConverter,
 			ObjectProvider<RecordFilterStrategy<Object, Object>> recordFilterStrategy,
@@ -83,7 +95,8 @@ class KafkaAnnotationDrivenConfiguration {
 			ObjectProvider<CommonErrorHandler> commonErrorHandler,
 			ObjectProvider<AfterRollbackProcessor<Object, Object>> afterRollbackProcessor,
 			ObjectProvider<RecordInterceptor<Object, Object>> recordInterceptor,
-			ObjectProvider<BatchInterceptor<Object, Object>> batchInterceptor) {
+			ObjectProvider<BatchInterceptor<Object, Object>> batchInterceptor,
+			ObjectProvider<Function<MessageListenerContainer, String>> threadNameSupplier) {
 		this.properties = properties;
 		this.recordMessageConverter = recordMessageConverter.getIfUnique();
 		this.recordFilterStrategy = recordFilterStrategy.getIfUnique();
@@ -96,11 +109,28 @@ class KafkaAnnotationDrivenConfiguration {
 		this.afterRollbackProcessor = afterRollbackProcessor.getIfUnique();
 		this.recordInterceptor = recordInterceptor.getIfUnique();
 		this.batchInterceptor = batchInterceptor.getIfUnique();
+		this.threadNameSupplier = threadNameSupplier.getIfUnique();
 	}
 
 	@Bean
 	@ConditionalOnMissingBean
+	@ConditionalOnThreading(Threading.PLATFORM)
 	ConcurrentKafkaListenerContainerFactoryConfigurer kafkaListenerContainerFactoryConfigurer() {
+		return configurer();
+	}
+
+	@Bean(name = "kafkaListenerContainerFactoryConfigurer")
+	@ConditionalOnMissingBean
+	@ConditionalOnThreading(Threading.VIRTUAL)
+	ConcurrentKafkaListenerContainerFactoryConfigurer kafkaListenerContainerFactoryConfigurerVirtualThreads() {
+		ConcurrentKafkaListenerContainerFactoryConfigurer configurer = configurer();
+		SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("kafka-");
+		executor.setVirtualThreads(true);
+		configurer.setListenerTaskExecutor(executor);
+		return configurer;
+	}
+
+	private ConcurrentKafkaListenerContainerFactoryConfigurer configurer() {
 		ConcurrentKafkaListenerContainerFactoryConfigurer configurer = new ConcurrentKafkaListenerContainerFactoryConfigurer();
 		configurer.setKafkaProperties(this.properties);
 		configurer.setBatchMessageConverter(this.batchMessageConverter);
@@ -113,6 +143,7 @@ ConcurrentKafkaListenerContainerFactoryConfigurer kafkaListenerContainerFactoryC
 		configurer.setAfterRollbackProcessor(this.afterRollbackProcessor);
 		configurer.setRecordInterceptor(this.recordInterceptor);
 		configurer.setBatchInterceptor(this.batchInterceptor);
+		configurer.setThreadNameSupplier(this.threadNameSupplier);
 		return configurer;
 	}
 
@@ -121,10 +152,11 @@ ConcurrentKafkaListenerContainerFactoryConfigurer kafkaListenerContainerFactoryC
 	ConcurrentKafkaListenerContainerFactory<?, ?> kafkaListenerContainerFactory(
 			ConcurrentKafkaListenerContainerFactoryConfigurer configurer,
 			ObjectProvider<ConsumerFactory<Object, Object>> kafkaConsumerFactory,
-			ObjectProvider<ContainerCustomizer<Object, Object, ConcurrentMessageListenerContainer<Object, Object>>> kafkaContainerCustomizer) {
+			ObjectProvider<ContainerCustomizer<Object, Object, ConcurrentMessageListenerContainer<Object, Object>>> kafkaContainerCustomizer,
+			ObjectProvider<SslBundles> sslBundles) {
 		ConcurrentKafkaListenerContainerFactory<Object, Object> factory = new ConcurrentKafkaListenerContainerFactory<>();
-		configurer.configure(factory, kafkaConsumerFactory
-			.getIfAvailable(() -> new DefaultKafkaConsumerFactory<>(this.properties.buildConsumerProperties())));
+		configurer.configure(factory, kafkaConsumerFactory.getIfAvailable(() -> new DefaultKafkaConsumerFactory<>(
+				this.properties.buildConsumerProperties(sslBundles.getIfAvailable()))));
 		kafkaContainerCustomizer.ifAvailable(factory::setContainerCustomizer);
 		return factory;
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfiguration.java
index 9d73f58cd56c..18e02adb854a 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfiguration.java
@@ -35,6 +35,7 @@
 import org.springframework.boot.autoconfigure.kafka.KafkaProperties.Retry.Topic;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.boot.context.properties.PropertyMapper;
+import org.springframework.boot.ssl.SslBundles;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Import;
 import org.springframework.kafka.core.ConsumerFactory;
@@ -64,6 +65,8 @@
  * @author Moritz Halbritter
  * @author Andy Wilkinson
  * @author Phillip Webb
+ * @author Andy Wilkinson
+ * @author Scott Frederick
  * @since 1.5.0
  */
 @AutoConfiguration
@@ -95,6 +98,7 @@ PropertiesKafkaConnectionDetails kafkaConnectionDetails(KafkaProperties properti
 		map.from(kafkaProducerListener).to(kafkaTemplate::setProducerListener);
 		map.from(this.properties.getTemplate().getDefaultTopic()).to(kafkaTemplate::setDefaultTopic);
 		map.from(this.properties.getTemplate().getTransactionIdPrefix()).to(kafkaTemplate::setTransactionIdPrefix);
+		map.from(this.properties.getTemplate().isObservationEnabled()).to(kafkaTemplate::setObservationEnabled);
 		return kafkaTemplate;
 	}
 
@@ -107,8 +111,8 @@ public LoggingProducerListener<Object, Object> kafkaProducerListener() {
 	@Bean
 	@ConditionalOnMissingBean(ConsumerFactory.class)
 	public DefaultKafkaConsumerFactory<?, ?> kafkaConsumerFactory(KafkaConnectionDetails connectionDetails,
-			ObjectProvider<DefaultKafkaConsumerFactoryCustomizer> customizers) {
-		Map<String, Object> properties = this.properties.buildConsumerProperties();
+			ObjectProvider<DefaultKafkaConsumerFactoryCustomizer> customizers, ObjectProvider<SslBundles> sslBundles) {
+		Map<String, Object> properties = this.properties.buildConsumerProperties(sslBundles.getIfAvailable());
 		applyKafkaConnectionDetailsForConsumer(properties, connectionDetails);
 		DefaultKafkaConsumerFactory<Object, Object> factory = new DefaultKafkaConsumerFactory<>(properties);
 		customizers.orderedStream().forEach((customizer) -> customizer.customize(factory));
@@ -118,8 +122,8 @@ public LoggingProducerListener<Object, Object> kafkaProducerListener() {
 	@Bean
 	@ConditionalOnMissingBean(ProducerFactory.class)
 	public DefaultKafkaProducerFactory<?, ?> kafkaProducerFactory(KafkaConnectionDetails connectionDetails,
-			ObjectProvider<DefaultKafkaProducerFactoryCustomizer> customizers) {
-		Map<String, Object> properties = this.properties.buildProducerProperties();
+			ObjectProvider<DefaultKafkaProducerFactoryCustomizer> customizers, ObjectProvider<SslBundles> sslBundles) {
+		Map<String, Object> properties = this.properties.buildProducerProperties(sslBundles.getIfAvailable());
 		applyKafkaConnectionDetailsForProducer(properties, connectionDetails);
 		DefaultKafkaProducerFactory<?, ?> factory = new DefaultKafkaProducerFactory<>(properties);
 		String transactionIdPrefix = this.properties.getProducer().getTransactionIdPrefix();
@@ -155,8 +159,8 @@ public KafkaJaasLoginModuleInitializer kafkaJaasInitializer() throws IOException
 
 	@Bean
 	@ConditionalOnMissingBean
-	public KafkaAdmin kafkaAdmin(KafkaConnectionDetails connectionDetails) {
-		Map<String, Object> properties = this.properties.buildAdminProperties();
+	public KafkaAdmin kafkaAdmin(KafkaConnectionDetails connectionDetails, ObjectProvider<SslBundles> sslBundles) {
+		Map<String, Object> properties = this.properties.buildAdminProperties(sslBundles.getIfAvailable());
 		applyKafkaConnectionDetailsForAdmin(properties, connectionDetails);
 		KafkaAdmin kafkaAdmin = new KafkaAdmin(properties);
 		KafkaProperties.Admin admin = this.properties.getAdmin();
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaProperties.java
index 50a2890821bc..20085141e501 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaProperties.java
@@ -38,6 +38,8 @@
 import org.springframework.boot.context.properties.PropertyMapper;
 import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException;
 import org.springframework.boot.convert.DurationUnit;
+import org.springframework.boot.ssl.SslBundle;
+import org.springframework.boot.ssl.SslBundles;
 import org.springframework.core.io.Resource;
 import org.springframework.kafka.listener.ContainerProperties.AckMode;
 import org.springframework.kafka.security.jaas.KafkaJaasLoginModuleInitializer;
@@ -55,6 +57,8 @@
  * @author Artem Bilan
  * @author Nakul Mishra
  * @author Tomaz Fernandes
+ * @author Andy Wilkinson
+ * @author Scott Frederick
  * @since 1.5.0
  */
 @ConfigurationProperties(prefix = "spring.kafka")
@@ -157,7 +161,7 @@ public Retry getRetry() {
 		return this.retry;
 	}
 
-	private Map<String, Object> buildCommonProperties() {
+	private Map<String, Object> buildCommonProperties(SslBundles sslBundles) {
 		Map<String, Object> properties = new HashMap<>();
 		if (this.bootstrapServers != null) {
 			properties.put(CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG, this.bootstrapServers);
@@ -165,7 +169,7 @@ private Map<String, Object> buildCommonProperties() {
 		if (this.clientId != null) {
 			properties.put(CommonClientConfigs.CLIENT_ID_CONFIG, this.clientId);
 		}
-		properties.putAll(this.ssl.buildProperties());
+		properties.putAll(this.ssl.buildProperties(sslBundles));
 		properties.putAll(this.security.buildProperties());
 		if (!CollectionUtils.isEmpty(this.properties)) {
 			properties.putAll(this.properties);
@@ -177,13 +181,29 @@ private Map<String, Object> buildCommonProperties() {
 	 * Create an initial map of consumer properties from the state of this instance.
 	 * <p>
 	 * This allows you to add additional properties, if necessary, and override the
-	 * default kafkaConsumerFactory bean.
+	 * default {@code kafkaConsumerFactory} bean.
 	 * @return the consumer properties initialized with the customizations defined on this
 	 * instance
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of
+	 * {@link #buildConsumerProperties(SslBundles)}}
 	 */
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	public Map<String, Object> buildConsumerProperties() {
-		Map<String, Object> properties = buildCommonProperties();
-		properties.putAll(this.consumer.buildProperties());
+		return buildConsumerProperties(null);
+	}
+
+	/**
+	 * Create an initial map of consumer properties from the state of this instance.
+	 * <p>
+	 * This allows you to add additional properties, if necessary, and override the
+	 * default {@code kafkaConsumerFactory} bean.
+	 * @param sslBundles bundles providing SSL trust material
+	 * @return the consumer properties initialized with the customizations defined on this
+	 * instance
+	 */
+	public Map<String, Object> buildConsumerProperties(SslBundles sslBundles) {
+		Map<String, Object> properties = buildCommonProperties(sslBundles);
+		properties.putAll(this.consumer.buildProperties(sslBundles));
 		return properties;
 	}
 
@@ -191,13 +211,29 @@ public Map<String, Object> buildConsumerProperties() {
 	 * Create an initial map of producer properties from the state of this instance.
 	 * <p>
 	 * This allows you to add additional properties, if necessary, and override the
-	 * default kafkaProducerFactory bean.
+	 * default {@code kafkaProducerFactory} bean.
 	 * @return the producer properties initialized with the customizations defined on this
 	 * instance
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of
+	 * {@link #buildProducerProperties(SslBundles)}}
 	 */
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	public Map<String, Object> buildProducerProperties() {
-		Map<String, Object> properties = buildCommonProperties();
-		properties.putAll(this.producer.buildProperties());
+		return buildProducerProperties(null);
+	}
+
+	/**
+	 * Create an initial map of producer properties from the state of this instance.
+	 * <p>
+	 * This allows you to add additional properties, if necessary, and override the
+	 * default {@code kafkaProducerFactory} bean.
+	 * @param sslBundles bundles providing SSL trust material
+	 * @return the producer properties initialized with the customizations defined on this
+	 * instance
+	 */
+	public Map<String, Object> buildProducerProperties(SslBundles sslBundles) {
+		Map<String, Object> properties = buildCommonProperties(sslBundles);
+		properties.putAll(this.producer.buildProperties(sslBundles));
 		return properties;
 	}
 
@@ -205,13 +241,29 @@ public Map<String, Object> buildProducerProperties() {
 	 * Create an initial map of admin properties from the state of this instance.
 	 * <p>
 	 * This allows you to add additional properties, if necessary, and override the
-	 * default kafkaAdmin bean.
+	 * default {@code kafkaAdmin} bean.
 	 * @return the admin properties initialized with the customizations defined on this
 	 * instance
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of
+	 * {@link #buildAdminProperties(SslBundles)}}
 	 */
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	public Map<String, Object> buildAdminProperties() {
-		Map<String, Object> properties = buildCommonProperties();
-		properties.putAll(this.admin.buildProperties());
+		return buildAdminProperties(null);
+	}
+
+	/**
+	 * Create an initial map of admin properties from the state of this instance.
+	 * <p>
+	 * This allows you to add additional properties, if necessary, and override the
+	 * default {@code kafkaAdmin} bean.
+	 * @param sslBundles bundles providing SSL trust material
+	 * @return the admin properties initialized with the customizations defined on this
+	 * instance
+	 */
+	public Map<String, Object> buildAdminProperties(SslBundles sslBundles) {
+		Map<String, Object> properties = buildCommonProperties(sslBundles);
+		properties.putAll(this.admin.buildProperties(sslBundles));
 		return properties;
 	}
 
@@ -221,10 +273,25 @@ public Map<String, Object> buildAdminProperties() {
 	 * This allows you to add additional properties, if necessary.
 	 * @return the streams properties initialized with the customizations defined on this
 	 * instance
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of
+	 * {@link #buildStreamsProperties(SslBundles)}}
 	 */
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	public Map<String, Object> buildStreamsProperties() {
-		Map<String, Object> properties = buildCommonProperties();
-		properties.putAll(this.streams.buildProperties());
+		return buildStreamsProperties(null);
+	}
+
+	/**
+	 * Create an initial map of streams properties from the state of this instance.
+	 * <p>
+	 * This allows you to add additional properties, if necessary.
+	 * @param sslBundles bundles providing SSL trust material
+	 * @return the streams properties initialized with the customizations defined on this
+	 * instance
+	 */
+	public Map<String, Object> buildStreamsProperties(SslBundles sslBundles) {
+		Map<String, Object> properties = buildCommonProperties(sslBundles);
+		properties.putAll(this.streams.buildProperties(sslBundles));
 		return properties;
 	}
 
@@ -426,7 +493,7 @@ public Map<String, String> getProperties() {
 			return this.properties;
 		}
 
-		public Map<String, Object> buildProperties() {
+		public Map<String, Object> buildProperties(SslBundles sslBundles) {
 			Properties properties = new Properties();
 			PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
 			map.from(this::getAutoCommitInterval)
@@ -451,7 +518,7 @@ public Map<String, Object> buildProperties() {
 			map.from(this::getKeyDeserializer).to(properties.in(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG));
 			map.from(this::getValueDeserializer).to(properties.in(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG));
 			map.from(this::getMaxPollRecords).to(properties.in(ConsumerConfig.MAX_POLL_RECORDS_CONFIG));
-			return properties.with(this.ssl, this.security, this.properties);
+			return properties.with(this.ssl, this.security, this.properties, sslBundles);
 		}
 
 	}
@@ -613,7 +680,7 @@ public Map<String, String> getProperties() {
 			return this.properties;
 		}
 
-		public Map<String, Object> buildProperties() {
+		public Map<String, Object> buildProperties(SslBundles sslBundles) {
 			Properties properties = new Properties();
 			PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
 			map.from(this::getAcks).to(properties.in(ProducerConfig.ACKS_CONFIG));
@@ -627,7 +694,7 @@ public Map<String, Object> buildProperties() {
 			map.from(this::getKeySerializer).to(properties.in(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG));
 			map.from(this::getRetries).to(properties.in(ProducerConfig.RETRIES_CONFIG));
 			map.from(this::getValueSerializer).to(properties.in(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG));
-			return properties.with(this.ssl, this.security, this.properties);
+			return properties.with(this.ssl, this.security, this.properties, sslBundles);
 		}
 
 	}
@@ -734,11 +801,11 @@ public Map<String, String> getProperties() {
 			return this.properties;
 		}
 
-		public Map<String, Object> buildProperties() {
+		public Map<String, Object> buildProperties(SslBundles sslBundles) {
 			Properties properties = new Properties();
 			PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
 			map.from(this::getClientId).to(properties.in(ProducerConfig.CLIENT_ID_CONFIG));
-			return properties.with(this.ssl, this.security, this.properties);
+			return properties.with(this.ssl, this.security, this.properties, sslBundles);
 		}
 
 	}
@@ -837,7 +904,8 @@ public void setBootstrapServers(List<String> bootstrapServers) {
 			this.bootstrapServers = bootstrapServers;
 		}
 
-		@DeprecatedConfigurationProperty(replacement = "spring.kafka.streams.state-store-cache-max-size")
+		@DeprecatedConfigurationProperty(replacement = "spring.kafka.streams.state-store-cache-max-size",
+				since = "3.1.0")
 		@Deprecated(since = "3.1.0", forRemoval = true)
 		public DataSize getCacheMaxSizeBuffering() {
 			return this.cacheMaxSizeBuffering;
@@ -884,7 +952,7 @@ public Map<String, String> getProperties() {
 			return this.properties;
 		}
 
-		public Map<String, Object> buildProperties() {
+		public Map<String, Object> buildProperties(SslBundles sslBundles) {
 			Properties properties = new Properties();
 			PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
 			map.from(this::getApplicationId).to(properties.in("application.id"));
@@ -898,7 +966,7 @@ public Map<String, Object> buildProperties() {
 			map.from(this::getClientId).to(properties.in(CommonClientConfigs.CLIENT_ID_CONFIG));
 			map.from(this::getReplicationFactor).to(properties.in("replication.factor"));
 			map.from(this::getStateDir).to(properties.in("state.dir"));
-			return properties.with(this.ssl, this.security, this.properties);
+			return properties.with(this.ssl, this.security, this.properties, sslBundles);
 		}
 
 	}
@@ -916,6 +984,11 @@ public static class Template {
 		 */
 		private String transactionIdPrefix;
 
+		/**
+		 * Whether to enable observation.
+		 */
+		private boolean observationEnabled;
+
 		public String getDefaultTopic() {
 			return this.defaultTopic;
 		}
@@ -932,6 +1005,14 @@ public void setTransactionIdPrefix(String transactionIdPrefix) {
 			this.transactionIdPrefix = transactionIdPrefix;
 		}
 
+		public boolean isObservationEnabled() {
+			return this.observationEnabled;
+		}
+
+		public void setObservationEnabled(boolean observationEnabled) {
+			this.observationEnabled = observationEnabled;
+		}
+
 	}
 
 	public static class Listener {
@@ -1043,6 +1124,17 @@ public enum Type {
 		 */
 		private boolean autoStartup = true;
 
+		/**
+		 * Whether to instruct the container to change the consumer thread name during
+		 * initialization.
+		 */
+		private Boolean changeConsumerThreadName;
+
+		/**
+		 * Whether to enable observation.
+		 */
+		private boolean observationEnabled;
+
 		public Type getType() {
 			return this.type;
 		}
@@ -1179,10 +1271,31 @@ public void setAutoStartup(boolean autoStartup) {
 			this.autoStartup = autoStartup;
 		}
 
+		public Boolean getChangeConsumerThreadName() {
+			return this.changeConsumerThreadName;
+		}
+
+		public void setChangeConsumerThreadName(Boolean changeConsumerThreadName) {
+			this.changeConsumerThreadName = changeConsumerThreadName;
+		}
+
+		public boolean isObservationEnabled() {
+			return this.observationEnabled;
+		}
+
+		public void setObservationEnabled(boolean observationEnabled) {
+			this.observationEnabled = observationEnabled;
+		}
+
 	}
 
 	public static class Ssl {
 
+		/**
+		 * Name of the SSL bundle to use.
+		 */
+		private String bundle;
+
 		/**
 		 * Password of the private key in either key store key or key store file.
 		 */
@@ -1238,6 +1351,14 @@ public static class Ssl {
 		 */
 		private String protocol;
 
+		public String getBundle() {
+			return this.bundle;
+		}
+
+		public void setBundle(String bundle) {
+			this.bundle = bundle;
+		}
+
 		public String getKeyPassword() {
 			return this.keyPassword;
 		}
@@ -1326,26 +1447,39 @@ public void setProtocol(String protocol) {
 			this.protocol = protocol;
 		}
 
+		@Deprecated(since = "3.2.0", forRemoval = true)
 		public Map<String, Object> buildProperties() {
+			return buildProperties(null);
+		}
+
+		public Map<String, Object> buildProperties(SslBundles sslBundles) {
 			validate();
 			Properties properties = new Properties();
-			PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
-			map.from(this::getKeyPassword).to(properties.in(SslConfigs.SSL_KEY_PASSWORD_CONFIG));
-			map.from(this::getKeyStoreCertificateChain)
-				.to(properties.in(SslConfigs.SSL_KEYSTORE_CERTIFICATE_CHAIN_CONFIG));
-			map.from(this::getKeyStoreKey).to(properties.in(SslConfigs.SSL_KEYSTORE_KEY_CONFIG));
-			map.from(this::getKeyStoreLocation)
-				.as(this::resourceToPath)
-				.to(properties.in(SslConfigs.SSL_KEYSTORE_LOCATION_CONFIG));
-			map.from(this::getKeyStorePassword).to(properties.in(SslConfigs.SSL_KEYSTORE_PASSWORD_CONFIG));
-			map.from(this::getKeyStoreType).to(properties.in(SslConfigs.SSL_KEYSTORE_TYPE_CONFIG));
-			map.from(this::getTrustStoreCertificates).to(properties.in(SslConfigs.SSL_TRUSTSTORE_CERTIFICATES_CONFIG));
-			map.from(this::getTrustStoreLocation)
-				.as(this::resourceToPath)
-				.to(properties.in(SslConfigs.SSL_TRUSTSTORE_LOCATION_CONFIG));
-			map.from(this::getTrustStorePassword).to(properties.in(SslConfigs.SSL_TRUSTSTORE_PASSWORD_CONFIG));
-			map.from(this::getTrustStoreType).to(properties.in(SslConfigs.SSL_TRUSTSTORE_TYPE_CONFIG));
-			map.from(this::getProtocol).to(properties.in(SslConfigs.SSL_PROTOCOL_CONFIG));
+			if (getBundle() != null) {
+				properties.in(SslConfigs.SSL_ENGINE_FACTORY_CLASS_CONFIG)
+					.accept(SslBundleSslEngineFactory.class.getName());
+				properties.in(SslBundle.class.getName()).accept(sslBundles.getBundle(getBundle()));
+			}
+			else {
+				PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+				map.from(this::getKeyPassword).to(properties.in(SslConfigs.SSL_KEY_PASSWORD_CONFIG));
+				map.from(this::getKeyStoreCertificateChain)
+					.to(properties.in(SslConfigs.SSL_KEYSTORE_CERTIFICATE_CHAIN_CONFIG));
+				map.from(this::getKeyStoreKey).to(properties.in(SslConfigs.SSL_KEYSTORE_KEY_CONFIG));
+				map.from(this::getKeyStoreLocation)
+					.as(this::resourceToPath)
+					.to(properties.in(SslConfigs.SSL_KEYSTORE_LOCATION_CONFIG));
+				map.from(this::getKeyStorePassword).to(properties.in(SslConfigs.SSL_KEYSTORE_PASSWORD_CONFIG));
+				map.from(this::getKeyStoreType).to(properties.in(SslConfigs.SSL_KEYSTORE_TYPE_CONFIG));
+				map.from(this::getTrustStoreCertificates)
+					.to(properties.in(SslConfigs.SSL_TRUSTSTORE_CERTIFICATES_CONFIG));
+				map.from(this::getTrustStoreLocation)
+					.as(this::resourceToPath)
+					.to(properties.in(SslConfigs.SSL_TRUSTSTORE_LOCATION_CONFIG));
+				map.from(this::getTrustStorePassword).to(properties.in(SslConfigs.SSL_TRUSTSTORE_PASSWORD_CONFIG));
+				map.from(this::getTrustStoreType).to(properties.in(SslConfigs.SSL_TRUSTSTORE_TYPE_CONFIG));
+				map.from(this::getProtocol).to(properties.in(SslConfigs.SSL_PROTOCOL_CONFIG));
+			}
 			return properties;
 		}
 
@@ -1358,6 +1492,22 @@ private void validate() {
 				entries.put("spring.kafka.ssl.trust-store-certificates", getTrustStoreCertificates());
 				entries.put("spring.kafka.ssl.trust-store-location", getTrustStoreLocation());
 			});
+			MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> {
+				entries.put("spring.kafka.ssl.bundle", getBundle());
+				entries.put("spring.kafka.ssl.key-store-key", getKeyStoreKey());
+			});
+			MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> {
+				entries.put("spring.kafka.ssl.bundle", getBundle());
+				entries.put("spring.kafka.ssl.key-store-location", getKeyStoreLocation());
+			});
+			MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> {
+				entries.put("spring.kafka.ssl.bundle", getBundle());
+				entries.put("spring.kafka.ssl.trust-store-certificates", getTrustStoreCertificates());
+			});
+			MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> {
+				entries.put("spring.kafka.ssl.bundle", getBundle());
+				entries.put("spring.kafka.ssl.trust-store-location", getTrustStoreLocation());
+			});
 		}
 
 		private String resourceToPath(Resource resource) {
@@ -1613,8 +1763,8 @@ <V> java.util.function.Consumer<V> in(String key) {
 			return (value) -> put(key, value);
 		}
 
-		Properties with(Ssl ssl, Security security, Map<String, String> properties) {
-			putAll(ssl.buildProperties());
+		Properties with(Ssl ssl, Security security, Map<String, String> properties, SslBundles sslBundles) {
+			putAll(ssl.buildProperties(sslBundles));
 			putAll(security.buildProperties());
 			putAll(properties);
 			return this;
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaStreamsAnnotationDrivenConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaStreamsAnnotationDrivenConfiguration.java
index 28e35d3699f4..701384326303 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaStreamsAnnotationDrivenConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaStreamsAnnotationDrivenConfiguration.java
@@ -30,6 +30,7 @@
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException;
+import org.springframework.boot.ssl.SslBundles;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.core.env.Environment;
@@ -46,6 +47,7 @@
  * @author EddĂș MelĂ©ndez
  * @author Moritz Halbritter
  * @author Andy Wilkinson
+ * @author Scott Frederick
  */
 @Configuration(proxyBeanMethods = false)
 @ConditionalOnClass(StreamsBuilder.class)
@@ -61,8 +63,8 @@ class KafkaStreamsAnnotationDrivenConfiguration {
 	@ConditionalOnMissingBean
 	@Bean(KafkaStreamsDefaultConfiguration.DEFAULT_STREAMS_CONFIG_BEAN_NAME)
 	KafkaStreamsConfiguration defaultKafkaStreamsConfig(Environment environment,
-			KafkaConnectionDetails connectionDetails) {
-		Map<String, Object> properties = this.properties.buildStreamsProperties();
+			KafkaConnectionDetails connectionDetails, ObjectProvider<SslBundles> sslBundles) {
+		Map<String, Object> properties = this.properties.buildStreamsProperties(sslBundles.getIfAvailable());
 		applyKafkaConnectionDetailsForStreams(properties, connectionDetails);
 		if (this.properties.getStreams().getApplicationId() == null) {
 			String applicationName = environment.getProperty("spring.application.name");
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/SslBundleSslEngineFactory.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/SslBundleSslEngineFactory.java
new file mode 100644
index 000000000000..5c5e93ebf56a
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/SslBundleSslEngineFactory.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.kafka;
+
+import java.io.IOException;
+import java.security.KeyStore;
+import java.util.Map;
+import java.util.Set;
+
+import javax.net.ssl.SSLEngine;
+import javax.net.ssl.SSLParameters;
+
+import org.apache.kafka.common.security.auth.SslEngineFactory;
+
+import org.springframework.boot.ssl.SslBundle;
+
+/**
+ * An {@link SslEngineFactory} that configures creates an {@link SSLEngine} from an
+ * {@link SslBundle}.
+ *
+ * @author Andy Wilkinson
+ * @author Scott Frederick
+ * @since 3.2.0
+ */
+public class SslBundleSslEngineFactory implements SslEngineFactory {
+
+	private static final String SSL_BUNDLE_CONFIG_NAME = SslBundle.class.getName();
+
+	private Map<String, ?> configs;
+
+	private volatile SslBundle sslBundle;
+
+	@Override
+	public void configure(Map<String, ?> configs) {
+		this.configs = configs;
+		this.sslBundle = (SslBundle) configs.get(SSL_BUNDLE_CONFIG_NAME);
+	}
+
+	@Override
+	public void close() throws IOException {
+
+	}
+
+	@Override
+	public SSLEngine createClientSslEngine(String peerHost, int peerPort, String endpointIdentification) {
+		SSLEngine sslEngine = this.sslBundle.createSslContext().createSSLEngine(peerHost, peerPort);
+		sslEngine.setUseClientMode(true);
+		SSLParameters sslParams = sslEngine.getSSLParameters();
+		sslParams.setEndpointIdentificationAlgorithm(endpointIdentification);
+		sslEngine.setSSLParameters(sslParams);
+		return sslEngine;
+	}
+
+	@Override
+	public SSLEngine createServerSslEngine(String peerHost, int peerPort) {
+		SSLEngine sslEngine = this.sslBundle.createSslContext().createSSLEngine(peerHost, peerPort);
+		sslEngine.setUseClientMode(false);
+		return sslEngine;
+	}
+
+	@Override
+	public boolean shouldBeRebuilt(Map<String, Object> nextConfigs) {
+		return !nextConfigs.equals(this.configs);
+	}
+
+	@Override
+	public Set<String> reconfigurableConfigs() {
+		return Set.of(SSL_BUNDLE_CONFIG_NAME);
+	}
+
+	@Override
+	public KeyStore keystore() {
+		return this.sslBundle.getStores().getKeyStore();
+	}
+
+	@Override
+	public KeyStore truststore() {
+		return this.sslBundle.getStores().getTrustStore();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseAutoConfiguration.java
index f19ab38f011b..8beb5ffcae18 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseAutoConfiguration.java
@@ -113,6 +113,8 @@ public SpringLiquibase liquibase(ObjectProvider<DataSource> dataSource,
 			liquibase.setRollbackFile(properties.getRollbackFile());
 			liquibase.setTestRollbackOnUpdate(properties.isTestRollbackOnUpdate());
 			liquibase.setTag(properties.getTag());
+			liquibase.setShowSummary(properties.getShowSummary());
+			liquibase.setShowSummaryOutput(properties.getShowSummaryOutput());
 			return liquibase;
 		}
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseProperties.java
index 478897d053d9..fa92d5c5694f 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseProperties.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -19,10 +19,11 @@
 import java.io.File;
 import java.util.Map;
 
+import liquibase.UpdateSummaryEnum;
+import liquibase.UpdateSummaryOutputEnum;
 import liquibase.integration.spring.SpringLiquibase;
 
 import org.springframework.boot.context.properties.ConfigurationProperties;
-import org.springframework.boot.context.properties.DeprecatedConfigurationProperty;
 import org.springframework.util.Assert;
 
 /**
@@ -136,6 +137,18 @@ public class LiquibaseProperties {
 	 */
 	private String tag;
 
+	/**
+	 * Whether to print a summary of the update operation. Values can be 'off', 'summary'
+	 * (default), 'verbose'
+	 */
+	private UpdateSummaryEnum showSummary;
+
+	/**
+	 * Where to print a summary of the update operation. Values can be 'log' (default),
+	 * 'console', or 'all'.
+	 */
+	private UpdateSummaryOutputEnum showSummaryOutput;
+
 	public String getChangeLog() {
 		return this.changeLog;
 	}
@@ -257,17 +270,6 @@ public void setLabelFilter(String labelFilter) {
 		this.labelFilter = labelFilter;
 	}
 
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	@DeprecatedConfigurationProperty(replacement = "spring.liquibase.label-filter")
-	public String getLabels() {
-		return getLabelFilter();
-	}
-
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	public void setLabels(String labels) {
-		setLabelFilter(labels);
-	}
-
 	public Map<String, String> getParameters() {
 		return this.parameters;
 	}
@@ -300,4 +302,20 @@ public void setTag(String tag) {
 		this.tag = tag;
 	}
 
+	public UpdateSummaryEnum getShowSummary() {
+		return this.showSummary;
+	}
+
+	public void setShowSummary(UpdateSummaryEnum showSummary) {
+		this.showSummary = showSummary;
+	}
+
+	public UpdateSummaryOutputEnum getShowSummaryOutput() {
+		return this.showSummaryOutput;
+	}
+
+	public void setShowSummaryOutput(UpdateSummaryOutputEnum showSummaryOutput) {
+		this.showSummaryOutput = showSummaryOutput;
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/logging/ConditionEvaluationReportLogger.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/logging/ConditionEvaluationReportLogger.java
index 49a3036fd67d..1af820991ab5 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/logging/ConditionEvaluationReportLogger.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/logging/ConditionEvaluationReportLogger.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -80,7 +80,7 @@ else if (isCrashReport) {
 
 	private void logMessage(String logLevel) {
 		this.logger.info(String.format("%n%nError starting ApplicationContext. To display the "
-				+ "condition evaluation report re-run your application with '" + logLevel + "' enabled."));
+				+ "condition evaluation report re-run your application with '%s' enabled.", logLevel));
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mongo/MongoPropertiesClientSettingsBuilderCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mongo/MongoPropertiesClientSettingsBuilderCustomizer.java
index 222841879b6d..691064cd5e69 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mongo/MongoPropertiesClientSettingsBuilderCustomizer.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mongo/MongoPropertiesClientSettingsBuilderCustomizer.java
@@ -34,7 +34,7 @@
  * @author Scott Frederick
  * @author Safeer Ansari
  * @since 2.4.0
- * @deprecated since 3.1.0 in favor of
+ * @deprecated since 3.1.0 for removal in 3.3.0 in favor of
  * {@link StandardMongoClientSettingsBuilderCustomizer}
  */
 @Deprecated(since = "3.1.0", forRemoval = true)
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mongo/MongoReactiveAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mongo/MongoReactiveAutoConfiguration.java
index 1157e002945a..5426b6f17fc9 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mongo/MongoReactiveAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mongo/MongoReactiveAutoConfiguration.java
@@ -18,7 +18,7 @@
 
 import com.mongodb.MongoClientSettings;
 import com.mongodb.MongoClientSettings.Builder;
-import com.mongodb.connection.netty.NettyStreamFactoryFactory;
+import com.mongodb.connection.TransportSettings;
 import com.mongodb.reactivestreams.client.MongoClient;
 import io.netty.channel.EventLoopGroup;
 import io.netty.channel.nio.NioEventLoopGroup;
@@ -113,11 +113,10 @@ static final class NettyDriverMongoClientSettingsBuilderCustomizer
 
 		@Override
 		public void customize(Builder builder) {
-			if (!isStreamFactoryFactoryDefined(this.settings.getIfAvailable())) {
+			if (!isCustomTransportConfiguration(this.settings.getIfAvailable())) {
 				NioEventLoopGroup eventLoopGroup = new NioEventLoopGroup();
 				this.eventLoopGroup = eventLoopGroup;
-				builder
-					.streamFactoryFactory(NettyStreamFactoryFactory.builder().eventLoopGroup(eventLoopGroup).build());
+				builder.transportSettings(TransportSettings.nettyBuilder().eventLoopGroup(eventLoopGroup).build());
 			}
 		}
 
@@ -130,8 +129,10 @@ public void destroy() {
 			}
 		}
 
-		private boolean isStreamFactoryFactoryDefined(MongoClientSettings settings) {
-			return settings != null && settings.getStreamFactoryFactory() != null;
+		@SuppressWarnings("deprecation")
+		private boolean isCustomTransportConfiguration(MongoClientSettings settings) {
+			return settings != null
+					&& (settings.getTransportSettings() != null || settings.getStreamFactoryFactory() != null);
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfiguration.java
index a7580754f8ff..ed4e87c7e2fb 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfiguration.java
@@ -24,6 +24,7 @@
 import java.util.concurrent.TimeUnit;
 
 import org.neo4j.driver.AuthToken;
+import org.neo4j.driver.AuthTokenManager;
 import org.neo4j.driver.AuthTokens;
 import org.neo4j.driver.Config;
 import org.neo4j.driver.Config.TrustStrategy;
@@ -63,8 +64,9 @@ public class Neo4jAutoConfiguration {
 
 	@Bean
 	@ConditionalOnMissingBean(Neo4jConnectionDetails.class)
-	PropertiesNeo4jConnectionDetails neo4jConnectionDetails(Neo4jProperties properties) {
-		return new PropertiesNeo4jConnectionDetails(properties);
+	PropertiesNeo4jConnectionDetails neo4jConnectionDetails(Neo4jProperties properties,
+			ObjectProvider<AuthTokenManager> authTokenManager) {
+		return new PropertiesNeo4jConnectionDetails(properties, authTokenManager.getIfUnique());
 	}
 
 	@Bean
@@ -72,9 +74,14 @@ PropertiesNeo4jConnectionDetails neo4jConnectionDetails(Neo4jProperties properti
 	public Driver neo4jDriver(Neo4jProperties properties, Environment environment,
 			Neo4jConnectionDetails connectionDetails,
 			ObjectProvider<ConfigBuilderCustomizer> configBuilderCustomizers) {
-		AuthToken authToken = connectionDetails.getAuthToken();
+
 		Config config = mapDriverConfig(properties, connectionDetails,
 				configBuilderCustomizers.orderedStream().toList());
+		AuthTokenManager authTokenManager = connectionDetails.getAuthTokenManager();
+		if (authTokenManager != null) {
+			return GraphDatabase.driver(connectionDetails.getUri(), authTokenManager, config);
+		}
+		AuthToken authToken = connectionDetails.getAuthToken();
 		return GraphDatabase.driver(connectionDetails.getUri(), authToken, config);
 	}
 
@@ -156,22 +163,20 @@ private Config.TrustStrategy mapTrustStrategy(Neo4jProperties.Security securityP
 
 	private TrustStrategy createTrustStrategy(Neo4jProperties.Security securityProperties, String propertyName,
 			Security.TrustStrategy strategy) {
-		switch (strategy) {
-			case TRUST_ALL_CERTIFICATES:
-				return TrustStrategy.trustAllCertificates();
-			case TRUST_SYSTEM_CA_SIGNED_CERTIFICATES:
-				return TrustStrategy.trustSystemCertificates();
-			case TRUST_CUSTOM_CA_SIGNED_CERTIFICATES:
+		return switch (strategy) {
+			case TRUST_ALL_CERTIFICATES -> TrustStrategy.trustAllCertificates();
+			case TRUST_SYSTEM_CA_SIGNED_CERTIFICATES -> TrustStrategy.trustSystemCertificates();
+			case TRUST_CUSTOM_CA_SIGNED_CERTIFICATES -> {
 				File certFile = securityProperties.getCertFile();
 				if (certFile == null || !certFile.isFile()) {
 					throw new InvalidConfigurationPropertyValueException(propertyName, strategy.name(),
 							"Configured trust strategy requires a certificate file.");
 				}
-				return TrustStrategy.trustCustomCertificateSignedBy(certFile);
-			default:
-				throw new InvalidConfigurationPropertyValueException(propertyName, strategy.name(),
-						"Unknown strategy.");
-		}
+				yield TrustStrategy.trustCustomCertificateSignedBy(certFile);
+			}
+			default -> throw new InvalidConfigurationPropertyValueException(propertyName, strategy.name(),
+					"Unknown strategy.");
+		};
 	}
 
 	/**
@@ -181,8 +186,11 @@ static class PropertiesNeo4jConnectionDetails implements Neo4jConnectionDetails
 
 		private final Neo4jProperties properties;
 
-		PropertiesNeo4jConnectionDetails(Neo4jProperties properties) {
+		private final AuthTokenManager authTokenManager;
+
+		PropertiesNeo4jConnectionDetails(Neo4jProperties properties, AuthTokenManager authTokenManager) {
 			this.properties = properties;
+			this.authTokenManager = authTokenManager;
 		}
 
 		@Override
@@ -211,6 +219,11 @@ public AuthToken getAuthToken() {
 			return AuthTokens.none();
 		}
 
+		@Override
+		public AuthTokenManager getAuthTokenManager() {
+			return this.authTokenManager;
+		}
+
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/neo4j/Neo4jConnectionDetails.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/neo4j/Neo4jConnectionDetails.java
index 17a950ebd8bd..f10a122338b9 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/neo4j/Neo4jConnectionDetails.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/neo4j/Neo4jConnectionDetails.java
@@ -19,6 +19,7 @@
 import java.net.URI;
 
 import org.neo4j.driver.AuthToken;
+import org.neo4j.driver.AuthTokenManager;
 import org.neo4j.driver.AuthTokens;
 
 import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
@@ -49,4 +50,14 @@ default AuthToken getAuthToken() {
 		return AuthTokens.none();
 	}
 
+	/**
+	 * Returns the {@link AuthTokenManager} to use for authentication. Defaults to
+	 * {@code null} in which case the {@link #getAuthToken() auth token} should be used.
+	 * @return the auth token manager
+	 * @since 3.2.0
+	 */
+	default AuthTokenManager getAuthTokenManager() {
+		return null;
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaAutoConfiguration.java
index 0dfdd3eb8bab..4b78b65bfda4 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -24,6 +24,7 @@
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
 import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration;
+import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizationAutoConfiguration;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.context.annotation.Import;
 import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
@@ -37,7 +38,9 @@
  * @author Andy Wilkinson
  * @since 1.0.0
  */
-@AutoConfiguration(after = DataSourceAutoConfiguration.class, before = TransactionAutoConfiguration.class)
+@AutoConfiguration(
+		after = { DataSourceAutoConfiguration.class, TransactionManagerCustomizationAutoConfiguration.class },
+		before = TransactionAutoConfiguration.class)
 @ConditionalOnClass({ LocalContainerEntityManagerFactoryBean.class, EntityManager.class, SessionImplementor.class })
 @EnableConfigurationProperties(JpaProperties.class)
 @Import(HibernateJpaConfiguration.class)
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/orm/jpa/JpaBaseConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/orm/jpa/JpaBaseConfiguration.java
index 0520ff094866..90355acc6692 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/orm/jpa/JpaBaseConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/orm/jpa/JpaBaseConfiguration.java
@@ -93,7 +93,8 @@ protected JpaBaseConfiguration(DataSource dataSource, JpaProperties properties,
 	public PlatformTransactionManager transactionManager(
 			ObjectProvider<TransactionManagerCustomizers> transactionManagerCustomizers) {
 		JpaTransactionManager transactionManager = new JpaTransactionManager();
-		transactionManagerCustomizers.ifAvailable((customizers) -> customizers.customize(transactionManager));
+		transactionManagerCustomizers
+			.ifAvailable((customizers) -> customizers.customize((TransactionManager) transactionManager));
 		return transactionManager;
 	}
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/DeadLetterPolicyMapper.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/DeadLetterPolicyMapper.java
new file mode 100644
index 000000000000..fc4a4f64b6bc
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/DeadLetterPolicyMapper.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.pulsar;
+
+import org.apache.pulsar.client.api.DeadLetterPolicy;
+import org.apache.pulsar.client.api.DeadLetterPolicy.DeadLetterPolicyBuilder;
+
+import org.springframework.boot.context.properties.PropertyMapper;
+import org.springframework.util.Assert;
+
+/**
+ * Helper class used to map {@link PulsarProperties.Consumer.DeadLetterPolicy dead letter
+ * policy properties}.
+ *
+ * @author Chris Bono
+ * @author Phillip Webb
+ */
+final class DeadLetterPolicyMapper {
+
+	private DeadLetterPolicyMapper() {
+	}
+
+	static DeadLetterPolicy map(PulsarProperties.Consumer.DeadLetterPolicy policy) {
+		Assert.state(policy.getMaxRedeliverCount() > 0,
+				"Pulsar DeadLetterPolicy must have a positive 'max-redelivery-count' property value");
+		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+		DeadLetterPolicyBuilder builder = DeadLetterPolicy.builder();
+		map.from(policy::getMaxRedeliverCount).to(builder::maxRedeliverCount);
+		map.from(policy::getRetryLetterTopic).to(builder::retryLetterTopic);
+		map.from(policy::getDeadLetterTopic).to(builder::deadLetterTopic);
+		map.from(policy::getInitialSubscriptionName).to(builder::initialSubscriptionName);
+		return builder.build();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PropertiesPulsarConnectionDetails.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PropertiesPulsarConnectionDetails.java
new file mode 100644
index 000000000000..51ed0fadc322
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PropertiesPulsarConnectionDetails.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.pulsar;
+
+/**
+ * Adapts {@link PulsarProperties} to {@link PulsarConnectionDetails}.
+ *
+ * @author Chris Bono
+ */
+class PropertiesPulsarConnectionDetails implements PulsarConnectionDetails {
+
+	private final PulsarProperties pulsarProperties;
+
+	PropertiesPulsarConnectionDetails(PulsarProperties pulsarProperties) {
+		this.pulsarProperties = pulsarProperties;
+	}
+
+	@Override
+	public String getBrokerUrl() {
+		return this.pulsarProperties.getClient().getServiceUrl();
+	}
+
+	@Override
+	public String getAdminUrl() {
+		return this.pulsarProperties.getAdmin().getServiceUrl();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfiguration.java
new file mode 100644
index 000000000000..c60565b5918f
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfiguration.java
@@ -0,0 +1,205 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.pulsar;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.pulsar.client.api.ConsumerBuilder;
+import org.apache.pulsar.client.api.ProducerBuilder;
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.client.api.ReaderBuilder;
+import org.apache.pulsar.client.api.interceptor.ProducerInterceptor;
+
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.autoconfigure.thread.Threading;
+import org.springframework.boot.util.LambdaSafe;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.core.env.Environment;
+import org.springframework.core.task.VirtualThreadTaskExecutor;
+import org.springframework.pulsar.annotation.EnablePulsar;
+import org.springframework.pulsar.config.ConcurrentPulsarListenerContainerFactory;
+import org.springframework.pulsar.config.DefaultPulsarReaderContainerFactory;
+import org.springframework.pulsar.config.PulsarAnnotationSupportBeanNames;
+import org.springframework.pulsar.core.CachingPulsarProducerFactory;
+import org.springframework.pulsar.core.ConsumerBuilderCustomizer;
+import org.springframework.pulsar.core.DefaultPulsarConsumerFactory;
+import org.springframework.pulsar.core.DefaultPulsarProducerFactory;
+import org.springframework.pulsar.core.DefaultPulsarReaderFactory;
+import org.springframework.pulsar.core.ProducerBuilderCustomizer;
+import org.springframework.pulsar.core.PulsarConsumerFactory;
+import org.springframework.pulsar.core.PulsarProducerFactory;
+import org.springframework.pulsar.core.PulsarReaderFactory;
+import org.springframework.pulsar.core.PulsarTemplate;
+import org.springframework.pulsar.core.ReaderBuilderCustomizer;
+import org.springframework.pulsar.core.SchemaResolver;
+import org.springframework.pulsar.core.TopicResolver;
+import org.springframework.pulsar.listener.PulsarContainerProperties;
+import org.springframework.pulsar.reader.PulsarReaderContainerProperties;
+
+/**
+ * {@link EnableAutoConfiguration Auto-configuration} for Apache Pulsar.
+ *
+ * @author Chris Bono
+ * @author Soby Chacko
+ * @author Alexander Preuß
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+@AutoConfiguration
+@ConditionalOnClass({ PulsarClient.class, PulsarTemplate.class })
+@Import(PulsarConfiguration.class)
+public class PulsarAutoConfiguration {
+
+	private PulsarProperties properties;
+
+	private PulsarPropertiesMapper propertiesMapper;
+
+	PulsarAutoConfiguration(PulsarProperties properties) {
+		this.properties = properties;
+		this.propertiesMapper = new PulsarPropertiesMapper(properties);
+	}
+
+	@Bean
+	@ConditionalOnMissingBean(PulsarProducerFactory.class)
+	@ConditionalOnProperty(name = "spring.pulsar.producer.cache.enabled", havingValue = "false")
+	DefaultPulsarProducerFactory<?> pulsarProducerFactory(PulsarClient pulsarClient, TopicResolver topicResolver,
+			ObjectProvider<ProducerBuilderCustomizer<?>> customizersProvider) {
+		List<ProducerBuilderCustomizer<Object>> lambdaSafeCustomizers = lambdaSafeProducerBuilderCustomizers(
+				customizersProvider);
+		return new DefaultPulsarProducerFactory<>(pulsarClient, this.properties.getProducer().getTopicName(),
+				lambdaSafeCustomizers, topicResolver);
+	}
+
+	@Bean
+	@ConditionalOnMissingBean(PulsarProducerFactory.class)
+	@ConditionalOnProperty(name = "spring.pulsar.producer.cache.enabled", havingValue = "true", matchIfMissing = true)
+	CachingPulsarProducerFactory<?> cachingPulsarProducerFactory(PulsarClient pulsarClient, TopicResolver topicResolver,
+			ObjectProvider<ProducerBuilderCustomizer<?>> customizersProvider) {
+		PulsarProperties.Producer.Cache cacheProperties = this.properties.getProducer().getCache();
+		List<ProducerBuilderCustomizer<Object>> lambdaSafeCustomizers = lambdaSafeProducerBuilderCustomizers(
+				customizersProvider);
+		return new CachingPulsarProducerFactory<>(pulsarClient, this.properties.getProducer().getTopicName(),
+				lambdaSafeCustomizers, topicResolver, cacheProperties.getExpireAfterAccess(),
+				cacheProperties.getMaximumSize(), cacheProperties.getInitialCapacity());
+	}
+
+	private List<ProducerBuilderCustomizer<Object>> lambdaSafeProducerBuilderCustomizers(
+			ObjectProvider<ProducerBuilderCustomizer<?>> customizersProvider) {
+		List<ProducerBuilderCustomizer<?>> customizers = new ArrayList<>();
+		customizers.add(this.propertiesMapper::customizeProducerBuilder);
+		customizers.addAll(customizersProvider.orderedStream().toList());
+		return List.of((builder) -> applyProducerBuilderCustomizers(customizers, builder));
+	}
+
+	@SuppressWarnings("unchecked")
+	private void applyProducerBuilderCustomizers(List<ProducerBuilderCustomizer<?>> customizers,
+			ProducerBuilder<?> builder) {
+		LambdaSafe.callbacks(ProducerBuilderCustomizer.class, customizers, builder)
+			.invoke((customizer) -> customizer.customize(builder));
+	}
+
+	@Bean
+	@ConditionalOnMissingBean
+	PulsarTemplate<?> pulsarTemplate(PulsarProducerFactory<?> pulsarProducerFactory,
+			ObjectProvider<ProducerInterceptor> producerInterceptors, SchemaResolver schemaResolver,
+			TopicResolver topicResolver) {
+		return new PulsarTemplate<>(pulsarProducerFactory, producerInterceptors.orderedStream().toList(),
+				schemaResolver, topicResolver, this.properties.getTemplate().isObservationsEnabled());
+	}
+
+	@Bean
+	@ConditionalOnMissingBean(PulsarConsumerFactory.class)
+	DefaultPulsarConsumerFactory<Object> pulsarConsumerFactory(PulsarClient pulsarClient,
+			ObjectProvider<ConsumerBuilderCustomizer<?>> customizersProvider) {
+		List<ConsumerBuilderCustomizer<?>> customizers = new ArrayList<>();
+		customizers.add(this.propertiesMapper::customizeConsumerBuilder);
+		customizers.addAll(customizersProvider.orderedStream().toList());
+		List<ConsumerBuilderCustomizer<Object>> lambdaSafeCustomizers = List
+			.of((builder) -> applyConsumerBuilderCustomizers(customizers, builder));
+		return new DefaultPulsarConsumerFactory<>(pulsarClient, lambdaSafeCustomizers);
+	}
+
+	@SuppressWarnings("unchecked")
+	private void applyConsumerBuilderCustomizers(List<ConsumerBuilderCustomizer<?>> customizers,
+			ConsumerBuilder<?> builder) {
+		LambdaSafe.callbacks(ConsumerBuilderCustomizer.class, customizers, builder)
+			.invoke((customizer) -> customizer.customize(builder));
+	}
+
+	@Bean
+	@ConditionalOnMissingBean(name = "pulsarListenerContainerFactory")
+	ConcurrentPulsarListenerContainerFactory<Object> pulsarListenerContainerFactory(
+			PulsarConsumerFactory<Object> pulsarConsumerFactory, SchemaResolver schemaResolver,
+			TopicResolver topicResolver, Environment environment) {
+		PulsarContainerProperties containerProperties = new PulsarContainerProperties();
+		containerProperties.setSchemaResolver(schemaResolver);
+		containerProperties.setTopicResolver(topicResolver);
+		if (Threading.VIRTUAL.isActive(environment)) {
+			containerProperties.setConsumerTaskExecutor(new VirtualThreadTaskExecutor());
+		}
+		this.propertiesMapper.customizeContainerProperties(containerProperties);
+		return new ConcurrentPulsarListenerContainerFactory<>(pulsarConsumerFactory, containerProperties);
+	}
+
+	@Bean
+	@ConditionalOnMissingBean(PulsarReaderFactory.class)
+	DefaultPulsarReaderFactory<?> pulsarReaderFactory(PulsarClient pulsarClient,
+			ObjectProvider<ReaderBuilderCustomizer<?>> customizersProvider) {
+		List<ReaderBuilderCustomizer<?>> customizers = new ArrayList<>();
+		customizers.add(this.propertiesMapper::customizeReaderBuilder);
+		customizers.addAll(customizersProvider.orderedStream().toList());
+		List<ReaderBuilderCustomizer<Object>> lambdaSafeCustomizers = List
+			.of((builder) -> applyReaderBuilderCustomizers(customizers, builder));
+		return new DefaultPulsarReaderFactory<>(pulsarClient, lambdaSafeCustomizers);
+	}
+
+	@SuppressWarnings("unchecked")
+	private void applyReaderBuilderCustomizers(List<ReaderBuilderCustomizer<?>> customizers, ReaderBuilder<?> builder) {
+		LambdaSafe.callbacks(ReaderBuilderCustomizer.class, customizers, builder)
+			.invoke((customizer) -> customizer.customize(builder));
+	}
+
+	@Bean
+	@ConditionalOnMissingBean(name = "pulsarReaderContainerFactory")
+	DefaultPulsarReaderContainerFactory<?> pulsarReaderContainerFactory(PulsarReaderFactory<?> pulsarReaderFactory,
+			SchemaResolver schemaResolver, Environment environment) {
+		PulsarReaderContainerProperties readerContainerProperties = new PulsarReaderContainerProperties();
+		readerContainerProperties.setSchemaResolver(schemaResolver);
+		if (Threading.VIRTUAL.isActive(environment)) {
+			readerContainerProperties.setReaderTaskExecutor(new VirtualThreadTaskExecutor());
+		}
+		this.propertiesMapper.customizeReaderContainerProperties(readerContainerProperties);
+		return new DefaultPulsarReaderContainerFactory<>(pulsarReaderFactory, readerContainerProperties);
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	@EnablePulsar
+	@ConditionalOnMissingBean(name = { PulsarAnnotationSupportBeanNames.PULSAR_LISTENER_ANNOTATION_PROCESSOR_BEAN_NAME,
+			PulsarAnnotationSupportBeanNames.PULSAR_READER_ANNOTATION_PROCESSOR_BEAN_NAME })
+	static class EnablePulsarConfiguration {
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarConfiguration.java
new file mode 100644
index 000000000000..64efd410ccfa
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarConfiguration.java
@@ -0,0 +1,180 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.pulsar;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.pulsar.client.admin.PulsarAdminBuilder;
+import org.apache.pulsar.client.api.ClientBuilder;
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.client.api.PulsarClientException;
+import org.apache.pulsar.client.api.Schema;
+import org.apache.pulsar.common.schema.SchemaType;
+
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Defaults.SchemaInfo;
+import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Defaults.TypeMapping;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.util.LambdaSafe;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.pulsar.core.DefaultPulsarClientFactory;
+import org.springframework.pulsar.core.DefaultSchemaResolver;
+import org.springframework.pulsar.core.DefaultTopicResolver;
+import org.springframework.pulsar.core.PulsarAdminBuilderCustomizer;
+import org.springframework.pulsar.core.PulsarAdministration;
+import org.springframework.pulsar.core.PulsarClientBuilderCustomizer;
+import org.springframework.pulsar.core.PulsarClientFactory;
+import org.springframework.pulsar.core.SchemaResolver;
+import org.springframework.pulsar.core.SchemaResolver.SchemaResolverCustomizer;
+import org.springframework.pulsar.core.TopicResolver;
+import org.springframework.pulsar.function.PulsarFunction;
+import org.springframework.pulsar.function.PulsarFunctionAdministration;
+import org.springframework.pulsar.function.PulsarSink;
+import org.springframework.pulsar.function.PulsarSource;
+
+/**
+ * Common configuration used by both {@link PulsarAutoConfiguration} and
+ * {@link PulsarReactiveAutoConfiguration}. A separate configuration class is used so that
+ * {@link PulsarAutoConfiguration} can be excluded for reactive only application.
+ *
+ * @author Chris Bono
+ * @author Phillip Webb
+ */
+@Configuration(proxyBeanMethods = false)
+@EnableConfigurationProperties(PulsarProperties.class)
+class PulsarConfiguration {
+
+	private final PulsarProperties properties;
+
+	private final PulsarPropertiesMapper propertiesMapper;
+
+	PulsarConfiguration(PulsarProperties properties) {
+		this.properties = properties;
+		this.propertiesMapper = new PulsarPropertiesMapper(properties);
+	}
+
+	@Bean
+	@ConditionalOnMissingBean(PulsarConnectionDetails.class)
+	PropertiesPulsarConnectionDetails pulsarConnectionDetails() {
+		return new PropertiesPulsarConnectionDetails(this.properties);
+	}
+
+	@Bean
+	@ConditionalOnMissingBean(PulsarClientFactory.class)
+	DefaultPulsarClientFactory pulsarClientFactory(PulsarConnectionDetails connectionDetails,
+			ObjectProvider<PulsarClientBuilderCustomizer> customizersProvider) {
+		List<PulsarClientBuilderCustomizer> allCustomizers = new ArrayList<>();
+		allCustomizers.add((builder) -> this.propertiesMapper.customizeClientBuilder(builder, connectionDetails));
+		allCustomizers.addAll(customizersProvider.orderedStream().toList());
+		DefaultPulsarClientFactory clientFactory = new DefaultPulsarClientFactory(
+				(clientBuilder) -> applyClientBuilderCustomizers(allCustomizers, clientBuilder));
+		return clientFactory;
+	}
+
+	private void applyClientBuilderCustomizers(List<PulsarClientBuilderCustomizer> customizers,
+			ClientBuilder clientBuilder) {
+		customizers.forEach((customizer) -> customizer.customize(clientBuilder));
+	}
+
+	@Bean
+	@ConditionalOnMissingBean
+	PulsarClient pulsarClient(PulsarClientFactory clientFactory) throws PulsarClientException {
+		return clientFactory.createClient();
+	}
+
+	@Bean
+	@ConditionalOnMissingBean
+	PulsarAdministration pulsarAdministration(PulsarConnectionDetails connectionDetails,
+			ObjectProvider<PulsarAdminBuilderCustomizer> pulsarAdminBuilderCustomizers) {
+		List<PulsarAdminBuilderCustomizer> allCustomizers = new ArrayList<>();
+		allCustomizers.add((builder) -> this.propertiesMapper.customizeAdminBuilder(builder, connectionDetails));
+		allCustomizers.addAll(pulsarAdminBuilderCustomizers.orderedStream().toList());
+		return new PulsarAdministration((adminBuilder) -> applyAdminBuilderCustomizers(allCustomizers, adminBuilder));
+	}
+
+	private void applyAdminBuilderCustomizers(List<PulsarAdminBuilderCustomizer> customizers,
+			PulsarAdminBuilder adminBuilder) {
+		customizers.forEach((customizer) -> customizer.customize(adminBuilder));
+	}
+
+	@Bean
+	@ConditionalOnMissingBean(SchemaResolver.class)
+	DefaultSchemaResolver pulsarSchemaResolver(ObjectProvider<SchemaResolverCustomizer<?>> schemaResolverCustomizers) {
+		DefaultSchemaResolver schemaResolver = new DefaultSchemaResolver();
+		addCustomSchemaMappings(schemaResolver, this.properties.getDefaults().getTypeMappings());
+		applySchemaResolverCustomizers(schemaResolverCustomizers.orderedStream().toList(), schemaResolver);
+		return schemaResolver;
+	}
+
+	private void addCustomSchemaMappings(DefaultSchemaResolver schemaResolver, List<TypeMapping> typeMappings) {
+		if (typeMappings != null) {
+			typeMappings.forEach((typeMapping) -> addCustomSchemaMapping(schemaResolver, typeMapping));
+		}
+	}
+
+	private void addCustomSchemaMapping(DefaultSchemaResolver schemaResolver, TypeMapping typeMapping) {
+		SchemaInfo schemaInfo = typeMapping.schemaInfo();
+		if (schemaInfo != null) {
+			Class<?> messageType = typeMapping.messageType();
+			SchemaType schemaType = schemaInfo.schemaType();
+			Class<?> messageKeyType = schemaInfo.messageKeyType();
+			Schema<?> schema = schemaResolver.resolveSchema(schemaType, messageType, messageKeyType).orElseThrow();
+			schemaResolver.addCustomSchemaMapping(typeMapping.messageType(), schema);
+		}
+	}
+
+	@SuppressWarnings("unchecked")
+	private void applySchemaResolverCustomizers(List<SchemaResolverCustomizer<?>> customizers,
+			DefaultSchemaResolver schemaResolver) {
+		LambdaSafe.callbacks(SchemaResolverCustomizer.class, customizers, schemaResolver)
+			.invoke((customizer) -> customizer.customize(schemaResolver));
+	}
+
+	@Bean
+	@ConditionalOnMissingBean(TopicResolver.class)
+	DefaultTopicResolver pulsarTopicResolver() {
+		DefaultTopicResolver topicResolver = new DefaultTopicResolver();
+		List<TypeMapping> typeMappings = this.properties.getDefaults().getTypeMappings();
+		if (typeMappings != null) {
+			typeMappings.forEach((typeMapping) -> addCustomTopicMapping(topicResolver, typeMapping));
+		}
+		return topicResolver;
+	}
+
+	private void addCustomTopicMapping(DefaultTopicResolver topicResolver, TypeMapping typeMapping) {
+		String topicName = typeMapping.topicName();
+		if (topicName != null) {
+			topicResolver.addCustomTopicMapping(typeMapping.messageType(), topicName);
+		}
+	}
+
+	@Bean
+	@ConditionalOnMissingBean
+	@ConditionalOnProperty(name = "spring.pulsar.function.enabled", havingValue = "true", matchIfMissing = true)
+	PulsarFunctionAdministration pulsarFunctionAdministration(PulsarAdministration pulsarAdministration,
+			ObjectProvider<PulsarFunction> pulsarFunctions, ObjectProvider<PulsarSink> pulsarSinks,
+			ObjectProvider<PulsarSource> pulsarSources) {
+		PulsarProperties.Function properties = this.properties.getFunction();
+		return new PulsarFunctionAdministration(pulsarAdministration, pulsarFunctions, pulsarSinks, pulsarSources,
+				properties.isFailFast(), properties.isPropagateFailures(), properties.isPropagateStopFailures());
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarConnectionDetails.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarConnectionDetails.java
new file mode 100644
index 000000000000..1d21f5802e46
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarConnectionDetails.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.pulsar;
+
+import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
+
+/**
+ * Details required to establish a connection to a Pulsar service.
+ *
+ * @author Chris Bono
+ * @since 3.2.0
+ */
+public interface PulsarConnectionDetails extends ConnectionDetails {
+
+	/**
+	 * URL used to connect to the broker.
+	 * @return the service URL
+	 */
+	String getBrokerUrl();
+
+	/**
+	 * URL user to connect to the admin endpoint.
+	 * @return the admin URL
+	 */
+	String getAdminUrl();
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarProperties.java
new file mode 100644
index 000000000000..7597da0c08d1
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarProperties.java
@@ -0,0 +1,890 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.pulsar;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import org.apache.pulsar.client.api.CompressionType;
+import org.apache.pulsar.client.api.HashingScheme;
+import org.apache.pulsar.client.api.MessageRoutingMode;
+import org.apache.pulsar.client.api.ProducerAccessMode;
+import org.apache.pulsar.client.api.RegexSubscriptionMode;
+import org.apache.pulsar.client.api.SubscriptionInitialPosition;
+import org.apache.pulsar.client.api.SubscriptionMode;
+import org.apache.pulsar.client.api.SubscriptionType;
+import org.apache.pulsar.common.schema.SchemaType;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.context.properties.NestedConfigurationProperty;
+import org.springframework.util.Assert;
+
+/**
+ * Configuration properties Apache Pulsar.
+ *
+ * @author Chris Bono
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+@ConfigurationProperties("spring.pulsar")
+public class PulsarProperties {
+
+	private final Client client = new Client();
+
+	private final Admin admin = new Admin();
+
+	private final Defaults defaults = new Defaults();
+
+	private final Function function = new Function();
+
+	private final Producer producer = new Producer();
+
+	private final Consumer consumer = new Consumer();
+
+	private final Listener listener = new Listener();
+
+	private final Reader reader = new Reader();
+
+	private final Template template = new Template();
+
+	public Client getClient() {
+		return this.client;
+	}
+
+	public Admin getAdmin() {
+		return this.admin;
+	}
+
+	public Defaults getDefaults() {
+		return this.defaults;
+	}
+
+	public Producer getProducer() {
+		return this.producer;
+	}
+
+	public Consumer getConsumer() {
+		return this.consumer;
+	}
+
+	public Listener getListener() {
+		return this.listener;
+	}
+
+	public Reader getReader() {
+		return this.reader;
+	}
+
+	public Function getFunction() {
+		return this.function;
+	}
+
+	public Template getTemplate() {
+		return this.template;
+	}
+
+	public static class Client {
+
+		/**
+		 * Pulsar service URL in the format '(pulsar|pulsar+ssl)://host:port'.
+		 */
+		private String serviceUrl = "pulsar://localhost:6650";
+
+		/**
+		 * Client operation timeout.
+		 */
+		private Duration operationTimeout = Duration.ofSeconds(30);
+
+		/**
+		 * Client lookup timeout.
+		 */
+		private Duration lookupTimeout;
+
+		/**
+		 * Duration to wait for a connection to a broker to be established.
+		 */
+		private Duration connectionTimeout = Duration.ofSeconds(10);
+
+		/**
+		 * Authentication settings.
+		 */
+		private final Authentication authentication = new Authentication();
+
+		public String getServiceUrl() {
+			return this.serviceUrl;
+		}
+
+		public void setServiceUrl(String serviceUrl) {
+			this.serviceUrl = serviceUrl;
+		}
+
+		public Duration getOperationTimeout() {
+			return this.operationTimeout;
+		}
+
+		public void setOperationTimeout(Duration operationTimeout) {
+			this.operationTimeout = operationTimeout;
+		}
+
+		public Duration getLookupTimeout() {
+			return this.lookupTimeout;
+		}
+
+		public void setLookupTimeout(Duration lookupTimeout) {
+			this.lookupTimeout = lookupTimeout;
+		}
+
+		public Duration getConnectionTimeout() {
+			return this.connectionTimeout;
+		}
+
+		public void setConnectionTimeout(Duration connectionTimeout) {
+			this.connectionTimeout = connectionTimeout;
+		}
+
+		public Authentication getAuthentication() {
+			return this.authentication;
+		}
+
+	}
+
+	public static class Admin {
+
+		/**
+		 * Pulsar web URL for the admin endpoint in the format '(http|https)://host:port'.
+		 */
+		private String serviceUrl = "http://localhost:8080";
+
+		/**
+		 * Duration to wait for a connection to server to be established.
+		 */
+		private Duration connectionTimeout = Duration.ofMinutes(1);
+
+		/**
+		 * Server response read time out for any request.
+		 */
+		private Duration readTimeout = Duration.ofMinutes(1);
+
+		/**
+		 * Server request time out for any request.
+		 */
+		private Duration requestTimeout = Duration.ofMinutes(5);
+
+		/**
+		 * Authentication settings.
+		 */
+		private final Authentication authentication = new Authentication();
+
+		public String getServiceUrl() {
+			return this.serviceUrl;
+		}
+
+		public void setServiceUrl(String serviceUrl) {
+			this.serviceUrl = serviceUrl;
+		}
+
+		public Duration getConnectionTimeout() {
+			return this.connectionTimeout;
+		}
+
+		public void setConnectionTimeout(Duration connectionTimeout) {
+			this.connectionTimeout = connectionTimeout;
+		}
+
+		public Duration getReadTimeout() {
+			return this.readTimeout;
+		}
+
+		public void setReadTimeout(Duration readTimeout) {
+			this.readTimeout = readTimeout;
+		}
+
+		public Duration getRequestTimeout() {
+			return this.requestTimeout;
+		}
+
+		public void setRequestTimeout(Duration requestTimeout) {
+			this.requestTimeout = requestTimeout;
+		}
+
+		public Authentication getAuthentication() {
+			return this.authentication;
+		}
+
+	}
+
+	public static class Defaults {
+
+		/**
+		 * List of mappings from message type to topic name and schema info to use as a
+		 * defaults when a topic name and/or schema is not explicitly specified when
+		 * producing or consuming messages of the mapped type.
+		 */
+		private List<TypeMapping> typeMappings = new ArrayList<>();
+
+		public List<TypeMapping> getTypeMappings() {
+			return this.typeMappings;
+		}
+
+		public void setTypeMappings(List<TypeMapping> typeMappings) {
+			this.typeMappings = typeMappings;
+		}
+
+		/**
+		 * A mapping from message type to topic and/or schema info to use (at least one of
+		 * {@code topicName} or {@code schemaInfo} must be specified.
+		 *
+		 * @param messageType the message type
+		 * @param topicName the topic name
+		 * @param schemaInfo the schema info
+		 */
+		public record TypeMapping(Class<?> messageType, String topicName, SchemaInfo schemaInfo) {
+
+			public TypeMapping {
+				Assert.notNull(messageType, "messageType must not be null");
+				Assert.isTrue(topicName != null || schemaInfo != null,
+						"At least one of topicName or schemaInfo must not be null");
+			}
+
+		}
+
+		/**
+		 * Represents a schema - holds enough information to construct an actual schema
+		 * instance.
+		 *
+		 * @param schemaType schema type
+		 * @param messageKeyType message key type (required for key value type)
+		 */
+		public record SchemaInfo(SchemaType schemaType, Class<?> messageKeyType) {
+
+			public SchemaInfo {
+				Assert.notNull(schemaType, "schemaType must not be null");
+				Assert.isTrue(schemaType != SchemaType.NONE, "schemaType 'NONE' not supported");
+				Assert.isTrue(messageKeyType == null || schemaType == SchemaType.KEY_VALUE,
+						"messageKeyType can only be set when schemaType is KEY_VALUE");
+			}
+
+		}
+
+	}
+
+	public static class Function {
+
+		/**
+		 * Whether to stop processing further function creates/updates when a failure
+		 * occurs.
+		 */
+		private boolean failFast = true;
+
+		/**
+		 * Whether to throw an exception if any failure is encountered during server
+		 * startup while creating/updating functions.
+		 */
+		private boolean propagateFailures = true;
+
+		/**
+		 * Whether to throw an exception if any failure is encountered during server
+		 * shutdown while enforcing stop policy on functions.
+		 */
+		private boolean propagateStopFailures = false;
+
+		public boolean isFailFast() {
+			return this.failFast;
+		}
+
+		public void setFailFast(boolean failFast) {
+			this.failFast = failFast;
+		}
+
+		public boolean isPropagateFailures() {
+			return this.propagateFailures;
+		}
+
+		public void setPropagateFailures(boolean propagateFailures) {
+			this.propagateFailures = propagateFailures;
+		}
+
+		public boolean isPropagateStopFailures() {
+			return this.propagateStopFailures;
+		}
+
+		public void setPropagateStopFailures(boolean propagateStopFailures) {
+			this.propagateStopFailures = propagateStopFailures;
+		}
+
+	}
+
+	public static class Producer {
+
+		/**
+		 * Name for the producer. If not assigned, a unique name is generated.
+		 */
+		private String name;
+
+		/**
+		 * Topic the producer will publish to.
+		 */
+		private String topicName;
+
+		/**
+		 * Time before a message has to be acknowledged by the broker.
+		 */
+		private Duration sendTimeout = Duration.ofSeconds(30);
+
+		/**
+		 * Message routing mode for a partitioned producer.
+		 */
+		private MessageRoutingMode messageRoutingMode = MessageRoutingMode.RoundRobinPartition;
+
+		/**
+		 * Message hashing scheme to choose the partition to which the message is
+		 * published.
+		 */
+		private HashingScheme hashingScheme = HashingScheme.JavaStringHash;
+
+		/**
+		 * Whether to automatically batch messages.
+		 */
+		private boolean batchingEnabled = true;
+
+		/**
+		 * Whether to split large-size messages into multiple chunks.
+		 */
+		private boolean chunkingEnabled;
+
+		/**
+		 * Message compression type.
+		 */
+		private CompressionType compressionType;
+
+		/**
+		 * Type of access to the topic the producer requires.
+		 */
+		private ProducerAccessMode accessMode = ProducerAccessMode.Shared;
+
+		private final Cache cache = new Cache();
+
+		public String getName() {
+			return this.name;
+		}
+
+		public void setName(String name) {
+			this.name = name;
+		}
+
+		public String getTopicName() {
+			return this.topicName;
+		}
+
+		public void setTopicName(String topicName) {
+			this.topicName = topicName;
+		}
+
+		public Duration getSendTimeout() {
+			return this.sendTimeout;
+		}
+
+		public void setSendTimeout(Duration sendTimeout) {
+			this.sendTimeout = sendTimeout;
+		}
+
+		public MessageRoutingMode getMessageRoutingMode() {
+			return this.messageRoutingMode;
+		}
+
+		public void setMessageRoutingMode(MessageRoutingMode messageRoutingMode) {
+			this.messageRoutingMode = messageRoutingMode;
+		}
+
+		public HashingScheme getHashingScheme() {
+			return this.hashingScheme;
+		}
+
+		public void setHashingScheme(HashingScheme hashingScheme) {
+			this.hashingScheme = hashingScheme;
+		}
+
+		public boolean isBatchingEnabled() {
+			return this.batchingEnabled;
+		}
+
+		public void setBatchingEnabled(boolean batchingEnabled) {
+			this.batchingEnabled = batchingEnabled;
+		}
+
+		public boolean isChunkingEnabled() {
+			return this.chunkingEnabled;
+		}
+
+		public void setChunkingEnabled(boolean chunkingEnabled) {
+			this.chunkingEnabled = chunkingEnabled;
+		}
+
+		public CompressionType getCompressionType() {
+			return this.compressionType;
+		}
+
+		public void setCompressionType(CompressionType compressionType) {
+			this.compressionType = compressionType;
+		}
+
+		public ProducerAccessMode getAccessMode() {
+			return this.accessMode;
+		}
+
+		public void setAccessMode(ProducerAccessMode accessMode) {
+			this.accessMode = accessMode;
+		}
+
+		public Cache getCache() {
+			return this.cache;
+		}
+
+		public static class Cache {
+
+			/**
+			 * Time period to expire unused entries in the cache.
+			 */
+			private Duration expireAfterAccess = Duration.ofMinutes(1);
+
+			/**
+			 * Maximum size of cache (entries).
+			 */
+			private long maximumSize = 1000L;
+
+			/**
+			 * Initial size of cache.
+			 */
+			private int initialCapacity = 50;
+
+			public Duration getExpireAfterAccess() {
+				return this.expireAfterAccess;
+			}
+
+			public void setExpireAfterAccess(Duration expireAfterAccess) {
+				this.expireAfterAccess = expireAfterAccess;
+			}
+
+			public long getMaximumSize() {
+				return this.maximumSize;
+			}
+
+			public void setMaximumSize(long maximumSize) {
+				this.maximumSize = maximumSize;
+			}
+
+			public int getInitialCapacity() {
+				return this.initialCapacity;
+			}
+
+			public void setInitialCapacity(int initialCapacity) {
+				this.initialCapacity = initialCapacity;
+			}
+
+		}
+
+	}
+
+	public static class Consumer {
+
+		/**
+		 * Consumer name to identify a particular consumer from the topic stats.
+		 */
+		private String name;
+
+		/**
+		 * Topics the consumer subscribes to.
+		 */
+		private List<String> topics;
+
+		/**
+		 * Pattern for topics the consumer subscribes to.
+		 */
+		private Pattern topicsPattern;
+
+		/**
+		 * Priority level for shared subscription consumers.
+		 */
+		private int priorityLevel = 0;
+
+		/**
+		 * Whether to read messages from the compacted topic rather than the full message
+		 * backlog.
+		 */
+		private boolean readCompacted = false;
+
+		/**
+		 * Dead letter policy to use.
+		 */
+		@NestedConfigurationProperty
+		private DeadLetterPolicy deadLetterPolicy;
+
+		/**
+		 * Consumer subscription properties.
+		 */
+		private final Subscription subscription = new Subscription();
+
+		/**
+		 * Whether to auto retry messages.
+		 */
+		private boolean retryEnable = false;
+
+		public String getName() {
+			return this.name;
+		}
+
+		public void setName(String name) {
+			this.name = name;
+		}
+
+		public Consumer.Subscription getSubscription() {
+			return this.subscription;
+		}
+
+		public List<String> getTopics() {
+			return this.topics;
+		}
+
+		public void setTopics(List<String> topics) {
+			this.topics = topics;
+		}
+
+		public Pattern getTopicsPattern() {
+			return this.topicsPattern;
+		}
+
+		public void setTopicsPattern(Pattern topicsPattern) {
+			this.topicsPattern = topicsPattern;
+		}
+
+		public int getPriorityLevel() {
+			return this.priorityLevel;
+		}
+
+		public void setPriorityLevel(int priorityLevel) {
+			this.priorityLevel = priorityLevel;
+		}
+
+		public boolean isReadCompacted() {
+			return this.readCompacted;
+		}
+
+		public void setReadCompacted(boolean readCompacted) {
+			this.readCompacted = readCompacted;
+		}
+
+		public DeadLetterPolicy getDeadLetterPolicy() {
+			return this.deadLetterPolicy;
+		}
+
+		public void setDeadLetterPolicy(DeadLetterPolicy deadLetterPolicy) {
+			this.deadLetterPolicy = deadLetterPolicy;
+		}
+
+		public boolean isRetryEnable() {
+			return this.retryEnable;
+		}
+
+		public void setRetryEnable(boolean retryEnable) {
+			this.retryEnable = retryEnable;
+		}
+
+		public static class Subscription {
+
+			/**
+			 * Subscription name for the consumer.
+			 */
+			private String name;
+
+			/**
+			 * Position where to initialize a newly created subscription.
+			 */
+			private SubscriptionInitialPosition initialPosition = SubscriptionInitialPosition.Latest;
+
+			/**
+			 * Subscription mode to be used when subscribing to the topic.
+			 */
+			private SubscriptionMode mode = SubscriptionMode.Durable;
+
+			/**
+			 * Determines which type of topics (persistent, non-persistent, or all) the
+			 * consumer should be subscribed to when using pattern subscriptions.
+			 */
+			private RegexSubscriptionMode topicsMode = RegexSubscriptionMode.PersistentOnly;
+
+			/**
+			 * Subscription type to be used when subscribing to a topic.
+			 */
+			private SubscriptionType type = SubscriptionType.Exclusive;
+
+			public String getName() {
+				return this.name;
+			}
+
+			public void setName(String name) {
+				this.name = name;
+			}
+
+			public SubscriptionInitialPosition getInitialPosition() {
+				return this.initialPosition;
+			}
+
+			public void setInitialPosition(SubscriptionInitialPosition initialPosition) {
+				this.initialPosition = initialPosition;
+			}
+
+			public SubscriptionMode getMode() {
+				return this.mode;
+			}
+
+			public void setMode(SubscriptionMode mode) {
+				this.mode = mode;
+			}
+
+			public RegexSubscriptionMode getTopicsMode() {
+				return this.topicsMode;
+			}
+
+			public void setTopicsMode(RegexSubscriptionMode topicsMode) {
+				this.topicsMode = topicsMode;
+			}
+
+			public SubscriptionType getType() {
+				return this.type;
+			}
+
+			public void setType(SubscriptionType type) {
+				this.type = type;
+			}
+
+		}
+
+		public static class DeadLetterPolicy {
+
+			/**
+			 * Maximum number of times that a message will be redelivered before being
+			 * sent to the dead letter queue.
+			 */
+			private int maxRedeliverCount;
+
+			/**
+			 * Name of the retry topic where the failing messages will be sent.
+			 */
+			private String retryLetterTopic;
+
+			/**
+			 * Name of the dead topic where the failing messages will be sent.
+			 */
+			private String deadLetterTopic;
+
+			/**
+			 * Name of the initial subscription of the dead letter topic. When not set,
+			 * the initial subscription will not be created. However, when the property is
+			 * set then the broker's 'allowAutoSubscriptionCreation' must be enabled or
+			 * the DLQ producer will fail.
+			 */
+			private String initialSubscriptionName;
+
+			public int getMaxRedeliverCount() {
+				return this.maxRedeliverCount;
+			}
+
+			public void setMaxRedeliverCount(int maxRedeliverCount) {
+				this.maxRedeliverCount = maxRedeliverCount;
+			}
+
+			public String getRetryLetterTopic() {
+				return this.retryLetterTopic;
+			}
+
+			public void setRetryLetterTopic(String retryLetterTopic) {
+				this.retryLetterTopic = retryLetterTopic;
+			}
+
+			public String getDeadLetterTopic() {
+				return this.deadLetterTopic;
+			}
+
+			public void setDeadLetterTopic(String deadLetterTopic) {
+				this.deadLetterTopic = deadLetterTopic;
+			}
+
+			public String getInitialSubscriptionName() {
+				return this.initialSubscriptionName;
+			}
+
+			public void setInitialSubscriptionName(String initialSubscriptionName) {
+				this.initialSubscriptionName = initialSubscriptionName;
+			}
+
+		}
+
+	}
+
+	public static class Listener {
+
+		/**
+		 * SchemaType of the consumed messages.
+		 */
+		private SchemaType schemaType;
+
+		/**
+		 * Whether to record observations for when the Observations API is available and
+		 * the client supports it.
+		 */
+		private boolean observationEnabled = true;
+
+		public SchemaType getSchemaType() {
+			return this.schemaType;
+		}
+
+		public void setSchemaType(SchemaType schemaType) {
+			this.schemaType = schemaType;
+		}
+
+		public boolean isObservationEnabled() {
+			return this.observationEnabled;
+		}
+
+		public void setObservationEnabled(boolean observationEnabled) {
+			this.observationEnabled = observationEnabled;
+		}
+
+	}
+
+	public static class Reader {
+
+		/**
+		 * Reader name.
+		 */
+		private String name;
+
+		/**
+		 * Topis the reader subscribes to.
+		 */
+		private List<String> topics;
+
+		/**
+		 * Subscription name.
+		 */
+		private String subscriptionName;
+
+		/**
+		 * Prefix of subscription role.
+		 */
+		private String subscriptionRolePrefix;
+
+		/**
+		 * Whether to read messages from a compacted topic rather than a full message
+		 * backlog of a topic.
+		 */
+		private boolean readCompacted;
+
+		public String getName() {
+			return this.name;
+		}
+
+		public void setName(String name) {
+			this.name = name;
+		}
+
+		public List<String> getTopics() {
+			return this.topics;
+		}
+
+		public void setTopics(List<String> topics) {
+			this.topics = topics;
+		}
+
+		public String getSubscriptionName() {
+			return this.subscriptionName;
+		}
+
+		public void setSubscriptionName(String subscriptionName) {
+			this.subscriptionName = subscriptionName;
+		}
+
+		public String getSubscriptionRolePrefix() {
+			return this.subscriptionRolePrefix;
+		}
+
+		public void setSubscriptionRolePrefix(String subscriptionRolePrefix) {
+			this.subscriptionRolePrefix = subscriptionRolePrefix;
+		}
+
+		public boolean isReadCompacted() {
+			return this.readCompacted;
+		}
+
+		public void setReadCompacted(boolean readCompacted) {
+			this.readCompacted = readCompacted;
+		}
+
+	}
+
+	public static class Template {
+
+		/**
+		 * Whether to record observations for when the Observations API is available.
+		 */
+		private boolean observationsEnabled = true;
+
+		public boolean isObservationsEnabled() {
+			return this.observationsEnabled;
+		}
+
+		public void setObservationsEnabled(boolean observationsEnabled) {
+			this.observationsEnabled = observationsEnabled;
+		}
+
+	}
+
+	public static class Authentication {
+
+		/**
+		 * Fully qualified class name of the authentication plugin.
+		 */
+		private String pluginClassName;
+
+		/**
+		 * Authentication parameter(s) as a map of parameter names to parameter values.
+		 */
+		private Map<String, String> param = new LinkedHashMap<>();
+
+		public String getPluginClassName() {
+			return this.pluginClassName;
+		}
+
+		public void setPluginClassName(String pluginClassName) {
+			this.pluginClassName = pluginClassName;
+		}
+
+		public Map<String, String> getParam() {
+			return this.param;
+		}
+
+		public void setParam(Map<String, String> param) {
+			this.param = param;
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesMapper.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesMapper.java
new file mode 100644
index 000000000000..04246d0e91c2
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesMapper.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.pulsar;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+
+import org.apache.pulsar.client.admin.PulsarAdminBuilder;
+import org.apache.pulsar.client.api.ClientBuilder;
+import org.apache.pulsar.client.api.ConsumerBuilder;
+import org.apache.pulsar.client.api.ProducerBuilder;
+import org.apache.pulsar.client.api.PulsarClientException.UnsupportedAuthenticationException;
+import org.apache.pulsar.client.api.ReaderBuilder;
+
+import org.springframework.boot.context.properties.PropertyMapper;
+import org.springframework.pulsar.listener.PulsarContainerProperties;
+import org.springframework.pulsar.reader.PulsarReaderContainerProperties;
+import org.springframework.util.StringUtils;
+
+/**
+ * Helper class used to map {@link PulsarProperties} to various builder customizers.
+ *
+ * @author Chris Bono
+ * @author Phillip Webb
+ */
+final class PulsarPropertiesMapper {
+
+	private final PulsarProperties properties;
+
+	PulsarPropertiesMapper(PulsarProperties properties) {
+		this.properties = properties;
+	}
+
+	void customizeClientBuilder(ClientBuilder clientBuilder, PulsarConnectionDetails connectionDetails) {
+		PulsarProperties.Client properties = this.properties.getClient();
+		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+		map.from(connectionDetails::getBrokerUrl).to(clientBuilder::serviceUrl);
+		map.from(properties::getConnectionTimeout).to(timeoutProperty(clientBuilder::connectionTimeout));
+		map.from(properties::getOperationTimeout).to(timeoutProperty(clientBuilder::operationTimeout));
+		map.from(properties::getLookupTimeout).to(timeoutProperty(clientBuilder::lookupTimeout));
+		customizeAuthentication(clientBuilder::authentication, properties.getAuthentication());
+	}
+
+	void customizeAdminBuilder(PulsarAdminBuilder adminBuilder, PulsarConnectionDetails connectionDetails) {
+		PulsarProperties.Admin properties = this.properties.getAdmin();
+		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+		map.from(connectionDetails::getAdminUrl).to(adminBuilder::serviceHttpUrl);
+		map.from(properties::getConnectionTimeout).to(timeoutProperty(adminBuilder::connectionTimeout));
+		map.from(properties::getReadTimeout).to(timeoutProperty(adminBuilder::readTimeout));
+		map.from(properties::getRequestTimeout).to(timeoutProperty(adminBuilder::requestTimeout));
+		customizeAuthentication(adminBuilder::authentication, properties.getAuthentication());
+	}
+
+	private void customizeAuthentication(AuthenticationConsumer authentication,
+			PulsarProperties.Authentication properties) {
+		if (StringUtils.hasText(properties.getPluginClassName())) {
+			try {
+				authentication.accept(properties.getPluginClassName(), properties.getParam());
+			}
+			catch (UnsupportedAuthenticationException ex) {
+				throw new IllegalStateException("Unable to configure Pulsar authentication", ex);
+			}
+		}
+	}
+
+	<T> void customizeProducerBuilder(ProducerBuilder<T> producerBuilder) {
+		PulsarProperties.Producer properties = this.properties.getProducer();
+		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+		map.from(properties::getName).to(producerBuilder::producerName);
+		map.from(properties::getTopicName).to(producerBuilder::topic);
+		map.from(properties::getSendTimeout).to(timeoutProperty(producerBuilder::sendTimeout));
+		map.from(properties::getMessageRoutingMode).to(producerBuilder::messageRoutingMode);
+		map.from(properties::getHashingScheme).to(producerBuilder::hashingScheme);
+		map.from(properties::isBatchingEnabled).to(producerBuilder::enableBatching);
+		map.from(properties::isChunkingEnabled).to(producerBuilder::enableChunking);
+		map.from(properties::getCompressionType).to(producerBuilder::compressionType);
+		map.from(properties::getAccessMode).to(producerBuilder::accessMode);
+	}
+
+	<T> void customizeConsumerBuilder(ConsumerBuilder<T> consumerBuilder) {
+		PulsarProperties.Consumer properties = this.properties.getConsumer();
+		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+		map.from(properties::getName).to(consumerBuilder::consumerName);
+		map.from(properties::getTopics).as(ArrayList::new).to(consumerBuilder::topics);
+		map.from(properties::getTopicsPattern).to(consumerBuilder::topicsPattern);
+		map.from(properties::getPriorityLevel).to(consumerBuilder::priorityLevel);
+		map.from(properties::isReadCompacted).to(consumerBuilder::readCompacted);
+		map.from(properties::getDeadLetterPolicy).as(DeadLetterPolicyMapper::map).to(consumerBuilder::deadLetterPolicy);
+		map.from(properties::isRetryEnable).to(consumerBuilder::enableRetry);
+		customizeConsumerBuilderSubscription(consumerBuilder);
+	}
+
+	private void customizeConsumerBuilderSubscription(ConsumerBuilder<?> consumerBuilder) {
+		PulsarProperties.Consumer.Subscription properties = this.properties.getConsumer().getSubscription();
+		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+		map.from(properties::getName).to(consumerBuilder::subscriptionName);
+		map.from(properties::getInitialPosition).to(consumerBuilder::subscriptionInitialPosition);
+		map.from(properties::getMode).to(consumerBuilder::subscriptionMode);
+		map.from(properties::getTopicsMode).to(consumerBuilder::subscriptionTopicsMode);
+		map.from(properties::getType).to(consumerBuilder::subscriptionType);
+	}
+
+	void customizeContainerProperties(PulsarContainerProperties containerProperties) {
+		customizePulsarContainerConsumerSubscriptionProperties(containerProperties);
+		customizePulsarContainerListenerProperties(containerProperties);
+	}
+
+	private void customizePulsarContainerConsumerSubscriptionProperties(PulsarContainerProperties containerProperties) {
+		PulsarProperties.Consumer.Subscription properties = this.properties.getConsumer().getSubscription();
+		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+		map.from(properties::getType).to(containerProperties::setSubscriptionType);
+	}
+
+	private void customizePulsarContainerListenerProperties(PulsarContainerProperties containerProperties) {
+		PulsarProperties.Listener properties = this.properties.getListener();
+		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+		map.from(properties::getSchemaType).to(containerProperties::setSchemaType);
+		map.from(properties::isObservationEnabled).to(containerProperties::setObservationEnabled);
+	}
+
+	<T> void customizeReaderBuilder(ReaderBuilder<T> readerBuilder) {
+		PulsarProperties.Reader properties = this.properties.getReader();
+		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+		map.from(properties::getName).to(readerBuilder::readerName);
+		map.from(properties::getTopics).to(readerBuilder::topics);
+		map.from(properties::getSubscriptionName).to(readerBuilder::subscriptionName);
+		map.from(properties::getSubscriptionRolePrefix).to(readerBuilder::subscriptionRolePrefix);
+		map.from(properties::isReadCompacted).to(readerBuilder::readCompacted);
+	}
+
+	void customizeReaderContainerProperties(PulsarReaderContainerProperties readerContainerProperties) {
+		PulsarProperties.Reader properties = this.properties.getReader();
+		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+		map.from(properties::getTopics).to(readerContainerProperties::setTopics);
+	}
+
+	private Consumer<Duration> timeoutProperty(BiConsumer<Integer, TimeUnit> setter) {
+		return (duration) -> setter.accept((int) duration.toMillis(), TimeUnit.MILLISECONDS);
+	}
+
+	private interface AuthenticationConsumer {
+
+		void accept(String authPluginClassName, Map<String, String> authParams)
+				throws UnsupportedAuthenticationException;
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactiveAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactiveAutoConfiguration.java
new file mode 100644
index 000000000000..4c2aeb172d52
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactiveAutoConfiguration.java
@@ -0,0 +1,201 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.pulsar;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.reactive.client.adapter.AdaptedReactivePulsarClientFactory;
+import org.apache.pulsar.reactive.client.adapter.ProducerCacheProvider;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageConsumerBuilder;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageReaderBuilder;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderBuilder;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderCache;
+import org.apache.pulsar.reactive.client.api.ReactivePulsarClient;
+import org.apache.pulsar.reactive.client.producercache.CaffeineShadedProducerCacheProvider;
+
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.util.LambdaSafe;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.pulsar.config.PulsarAnnotationSupportBeanNames;
+import org.springframework.pulsar.core.SchemaResolver;
+import org.springframework.pulsar.core.TopicResolver;
+import org.springframework.pulsar.reactive.config.DefaultReactivePulsarListenerContainerFactory;
+import org.springframework.pulsar.reactive.config.annotation.EnableReactivePulsar;
+import org.springframework.pulsar.reactive.core.DefaultReactivePulsarConsumerFactory;
+import org.springframework.pulsar.reactive.core.DefaultReactivePulsarReaderFactory;
+import org.springframework.pulsar.reactive.core.DefaultReactivePulsarSenderFactory;
+import org.springframework.pulsar.reactive.core.ReactiveMessageConsumerBuilderCustomizer;
+import org.springframework.pulsar.reactive.core.ReactiveMessageReaderBuilderCustomizer;
+import org.springframework.pulsar.reactive.core.ReactiveMessageSenderBuilderCustomizer;
+import org.springframework.pulsar.reactive.core.ReactivePulsarConsumerFactory;
+import org.springframework.pulsar.reactive.core.ReactivePulsarReaderFactory;
+import org.springframework.pulsar.reactive.core.ReactivePulsarSenderFactory;
+import org.springframework.pulsar.reactive.core.ReactivePulsarTemplate;
+import org.springframework.pulsar.reactive.listener.ReactivePulsarContainerProperties;
+
+/**
+ * {@link EnableAutoConfiguration Auto-configuration} for Spring for Apache Pulsar
+ * Reactive.
+ *
+ * @author Chris Bono
+ * @author Christophe Bornet
+ * @since 3.2.0
+ */
+@AutoConfiguration(after = PulsarAutoConfiguration.class)
+@ConditionalOnClass({ PulsarClient.class, ReactivePulsarClient.class, ReactivePulsarTemplate.class })
+@Import(PulsarConfiguration.class)
+public class PulsarReactiveAutoConfiguration {
+
+	private final PulsarProperties properties;
+
+	private final PulsarReactivePropertiesMapper propertiesMapper;
+
+	PulsarReactiveAutoConfiguration(PulsarProperties properties) {
+		this.properties = properties;
+		this.propertiesMapper = new PulsarReactivePropertiesMapper(properties);
+	}
+
+	@Bean
+	@ConditionalOnMissingBean
+	ReactivePulsarClient reactivePulsarClient(PulsarClient pulsarClient) {
+		return AdaptedReactivePulsarClientFactory.create(pulsarClient);
+	}
+
+	@Bean
+	@ConditionalOnMissingBean(ProducerCacheProvider.class)
+	@ConditionalOnClass(CaffeineShadedProducerCacheProvider.class)
+	@ConditionalOnProperty(name = "spring.pulsar.producer.cache.enabled", havingValue = "true", matchIfMissing = true)
+	CaffeineShadedProducerCacheProvider reactivePulsarProducerCacheProvider() {
+		PulsarProperties.Producer.Cache properties = this.properties.getProducer().getCache();
+		return new CaffeineShadedProducerCacheProvider(properties.getExpireAfterAccess(), Duration.ofMinutes(10),
+				properties.getMaximumSize(), properties.getInitialCapacity());
+	}
+
+	@Bean
+	@ConditionalOnMissingBean
+	@ConditionalOnProperty(name = "spring.pulsar.producer.cache.enabled", havingValue = "true", matchIfMissing = true)
+	ReactiveMessageSenderCache reactivePulsarMessageSenderCache(
+			ObjectProvider<ProducerCacheProvider> producerCacheProvider) {
+		return reactivePulsarMessageSenderCache(producerCacheProvider.getIfAvailable());
+	}
+
+	private ReactiveMessageSenderCache reactivePulsarMessageSenderCache(ProducerCacheProvider producerCacheProvider) {
+		return (producerCacheProvider != null) ? AdaptedReactivePulsarClientFactory.createCache(producerCacheProvider)
+				: AdaptedReactivePulsarClientFactory.createCache();
+	}
+
+	@Bean
+	@ConditionalOnMissingBean(ReactivePulsarSenderFactory.class)
+	DefaultReactivePulsarSenderFactory<?> reactivePulsarSenderFactory(ReactivePulsarClient reactivePulsarClient,
+			ObjectProvider<ReactiveMessageSenderCache> reactiveMessageSenderCache, TopicResolver topicResolver,
+			ObjectProvider<ReactiveMessageSenderBuilderCustomizer<?>> customizersProvider) {
+		List<ReactiveMessageSenderBuilderCustomizer<?>> customizers = new ArrayList<>();
+		customizers.add(this.propertiesMapper::customizeMessageSenderBuilder);
+		customizers.addAll(customizersProvider.orderedStream().toList());
+		List<ReactiveMessageSenderBuilderCustomizer<Object>> lambdaSafeCustomizers = List
+			.of((builder) -> applyMessageSenderBuilderCustomizers(customizers, builder));
+		return DefaultReactivePulsarSenderFactory.builderFor(reactivePulsarClient)
+			.withDefaultConfigCustomizers(lambdaSafeCustomizers)
+			.withMessageSenderCache(reactiveMessageSenderCache.getIfAvailable())
+			.withTopicResolver(topicResolver)
+			.build();
+	}
+
+	@SuppressWarnings("unchecked")
+	private void applyMessageSenderBuilderCustomizers(List<ReactiveMessageSenderBuilderCustomizer<?>> customizers,
+			ReactiveMessageSenderBuilder<?> builder) {
+		LambdaSafe.callbacks(ReactiveMessageSenderBuilderCustomizer.class, customizers, builder)
+			.invoke((customizer) -> customizer.customize(builder));
+	}
+
+	@Bean
+	@ConditionalOnMissingBean(ReactivePulsarConsumerFactory.class)
+	DefaultReactivePulsarConsumerFactory<?> reactivePulsarConsumerFactory(
+			ReactivePulsarClient pulsarReactivePulsarClient,
+			ObjectProvider<ReactiveMessageConsumerBuilderCustomizer<?>> customizersProvider) {
+		List<ReactiveMessageConsumerBuilderCustomizer<?>> customizers = new ArrayList<>();
+		customizers.add(this.propertiesMapper::customizeMessageConsumerBuilder);
+		customizers.addAll(customizersProvider.orderedStream().toList());
+		List<ReactiveMessageConsumerBuilderCustomizer<Object>> lambdaSafeCustomizers = List
+			.of((builder) -> applyMessageConsumerBuilderCustomizers(customizers, builder));
+		return new DefaultReactivePulsarConsumerFactory<>(pulsarReactivePulsarClient, lambdaSafeCustomizers);
+	}
+
+	@SuppressWarnings("unchecked")
+	private void applyMessageConsumerBuilderCustomizers(List<ReactiveMessageConsumerBuilderCustomizer<?>> customizers,
+			ReactiveMessageConsumerBuilder<?> builder) {
+		LambdaSafe.callbacks(ReactiveMessageConsumerBuilderCustomizer.class, customizers, builder)
+			.invoke((customizer) -> customizer.customize(builder));
+	}
+
+	@Bean
+	@ConditionalOnMissingBean(name = "reactivePulsarListenerContainerFactory")
+	DefaultReactivePulsarListenerContainerFactory<?> reactivePulsarListenerContainerFactory(
+			ReactivePulsarConsumerFactory<Object> reactivePulsarConsumerFactory, SchemaResolver schemaResolver,
+			TopicResolver topicResolver) {
+		ReactivePulsarContainerProperties<Object> containerProperties = new ReactivePulsarContainerProperties<>();
+		containerProperties.setSchemaResolver(schemaResolver);
+		containerProperties.setTopicResolver(topicResolver);
+		this.propertiesMapper.customizeContainerProperties(containerProperties);
+		return new DefaultReactivePulsarListenerContainerFactory<>(reactivePulsarConsumerFactory, containerProperties);
+	}
+
+	@Bean
+	@ConditionalOnMissingBean(ReactivePulsarReaderFactory.class)
+	DefaultReactivePulsarReaderFactory<?> reactivePulsarReaderFactory(ReactivePulsarClient reactivePulsarClient,
+			ObjectProvider<ReactiveMessageReaderBuilderCustomizer<?>> customizersProvider) {
+		List<ReactiveMessageReaderBuilderCustomizer<?>> customizers = new ArrayList<>();
+		customizers.add(this.propertiesMapper::customizeMessageReaderBuilder);
+		customizers.addAll(customizersProvider.orderedStream().toList());
+		List<ReactiveMessageReaderBuilderCustomizer<Object>> lambdaSafeCustomizers = List
+			.of((builder) -> applyMessageReaderBuilderCustomizers(customizers, builder));
+		return new DefaultReactivePulsarReaderFactory<>(reactivePulsarClient, lambdaSafeCustomizers);
+	}
+
+	@SuppressWarnings("unchecked")
+	private void applyMessageReaderBuilderCustomizers(List<ReactiveMessageReaderBuilderCustomizer<?>> customizers,
+			ReactiveMessageReaderBuilder<?> builder) {
+		LambdaSafe.callbacks(ReactiveMessageReaderBuilderCustomizer.class, customizers, builder)
+			.invoke((customizer) -> customizer.customize(builder));
+	}
+
+	@Bean
+	@ConditionalOnMissingBean
+	ReactivePulsarTemplate<?> pulsarReactiveTemplate(ReactivePulsarSenderFactory<?> reactivePulsarSenderFactory,
+			SchemaResolver schemaResolver, TopicResolver topicResolver) {
+		return new ReactivePulsarTemplate<>(reactivePulsarSenderFactory, schemaResolver, topicResolver);
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	@EnableReactivePulsar
+	@ConditionalOnMissingBean(
+			name = PulsarAnnotationSupportBeanNames.REACTIVE_PULSAR_LISTENER_ANNOTATION_PROCESSOR_BEAN_NAME)
+	static class EnableReactivePulsarConfiguration {
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactivePropertiesMapper.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactivePropertiesMapper.java
new file mode 100644
index 000000000000..2f79bbae615f
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactivePropertiesMapper.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.pulsar;
+
+import java.util.ArrayList;
+
+import org.apache.pulsar.reactive.client.api.ReactiveMessageConsumerBuilder;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageReaderBuilder;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderBuilder;
+
+import org.springframework.boot.context.properties.PropertyMapper;
+import org.springframework.pulsar.reactive.listener.ReactivePulsarContainerProperties;
+
+/**
+ * Helper class used to map reactive {@link PulsarProperties} to various builder
+ * customizers.
+ *
+ * @author Chris Bono
+ * @author Phillip Webb
+ */
+final class PulsarReactivePropertiesMapper {
+
+	private final PulsarProperties properties;
+
+	PulsarReactivePropertiesMapper(PulsarProperties properties) {
+		this.properties = properties;
+	}
+
+	<T> void customizeMessageSenderBuilder(ReactiveMessageSenderBuilder<T> builder) {
+		PulsarProperties.Producer properties = this.properties.getProducer();
+		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+		map.from(properties::getName).to(builder::producerName);
+		map.from(properties::getTopicName).to(builder::topic);
+		map.from(properties::getSendTimeout).to(builder::sendTimeout);
+		map.from(properties::getMessageRoutingMode).to(builder::messageRoutingMode);
+		map.from(properties::getHashingScheme).to(builder::hashingScheme);
+		map.from(properties::isBatchingEnabled).to(builder::batchingEnabled);
+		map.from(properties::isChunkingEnabled).to(builder::chunkingEnabled);
+		map.from(properties::getCompressionType).to(builder::compressionType);
+		map.from(properties::getAccessMode).to(builder::accessMode);
+	}
+
+	<T> void customizeMessageConsumerBuilder(ReactiveMessageConsumerBuilder<T> builder) {
+		PulsarProperties.Consumer properties = this.properties.getConsumer();
+		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+		map.from(properties::getName).to(builder::consumerName);
+		map.from(properties::getTopics).as(ArrayList::new).to(builder::topics);
+		map.from(properties::getTopicsPattern).to(builder::topicsPattern);
+		map.from(properties::getPriorityLevel).to(builder::priorityLevel);
+		map.from(properties::isReadCompacted).to(builder::readCompacted);
+		map.from(properties::getDeadLetterPolicy).as(DeadLetterPolicyMapper::map).to(builder::deadLetterPolicy);
+		map.from(properties::isRetryEnable).to(builder::retryLetterTopicEnable);
+		customizerMessageConsumerBuilderSubscription(builder);
+	}
+
+	private <T> void customizerMessageConsumerBuilderSubscription(ReactiveMessageConsumerBuilder<T> builder) {
+		PulsarProperties.Consumer.Subscription properties = this.properties.getConsumer().getSubscription();
+		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+		map.from(properties::getName).to(builder::subscriptionName);
+		map.from(properties::getInitialPosition).to(builder::subscriptionInitialPosition);
+		map.from(properties::getMode).to(builder::subscriptionMode);
+		map.from(properties::getTopicsMode).to(builder::topicsPatternSubscriptionMode);
+		map.from(properties::getType).to(builder::subscriptionType);
+	}
+
+	<T> void customizeContainerProperties(ReactivePulsarContainerProperties<T> containerProperties) {
+		customizePulsarContainerConsumerSubscriptionProperties(containerProperties);
+		customizePulsarContainerListenerProperties(containerProperties);
+	}
+
+	private void customizePulsarContainerConsumerSubscriptionProperties(
+			ReactivePulsarContainerProperties<?> containerProperties) {
+		PulsarProperties.Consumer.Subscription properties = this.properties.getConsumer().getSubscription();
+		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+		map.from(properties::getType).to(containerProperties::setSubscriptionType);
+	}
+
+	private void customizePulsarContainerListenerProperties(ReactivePulsarContainerProperties<?> containerProperties) {
+		PulsarProperties.Listener properties = this.properties.getListener();
+		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+		map.from(properties::getSchemaType).to(containerProperties::setSchemaType);
+	}
+
+	void customizeMessageReaderBuilder(ReactiveMessageReaderBuilder<?> builder) {
+		PulsarProperties.Reader properties = this.properties.getReader();
+		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+		map.from(properties::getName).to(builder::readerName);
+		map.from(properties::getTopics).to(builder::topics);
+		map.from(properties::getSubscriptionName).to(builder::subscriptionName);
+		map.from(properties::getSubscriptionRolePrefix).to(builder::generatedSubscriptionNamePrefix);
+		map.from(properties::isReadCompacted).to(builder::readCompacted);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/package-info.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/package-info.java
new file mode 100644
index 000000000000..d6ce8ee1d218
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Auto-configuration for Spring for Apache Pulsar.
+ */
+package org.springframework.boot.autoconfigure.pulsar;
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/r2dbc/ConnectionFactoryConfigurations.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/r2dbc/ConnectionFactoryConfigurations.java
index 987b80fee7fc..6807a349f77b 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/r2dbc/ConnectionFactoryConfigurations.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/r2dbc/ConnectionFactoryConfigurations.java
@@ -33,6 +33,7 @@
 import org.springframework.boot.context.properties.bind.BindResult;
 import org.springframework.boot.context.properties.bind.Bindable;
 import org.springframework.boot.context.properties.bind.Binder;
+import org.springframework.boot.r2dbc.ConnectionFactoryDecorator;
 import org.springframework.boot.r2dbc.EmbeddedDatabaseConnection;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Condition;
@@ -54,12 +55,14 @@
  * @author Moritz Halbritter
  * @author Andy Wilkinson
  * @author Phillip Webb
+ * @author Moritz Halbritter
  */
 abstract class ConnectionFactoryConfigurations {
 
 	protected static ConnectionFactory createConnectionFactory(R2dbcProperties properties,
 			R2dbcConnectionDetails connectionDetails, ClassLoader classLoader,
-			List<ConnectionFactoryOptionsBuilderCustomizer> optionsCustomizers) {
+			List<ConnectionFactoryOptionsBuilderCustomizer> optionsCustomizers,
+			List<ConnectionFactoryDecorator> decorators) {
 		try {
 			return org.springframework.boot.r2dbc.ConnectionFactoryBuilder
 				.withOptions(new ConnectionFactoryOptionsInitializer().initialize(properties, connectionDetails,
@@ -69,6 +72,7 @@ protected static ConnectionFactory createConnectionFactory(R2dbcProperties prope
 						optionsCustomizer.customize(options);
 					}
 				})
+				.decorators(decorators)
 				.build();
 		}
 		catch (IllegalStateException ex) {
@@ -93,10 +97,11 @@ static class PooledConnectionFactoryConfiguration {
 			@Bean(destroyMethod = "dispose")
 			ConnectionPool connectionFactory(R2dbcProperties properties,
 					ObjectProvider<R2dbcConnectionDetails> connectionDetails, ResourceLoader resourceLoader,
-					ObjectProvider<ConnectionFactoryOptionsBuilderCustomizer> customizers) {
+					ObjectProvider<ConnectionFactoryOptionsBuilderCustomizer> customizers,
+					ObjectProvider<ConnectionFactoryDecorator> decorators) {
 				ConnectionFactory connectionFactory = createConnectionFactory(properties,
 						connectionDetails.getIfAvailable(), resourceLoader.getClassLoader(),
-						customizers.orderedStream().toList());
+						customizers.orderedStream().toList(), decorators.orderedStream().toList());
 				R2dbcProperties.Pool pool = properties.getPool();
 				PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
 				ConnectionPoolConfiguration.Builder builder = ConnectionPoolConfiguration.builder(connectionFactory);
@@ -126,9 +131,11 @@ static class GenericConfiguration {
 		@Bean
 		ConnectionFactory connectionFactory(R2dbcProperties properties,
 				ObjectProvider<R2dbcConnectionDetails> connectionDetails, ResourceLoader resourceLoader,
-				ObjectProvider<ConnectionFactoryOptionsBuilderCustomizer> customizers) {
+				ObjectProvider<ConnectionFactoryOptionsBuilderCustomizer> customizers,
+				ObjectProvider<ConnectionFactoryDecorator> decorators) {
 			return createConnectionFactory(properties, connectionDetails.getIfAvailable(),
-					resourceLoader.getClassLoader(), customizers.orderedStream().toList());
+					resourceLoader.getClassLoader(), customizers.orderedStream().toList(),
+					decorators.orderedStream().toList());
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/ReactorAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/ReactorAutoConfiguration.java
new file mode 100644
index 000000000000..9323e6eca46a
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/ReactorAutoConfiguration.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.reactor;
+
+import reactor.core.publisher.Hooks;
+
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * {@link EnableAutoConfiguration Auto-configuration} for Reactor.
+ *
+ * @author Brian Clozel
+ * @since 3.2.0
+ */
+@Configuration(proxyBeanMethods = false)
+@ConditionalOnClass(Hooks.class)
+@EnableConfigurationProperties(ReactorProperties.class)
+public class ReactorAutoConfiguration {
+
+	ReactorAutoConfiguration(ReactorProperties properties) {
+		if (properties.getContextPropagation() == ReactorProperties.ContextPropagationMode.AUTO) {
+			Hooks.enableAutomaticContextPropagation();
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/ReactorProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/ReactorProperties.java
new file mode 100644
index 000000000000..c82da8b52389
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/ReactorProperties.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.reactor;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * Configuration properties for Reactor.
+ *
+ * @author Brian Clozel
+ * @since 3.2.0
+ */
+@ConfigurationProperties(prefix = "spring.reactor")
+public class ReactorProperties {
+
+	/**
+	 * Context Propagation support mode for Reactor operators.
+	 */
+	private ContextPropagationMode contextPropagation = ContextPropagationMode.LIMITED;
+
+	public ContextPropagationMode getContextPropagation() {
+		return this.contextPropagation;
+	}
+
+	public void setContextPropagation(ContextPropagationMode contextPropagation) {
+		this.contextPropagation = contextPropagation;
+	}
+
+	public enum ContextPropagationMode {
+
+		/**
+		 * Context Propagation is applied to all Reactor operators.
+		 */
+		AUTO,
+
+		/**
+		 * Context Propagation is only applied to "tap" and "handle" Reactor operators.
+		 */
+		LIMITED
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/netty/ReactorNettyConfigurations.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/netty/ReactorNettyConfigurations.java
index b6e3ba354805..35867272331f 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/netty/ReactorNettyConfigurations.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/netty/ReactorNettyConfigurations.java
@@ -20,7 +20,7 @@
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
-import org.springframework.http.client.reactive.ReactorResourceFactory;
+import org.springframework.http.client.ReactorResourceFactory;
 
 /**
  * Configurations for Reactor Netty. Those should be {@code @Import} in a regular
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/package-info.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/package-info.java
new file mode 100644
index 000000000000..4b55cfe4d534
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Auto-configuration for Reactor.
+ */
+package org.springframework.boot.autoconfigure.reactor;
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketProperties.java
index f1921f23885c..bba90f2f7ef1 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketProperties.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -25,7 +25,7 @@
 import org.springframework.util.unit.DataSize;
 
 /**
- * {@link ConfigurationProperties properties} for RSocket support.
+ * {@link ConfigurationProperties Properties} for RSocket support.
  *
  * @author Brian Clozel
  * @author Chris Bono
@@ -73,6 +73,8 @@ public static class Server {
 		@NestedConfigurationProperty
 		private Ssl ssl;
 
+		private final Spec spec = new Spec();
+
 		public Integer getPort() {
 			return this.port;
 		}
@@ -121,6 +123,66 @@ public void setSsl(Ssl ssl) {
 			this.ssl = ssl;
 		}
 
+		public Spec getSpec() {
+			return this.spec;
+		}
+
+		public static class Spec {
+
+			/**
+			 * Sub-protocols to use in websocket handshake signature.
+			 */
+			private String protocols;
+
+			/**
+			 * Maximum allowable frame payload length.
+			 */
+			private DataSize maxFramePayloadLength = DataSize.ofBytes(65536);
+
+			/**
+			 * Whether to proxy websocket ping frames or respond to them.
+			 */
+			private boolean handlePing;
+
+			/**
+			 * Whether the websocket compression extension is enabled.
+			 */
+			private boolean compress;
+
+			public String getProtocols() {
+				return this.protocols;
+			}
+
+			public void setProtocols(String protocols) {
+				this.protocols = protocols;
+			}
+
+			public DataSize getMaxFramePayloadLength() {
+				return this.maxFramePayloadLength;
+			}
+
+			public void setMaxFramePayloadLength(DataSize maxFramePayloadLength) {
+				this.maxFramePayloadLength = maxFramePayloadLength;
+			}
+
+			public boolean isHandlePing() {
+				return this.handlePing;
+			}
+
+			public void setHandlePing(boolean handlePing) {
+				this.handlePing = handlePing;
+			}
+
+			public boolean isCompress() {
+				return this.compress;
+			}
+
+			public void setCompress(boolean compress) {
+				this.compress = compress;
+			}
+
+		}
+
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketServerAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketServerAutoConfiguration.java
index 96bbbc454ba2..c9ae1d033847 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketServerAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketServerAutoConfiguration.java
@@ -16,10 +16,13 @@
 
 package org.springframework.boot.autoconfigure.rsocket;
 
+import java.util.function.Consumer;
+
 import io.rsocket.core.RSocketServer;
 import io.rsocket.frame.decoder.PayloadDecoder;
 import io.rsocket.transport.netty.server.TcpServerTransport;
 import reactor.netty.http.server.HttpServer;
+import reactor.netty.http.server.WebsocketServerSpec.Builder;
 
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
@@ -31,6 +34,7 @@
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
 import org.springframework.boot.autoconfigure.reactor.netty.ReactorNettyConfigurations;
+import org.springframework.boot.autoconfigure.rsocket.RSocketProperties.Server.Spec;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.boot.context.properties.PropertyMapper;
 import org.springframework.boot.rsocket.context.RSocketServerBootstrap;
@@ -43,9 +47,10 @@
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Import;
 import org.springframework.core.io.buffer.NettyDataBufferFactory;
-import org.springframework.http.client.reactive.ReactorResourceFactory;
+import org.springframework.http.client.ReactorResourceFactory;
 import org.springframework.messaging.rsocket.RSocketStrategies;
 import org.springframework.messaging.rsocket.annotation.support.RSocketMessageHandler;
+import org.springframework.util.unit.DataSize;
 
 /**
  * {@link EnableAutoConfiguration Auto-configuration} for RSocket servers. In the case of
@@ -73,7 +78,18 @@ static class WebFluxServerConfiguration {
 		RSocketWebSocketNettyRouteProvider rSocketWebsocketRouteProvider(RSocketProperties properties,
 				RSocketMessageHandler messageHandler, ObjectProvider<RSocketServerCustomizer> customizers) {
 			return new RSocketWebSocketNettyRouteProvider(properties.getServer().getMappingPath(),
-					messageHandler.responder(), customizers.orderedStream());
+					messageHandler.responder(), customizeWebsocketServerSpec(properties.getServer().getSpec()),
+					customizers.orderedStream());
+		}
+
+		private Consumer<Builder> customizeWebsocketServerSpec(Spec spec) {
+			return (builder) -> {
+				PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+				map.from(spec.getProtocols()).to(builder::protocols);
+				map.from(spec.getMaxFramePayloadLength()).asInt(DataSize::toBytes).to(builder::maxFramePayloadLength);
+				map.from(spec.isHandlePing()).to(builder::handlePing);
+				map.from(spec.isCompress()).to(builder::compress);
+			};
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketWebSocketNettyRouteProvider.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketWebSocketNettyRouteProvider.java
index 5cb8d7f374f5..f70f9d41d546 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketWebSocketNettyRouteProvider.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketWebSocketNettyRouteProvider.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -17,6 +17,7 @@
 package org.springframework.boot.autoconfigure.rsocket;
 
 import java.util.List;
+import java.util.function.Consumer;
 import java.util.stream.Stream;
 
 import io.rsocket.SocketAcceptor;
@@ -24,6 +25,8 @@
 import io.rsocket.transport.ServerTransport;
 import io.rsocket.transport.netty.server.WebsocketRouteTransport;
 import reactor.netty.http.server.HttpServerRoutes;
+import reactor.netty.http.server.WebsocketServerSpec;
+import reactor.netty.http.server.WebsocketServerSpec.Builder;
 
 import org.springframework.boot.rsocket.server.RSocketServerCustomizer;
 import org.springframework.boot.web.embedded.netty.NettyRouteProvider;
@@ -32,6 +35,7 @@
  * {@link NettyRouteProvider} that configures an RSocket Websocket endpoint.
  *
  * @author Brian Clozel
+ * @author Leo Li
  */
 class RSocketWebSocketNettyRouteProvider implements NettyRouteProvider {
 
@@ -41,10 +45,13 @@ class RSocketWebSocketNettyRouteProvider implements NettyRouteProvider {
 
 	private final List<RSocketServerCustomizer> customizers;
 
+	private final Consumer<Builder> serverSpecCustomizer;
+
 	RSocketWebSocketNettyRouteProvider(String mappingPath, SocketAcceptor socketAcceptor,
-			Stream<RSocketServerCustomizer> customizers) {
+			Consumer<Builder> serverSpecCustomizer, Stream<RSocketServerCustomizer> customizers) {
 		this.mappingPath = mappingPath;
 		this.socketAcceptor = socketAcceptor;
+		this.serverSpecCustomizer = serverSpecCustomizer;
 		this.customizers = customizers.toList();
 	}
 
@@ -53,7 +60,14 @@ public HttpServerRoutes apply(HttpServerRoutes httpServerRoutes) {
 		RSocketServer server = RSocketServer.create(this.socketAcceptor);
 		this.customizers.forEach((customizer) -> customizer.customize(server));
 		ServerTransport.ConnectionAcceptor connectionAcceptor = server.asConnectionAcceptor();
-		return httpServerRoutes.ws(this.mappingPath, WebsocketRouteTransport.newHandler(connectionAcceptor));
+		return httpServerRoutes.ws(this.mappingPath, WebsocketRouteTransport.newHandler(connectionAcceptor),
+				createWebsocketServerSpec());
+	}
+
+	private WebsocketServerSpec createWebsocketServerSpec() {
+		WebsocketServerSpec.Builder builder = WebsocketServerSpec.builder();
+		this.serverSpecCustomizer.accept(builder);
+		return builder.build();
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/SecurityProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/SecurityProperties.java
index c3d5fcdd3fdf..504044c9eb98 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/SecurityProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/SecurityProperties.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -75,12 +75,12 @@ public Filter getFilter() {
 	public static class Filter {
 
 		/**
-		 * Security filter chain order.
+		 * Security filter chain order for Servlet-based web applications.
 		 */
 		private int order = DEFAULT_FILTER_ORDER;
 
 		/**
-		 * Security filter chain dispatcher types.
+		 * Security filter chain dispatcher types for Servlet-based web applications.
 		 */
 		private Set<DispatcherType> dispatcherTypes = EnumSet.allOf(DispatcherType.class);
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/client/reactive/ReactiveOAuth2ClientConfigurations.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/client/reactive/ReactiveOAuth2ClientConfigurations.java
index 8eb3871b9377..8d25fbc2e345 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/client/reactive/ReactiveOAuth2ClientConfigurations.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/client/reactive/ReactiveOAuth2ClientConfigurations.java
@@ -28,6 +28,7 @@
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Conditional;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
 import org.springframework.security.config.web.server.ServerHttpSecurity;
 import org.springframework.security.oauth2.client.InMemoryReactiveOAuth2AuthorizedClientService;
 import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientService;
@@ -37,6 +38,7 @@
 import org.springframework.security.oauth2.client.web.server.AuthenticatedPrincipalServerOAuth2AuthorizedClientRepository;
 import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository;
 import org.springframework.security.web.server.SecurityWebFilterChain;
+import org.springframework.security.web.server.WebFilterChainProxy;
 
 import static org.springframework.security.config.Customizer.withDefaults;
 
@@ -92,6 +94,13 @@ SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
 				return http.build();
 			}
 
+			@Configuration(proxyBeanMethods = false)
+			@ConditionalOnMissingBean(WebFilterChainProxy.class)
+			@EnableWebFluxSecurity
+			static class EnableWebFluxSecurityConfiguration {
+
+			}
+
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerJwkConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerJwkConfiguration.java
index 3b2546865b71..31cb13aa60cc 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerJwkConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerJwkConfiguration.java
@@ -24,7 +24,6 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Set;
-import java.util.function.Supplier;
 
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
@@ -36,6 +35,7 @@
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Conditional;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
 import org.springframework.security.config.web.server.ServerHttpSecurity;
 import org.springframework.security.config.web.server.ServerHttpSecurity.OAuth2ResourceServerSpec;
 import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
@@ -50,6 +50,7 @@
 import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
 import org.springframework.security.oauth2.jwt.SupplierReactiveJwtDecoder;
 import org.springframework.security.web.server.SecurityWebFilterChain;
+import org.springframework.security.web.server.WebFilterChainProxy;
 import org.springframework.util.CollectionUtils;
 
 /**
@@ -62,6 +63,7 @@
  * @author HaiTao Zhang
  * @author Anastasiia Losieva
  * @author Mushtaq Ahmed
+ * @author Roman Golovin
  */
 @Configuration(proxyBeanMethods = false)
 class ReactiveOAuth2ResourceServerJwkConfiguration {
@@ -72,8 +74,12 @@ static class JwtConfiguration {
 
 		private final OAuth2ResourceServerProperties.Jwt properties;
 
-		JwtConfiguration(OAuth2ResourceServerProperties properties) {
+		private final List<OAuth2TokenValidator<Jwt>> additionalValidators;
+
+		JwtConfiguration(OAuth2ResourceServerProperties properties,
+				ObjectProvider<OAuth2TokenValidator<Jwt>> additionalValidators) {
 			this.properties = properties.getJwt();
+			this.additionalValidators = additionalValidators.orderedStream().toList();
 		}
 
 		@Bean
@@ -85,8 +91,8 @@ ReactiveJwtDecoder jwtDecoder(ObjectProvider<JwkSetUriReactiveJwtDecoderBuilderC
 			customizers.orderedStream().forEach((customizer) -> customizer.customize(builder));
 			NimbusReactiveJwtDecoder nimbusReactiveJwtDecoder = builder.build();
 			String issuerUri = this.properties.getIssuerUri();
-			Supplier<OAuth2TokenValidator<Jwt>> defaultValidator = (issuerUri != null)
-					? () -> JwtValidators.createDefaultWithIssuer(issuerUri) : JwtValidators::createDefault;
+			OAuth2TokenValidator<Jwt> defaultValidator = (issuerUri != null)
+					? JwtValidators.createDefaultWithIssuer(issuerUri) : JwtValidators.createDefault();
 			nimbusReactiveJwtDecoder.setJwtValidator(getValidators(defaultValidator));
 			return nimbusReactiveJwtDecoder;
 		}
@@ -97,16 +103,18 @@ private void jwsAlgorithms(Set<SignatureAlgorithm> signatureAlgorithms) {
 			}
 		}
 
-		private OAuth2TokenValidator<Jwt> getValidators(Supplier<OAuth2TokenValidator<Jwt>> defaultValidator) {
-			OAuth2TokenValidator<Jwt> defaultValidators = defaultValidator.get();
+		private OAuth2TokenValidator<Jwt> getValidators(OAuth2TokenValidator<Jwt> defaultValidator) {
 			List<String> audiences = this.properties.getAudiences();
-			if (CollectionUtils.isEmpty(audiences)) {
-				return defaultValidators;
+			if (CollectionUtils.isEmpty(audiences) && this.additionalValidators.isEmpty()) {
+				return defaultValidator;
 			}
 			List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>();
-			validators.add(defaultValidators);
-			validators.add(new JwtClaimValidator<List<String>>(JwtClaimNames.AUD,
-					(aud) -> aud != null && !Collections.disjoint(aud, audiences)));
+			validators.add(defaultValidator);
+			if (!CollectionUtils.isEmpty(audiences)) {
+				validators.add(new JwtClaimValidator<List<String>>(JwtClaimNames.AUD,
+						(aud) -> aud != null && !Collections.disjoint(aud, audiences)));
+			}
+			validators.addAll(this.additionalValidators);
 			return new DelegatingOAuth2TokenValidator<>(validators);
 		}
 
@@ -118,7 +126,7 @@ NimbusReactiveJwtDecoder jwtDecoderByPublicKeyValue() throws Exception {
 			NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withPublicKey(publicKey)
 				.signatureAlgorithm(SignatureAlgorithm.from(exactlyOneAlgorithm()))
 				.build();
-			jwtDecoder.setJwtValidator(getValidators(JwtValidators::createDefault));
+			jwtDecoder.setJwtValidator(getValidators(JwtValidators.createDefault()));
 			return jwtDecoder;
 		}
 
@@ -148,7 +156,7 @@ SupplierReactiveJwtDecoder jwtDecoderByIssuerUri(
 				customizers.orderedStream().forEach((customizer) -> customizer.customize(builder));
 				NimbusReactiveJwtDecoder jwtDecoder = builder.build();
 				jwtDecoder.setJwtValidator(
-						getValidators(() -> JwtValidators.createDefaultWithIssuer(this.properties.getIssuerUri())));
+						getValidators(JwtValidators.createDefaultWithIssuer(this.properties.getIssuerUri())));
 				return jwtDecoder;
 			});
 		}
@@ -171,6 +179,13 @@ private void customDecoder(OAuth2ResourceServerSpec server, ReactiveJwtDecoder d
 			server.jwt((jwt) -> jwt.jwtDecoder(decoder));
 		}
 
+		@Configuration(proxyBeanMethods = false)
+		@ConditionalOnMissingBean(WebFilterChainProxy.class)
+		@EnableWebFluxSecurity
+		static class EnableWebFluxSecurityConfiguration {
+
+		}
+
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerOpaqueTokenConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerOpaqueTokenConfiguration.java
index f4d9614253e8..dbeb778d8764 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerOpaqueTokenConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerOpaqueTokenConfiguration.java
@@ -22,10 +22,12 @@
 import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
 import org.springframework.security.config.web.server.ServerHttpSecurity;
 import org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenIntrospector;
 import org.springframework.security.oauth2.server.resource.introspection.SpringReactiveOpaqueTokenIntrospector;
 import org.springframework.security.web.server.SecurityWebFilterChain;
+import org.springframework.security.web.server.WebFilterChainProxy;
 
 import static org.springframework.security.config.Customizer.withDefaults;
 
@@ -64,6 +66,13 @@ SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
 			return http.build();
 		}
 
+		@Configuration(proxyBeanMethods = false)
+		@ConditionalOnMissingBean(WebFilterChainProxy.class)
+		@EnableWebFluxSecurity
+		static class EnableWebFluxSecurityConfiguration {
+
+		}
+
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerJwtConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerJwtConfiguration.java
index 5146570a28d0..84bafab99db0 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerJwtConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerJwtConfiguration.java
@@ -24,7 +24,6 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Set;
-import java.util.function.Supplier;
 
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
@@ -63,6 +62,7 @@
  * @author Artsiom Yudovin
  * @author HaiTao Zhang
  * @author Mushtaq Ahmed
+ * @author Roman Golovin
  */
 @Configuration(proxyBeanMethods = false)
 class OAuth2ResourceServerJwtConfiguration {
@@ -73,8 +73,12 @@ static class JwtDecoderConfiguration {
 
 		private final OAuth2ResourceServerProperties.Jwt properties;
 
-		JwtDecoderConfiguration(OAuth2ResourceServerProperties properties) {
+		private final List<OAuth2TokenValidator<Jwt>> additionalValidators;
+
+		JwtDecoderConfiguration(OAuth2ResourceServerProperties properties,
+				ObjectProvider<OAuth2TokenValidator<Jwt>> additionalValidators) {
 			this.properties = properties.getJwt();
+			this.additionalValidators = additionalValidators.orderedStream().toList();
 		}
 
 		@Bean
@@ -85,8 +89,8 @@ JwtDecoder jwtDecoderByJwkKeySetUri(ObjectProvider<JwkSetUriJwtDecoderBuilderCus
 			customizers.orderedStream().forEach((customizer) -> customizer.customize(builder));
 			NimbusJwtDecoder nimbusJwtDecoder = builder.build();
 			String issuerUri = this.properties.getIssuerUri();
-			Supplier<OAuth2TokenValidator<Jwt>> defaultValidator = (issuerUri != null)
-					? () -> JwtValidators.createDefaultWithIssuer(issuerUri) : JwtValidators::createDefault;
+			OAuth2TokenValidator<Jwt> defaultValidator = (issuerUri != null)
+					? JwtValidators.createDefaultWithIssuer(issuerUri) : JwtValidators.createDefault();
 			nimbusJwtDecoder.setJwtValidator(getValidators(defaultValidator));
 			return nimbusJwtDecoder;
 		}
@@ -97,16 +101,18 @@ private void jwsAlgorithms(Set<SignatureAlgorithm> signatureAlgorithms) {
 			}
 		}
 
-		private OAuth2TokenValidator<Jwt> getValidators(Supplier<OAuth2TokenValidator<Jwt>> defaultValidator) {
-			OAuth2TokenValidator<Jwt> defaultValidators = defaultValidator.get();
+		private OAuth2TokenValidator<Jwt> getValidators(OAuth2TokenValidator<Jwt> defaultValidator) {
 			List<String> audiences = this.properties.getAudiences();
-			if (CollectionUtils.isEmpty(audiences)) {
-				return defaultValidators;
+			if (CollectionUtils.isEmpty(audiences) && this.additionalValidators.isEmpty()) {
+				return defaultValidator;
 			}
 			List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>();
-			validators.add(defaultValidators);
-			validators.add(new JwtClaimValidator<List<String>>(JwtClaimNames.AUD,
-					(aud) -> aud != null && !Collections.disjoint(aud, audiences)));
+			validators.add(defaultValidator);
+			if (!CollectionUtils.isEmpty(audiences)) {
+				validators.add(new JwtClaimValidator<List<String>>(JwtClaimNames.AUD,
+						(aud) -> aud != null && !Collections.disjoint(aud, audiences)));
+			}
+			validators.addAll(this.additionalValidators);
 			return new DelegatingOAuth2TokenValidator<>(validators);
 		}
 
@@ -118,7 +124,7 @@ JwtDecoder jwtDecoderByPublicKeyValue() throws Exception {
 			NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(publicKey)
 				.signatureAlgorithm(SignatureAlgorithm.from(exactlyOneAlgorithm()))
 				.build();
-			jwtDecoder.setJwtValidator(getValidators(JwtValidators::createDefault));
+			jwtDecoder.setJwtValidator(getValidators(JwtValidators.createDefault()));
 			return jwtDecoder;
 		}
 
@@ -146,7 +152,7 @@ SupplierJwtDecoder jwtDecoderByIssuerUri(ObjectProvider<JwkSetUriJwtDecoderBuild
 				JwkSetUriJwtDecoderBuilder builder = NimbusJwtDecoder.withIssuerLocation(issuerUri);
 				customizers.orderedStream().forEach((customizer) -> customizer.customize(builder));
 				NimbusJwtDecoder jwtDecoder = builder.build();
-				jwtDecoder.setJwtValidator(getValidators(() -> JwtValidators.createDefaultWithIssuer(issuerUri)));
+				jwtDecoder.setJwtValidator(getValidators(JwtValidators.createDefaultWithIssuer(issuerUri)));
 				return jwtDecoder;
 			});
 		}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveSecurityAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveSecurityAutoConfiguration.java
index f995f66cdd69..5bcb151be46b 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveSecurityAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveSecurityAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -20,13 +20,19 @@
 
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.AnyNestedCondition;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
 import org.springframework.boot.autoconfigure.security.SecurityProperties;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Conditional;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.security.authentication.ReactiveAuthenticationManager;
 import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
+import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
+import org.springframework.security.web.server.SecurityWebFilterChain;
 import org.springframework.security.web.server.WebFilterChainProxy;
 import org.springframework.web.reactive.config.WebFluxConfigurer;
 
@@ -49,9 +55,33 @@ public class ReactiveSecurityAutoConfiguration {
 	@Configuration(proxyBeanMethods = false)
 	@ConditionalOnMissingBean(WebFilterChainProxy.class)
 	@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
+	@Conditional(EnableWebFluxSecurityCondition.class)
 	@EnableWebFluxSecurity
 	static class EnableWebFluxSecurityConfiguration {
 
 	}
 
+	static final class EnableWebFluxSecurityCondition extends AnyNestedCondition {
+
+		EnableWebFluxSecurityCondition() {
+			super(ConfigurationPhase.REGISTER_BEAN);
+		}
+
+		@ConditionalOnBean(ReactiveAuthenticationManager.class)
+		static final class ConditionalOnReactiveAuthenticationManagerBean {
+
+		}
+
+		@ConditionalOnBean(ReactiveUserDetailsService.class)
+		static final class ConditionalOnReactiveUserDetailsService {
+
+		}
+
+		@ConditionalOnBean(SecurityWebFilterChain.class)
+		static final class ConditionalOnSecurityWebFilterChain {
+
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveUserDetailsServiceAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveUserDetailsServiceAutoConfiguration.java
index c2f4ce2a7323..17d6e1315f54 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveUserDetailsServiceAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveUserDetailsServiceAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -28,6 +28,7 @@
 import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
 import org.springframework.boot.autoconfigure.rsocket.RSocketMessagingAutoConfiguration;
 import org.springframework.boot.autoconfigure.security.SecurityProperties;
@@ -55,13 +56,14 @@
  * @author Madhura Bhave
  * @since 2.0.0
  */
-@AutoConfiguration(after = RSocketMessagingAutoConfiguration.class)
+@AutoConfiguration(before = ReactiveSecurityAutoConfiguration.class, after = RSocketMessagingAutoConfiguration.class)
 @ConditionalOnClass({ ReactiveAuthenticationManager.class })
+@ConditionalOnMissingClass({ "org.springframework.security.oauth2.client.registration.ClientRegistrationRepository",
+		"org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenIntrospector" })
 @ConditionalOnMissingBean(
 		value = { ReactiveAuthenticationManager.class, ReactiveUserDetailsService.class,
 				ReactiveAuthenticationManagerResolver.class },
-		type = { "org.springframework.security.oauth2.jwt.ReactiveJwtDecoder",
-				"org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenIntrospector" })
+		type = { "org.springframework.security.oauth2.jwt.ReactiveJwtDecoder" })
 @Conditional(ReactiveUserDetailsServiceAutoConfiguration.ReactiveUserDetailsServiceCondition.class)
 @EnableConfigurationProperties(SecurityProperties.class)
 public class ReactiveUserDetailsServiceAutoConfiguration {
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyProperties.java
index 10f94192b669..8898587a46b1 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyProperties.java
@@ -295,7 +295,7 @@ public static class Singlesignon {
 			/**
 			 * Whether to sign authentication requests.
 			 */
-			private boolean signRequest = true;
+			private Boolean signRequest;
 
 			public String getUrl() {
 				return this.url;
@@ -317,7 +317,11 @@ public boolean isSignRequest() {
 				return this.signRequest;
 			}
 
-			public void setSignRequest(boolean signRequest) {
+			public Boolean getSignRequest() {
+				return this.signRequest;
+			}
+
+			public void setSignRequest(Boolean signRequest) {
 				this.signRequest = signRequest;
 			}
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyRegistrationConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyRegistrationConfiguration.java
index 2f45e4ac3f12..830077fae5b9 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyRegistrationConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyRegistrationConfiguration.java
@@ -79,10 +79,10 @@ private RelyingPartyRegistration asRegistration(Map.Entry<String, Registration>
 	private RelyingPartyRegistration asRegistration(String id, Registration properties) {
 		boolean usingMetadata = StringUtils.hasText(properties.getAssertingparty().getMetadataUri());
 		Builder builder = (!usingMetadata) ? RelyingPartyRegistration.withRegistrationId(id)
-				: createBuilderUsingMetadata(id, properties.getAssertingparty()).registrationId(id);
+				: createBuilderUsingMetadata(properties.getAssertingparty()).registrationId(id);
 		builder.assertionConsumerServiceLocation(properties.getAcs().getLocation());
 		builder.assertionConsumerServiceBinding(properties.getAcs().getBinding());
-		builder.assertingPartyDetails(mapAssertingParty(properties.getAssertingparty(), usingMetadata));
+		builder.assertingPartyDetails(mapAssertingParty(properties.getAssertingparty()));
 		builder.signingX509Credentials((credentials) -> properties.getSigning()
 			.getCredentials()
 			.stream()
@@ -110,7 +110,7 @@ private RelyingPartyRegistration asRegistration(String id, Registration properti
 		return registration;
 	}
 
-	private RelyingPartyRegistration.Builder createBuilderUsingMetadata(String id, AssertingParty properties) {
+	private RelyingPartyRegistration.Builder createBuilderUsingMetadata(AssertingParty properties) {
 		String requiredEntityId = properties.getEntityId();
 		Collection<Builder> candidates = RelyingPartyRegistrations
 			.collectionFromMetadataLocation(properties.getMetadataUri());
@@ -128,16 +128,13 @@ private Object getEntityId(RelyingPartyRegistration.Builder candidate) {
 		return result[0];
 	}
 
-	private Consumer<AssertingPartyDetails.Builder> mapAssertingParty(AssertingParty assertingParty,
-			boolean usingMetadata) {
+	private Consumer<AssertingPartyDetails.Builder> mapAssertingParty(AssertingParty assertingParty) {
 		return (details) -> {
 			PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
 			map.from(assertingParty::getEntityId).to(details::entityId);
 			map.from(assertingParty.getSinglesignon()::getBinding).to(details::singleSignOnServiceBinding);
 			map.from(assertingParty.getSinglesignon()::getUrl).to(details::singleSignOnServiceLocation);
-			map.from(assertingParty.getSinglesignon()::isSignRequest)
-				.when((signRequest) -> !usingMetadata)
-				.to(details::wantAuthnRequestsSigned);
+			map.from(assertingParty.getSinglesignon()::getSignRequest).to(details::wantAuthnRequestsSigned);
 			map.from(assertingParty.getSinglelogout()::getUrl).to(details::singleLogoutServiceLocation);
 			map.from(assertingParty.getSinglelogout()::getResponseUrl).to(details::singleLogoutServiceResponseLocation);
 			map.from(assertingParty.getSinglelogout()::getBinding).to(details::singleLogoutServiceBinding);
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/servlet/UserDetailsServiceAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/servlet/UserDetailsServiceAutoConfiguration.java
index 55c3dec9a6a7..396b50856c34 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/servlet/UserDetailsServiceAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/servlet/UserDetailsServiceAutoConfiguration.java
@@ -28,6 +28,7 @@
 import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass;
 import org.springframework.boot.autoconfigure.security.SecurityProperties;
 import org.springframework.context.annotation.Bean;
 import org.springframework.security.authentication.AuthenticationManager;
@@ -43,9 +44,7 @@
 /**
  * {@link EnableAutoConfiguration Auto-configuration} for a Spring Security in-memory
  * {@link AuthenticationManager}. Adds an {@link InMemoryUserDetailsManager} with a
- * default user and generated password. This can be disabled by providing a bean of type
- * {@link AuthenticationManager}, {@link AuthenticationProvider} or
- * {@link UserDetailsService}.
+ * default user and generated password.
  *
  * @author Dave Syer
  * @author Rob Winch
@@ -54,14 +53,12 @@
  */
 @AutoConfiguration
 @ConditionalOnClass(AuthenticationManager.class)
+@ConditionalOnMissingClass({ "org.springframework.security.oauth2.client.registration.ClientRegistrationRepository",
+		"org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector",
+		"org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository" })
 @ConditionalOnBean(ObjectPostProcessor.class)
-@ConditionalOnMissingBean(
-		value = { AuthenticationManager.class, AuthenticationProvider.class, UserDetailsService.class,
-				AuthenticationManagerResolver.class },
-		type = { "org.springframework.security.oauth2.jwt.JwtDecoder",
-				"org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector",
-				"org.springframework.security.oauth2.client.registration.ClientRegistrationRepository",
-				"org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository" })
+@ConditionalOnMissingBean(value = { AuthenticationManager.class, AuthenticationProvider.class, UserDetailsService.class,
+		AuthenticationManagerResolver.class }, type = "org.springframework.security.oauth2.jwt.JwtDecoder")
 public class UserDetailsServiceAutoConfiguration {
 
 	private static final String NOOP_PASSWORD_PREFIX = "{noop}";
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/session/HazelcastSessionConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/session/HazelcastSessionConfiguration.java
index ed568e8b8344..4a3432467c1e 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/session/HazelcastSessionConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/session/HazelcastSessionConfiguration.java
@@ -27,6 +27,8 @@
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Import;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
 import org.springframework.session.SessionRepository;
 import org.springframework.session.config.SessionRepositoryCustomizer;
 import org.springframework.session.hazelcast.HazelcastIndexedSessionRepository;
@@ -49,6 +51,7 @@
 class HazelcastSessionConfiguration {
 
 	@Bean
+	@Order(Ordered.HIGHEST_PRECEDENCE)
 	SessionRepositoryCustomizer<HazelcastIndexedSessionRepository> springBootSessionRepositoryCustomizer(
 			SessionProperties sessionProperties, HazelcastSessionProperties hazelcastSessionProperties,
 			ServerProperties serverProperties) {
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/session/MongoSessionConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/session/MongoSessionConfiguration.java
index 97d7c6fa9a8f..97910540f9eb 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/session/MongoSessionConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/session/MongoSessionConfiguration.java
@@ -25,6 +25,8 @@
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Import;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
 import org.springframework.data.mongodb.core.MongoOperations;
 import org.springframework.session.SessionRepository;
 import org.springframework.session.config.SessionRepositoryCustomizer;
@@ -47,6 +49,7 @@
 class MongoSessionConfiguration {
 
 	@Bean
+	@Order(Ordered.HIGHEST_PRECEDENCE)
 	SessionRepositoryCustomizer<MongoIndexedSessionRepository> springBootSessionRepositoryCustomizer(
 			SessionProperties sessionProperties, MongoSessionProperties mongoSessionProperties,
 			ServerProperties serverProperties) {
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/session/RedisSessionConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/session/RedisSessionConfiguration.java
index fefd83f5520e..71faf7798713 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/session/RedisSessionConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/session/RedisSessionConfiguration.java
@@ -27,6 +27,8 @@
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Import;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
 import org.springframework.data.redis.connection.RedisConnectionFactory;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.session.SessionRepository;
@@ -61,6 +63,7 @@ class RedisSessionConfiguration {
 	static class DefaultRedisSessionConfiguration {
 
 		@Bean
+		@Order(Ordered.HIGHEST_PRECEDENCE)
 		SessionRepositoryCustomizer<RedisSessionRepository> springBootSessionRepositoryCustomizer(
 				SessionProperties sessionProperties, RedisSessionProperties redisSessionProperties,
 				ServerProperties serverProperties) {
@@ -98,6 +101,7 @@ ConfigureRedisAction configureRedisAction(RedisSessionProperties redisSessionPro
 		}
 
 		@Bean
+		@Order(Ordered.HIGHEST_PRECEDENCE)
 		SessionRepositoryCustomizer<RedisIndexedSessionRepository> springBootSessionRepositoryCustomizer(
 				SessionProperties sessionProperties, RedisSessionProperties redisSessionProperties,
 				ServerProperties serverProperties) {
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/session/SessionAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/session/SessionAutoConfiguration.java
index 4d6329462831..b5e36068a8a3 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/session/SessionAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/session/SessionAutoConfiguration.java
@@ -41,8 +41,8 @@
 import org.springframework.boot.autoconfigure.web.reactive.WebSessionIdResolverAutoConfiguration;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.boot.context.properties.PropertyMapper;
+import org.springframework.boot.web.server.Cookie;
 import org.springframework.boot.web.server.Cookie.SameSite;
-import org.springframework.boot.web.servlet.server.Session.Cookie;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Conditional;
 import org.springframework.context.annotation.Configuration;
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/BundleContentProperty.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/BundleContentProperty.java
new file mode 100644
index 000000000000..7be8e3eceb37
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/BundleContentProperty.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.ssl;
+
+import java.io.FileNotFoundException;
+import java.net.URL;
+import java.nio.file.Path;
+
+import org.springframework.boot.ssl.pem.PemContent;
+import org.springframework.util.Assert;
+import org.springframework.util.ResourceUtils;
+import org.springframework.util.StringUtils;
+
+/**
+ * Helper utility to manage a single bundle content configuration property. May possibly
+ * contain PEM content, a location or a directory search pattern.
+ *
+ * @param name the configuration property name (excluding any prefix)
+ * @param value the configuration property value
+ * @author Phillip Webb
+ */
+record BundleContentProperty(String name, String value) {
+
+	/**
+	 * Return if the property value is PEM content.
+	 * @return if the value is PEM content
+	 */
+	boolean isPemContent() {
+		return PemContent.isPresentInText(this.value);
+	}
+
+	/**
+	 * Return if there is any property value present.
+	 * @return if the value is present
+	 */
+	boolean hasValue() {
+		return StringUtils.hasText(this.value);
+	}
+
+	Path toWatchPath() {
+		return toPath();
+	}
+
+	private Path toPath() {
+		try {
+			URL url = toUrl();
+			Assert.state(isFileUrl(url), () -> "Value '%s' is not a file URL".formatted(url));
+			return Path.of(url.toURI()).toAbsolutePath();
+		}
+		catch (Exception ex) {
+			throw new IllegalStateException("Unable to convert value of property '%s' to a path".formatted(this.name),
+					ex);
+		}
+	}
+
+	private URL toUrl() throws FileNotFoundException {
+		Assert.state(!isPemContent(), "Value contains PEM content");
+		return ResourceUtils.getURL(this.value);
+	}
+
+	private boolean isFileUrl(URL url) {
+		return "file".equalsIgnoreCase(url.getProtocol());
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/CertificateMatcher.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/CertificateMatcher.java
new file mode 100644
index 000000000000..3f25ecc2c0c4
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/CertificateMatcher.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.ssl;
+
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.security.SignatureException;
+import java.security.cert.Certificate;
+import java.util.List;
+import java.util.Objects;
+
+import org.springframework.util.Assert;
+
+/**
+ * Helper used to match certificates against a {@link PrivateKey}.
+ *
+ * @author Moritz Halbritter
+ * @author Phillip Webb
+ */
+class CertificateMatcher {
+
+	private static final byte[] DATA = new byte[256];
+	static {
+		for (int i = 0; i < DATA.length; i++) {
+			DATA[i] = (byte) i;
+		}
+	}
+
+	private final PrivateKey privateKey;
+
+	private final Signature signature;
+
+	private final byte[] generatedSignature;
+
+	CertificateMatcher(PrivateKey privateKey) {
+		Assert.notNull(privateKey, "Private key must not be null");
+		this.privateKey = privateKey;
+		this.signature = createSignature(privateKey);
+		Assert.notNull(this.signature, "Failed to create signature");
+		this.generatedSignature = sign(this.signature, privateKey);
+	}
+
+	private Signature createSignature(PrivateKey privateKey) {
+		try {
+			String algorithm = getSignatureAlgorithm(privateKey);
+			return (algorithm != null) ? Signature.getInstance(algorithm) : null;
+		}
+		catch (NoSuchAlgorithmException ex) {
+			return null;
+		}
+	}
+
+	private static String getSignatureAlgorithm(PrivateKey privateKey) {
+		// https://docs.oracle.com/en/java/javase/17/docs/specs/security/standard-names.html#signature-algorithms
+		// https://docs.oracle.com/en/java/javase/17/docs/specs/security/standard-names.html#keypairgenerator-algorithms
+		return switch (privateKey.getAlgorithm()) {
+			case "RSA" -> "SHA256withRSA";
+			case "DSA" -> "SHA256withDSA";
+			case "EC" -> "SHA256withECDSA";
+			case "EdDSA" -> "EdDSA";
+			default -> null;
+		};
+	}
+
+	boolean matchesAny(List<? extends Certificate> certificates) {
+		return (this.generatedSignature != null) && certificates.stream().anyMatch(this::matches);
+	}
+
+	boolean matches(Certificate certificate) {
+		return matches(certificate.getPublicKey());
+	}
+
+	private boolean matches(PublicKey publicKey) {
+		return (this.generatedSignature != null)
+				&& Objects.equals(this.privateKey.getAlgorithm(), publicKey.getAlgorithm()) && verify(publicKey);
+	}
+
+	private boolean verify(PublicKey publicKey) {
+		try {
+			this.signature.initVerify(publicKey);
+			this.signature.update(DATA);
+			return this.signature.verify(this.generatedSignature);
+		}
+		catch (InvalidKeyException | SignatureException ex) {
+			return false;
+		}
+	}
+
+	private static byte[] sign(Signature signature, PrivateKey privateKey) {
+		try {
+			signature.initSign(privateKey);
+			signature.update(DATA);
+			return signature.sign();
+		}
+		catch (InvalidKeyException | SignatureException ex) {
+			return null;
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/FileWatcher.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/FileWatcher.java
new file mode 100644
index 000000000000..eecad97b3b4e
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/FileWatcher.java
@@ -0,0 +1,229 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.ssl;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.ClosedWatchServiceException;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardWatchEventKinds;
+import java.nio.file.WatchEvent;
+import java.nio.file.WatchKey;
+import java.nio.file.WatchService;
+import java.time.Duration;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.core.log.LogMessage;
+import org.springframework.util.Assert;
+
+/**
+ * Watches files and directories and triggers a callback on change.
+ *
+ * @author Moritz Halbritter
+ * @author Phillip Webb
+ */
+class FileWatcher implements Closeable {
+
+	private static final Log logger = LogFactory.getLog(FileWatcher.class);
+
+	private final Duration quietPeriod;
+
+	private final Object lock = new Object();
+
+	private WatcherThread thread;
+
+	/**
+	 * Create a new {@link FileWatcher} instance.
+	 * @param quietPeriod the duration that no file changes should occur before triggering
+	 * actions
+	 */
+	FileWatcher(Duration quietPeriod) {
+		Assert.notNull(quietPeriod, "QuietPeriod must not be null");
+		this.quietPeriod = quietPeriod;
+	}
+
+	/**
+	 * Watch the given files or directories for changes.
+	 * @param paths the files or directories to watch
+	 * @param action the action to take when changes are detected
+	 */
+	void watch(Set<Path> paths, Runnable action) {
+		Assert.notNull(paths, "Paths must not be null");
+		Assert.notNull(action, "Action must not be null");
+		if (paths.isEmpty()) {
+			return;
+		}
+		synchronized (this.lock) {
+			try {
+				if (this.thread == null) {
+					this.thread = new WatcherThread();
+					this.thread.start();
+				}
+				this.thread.register(new Registration(paths, action));
+			}
+			catch (IOException ex) {
+				throw new UncheckedIOException("Failed to register paths for watching: " + paths, ex);
+			}
+		}
+	}
+
+	@Override
+	public void close() throws IOException {
+		synchronized (this.lock) {
+			if (this.thread != null) {
+				this.thread.close();
+				this.thread.interrupt();
+				try {
+					this.thread.join();
+				}
+				catch (InterruptedException ex) {
+					Thread.currentThread().interrupt();
+				}
+				this.thread = null;
+			}
+		}
+	}
+
+	/**
+	 * The watcher thread used to check for changes.
+	 */
+	private class WatcherThread extends Thread implements Closeable {
+
+		private final WatchService watchService = FileSystems.getDefault().newWatchService();
+
+		private final Map<WatchKey, List<Registration>> registrations = new ConcurrentHashMap<>();
+
+		private volatile boolean running = true;
+
+		WatcherThread() throws IOException {
+			setName("ssl-bundle-watcher");
+			setDaemon(true);
+			setUncaughtExceptionHandler(this::onThreadException);
+		}
+
+		private void onThreadException(Thread thread, Throwable throwable) {
+			logger.error("Uncaught exception in file watcher thread", throwable);
+		}
+
+		void register(Registration registration) throws IOException {
+			for (Path path : registration.paths()) {
+				if (!Files.isRegularFile(path) && !Files.isDirectory(path)) {
+					throw new IOException("'%s' is neither a file nor a directory".formatted(path));
+				}
+				Path directory = Files.isDirectory(path) ? path : path.getParent();
+				WatchKey watchKey = register(directory);
+				this.registrations.computeIfAbsent(watchKey, (key) -> new CopyOnWriteArrayList<>()).add(registration);
+			}
+		}
+
+		private WatchKey register(Path directory) throws IOException {
+			logger.debug(LogMessage.format("Registering '%s'", directory));
+			return directory.register(this.watchService, StandardWatchEventKinds.ENTRY_CREATE,
+					StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE);
+		}
+
+		@Override
+		public void run() {
+			logger.debug("Watch thread started");
+			Set<Runnable> actions = new HashSet<>();
+			while (this.running) {
+				try {
+					long timeout = FileWatcher.this.quietPeriod.toMillis();
+					WatchKey key = this.watchService.poll(timeout, TimeUnit.MILLISECONDS);
+					if (key == null) {
+						actions.forEach(this::runSafely);
+						actions.clear();
+					}
+					else {
+						accumulate(key, actions);
+						key.reset();
+					}
+				}
+				catch (InterruptedException ex) {
+					Thread.currentThread().interrupt();
+				}
+				catch (ClosedWatchServiceException ex) {
+					logger.debug("File watcher has been closed");
+					this.running = false;
+				}
+			}
+			logger.debug("Watch thread stopped");
+		}
+
+		private void runSafely(Runnable action) {
+			try {
+				action.run();
+			}
+			catch (Throwable ex) {
+				logger.error("Unexpected SSL reload error", ex);
+			}
+		}
+
+		private void accumulate(WatchKey key, Set<Runnable> actions) {
+			List<Registration> registrations = this.registrations.get(key);
+			Path directory = (Path) key.watchable();
+			for (WatchEvent<?> event : key.pollEvents()) {
+				Path file = directory.resolve((Path) event.context());
+				for (Registration registration : registrations) {
+					if (registration.manages(file)) {
+						actions.add(registration.action());
+					}
+				}
+			}
+		}
+
+		@Override
+		public void close() throws IOException {
+			this.running = false;
+			this.watchService.close();
+		}
+
+	}
+
+	/**
+	 * An individual watch registration.
+	 */
+	private record Registration(Set<Path> paths, Runnable action) {
+
+		Registration {
+			paths = paths.stream().map(Path::toAbsolutePath).collect(Collectors.toSet());
+		}
+
+		boolean manages(Path file) {
+			Path absolutePath = file.toAbsolutePath();
+			return this.paths.contains(absolutePath) || isInDirectories(absolutePath);
+		}
+
+		private boolean isInDirectories(Path file) {
+			return this.paths.stream().filter(Files::isDirectory).anyMatch(file::startsWith);
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PemSslBundleProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PemSslBundleProperties.java
index 16798cb2cb05..beb58d87dc91 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PemSslBundleProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PemSslBundleProperties.java
@@ -23,6 +23,7 @@
  *
  * @author Scott Frederick
  * @author Phillip Webb
+ * @author Moritz Halbritter
  * @since 3.1.0
  * @see PemSslStoreBundle
  */
@@ -57,7 +58,7 @@ public static class Store {
 		private String type;
 
 		/**
-		 * Location or content of the certificate in PEM format.
+		 * Location or content of the certificate or certificate chain in PEM format.
 		 */
 		private String certificate;
 
@@ -71,6 +72,11 @@ public static class Store {
 		 */
 		private String privateKeyPassword;
 
+		/**
+		 * Whether to verify that the private key matches the public key.
+		 */
+		private boolean verifyKeys;
+
 		public String getType() {
 			return this.type;
 		}
@@ -103,6 +109,14 @@ public void setPrivateKeyPassword(String privateKeyPassword) {
 			this.privateKeyPassword = privateKeyPassword;
 		}
 
+		public boolean isVerifyKeys() {
+			return this.verifyKeys;
+		}
+
+		public void setVerifyKeys(boolean verifyKeys) {
+			this.verifyKeys = verifyKeys;
+		}
+
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PropertiesSslBundle.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PropertiesSslBundle.java
index b25b88ad14f6..a76f5c2fa2b1 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PropertiesSslBundle.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PropertiesSslBundle.java
@@ -16,6 +16,9 @@
 
 package org.springframework.boot.autoconfigure.ssl;
 
+import java.io.IOException;
+import java.io.UncheckedIOException;
+
 import org.springframework.boot.autoconfigure.ssl.SslBundleProperties.Key;
 import org.springframework.boot.ssl.SslBundle;
 import org.springframework.boot.ssl.SslBundleKey;
@@ -24,8 +27,10 @@
 import org.springframework.boot.ssl.SslStoreBundle;
 import org.springframework.boot.ssl.jks.JksSslStoreBundle;
 import org.springframework.boot.ssl.jks.JksSslStoreDetails;
+import org.springframework.boot.ssl.pem.PemSslStore;
 import org.springframework.boot.ssl.pem.PemSslStoreBundle;
 import org.springframework.boot.ssl.pem.PemSslStoreDetails;
+import org.springframework.util.Assert;
 
 /**
  * {@link SslBundle} backed by {@link JksSslBundleProperties} or
@@ -94,7 +99,35 @@ public SslManagerBundle getManagers() {
 	 * @return an {@link SslBundle} instance
 	 */
 	public static SslBundle get(PemSslBundleProperties properties) {
-		return new PropertiesSslBundle(asSslStoreBundle(properties), properties);
+		try {
+			PemSslStore keyStore = getPemSslStore("keystore", properties.getKeystore());
+			if (keyStore != null) {
+				keyStore = keyStore.withAlias(properties.getKey().getAlias())
+					.withPassword(properties.getKey().getPassword());
+			}
+			PemSslStore trustStore = getPemSslStore("truststore", properties.getTruststore());
+			SslStoreBundle storeBundle = new PemSslStoreBundle(keyStore, trustStore);
+			return new PropertiesSslBundle(storeBundle, properties);
+		}
+		catch (IOException ex) {
+			throw new UncheckedIOException(ex);
+		}
+	}
+
+	private static PemSslStore getPemSslStore(String propertyName, PemSslBundleProperties.Store properties)
+			throws IOException {
+		PemSslStore pemSslStore = PemSslStore.load(asPemSslStoreDetails(properties));
+		if (properties.isVerifyKeys()) {
+			CertificateMatcher certificateMatcher = new CertificateMatcher(pemSslStore.privateKey());
+			Assert.state(certificateMatcher.matchesAny(pemSslStore.certificates()),
+					"Private key in %s matches none of the certificates in the chain".formatted(propertyName));
+		}
+		return pemSslStore;
+	}
+
+	private static PemSslStoreDetails asPemSslStoreDetails(PemSslBundleProperties.Store properties) {
+		return new PemSslStoreDetails(properties.getType(), properties.getCertificate(), properties.getPrivateKey(),
+				properties.getPrivateKeyPassword());
 	}
 
 	/**
@@ -103,18 +136,8 @@ public static SslBundle get(PemSslBundleProperties properties) {
 	 * @return an {@link SslBundle} instance
 	 */
 	public static SslBundle get(JksSslBundleProperties properties) {
-		return new PropertiesSslBundle(asSslStoreBundle(properties), properties);
-	}
-
-	private static SslStoreBundle asSslStoreBundle(PemSslBundleProperties properties) {
-		PemSslStoreDetails keyStoreDetails = asStoreDetails(properties.getKeystore());
-		PemSslStoreDetails trustStoreDetails = asStoreDetails(properties.getTruststore());
-		return new PemSslStoreBundle(keyStoreDetails, trustStoreDetails, properties.getKey().getAlias());
-	}
-
-	private static PemSslStoreDetails asStoreDetails(PemSslBundleProperties.Store properties) {
-		return new PemSslStoreDetails(properties.getType(), properties.getCertificate(), properties.getPrivateKey(),
-				properties.getPrivateKeyPassword());
+		SslStoreBundle storeBundle = asSslStoreBundle(properties);
+		return new PropertiesSslBundle(storeBundle, properties);
 	}
 
 	private static SslStoreBundle asSslStoreBundle(JksSslBundleProperties properties) {
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslAutoConfiguration.java
index 12b856c8a01d..1348f16b37b8 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslAutoConfiguration.java
@@ -16,8 +16,7 @@
 
 package org.springframework.boot.autoconfigure.ssl;
 
-import java.util.List;
-
+import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@@ -37,19 +36,27 @@
 @EnableConfigurationProperties(SslProperties.class)
 public class SslAutoConfiguration {
 
-	SslAutoConfiguration() {
+	private final SslProperties sslProperties;
+
+	SslAutoConfiguration(SslProperties sslProperties) {
+		this.sslProperties = sslProperties;
+	}
+
+	@Bean
+	FileWatcher fileWatcher() {
+		return new FileWatcher(this.sslProperties.getBundle().getWatch().getFile().getQuietPeriod());
 	}
 
 	@Bean
-	public SslPropertiesBundleRegistrar sslPropertiesSslBundleRegistrar(SslProperties sslProperties) {
-		return new SslPropertiesBundleRegistrar(sslProperties);
+	SslPropertiesBundleRegistrar sslPropertiesSslBundleRegistrar(FileWatcher fileWatcher) {
+		return new SslPropertiesBundleRegistrar(this.sslProperties, fileWatcher);
 	}
 
 	@Bean
 	@ConditionalOnMissingBean({ SslBundleRegistry.class, SslBundles.class })
-	public DefaultSslBundleRegistry sslBundleRegistry(List<SslBundleRegistrar> sslBundleRegistrars) {
+	DefaultSslBundleRegistry sslBundleRegistry(ObjectProvider<SslBundleRegistrar> sslBundleRegistrars) {
 		DefaultSslBundleRegistry registry = new DefaultSslBundleRegistry();
-		sslBundleRegistrars.forEach((registrar) -> registrar.registerBundles(registry));
+		sslBundleRegistrars.orderedStream().forEach((registrar) -> registrar.registerBundles(registry));
 		return registry;
 	}
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslBundleProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslBundleProperties.java
index e8b9fd1a4cba..b01201dba07e 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslBundleProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslBundleProperties.java
@@ -36,7 +36,7 @@ public abstract class SslBundleProperties {
 	private final Key key = new Key();
 
 	/**
-	 * Options for the SLL connection.
+	 * Options for the SSL connection.
 	 */
 	private final Options options = new Options();
 
@@ -45,6 +45,11 @@ public abstract class SslBundleProperties {
 	 */
 	private String protocol = SslBundle.DEFAULT_PROTOCOL;
 
+	/**
+	 * Whether to reload the SSL bundle.
+	 */
+	private boolean reloadOnUpdate;
+
 	public Key getKey() {
 		return this.key;
 	}
@@ -61,6 +66,14 @@ public void setProtocol(String protocol) {
 		this.protocol = protocol;
 	}
 
+	public boolean isReloadOnUpdate() {
+		return this.reloadOnUpdate;
+	}
+
+	public void setReloadOnUpdate(boolean reloadOnUpdate) {
+		this.reloadOnUpdate = reloadOnUpdate;
+	}
+
 	public static class Options {
 
 		/**
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslProperties.java
index 49aced749021..a755a871b8d7 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslProperties.java
@@ -16,6 +16,7 @@
 
 package org.springframework.boot.autoconfigure.ssl;
 
+import java.time.Duration;
 import java.util.LinkedHashMap;
 import java.util.Map;
 
@@ -25,6 +26,7 @@
  * Properties for centralized SSL trust material configuration.
  *
  * @author Scott Frederick
+ * @author Moritz Halbritter
  * @since 3.1.0
  */
 @ConfigurationProperties(prefix = "spring.ssl")
@@ -54,6 +56,11 @@ public static class Bundles {
 		 */
 		private final Map<String, JksSslBundleProperties> jks = new LinkedHashMap<>();
 
+		/**
+		 * Trust material watching.
+		 */
+		private final Watch watch = new Watch();
+
 		public Map<String, PemSslBundleProperties> getPem() {
 			return this.pem;
 		}
@@ -62,6 +69,40 @@ public Map<String, JksSslBundleProperties> getJks() {
 			return this.jks;
 		}
 
+		public Watch getWatch() {
+			return this.watch;
+		}
+
+		public static class Watch {
+
+			/**
+			 * File watching.
+			 */
+			private final File file = new File();
+
+			public File getFile() {
+				return this.file;
+			}
+
+			public static class File {
+
+				/**
+				 * Quiet period, after which changes are detected.
+				 */
+				private Duration quietPeriod = Duration.ofSeconds(10);
+
+				public Duration getQuietPeriod() {
+					return this.quietPeriod;
+				}
+
+				public void setQuietPeriod(Duration quietPeriod) {
+					this.quietPeriod = quietPeriod;
+				}
+
+			}
+
+		}
+
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrar.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrar.java
index 89a3e7c1265c..583702c82ce2 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrar.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrar.java
@@ -16,8 +16,14 @@
 
 package org.springframework.boot.autoconfigure.ssl;
 
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.function.Function;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
 
 import org.springframework.boot.ssl.SslBundle;
 import org.springframework.boot.ssl.SslBundleRegistry;
@@ -28,25 +34,73 @@
  *
  * @author Scott Frederick
  * @author Phillip Webb
+ * @author Moritz Halbritter
  */
 class SslPropertiesBundleRegistrar implements SslBundleRegistrar {
 
 	private final SslProperties.Bundles properties;
 
-	SslPropertiesBundleRegistrar(SslProperties properties) {
+	private final FileWatcher fileWatcher;
+
+	SslPropertiesBundleRegistrar(SslProperties properties, FileWatcher fileWatcher) {
 		this.properties = properties.getBundle();
+		this.fileWatcher = fileWatcher;
 	}
 
 	@Override
 	public void registerBundles(SslBundleRegistry registry) {
-		registerBundles(registry, this.properties.getPem(), PropertiesSslBundle::get);
-		registerBundles(registry, this.properties.getJks(), PropertiesSslBundle::get);
+		registerBundles(registry, this.properties.getPem(), PropertiesSslBundle::get, this::watchedPemPaths);
+		registerBundles(registry, this.properties.getJks(), PropertiesSslBundle::get, this::watchedJksPaths);
 	}
 
 	private <P extends SslBundleProperties> void registerBundles(SslBundleRegistry registry, Map<String, P> properties,
-			Function<P, SslBundle> bundleFactory) {
-		properties.forEach((bundleName, bundleProperties) -> registry.registerBundle(bundleName,
-				bundleFactory.apply(bundleProperties)));
+			Function<P, SslBundle> bundleFactory, Function<P, Set<Path>> watchedPaths) {
+		properties.forEach((bundleName, bundleProperties) -> {
+			Supplier<SslBundle> bundleSupplier = () -> bundleFactory.apply(bundleProperties);
+			try {
+				registry.registerBundle(bundleName, bundleSupplier.get());
+				if (bundleProperties.isReloadOnUpdate()) {
+					Supplier<Set<Path>> pathsSupplier = () -> watchedPaths.apply(bundleProperties);
+					watchForUpdates(registry, bundleName, pathsSupplier, bundleSupplier);
+				}
+			}
+			catch (IllegalStateException ex) {
+				throw new IllegalStateException("Unable to register SSL bundle '%s'".formatted(bundleName), ex);
+			}
+		});
+	}
+
+	private void watchForUpdates(SslBundleRegistry registry, String bundleName, Supplier<Set<Path>> pathsSupplier,
+			Supplier<SslBundle> bundleSupplier) {
+		try {
+			this.fileWatcher.watch(pathsSupplier.get(), () -> registry.updateBundle(bundleName, bundleSupplier.get()));
+		}
+		catch (RuntimeException ex) {
+			throw new IllegalStateException("Unable to watch for reload on update", ex);
+		}
+	}
+
+	private Set<Path> watchedJksPaths(JksSslBundleProperties properties) {
+		List<BundleContentProperty> watched = new ArrayList<>();
+		watched.add(new BundleContentProperty("keystore.location", properties.getKeystore().getLocation()));
+		watched.add(new BundleContentProperty("truststore.location", properties.getTruststore().getLocation()));
+		return watchedPaths(watched);
+	}
+
+	private Set<Path> watchedPemPaths(PemSslBundleProperties properties) {
+		List<BundleContentProperty> watched = new ArrayList<>();
+		watched.add(new BundleContentProperty("keystore.private-key", properties.getKeystore().getPrivateKey()));
+		watched.add(new BundleContentProperty("keystore.certificate", properties.getKeystore().getCertificate()));
+		watched.add(new BundleContentProperty("truststore.private-key", properties.getTruststore().getPrivateKey()));
+		watched.add(new BundleContentProperty("truststore.certificate", properties.getTruststore().getCertificate()));
+		return watchedPaths(watched);
+	}
+
+	private Set<Path> watchedPaths(List<BundleContentProperty> properties) {
+		return properties.stream()
+			.filter(BundleContentProperty::hasValue)
+			.map(BundleContentProperty::toWatchPath)
+			.collect(Collectors.toSet());
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfiguration.java
index 1ebb19871931..2f76a06a8c76 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,22 +16,12 @@
 
 package org.springframework.boot.autoconfigure.task;
 
-import java.util.concurrent.Executor;
-
-import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
-import org.springframework.boot.autoconfigure.task.TaskExecutionProperties.Shutdown;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
-import org.springframework.boot.task.TaskExecutorBuilder;
-import org.springframework.boot.task.TaskExecutorCustomizer;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Lazy;
-import org.springframework.core.task.TaskDecorator;
+import org.springframework.context.annotation.Import;
 import org.springframework.core.task.TaskExecutor;
-import org.springframework.scheduling.annotation.AsyncAnnotationBeanPostProcessor;
 import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
 
 /**
@@ -39,11 +29,16 @@
  *
  * @author Stephane Nicoll
  * @author Camille Vienot
+ * @author Moritz Halbritter
  * @since 2.1.0
  */
 @ConditionalOnClass(ThreadPoolTaskExecutor.class)
 @AutoConfiguration
 @EnableConfigurationProperties(TaskExecutionProperties.class)
+@Import({ TaskExecutorConfigurations.ThreadPoolTaskExecutorBuilderConfiguration.class,
+		TaskExecutorConfigurations.TaskExecutorBuilderConfiguration.class,
+		TaskExecutorConfigurations.SimpleAsyncTaskExecutorBuilderConfiguration.class,
+		TaskExecutorConfigurations.TaskExecutorConfiguration.class })
 public class TaskExecutionAutoConfiguration {
 
 	/**
@@ -51,33 +46,4 @@ public class TaskExecutionAutoConfiguration {
 	 */
 	public static final String APPLICATION_TASK_EXECUTOR_BEAN_NAME = "applicationTaskExecutor";
 
-	@Bean
-	@ConditionalOnMissingBean
-	public TaskExecutorBuilder taskExecutorBuilder(TaskExecutionProperties properties,
-			ObjectProvider<TaskExecutorCustomizer> taskExecutorCustomizers,
-			ObjectProvider<TaskDecorator> taskDecorator) {
-		TaskExecutionProperties.Pool pool = properties.getPool();
-		TaskExecutorBuilder builder = new TaskExecutorBuilder();
-		builder = builder.queueCapacity(pool.getQueueCapacity());
-		builder = builder.corePoolSize(pool.getCoreSize());
-		builder = builder.maxPoolSize(pool.getMaxSize());
-		builder = builder.allowCoreThreadTimeOut(pool.isAllowCoreThreadTimeout());
-		builder = builder.keepAlive(pool.getKeepAlive());
-		Shutdown shutdown = properties.getShutdown();
-		builder = builder.awaitTermination(shutdown.isAwaitTermination());
-		builder = builder.awaitTerminationPeriod(shutdown.getAwaitTerminationPeriod());
-		builder = builder.threadNamePrefix(properties.getThreadNamePrefix());
-		builder = builder.customizers(taskExecutorCustomizers.orderedStream()::iterator);
-		builder = builder.taskDecorator(taskDecorator.getIfUnique());
-		return builder;
-	}
-
-	@Lazy
-	@Bean(name = { APPLICATION_TASK_EXECUTOR_BEAN_NAME,
-			AsyncAnnotationBeanPostProcessor.DEFAULT_TASK_EXECUTOR_BEAN_NAME })
-	@ConditionalOnMissingBean(Executor.class)
-	public ThreadPoolTaskExecutor applicationTaskExecutor(TaskExecutorBuilder builder) {
-		return builder.build();
-	}
-
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionProperties.java
index c8bcc17ce999..9530f198289a 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionProperties.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -32,6 +32,8 @@ public class TaskExecutionProperties {
 
 	private final Pool pool = new Pool();
 
+	private final Simple simple = new Simple();
+
 	private final Shutdown shutdown = new Shutdown();
 
 	/**
@@ -39,6 +41,10 @@ public class TaskExecutionProperties {
 	 */
 	private String threadNamePrefix = "task-";
 
+	public Simple getSimple() {
+		return this.simple;
+	}
+
 	public Pool getPool() {
 		return this.pool;
 	}
@@ -55,6 +61,24 @@ public void setThreadNamePrefix(String threadNamePrefix) {
 		this.threadNamePrefix = threadNamePrefix;
 	}
 
+	public static class Simple {
+
+		/**
+		 * Set the maximum number of parallel accesses allowed. -1 indicates no
+		 * concurrency limit at all.
+		 */
+		private Integer concurrencyLimit;
+
+		public Integer getConcurrencyLimit() {
+			return this.concurrencyLimit;
+		}
+
+		public void setConcurrencyLimit(Integer concurrencyLimit) {
+			this.concurrencyLimit = concurrencyLimit;
+		}
+
+	}
+
 	public static class Pool {
 
 		/**
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutorConfigurations.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutorConfigurations.java
new file mode 100644
index 000000000000..ccefd966e49f
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutorConfigurations.java
@@ -0,0 +1,185 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.task;
+
+import java.util.concurrent.Executor;
+
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnThreading;
+import org.springframework.boot.autoconfigure.task.TaskExecutionProperties.Shutdown;
+import org.springframework.boot.autoconfigure.thread.Threading;
+import org.springframework.boot.task.SimpleAsyncTaskExecutorBuilder;
+import org.springframework.boot.task.SimpleAsyncTaskExecutorCustomizer;
+import org.springframework.boot.task.TaskExecutorBuilder;
+import org.springframework.boot.task.TaskExecutorCustomizer;
+import org.springframework.boot.task.ThreadPoolTaskExecutorBuilder;
+import org.springframework.boot.task.ThreadPoolTaskExecutorCustomizer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.core.task.SimpleAsyncTaskExecutor;
+import org.springframework.core.task.TaskDecorator;
+import org.springframework.core.task.TaskExecutor;
+import org.springframework.scheduling.annotation.AsyncAnnotationBeanPostProcessor;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+/**
+ * {@link TaskExecutor} configurations to be imported by
+ * {@link TaskExecutionAutoConfiguration} in a specific order.
+ *
+ * @author Andy Wilkinson
+ * @author Moritz Halbritter
+ */
+class TaskExecutorConfigurations {
+
+	@Configuration(proxyBeanMethods = false)
+	@ConditionalOnMissingBean(Executor.class)
+	@SuppressWarnings("removal")
+	static class TaskExecutorConfiguration {
+
+		@Bean(name = { TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME,
+				AsyncAnnotationBeanPostProcessor.DEFAULT_TASK_EXECUTOR_BEAN_NAME })
+		@ConditionalOnThreading(Threading.VIRTUAL)
+		SimpleAsyncTaskExecutor applicationTaskExecutorVirtualThreads(SimpleAsyncTaskExecutorBuilder builder) {
+			return builder.build();
+		}
+
+		@Lazy
+		@Bean(name = { TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME,
+				AsyncAnnotationBeanPostProcessor.DEFAULT_TASK_EXECUTOR_BEAN_NAME })
+		@ConditionalOnThreading(Threading.PLATFORM)
+		ThreadPoolTaskExecutor applicationTaskExecutor(TaskExecutorBuilder taskExecutorBuilder,
+				ObjectProvider<ThreadPoolTaskExecutorBuilder> threadPoolTaskExecutorBuilderProvider) {
+			ThreadPoolTaskExecutorBuilder threadPoolTaskExecutorBuilder = threadPoolTaskExecutorBuilderProvider
+				.getIfUnique();
+			if (threadPoolTaskExecutorBuilder != null) {
+				return threadPoolTaskExecutorBuilder.build();
+			}
+			return taskExecutorBuilder.build();
+		}
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	@SuppressWarnings("removal")
+	static class TaskExecutorBuilderConfiguration {
+
+		@Bean
+		@ConditionalOnMissingBean
+		@Deprecated(since = "3.2.0", forRemoval = true)
+		TaskExecutorBuilder taskExecutorBuilder(TaskExecutionProperties properties,
+				ObjectProvider<TaskExecutorCustomizer> taskExecutorCustomizers,
+				ObjectProvider<TaskDecorator> taskDecorator) {
+			TaskExecutionProperties.Pool pool = properties.getPool();
+			TaskExecutorBuilder builder = new TaskExecutorBuilder();
+			builder = builder.queueCapacity(pool.getQueueCapacity());
+			builder = builder.corePoolSize(pool.getCoreSize());
+			builder = builder.maxPoolSize(pool.getMaxSize());
+			builder = builder.allowCoreThreadTimeOut(pool.isAllowCoreThreadTimeout());
+			builder = builder.keepAlive(pool.getKeepAlive());
+			Shutdown shutdown = properties.getShutdown();
+			builder = builder.awaitTermination(shutdown.isAwaitTermination());
+			builder = builder.awaitTerminationPeriod(shutdown.getAwaitTerminationPeriod());
+			builder = builder.threadNamePrefix(properties.getThreadNamePrefix());
+			builder = builder.customizers(taskExecutorCustomizers.orderedStream()::iterator);
+			builder = builder.taskDecorator(taskDecorator.getIfUnique());
+			return builder;
+		}
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	@SuppressWarnings("removal")
+	static class ThreadPoolTaskExecutorBuilderConfiguration {
+
+		@Bean
+		@ConditionalOnMissingBean({ TaskExecutorBuilder.class, ThreadPoolTaskExecutorBuilder.class })
+		ThreadPoolTaskExecutorBuilder threadPoolTaskExecutorBuilder(TaskExecutionProperties properties,
+				ObjectProvider<ThreadPoolTaskExecutorCustomizer> threadPoolTaskExecutorCustomizers,
+				ObjectProvider<TaskExecutorCustomizer> taskExecutorCustomizers,
+				ObjectProvider<TaskDecorator> taskDecorator) {
+			TaskExecutionProperties.Pool pool = properties.getPool();
+			ThreadPoolTaskExecutorBuilder builder = new ThreadPoolTaskExecutorBuilder();
+			builder = builder.queueCapacity(pool.getQueueCapacity());
+			builder = builder.corePoolSize(pool.getCoreSize());
+			builder = builder.maxPoolSize(pool.getMaxSize());
+			builder = builder.allowCoreThreadTimeOut(pool.isAllowCoreThreadTimeout());
+			builder = builder.keepAlive(pool.getKeepAlive());
+			Shutdown shutdown = properties.getShutdown();
+			builder = builder.awaitTermination(shutdown.isAwaitTermination());
+			builder = builder.awaitTerminationPeriod(shutdown.getAwaitTerminationPeriod());
+			builder = builder.threadNamePrefix(properties.getThreadNamePrefix());
+			builder = builder.customizers(threadPoolTaskExecutorCustomizers.orderedStream()::iterator);
+			builder = builder.taskDecorator(taskDecorator.getIfUnique());
+			// Apply the deprecated TaskExecutorCustomizers, too
+			builder = builder.additionalCustomizers(taskExecutorCustomizers.orderedStream().map(this::adapt).toList());
+			return builder;
+		}
+
+		private ThreadPoolTaskExecutorCustomizer adapt(TaskExecutorCustomizer customizer) {
+			return customizer::customize;
+		}
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	static class SimpleAsyncTaskExecutorBuilderConfiguration {
+
+		private final TaskExecutionProperties properties;
+
+		private final ObjectProvider<SimpleAsyncTaskExecutorCustomizer> taskExecutorCustomizers;
+
+		private final ObjectProvider<TaskDecorator> taskDecorator;
+
+		SimpleAsyncTaskExecutorBuilderConfiguration(TaskExecutionProperties properties,
+				ObjectProvider<SimpleAsyncTaskExecutorCustomizer> taskExecutorCustomizers,
+				ObjectProvider<TaskDecorator> taskDecorator) {
+			this.properties = properties;
+			this.taskExecutorCustomizers = taskExecutorCustomizers;
+			this.taskDecorator = taskDecorator;
+		}
+
+		@Bean
+		@ConditionalOnMissingBean
+		@ConditionalOnThreading(Threading.PLATFORM)
+		SimpleAsyncTaskExecutorBuilder simpleAsyncTaskExecutorBuilder() {
+			return builder();
+		}
+
+		@Bean(name = "simpleAsyncTaskExecutorBuilder")
+		@ConditionalOnMissingBean
+		@ConditionalOnThreading(Threading.VIRTUAL)
+		SimpleAsyncTaskExecutorBuilder simpleAsyncTaskExecutorBuilderVirtualThreads() {
+			SimpleAsyncTaskExecutorBuilder builder = builder();
+			builder = builder.virtualThreads(true);
+			return builder;
+		}
+
+		private SimpleAsyncTaskExecutorBuilder builder() {
+			SimpleAsyncTaskExecutorBuilder builder = new SimpleAsyncTaskExecutorBuilder();
+			builder = builder.threadNamePrefix(this.properties.getThreadNamePrefix());
+			builder = builder.customizers(this.taskExecutorCustomizers.orderedStream()::iterator);
+			builder = builder.taskDecorator(this.taskDecorator.getIfUnique());
+			TaskExecutionProperties.Simple simple = this.properties.getSimple();
+			builder = builder.concurrencyLimit(simple.getConcurrencyLimit());
+			return builder;
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingAutoConfiguration.java
index a5dd93bf4f44..5909153ee8e3 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,22 +16,15 @@
 
 package org.springframework.boot.autoconfigure.task;
 
-import java.util.concurrent.ScheduledExecutorService;
-
-import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.boot.LazyInitializationExcludeFilter;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
-import org.springframework.boot.autoconfigure.task.TaskSchedulingProperties.Shutdown;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
-import org.springframework.boot.task.TaskSchedulerBuilder;
-import org.springframework.boot.task.TaskSchedulerCustomizer;
 import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
 import org.springframework.scheduling.TaskScheduler;
-import org.springframework.scheduling.annotation.SchedulingConfigurer;
 import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
 import org.springframework.scheduling.config.TaskManagementConfigUtils;
 
@@ -39,38 +32,22 @@
  * {@link EnableAutoConfiguration Auto-configuration} for {@link TaskScheduler}.
  *
  * @author Stephane Nicoll
+ * @author Moritz Halbritter
  * @since 2.1.0
  */
 @ConditionalOnClass(ThreadPoolTaskScheduler.class)
 @AutoConfiguration(after = TaskExecutionAutoConfiguration.class)
 @EnableConfigurationProperties(TaskSchedulingProperties.class)
+@Import({ TaskSchedulingConfigurations.ThreadPoolTaskSchedulerBuilderConfiguration.class,
+		TaskSchedulingConfigurations.TaskSchedulerBuilderConfiguration.class,
+		TaskSchedulingConfigurations.SimpleAsyncTaskSchedulerBuilderConfiguration.class,
+		TaskSchedulingConfigurations.TaskSchedulerConfiguration.class })
 public class TaskSchedulingAutoConfiguration {
 
-	@Bean
-	@ConditionalOnBean(name = TaskManagementConfigUtils.SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME)
-	@ConditionalOnMissingBean({ SchedulingConfigurer.class, TaskScheduler.class, ScheduledExecutorService.class })
-	public ThreadPoolTaskScheduler taskScheduler(TaskSchedulerBuilder builder) {
-		return builder.build();
-	}
-
 	@Bean
 	@ConditionalOnBean(name = TaskManagementConfigUtils.SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME)
 	public static LazyInitializationExcludeFilter scheduledBeanLazyInitializationExcludeFilter() {
 		return new ScheduledBeanLazyInitializationExcludeFilter();
 	}
 
-	@Bean
-	@ConditionalOnMissingBean
-	public TaskSchedulerBuilder taskSchedulerBuilder(TaskSchedulingProperties properties,
-			ObjectProvider<TaskSchedulerCustomizer> taskSchedulerCustomizers) {
-		TaskSchedulerBuilder builder = new TaskSchedulerBuilder();
-		builder = builder.poolSize(properties.getPool().getSize());
-		Shutdown shutdown = properties.getShutdown();
-		builder = builder.awaitTermination(shutdown.isAwaitTermination());
-		builder = builder.awaitTerminationPeriod(shutdown.getAwaitTerminationPeriod());
-		builder = builder.threadNamePrefix(properties.getThreadNamePrefix());
-		builder = builder.customizers(taskSchedulerCustomizers);
-		return builder;
-	}
-
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingConfigurations.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingConfigurations.java
new file mode 100644
index 000000000000..d05dc9a91ef7
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingConfigurations.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.task;
+
+import java.util.concurrent.ScheduledExecutorService;
+
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnThreading;
+import org.springframework.boot.autoconfigure.thread.Threading;
+import org.springframework.boot.task.SimpleAsyncTaskSchedulerBuilder;
+import org.springframework.boot.task.SimpleAsyncTaskSchedulerCustomizer;
+import org.springframework.boot.task.TaskSchedulerBuilder;
+import org.springframework.boot.task.TaskSchedulerCustomizer;
+import org.springframework.boot.task.ThreadPoolTaskSchedulerBuilder;
+import org.springframework.boot.task.ThreadPoolTaskSchedulerCustomizer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.TaskScheduler;
+import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
+import org.springframework.scheduling.config.TaskManagementConfigUtils;
+
+/**
+ * {@link TaskScheduler} configurations to be imported by
+ * {@link TaskSchedulingAutoConfiguration} in a specific order.
+ *
+ * @author Moritz Halbritter
+ */
+class TaskSchedulingConfigurations {
+
+	@Configuration(proxyBeanMethods = false)
+	@ConditionalOnBean(name = TaskManagementConfigUtils.SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME)
+	@ConditionalOnMissingBean({ TaskScheduler.class, ScheduledExecutorService.class })
+	@SuppressWarnings("removal")
+	static class TaskSchedulerConfiguration {
+
+		@Bean(name = "taskScheduler")
+		@ConditionalOnThreading(Threading.VIRTUAL)
+		SimpleAsyncTaskScheduler taskSchedulerVirtualThreads(SimpleAsyncTaskSchedulerBuilder builder) {
+			return builder.build();
+		}
+
+		@Bean
+		@ConditionalOnThreading(Threading.PLATFORM)
+		ThreadPoolTaskScheduler taskScheduler(TaskSchedulerBuilder taskSchedulerBuilder,
+				ObjectProvider<ThreadPoolTaskSchedulerBuilder> threadPoolTaskSchedulerBuilderProvider) {
+			ThreadPoolTaskSchedulerBuilder threadPoolTaskSchedulerBuilder = threadPoolTaskSchedulerBuilderProvider
+				.getIfUnique();
+			if (threadPoolTaskSchedulerBuilder != null) {
+				return threadPoolTaskSchedulerBuilder.build();
+			}
+			return taskSchedulerBuilder.build();
+		}
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	@SuppressWarnings("removal")
+	static class TaskSchedulerBuilderConfiguration {
+
+		@Bean
+		@ConditionalOnMissingBean
+		TaskSchedulerBuilder taskSchedulerBuilder(TaskSchedulingProperties properties,
+				ObjectProvider<TaskSchedulerCustomizer> taskSchedulerCustomizers) {
+			TaskSchedulerBuilder builder = new TaskSchedulerBuilder();
+			builder = builder.poolSize(properties.getPool().getSize());
+			TaskSchedulingProperties.Shutdown shutdown = properties.getShutdown();
+			builder = builder.awaitTermination(shutdown.isAwaitTermination());
+			builder = builder.awaitTerminationPeriod(shutdown.getAwaitTerminationPeriod());
+			builder = builder.threadNamePrefix(properties.getThreadNamePrefix());
+			builder = builder.customizers(taskSchedulerCustomizers);
+			return builder;
+		}
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	@SuppressWarnings("removal")
+	static class ThreadPoolTaskSchedulerBuilderConfiguration {
+
+		@Bean
+		@ConditionalOnMissingBean({ TaskSchedulerBuilder.class, ThreadPoolTaskSchedulerBuilder.class })
+		ThreadPoolTaskSchedulerBuilder threadPoolTaskSchedulerBuilder(TaskSchedulingProperties properties,
+				ObjectProvider<ThreadPoolTaskSchedulerCustomizer> threadPoolTaskSchedulerCustomizers,
+				ObjectProvider<TaskSchedulerCustomizer> taskSchedulerCustomizers) {
+			TaskSchedulingProperties.Shutdown shutdown = properties.getShutdown();
+			ThreadPoolTaskSchedulerBuilder builder = new ThreadPoolTaskSchedulerBuilder();
+			builder = builder.poolSize(properties.getPool().getSize());
+			builder = builder.awaitTermination(shutdown.isAwaitTermination());
+			builder = builder.awaitTerminationPeriod(shutdown.getAwaitTerminationPeriod());
+			builder = builder.threadNamePrefix(properties.getThreadNamePrefix());
+			builder = builder.customizers(threadPoolTaskSchedulerCustomizers);
+			// Apply the deprecated TaskSchedulerCustomizers, too
+			builder = builder.additionalCustomizers(taskSchedulerCustomizers.orderedStream().map(this::adapt).toList());
+			return builder;
+		}
+
+		private ThreadPoolTaskSchedulerCustomizer adapt(TaskSchedulerCustomizer customizer) {
+			return customizer::customize;
+		}
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	static class SimpleAsyncTaskSchedulerBuilderConfiguration {
+
+		private final TaskSchedulingProperties properties;
+
+		private final ObjectProvider<SimpleAsyncTaskSchedulerCustomizer> taskSchedulerCustomizers;
+
+		SimpleAsyncTaskSchedulerBuilderConfiguration(TaskSchedulingProperties properties,
+				ObjectProvider<SimpleAsyncTaskSchedulerCustomizer> taskSchedulerCustomizers) {
+			this.properties = properties;
+			this.taskSchedulerCustomizers = taskSchedulerCustomizers;
+		}
+
+		@Bean
+		@ConditionalOnMissingBean
+		@ConditionalOnThreading(Threading.PLATFORM)
+		SimpleAsyncTaskSchedulerBuilder simpleAsyncTaskSchedulerBuilder() {
+			return builder();
+		}
+
+		@Bean(name = "simpleAsyncTaskSchedulerBuilder")
+		@ConditionalOnMissingBean
+		@ConditionalOnThreading(Threading.VIRTUAL)
+		SimpleAsyncTaskSchedulerBuilder simpleAsyncTaskSchedulerBuilderVirtualThreads() {
+			SimpleAsyncTaskSchedulerBuilder builder = builder();
+			builder = builder.virtualThreads(true);
+			return builder;
+		}
+
+		private SimpleAsyncTaskSchedulerBuilder builder() {
+			SimpleAsyncTaskSchedulerBuilder builder = new SimpleAsyncTaskSchedulerBuilder();
+			builder = builder.threadNamePrefix(this.properties.getThreadNamePrefix());
+			builder = builder.customizers(this.taskSchedulerCustomizers.orderedStream()::iterator);
+			TaskSchedulingProperties.Simple simple = this.properties.getSimple();
+			builder = builder.concurrencyLimit(simple.getConcurrencyLimit());
+			return builder;
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingProperties.java
index f9bc7beac2c1..ea26f3261039 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingProperties.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -31,6 +31,8 @@ public class TaskSchedulingProperties {
 
 	private final Pool pool = new Pool();
 
+	private final Simple simple = new Simple();
+
 	private final Shutdown shutdown = new Shutdown();
 
 	/**
@@ -42,6 +44,10 @@ public Pool getPool() {
 		return this.pool;
 	}
 
+	public Simple getSimple() {
+		return this.simple;
+	}
+
 	public Shutdown getShutdown() {
 		return this.shutdown;
 	}
@@ -71,6 +77,24 @@ public void setSize(int size) {
 
 	}
 
+	public static class Simple {
+
+		/**
+		 * Set the maximum number of parallel accesses allowed. -1 indicates no
+		 * concurrency limit at all.
+		 */
+		private Integer concurrencyLimit;
+
+		public Integer getConcurrencyLimit() {
+			return this.concurrencyLimit;
+		}
+
+		public void setConcurrencyLimit(Integer concurrencyLimit) {
+			this.concurrencyLimit = concurrencyLimit;
+		}
+
+	}
+
 	public static class Shutdown {
 
 		/**
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/template/TemplateAvailabilityProviders.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/template/TemplateAvailabilityProviders.java
index b787dc413a09..a6d032875e43 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/template/TemplateAvailabilityProviders.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/template/TemplateAvailabilityProviders.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/thread/Threading.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/thread/Threading.java
new file mode 100644
index 000000000000..b82e29953fce
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/thread/Threading.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.thread;
+
+import org.springframework.boot.system.JavaVersion;
+import org.springframework.core.env.Environment;
+
+/**
+ * Threading of the application.
+ *
+ * @author Moritz Halbritter
+ * @since 3.2.0
+ */
+public enum Threading {
+
+	/**
+	 * Platform threads. Active if virtual threads are not active.
+	 */
+	PLATFORM {
+
+		@Override
+		public boolean isActive(Environment environment) {
+			return !VIRTUAL.isActive(environment);
+		}
+
+	},
+	/**
+	 * Virtual threads. Active if {@code spring.threads.virtual.enabled} is {@code true}
+	 * and running on Java 21 or later.
+	 */
+	VIRTUAL {
+
+		@Override
+		public boolean isActive(Environment environment) {
+			return environment.getProperty("spring.threads.virtual.enabled", boolean.class, false)
+					&& JavaVersion.getJavaVersion().isEqualOrNewerThan(JavaVersion.TWENTY_ONE);
+		}
+
+	};
+
+	/**
+	 * Determines whether the threading is active.
+	 * @param environment the environment
+	 * @return whether the threading is active
+	 */
+	public abstract boolean isActive(Environment environment);
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/thread/package-info.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/thread/package-info.java
new file mode 100644
index 000000000000..61c141a651aa
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/thread/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Classes related to threads.
+ */
+package org.springframework.boot.autoconfigure.thread;
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/ExecutionListenersTransactionManagerCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/ExecutionListenersTransactionManagerCustomizer.java
new file mode 100644
index 000000000000..18a072b0f837
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/ExecutionListenersTransactionManagerCustomizer.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.transaction;
+
+import java.util.List;
+
+import org.springframework.transaction.ConfigurableTransactionManager;
+import org.springframework.transaction.TransactionExecutionListener;
+
+/**
+ * {@link TransactionManagerCustomizer} that adds {@link TransactionExecutionListener
+ * execution listeners} to any transaction manager that is
+ * {@link ConfigurableTransactionManager configurable}.
+ *
+ * @author Andy Wilkinson
+ */
+class ExecutionListenersTransactionManagerCustomizer
+		implements TransactionManagerCustomizer<ConfigurableTransactionManager> {
+
+	private final List<TransactionExecutionListener> listeners;
+
+	ExecutionListenersTransactionManagerCustomizer(List<TransactionExecutionListener> listeners) {
+		this.listeners = listeners;
+	}
+
+	@Override
+	public void customize(ConfigurableTransactionManager transactionManager) {
+		this.listeners.forEach(transactionManager::addListener);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/PlatformTransactionManagerCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/PlatformTransactionManagerCustomizer.java
index 64c7fd927bdd..1b5cd099471e 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/PlatformTransactionManagerCustomizer.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/PlatformTransactionManagerCustomizer.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -26,14 +26,12 @@
  * @param <T> the transaction manager type
  * @author Phillip Webb
  * @since 1.5.0
+ * @deprecated since 3.2.0 for removal in 3.4.0 in favor of
+ * {@link TransactionManagerCustomizer}.
  */
+@Deprecated(since = "3.2.0", forRemoval = true)
 @FunctionalInterface
-public interface PlatformTransactionManagerCustomizer<T extends PlatformTransactionManager> {
-
-	/**
-	 * Customize the given transaction manager.
-	 * @param transactionManager the transaction manager to customize
-	 */
-	void customize(T transactionManager);
+public interface PlatformTransactionManagerCustomizer<T extends PlatformTransactionManager>
+		extends TransactionManagerCustomizer<T> {
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionAutoConfiguration.java
index 2c605e050a75..b1268020c454 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,14 +16,13 @@
 
 package org.springframework.boot.autoconfigure.transaction;
 
-import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.boot.LazyInitializationExcludeFilter;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate;
-import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.transaction.PlatformTransactionManager;
@@ -31,6 +30,7 @@
 import org.springframework.transaction.TransactionManager;
 import org.springframework.transaction.annotation.AbstractTransactionManagementConfiguration;
 import org.springframework.transaction.annotation.EnableTransactionManagement;
+import org.springframework.transaction.aspectj.AbstractTransactionAspect;
 import org.springframework.transaction.reactive.TransactionalOperator;
 import org.springframework.transaction.support.TransactionOperations;
 import org.springframework.transaction.support.TransactionTemplate;
@@ -44,16 +44,8 @@
  */
 @AutoConfiguration
 @ConditionalOnClass(PlatformTransactionManager.class)
-@EnableConfigurationProperties(TransactionProperties.class)
 public class TransactionAutoConfiguration {
 
-	@Bean
-	@ConditionalOnMissingBean
-	public TransactionManagerCustomizers platformTransactionManagerCustomizers(
-			ObjectProvider<PlatformTransactionManagerCustomizer<?>> customizers) {
-		return new TransactionManagerCustomizers(customizers.orderedStream().toList());
-	}
-
 	@Bean
 	@ConditionalOnMissingBean
 	@ConditionalOnSingleCandidate(ReactiveTransactionManager.class)
@@ -95,4 +87,15 @@ public static class CglibAutoProxyConfiguration {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	@ConditionalOnBean(AbstractTransactionAspect.class)
+	static class AspectJTransactionManagementConfiguration {
+
+		@Bean
+		static LazyInitializationExcludeFilter eagerTransactionAspect() {
+			return LazyInitializationExcludeFilter.forBeanTypes(AbstractTransactionAspect.class);
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizationAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizationAutoConfiguration.java
new file mode 100644
index 000000000000..aba33e3226c3
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizationAutoConfiguration.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.transaction;
+
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.TransactionExecutionListener;
+import org.springframework.transaction.TransactionManager;
+
+/**
+ * Auto-configuration for the customization of a {@link TransactionManager}.
+ *
+ * @author Andy Wilkinson
+ * @since 3.2.0
+ */
+@ConditionalOnClass(PlatformTransactionManager.class)
+@AutoConfiguration(before = TransactionAutoConfiguration.class)
+@EnableConfigurationProperties(TransactionProperties.class)
+public class TransactionManagerCustomizationAutoConfiguration {
+
+	@Bean
+	@ConditionalOnMissingBean
+	TransactionManagerCustomizers platformTransactionManagerCustomizers(
+			ObjectProvider<TransactionManagerCustomizer<?>> customizers) {
+		return TransactionManagerCustomizers.of(customizers.orderedStream().toList());
+	}
+
+	@Bean
+	ExecutionListenersTransactionManagerCustomizer transactionExecutionListeners(
+			ObjectProvider<TransactionExecutionListener> listeners) {
+		return new ExecutionListenersTransactionManagerCustomizer(listeners.orderedStream().toList());
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizer.java
new file mode 100644
index 000000000000..e268fe87a49f
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizer.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.transaction;
+
+import org.springframework.transaction.TransactionManager;
+
+/**
+ * Callback interface that can be implemented by beans wishing to customize
+ * {@link TransactionManager TransactionManagers} while retaining default
+ * auto-configuration.
+ *
+ * @param <T> the transaction manager type
+ * @author Andy Wilkinson
+ * @since 3.2.0
+ */
+public interface TransactionManagerCustomizer<T extends TransactionManager> {
+
+	/**
+	 * Customize the given transaction manager.
+	 * @param transactionManager the transaction manager to customize
+	 */
+	void customize(T transactionManager);
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizers.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizers.java
index 2bb603c4bc60..88f513a3c9d0 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizers.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizers.java
@@ -23,26 +23,69 @@
 
 import org.springframework.boot.util.LambdaSafe;
 import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.TransactionManager;
 
 /**
- * A collection of {@link PlatformTransactionManagerCustomizer}.
+ * A collection of {@link TransactionManagerCustomizer TransactionManagerCustomizers}.
  *
  * @author Phillip Webb
+ * @author Andy Wilkinson
  * @since 1.5.0
  */
 public class TransactionManagerCustomizers {
 
-	private final List<PlatformTransactionManagerCustomizer<?>> customizers;
+	private final List<? extends TransactionManagerCustomizer<?>> customizers;
 
+	/**
+	 * Creates a new {@code TransactionManagerCustomizers} instance containing the given
+	 * {@code customizers}.
+	 * @param customizers the customizers
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of {@link #of(Collection)}
+	 */
+	@SuppressWarnings("removal")
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	public TransactionManagerCustomizers(Collection<? extends PlatformTransactionManagerCustomizer<?>> customizers) {
-		this.customizers = (customizers != null) ? new ArrayList<>(customizers) : Collections.emptyList();
+		this((customizers != null) ? new ArrayList<>(customizers)
+				: Collections.<TransactionManagerCustomizer<?>>emptyList());
 	}
 
+	private TransactionManagerCustomizers(List<? extends TransactionManagerCustomizer<?>> customizers) {
+		this.customizers = customizers;
+	}
+
+	/**
+	 * Customize the given {@code platformTransactionManager}.
+	 * @param platformTransactionManager the platform transaction manager to customize
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of
+	 * {@link #customize(TransactionManager)}
+	 */
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public void customize(PlatformTransactionManager platformTransactionManager) {
+		customize((TransactionManager) platformTransactionManager);
+	}
+
+	/**
+	 * Customize the given {@code transactionManager}.
+	 * @param transactionManager the transaction manager to customize
+	 * @since 3.2.0
+	 */
 	@SuppressWarnings("unchecked")
-	public void customize(PlatformTransactionManager transactionManager) {
-		LambdaSafe.callbacks(PlatformTransactionManagerCustomizer.class, this.customizers, transactionManager)
+	public void customize(TransactionManager transactionManager) {
+		LambdaSafe.callbacks(TransactionManagerCustomizer.class, this.customizers, transactionManager)
 			.withLogger(TransactionManagerCustomizers.class)
 			.invoke((customizer) -> customizer.customize(transactionManager));
 	}
 
+	/**
+	 * Returns a new {@code TransactionManagerCustomizers} instance containing the given
+	 * {@code customizers}.
+	 * @param customizers the customizers
+	 * @return the new instance
+	 * @since 3.2.0
+	 */
+	public static TransactionManagerCustomizers of(Collection<? extends TransactionManagerCustomizer<?>> customizers) {
+		return new TransactionManagerCustomizers((customizers != null) ? new ArrayList<>(customizers)
+				: Collections.<TransactionManagerCustomizer<?>>emptyList());
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionProperties.java
index d48200a06f39..a9170ee0cd78 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionProperties.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -32,7 +32,7 @@
  * @since 1.5.0
  */
 @ConfigurationProperties(prefix = "spring.transaction")
-public class TransactionProperties implements PlatformTransactionManagerCustomizer<AbstractPlatformTransactionManager> {
+public class TransactionProperties implements TransactionManagerCustomizer<AbstractPlatformTransactionManager> {
 
 	/**
 	 * Default transaction timeout. If a duration suffix is not specified, seconds will be
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/jta/JndiJtaConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/jta/JndiJtaConfiguration.java
index 91b59dd02af0..3db22f4cc775 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/jta/JndiJtaConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/jta/JndiJtaConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -23,6 +23,7 @@
 import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizers;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.transaction.TransactionManager;
 import org.springframework.transaction.jta.JtaTransactionManager;
 
 /**
@@ -43,7 +44,8 @@ class JndiJtaConfiguration {
 	JtaTransactionManager transactionManager(
 			ObjectProvider<TransactionManagerCustomizers> transactionManagerCustomizers) {
 		JtaTransactionManager jtaTransactionManager = new JtaTransactionManager();
-		transactionManagerCustomizers.ifAvailable((customizers) -> customizers.customize(jtaTransactionManager));
+		transactionManagerCustomizers
+			.ifAvailable((customizers) -> customizers.customize((TransactionManager) jtaTransactionManager));
 		return jtaTransactionManager;
 	}
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/jta/JtaAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/jta/JtaAutoConfiguration.java
index a9040ac4ba1f..480e32b81dfa 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/jta/JtaAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/jta/JtaAutoConfiguration.java
@@ -25,6 +25,7 @@
 import org.springframework.boot.autoconfigure.jms.artemis.ArtemisAutoConfiguration;
 import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration;
 import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration;
+import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizationAutoConfiguration;
 import org.springframework.context.annotation.Import;
 
 /**
@@ -36,7 +37,8 @@
  * @since 1.2.0
  */
 @AutoConfiguration(before = { XADataSourceAutoConfiguration.class, ActiveMQAutoConfiguration.class,
-		ArtemisAutoConfiguration.class, HibernateJpaAutoConfiguration.class, TransactionAutoConfiguration.class })
+		ArtemisAutoConfiguration.class, HibernateJpaAutoConfiguration.class, TransactionAutoConfiguration.class,
+		TransactionManagerCustomizationAutoConfiguration.class })
 @ConditionalOnClass(jakarta.transaction.Transaction.class)
 @ConditionalOnProperty(prefix = "spring.jta", value = "enabled", matchIfMissing = true)
 @Import(JndiJtaConfiguration.class)
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/validation/ValidatorAdapter.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/validation/ValidatorAdapter.java
index 84bd614b1838..e6c792838d86 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/validation/ValidatorAdapter.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/validation/ValidatorAdapter.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -35,10 +35,11 @@
 /**
  * {@link Validator} implementation that delegates calls to another {@link Validator}.
  * This {@link Validator} implements Spring's {@link SmartValidator} interface but does
- * not implement the JSR-303 {@code javax.validator.Validator} interface.
+ * not implement the JSR-303 {@code jakarta.validator.Validator} interface.
  *
  * @author Stephane Nicoll
  * @author Phillip Webb
+ * @author Zisis Pavloudis
  * @since 2.0.0
  */
 public class ValidatorAdapter implements SmartValidator, ApplicationContextAware, InitializingBean, DisposableBean {
@@ -153,4 +154,13 @@ private static Validator wrap(Validator validator, boolean existingBean) {
 		return validator;
 	}
 
+	@Override
+	@SuppressWarnings("unchecked")
+	public <T> T unwrap(Class<T> type) {
+		if (type.isInstance(this.target)) {
+			return (T) this.target;
+		}
+		return this.target.unwrap(type);
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ServerProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ServerProperties.java
index a4e5bcf2d1a8..e8742b414f6a 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ServerProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ServerProperties.java
@@ -154,17 +154,6 @@ public void setServerHeader(String serverHeader) {
 		this.serverHeader = serverHeader;
 	}
 
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	@DeprecatedConfigurationProperty
-	public DataSize getMaxHttpHeaderSize() {
-		return getMaxHttpRequestHeaderSize();
-	}
-
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	public void setMaxHttpHeaderSize(DataSize maxHttpHeaderSize) {
-		setMaxHttpRequestHeaderSize(maxHttpHeaderSize);
-	}
-
 	public DataSize getMaxHttpRequestHeaderSize() {
 		return this.maxHttpRequestHeaderSize;
 	}
@@ -475,7 +464,7 @@ public static class Tomcat {
 		/**
 		 * Whether to reject requests with illegal header names or values.
 		 */
-		@Deprecated(since = "2.7.12", forRemoval = true)
+		@Deprecated(since = "2.7.12", forRemoval = true) // Remove in 3.3
 		private boolean rejectIllegalHeader = true;
 
 		/**
@@ -634,11 +623,13 @@ public void setConnectionTimeout(Duration connectionTimeout) {
 			this.connectionTimeout = connectionTimeout;
 		}
 
-		@DeprecatedConfigurationProperty(reason = "The setting has been deprecated in Tomcat")
+		@Deprecated(since = "3.2.0", forRemoval = true)
+		@DeprecatedConfigurationProperty(reason = "The setting has been deprecated in Tomcat", since = "3.2.0")
 		public boolean isRejectIllegalHeader() {
 			return this.rejectIllegalHeader;
 		}
 
+		@Deprecated(since = "3.2.0", forRemoval = true)
 		public void setRejectIllegalHeader(boolean rejectIllegalHeader) {
 			this.rejectIllegalHeader = rejectIllegalHeader;
 		}
@@ -1123,6 +1114,12 @@ public static class Jetty {
 		 */
 		private DataSize maxHttpResponseHeaderSize = DataSize.ofKilobytes(8);
 
+		/**
+		 * Maximum number of connections that the server accepts and processes at any
+		 * given time.
+		 */
+		private int maxConnections = -1;
+
 		public Accesslog getAccesslog() {
 			return this.accesslog;
 		}
@@ -1155,6 +1152,14 @@ public void setMaxHttpResponseHeaderSize(DataSize maxHttpResponseHeaderSize) {
 			this.maxHttpResponseHeaderSize = maxHttpResponseHeaderSize;
 		}
 
+		public int getMaxConnections() {
+			return this.maxConnections;
+		}
+
+		public void setMaxConnections(int maxConnections) {
+			this.maxConnections = maxConnections;
+		}
+
 		/**
 		 * Jetty access log properties.
 		 */
@@ -1395,11 +1400,6 @@ public static class Netty {
 		 */
 		private DataSize initialBufferSize = DataSize.ofBytes(128);
 
-		/**
-		 * Maximum chunk size that can be decoded for an HTTP request.
-		 */
-		private DataSize maxChunkSize = DataSize.ofKilobytes(8);
-
 		/**
 		 * Maximum length that can be decoded for an HTTP request's initial line.
 		 */
@@ -1446,17 +1446,6 @@ public void setInitialBufferSize(DataSize initialBufferSize) {
 			this.initialBufferSize = initialBufferSize;
 		}
 
-		@Deprecated(since = "3.0.0", forRemoval = true)
-		@DeprecatedConfigurationProperty(reason = "Deprecated for removal in Reactor Netty")
-		public DataSize getMaxChunkSize() {
-			return this.maxChunkSize;
-		}
-
-		@Deprecated(since = "3.0.0", forRemoval = true)
-		public void setMaxChunkSize(DataSize maxChunkSize) {
-			this.maxChunkSize = maxChunkSize;
-		}
-
 		public DataSize getMaxInitialLineLength() {
 			return this.maxInitialLineLength;
 		}
@@ -1647,7 +1636,7 @@ public void setMaxCookies(Integer maxCookies) {
 			this.maxCookies = maxCookies;
 		}
 
-		@DeprecatedConfigurationProperty(replacement = "server.undertow.decode-slash")
+		@DeprecatedConfigurationProperty(replacement = "server.undertow.decode-slash", since = "3.0.3")
 		@Deprecated(forRemoval = true, since = "3.0.3")
 		public boolean isAllowEncodedSlash() {
 			return this.allowEncodedSlash;
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/AutoConfiguredRestClientSsl.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/AutoConfiguredRestClientSsl.java
new file mode 100644
index 000000000000..8fc9dd9663b2
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/AutoConfiguredRestClientSsl.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.web.client;
+
+import java.util.function.Consumer;
+
+import org.springframework.boot.ssl.SslBundle;
+import org.springframework.boot.ssl.SslBundles;
+import org.springframework.boot.web.client.ClientHttpRequestFactories;
+import org.springframework.boot.web.client.ClientHttpRequestFactorySettings;
+import org.springframework.http.client.ClientHttpRequestFactory;
+import org.springframework.web.client.RestClient;
+
+/**
+ * An auto-configured {@link RestClientSsl} implementation.
+ *
+ * @author Phillip Webb
+ */
+class AutoConfiguredRestClientSsl implements RestClientSsl {
+
+	private final SslBundles sslBundles;
+
+	AutoConfiguredRestClientSsl(SslBundles sslBundles) {
+		this.sslBundles = sslBundles;
+	}
+
+	@Override
+	public Consumer<RestClient.Builder> fromBundle(String bundleName) {
+		return fromBundle(this.sslBundles.getBundle(bundleName));
+	}
+
+	@Override
+	public Consumer<RestClient.Builder> fromBundle(SslBundle bundle) {
+		return (builder) -> {
+			ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS.withSslBundle(bundle);
+			ClientHttpRequestFactory requestFactory = ClientHttpRequestFactories.get(settings);
+			builder.requestFactory(requestFactory);
+		};
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/HttpMessageConvertersRestClientCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/HttpMessageConvertersRestClientCustomizer.java
new file mode 100644
index 000000000000..c5372d5d7f84
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/HttpMessageConvertersRestClientCustomizer.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.web.client;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
+import org.springframework.boot.web.client.RestClientCustomizer;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.util.Assert;
+import org.springframework.web.client.RestClient;
+
+/**
+ * {@link RestClientCustomizer} to apply {@link HttpMessageConverter
+ * HttpMessageConverters}.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public class HttpMessageConvertersRestClientCustomizer implements RestClientCustomizer {
+
+	private final Iterable<? extends HttpMessageConverter<?>> messageConverters;
+
+	public HttpMessageConvertersRestClientCustomizer(HttpMessageConverter<?>... messageConverters) {
+		Assert.notNull(messageConverters, "MessageConverters must not be null");
+		this.messageConverters = Arrays.asList(messageConverters);
+	}
+
+	HttpMessageConvertersRestClientCustomizer(HttpMessageConverters messageConverters) {
+		this.messageConverters = messageConverters;
+	}
+
+	@Override
+	public void customize(RestClient.Builder restClientBuilder) {
+		restClientBuilder.messageConverters(this::configureMessageConverters);
+	}
+
+	private void configureMessageConverters(List<HttpMessageConverter<?>> messageConverters) {
+		if (this.messageConverters != null) {
+			messageConverters.clear();
+			this.messageConverters.forEach(messageConverters::add);
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/NotReactiveWebApplicationCondition.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/NotReactiveWebApplicationCondition.java
new file mode 100644
index 000000000000..622f4ee19304
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/NotReactiveWebApplicationCondition.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.web.client;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
+import org.springframework.boot.autoconfigure.condition.NoneNestedConditions;
+import org.springframework.boot.autoconfigure.condition.SpringBootCondition;
+
+/**
+ * {@link SpringBootCondition} that applies only when running in a non-reactive web
+ * application.
+ *
+ * @author Phillip Webb
+ */
+class NotReactiveWebApplicationCondition extends NoneNestedConditions {
+
+	NotReactiveWebApplicationCondition() {
+		super(ConfigurationPhase.PARSE_CONFIGURATION);
+	}
+
+	@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
+	private static class ReactiveWebApplication {
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestClientAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestClientAutoConfiguration.java
new file mode 100644
index 000000000000..6198b70bfaee
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestClientAutoConfiguration.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.web.client;
+
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
+import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
+import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration;
+import org.springframework.boot.ssl.SslBundles;
+import org.springframework.boot.web.client.ClientHttpRequestFactories;
+import org.springframework.boot.web.client.ClientHttpRequestFactorySettings;
+import org.springframework.boot.web.client.RestClientCustomizer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Conditional;
+import org.springframework.context.annotation.Scope;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.web.client.RestClient;
+
+/**
+ * {@link EnableAutoConfiguration Auto-configuration} for {@link RestClient}.
+ * <p>
+ * This will produce a {@link RestClient.Builder RestClient.Builder} bean with the
+ * {@code prototype} scope, meaning each injection point will receive a newly cloned
+ * instance of the builder.
+ *
+ * @author Arjen Poutsma
+ * @author Moritz Halbritter
+ * @since 3.2.0
+ */
+@AutoConfiguration(after = { HttpMessageConvertersAutoConfiguration.class, SslAutoConfiguration.class })
+@ConditionalOnClass(RestClient.class)
+@Conditional(NotReactiveWebApplicationCondition.class)
+public class RestClientAutoConfiguration {
+
+	@Bean
+	@ConditionalOnMissingBean
+	@Order(Ordered.LOWEST_PRECEDENCE)
+	HttpMessageConvertersRestClientCustomizer httpMessageConvertersRestClientCustomizer(
+			ObjectProvider<HttpMessageConverters> messageConverters) {
+		return new HttpMessageConvertersRestClientCustomizer(messageConverters.getIfUnique());
+	}
+
+	@Bean
+	@ConditionalOnMissingBean(RestClientSsl.class)
+	@ConditionalOnBean(SslBundles.class)
+	AutoConfiguredRestClientSsl restClientSsl(SslBundles sslBundles) {
+		return new AutoConfiguredRestClientSsl(sslBundles);
+	}
+
+	@Bean
+	@ConditionalOnMissingBean
+	RestClientBuilderConfigurer restClientBuilderConfigurer(ObjectProvider<RestClientCustomizer> customizerProvider) {
+		RestClientBuilderConfigurer configurer = new RestClientBuilderConfigurer();
+		configurer.setRestClientCustomizers(customizerProvider.orderedStream().toList());
+		return configurer;
+	}
+
+	@Bean
+	@Scope("prototype")
+	@ConditionalOnMissingBean
+	RestClient.Builder restClientBuilder(RestClientBuilderConfigurer restClientBuilderConfigurer) {
+		RestClient.Builder builder = RestClient.builder()
+			.requestFactory(ClientHttpRequestFactories.get(ClientHttpRequestFactorySettings.DEFAULTS));
+		return restClientBuilderConfigurer.configure(builder);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestClientBuilderConfigurer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestClientBuilderConfigurer.java
new file mode 100644
index 000000000000..8d6f57bd461f
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestClientBuilderConfigurer.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.web.client;
+
+import java.util.List;
+
+import org.springframework.boot.web.client.RestClientCustomizer;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.client.RestClient.Builder;
+
+/**
+ * Configure {@link RestClient.Builder} with sensible defaults.
+ *
+ * @author Moritz Halbritter
+ * @since 3.2.0
+ */
+public class RestClientBuilderConfigurer {
+
+	private List<RestClientCustomizer> customizers;
+
+	void setRestClientCustomizers(List<RestClientCustomizer> customizers) {
+		this.customizers = customizers;
+	}
+
+	/**
+	 * Configure the specified {@link RestClient.Builder}. The builder can be further
+	 * tuned and default settings can be overridden.
+	 * @param builder the {@link RestClient.Builder} instance to configure
+	 * @return the configured builder
+	 */
+	public RestClient.Builder configure(RestClient.Builder builder) {
+		applyCustomizers(builder);
+		return builder;
+	}
+
+	private void applyCustomizers(Builder builder) {
+		if (this.customizers != null) {
+			for (RestClientCustomizer customizer : this.customizers) {
+				customizer.customize(builder);
+			}
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestClientSsl.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestClientSsl.java
new file mode 100644
index 000000000000..fd892efb4326
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestClientSsl.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.web.client;
+
+import java.util.function.Consumer;
+
+import org.springframework.boot.ssl.NoSuchSslBundleException;
+import org.springframework.boot.ssl.SslBundle;
+import org.springframework.boot.web.client.ClientHttpRequestFactories;
+import org.springframework.boot.web.client.ClientHttpRequestFactorySettings;
+import org.springframework.http.client.ClientHttpRequestFactory;
+import org.springframework.web.client.RestClient;
+
+/**
+ * Interface that can be used to {@link RestClient.Builder#apply apply} SSL configuration
+ * to a {@link org.springframework.web.client.RestClient.Builder RestClient.Builder}.
+ * <p>
+ * Typically used as follows: <pre class="code">
+ * &#064;Bean
+ * public MyBean myBean(RestClient.Builder restClientBuilder, RestClientSsl ssl) {
+ *     RestClient restClientrestClient= restClientBuilder.apply(ssl.fromBundle("mybundle")).build();
+ *     return new MyBean(webClient);
+ * }
+ * </pre> NOTE: Apply SSL configuration will replace any previously
+ * {@link RestClient.Builder#requestFactory configured} {@link ClientHttpRequestFactory}.
+ * If you need to configure {@link ClientHttpRequestFactory} with more than just SSL
+ * consider using a {@link ClientHttpRequestFactorySettings} with
+ * {@link ClientHttpRequestFactories}.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public interface RestClientSsl {
+
+	/**
+	 * Return a {@link Consumer} that will apply SSL configuration for the named
+	 * {@link SslBundle} to a {@link org.springframework.web.client.RestClient.Builder
+	 * RestClient.Builder}.
+	 * @param bundleName the name of the SSL bundle to apply
+	 * @return a {@link Consumer} to apply the configuration
+	 * @throws NoSuchSslBundleException if a bundle with the provided name does not exist
+	 */
+	Consumer<RestClient.Builder> fromBundle(String bundleName) throws NoSuchSslBundleException;
+
+	/**
+	 * Return a {@link Consumer} that will apply SSL configuration for the
+	 * {@link SslBundle} to a {@link org.springframework.web.client.RestClient.Builder
+	 * RestClient.Builder}.
+	 * @param bundle the SSL bundle to apply
+	 * @return a {@link Consumer} to apply the configuration
+	 */
+	Consumer<RestClient.Builder> fromBundle(SslBundle bundle);
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestTemplateAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestTemplateAutoConfiguration.java
index bbefd47fccf4..70bc8c50c6c7 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestTemplateAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestTemplateAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -21,12 +21,8 @@
 import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type;
-import org.springframework.boot.autoconfigure.condition.NoneNestedConditions;
 import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
 import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
-import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration.NotReactiveWebApplicationCondition;
 import org.springframework.boot.web.client.RestTemplateBuilder;
 import org.springframework.boot.web.client.RestTemplateCustomizer;
 import org.springframework.boot.web.client.RestTemplateRequestCustomizer;
@@ -49,7 +45,6 @@ public class RestTemplateAutoConfiguration {
 
 	@Bean
 	@Lazy
-	@ConditionalOnMissingBean
 	public RestTemplateBuilderConfigurer restTemplateBuilderConfigurer(
 			ObjectProvider<HttpMessageConverters> messageConverters,
 			ObjectProvider<RestTemplateCustomizer> restTemplateCustomizers,
@@ -69,17 +64,4 @@ public RestTemplateBuilder restTemplateBuilder(RestTemplateBuilderConfigurer res
 		return restTemplateBuilderConfigurer.configure(builder);
 	}
 
-	static class NotReactiveWebApplicationCondition extends NoneNestedConditions {
-
-		NotReactiveWebApplicationCondition() {
-			super(ConfigurationPhase.PARSE_CONFIGURATION);
-		}
-
-		@ConditionalOnWebApplication(type = Type.REACTIVE)
-		private static class ReactiveWebApplication {
-
-		}
-
-	}
-
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/EmbeddedWebServerFactoryCustomizerAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/EmbeddedWebServerFactoryCustomizerAutoConfiguration.java
index eca465353db6..371ab5034526 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/EmbeddedWebServerFactoryCustomizerAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/EmbeddedWebServerFactoryCustomizerAutoConfiguration.java
@@ -19,9 +19,9 @@
 import io.undertow.Undertow;
 import org.apache.catalina.startup.Tomcat;
 import org.apache.coyote.UpgradeProtocol;
+import org.eclipse.jetty.ee10.webapp.WebAppContext;
 import org.eclipse.jetty.server.Server;
 import org.eclipse.jetty.util.Loader;
-import org.eclipse.jetty.webapp.WebAppContext;
 import org.xnio.SslClientAuthMode;
 import reactor.netty.http.server.HttpServer;
 
@@ -29,7 +29,9 @@
 import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnNotWarDeployment;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnThreading;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
+import org.springframework.boot.autoconfigure.thread.Threading;
 import org.springframework.boot.autoconfigure.web.ServerProperties;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.context.annotation.Bean;
@@ -62,6 +64,12 @@ public TomcatWebServerFactoryCustomizer tomcatWebServerFactoryCustomizer(Environ
 			return new TomcatWebServerFactoryCustomizer(environment, serverProperties);
 		}
 
+		@Bean
+		@ConditionalOnThreading(Threading.VIRTUAL)
+		TomcatVirtualThreadsWebServerFactoryCustomizer tomcatVirtualThreadsProtocolHandlerCustomizer() {
+			return new TomcatVirtualThreadsWebServerFactoryCustomizer();
+		}
+
 	}
 
 	/**
@@ -77,6 +85,13 @@ public JettyWebServerFactoryCustomizer jettyWebServerFactoryCustomizer(Environme
 			return new JettyWebServerFactoryCustomizer(environment, serverProperties);
 		}
 
+		@Bean
+		@ConditionalOnThreading(Threading.VIRTUAL)
+		JettyVirtualThreadsWebServerFactoryCustomizer jettyVirtualThreadsWebServerFactoryCustomizer(
+				ServerProperties serverProperties) {
+			return new JettyVirtualThreadsWebServerFactoryCustomizer(serverProperties);
+		}
+
 	}
 
 	/**
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/JettyThreadPool.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/JettyThreadPool.java
new file mode 100644
index 000000000000..7c8dadadb908
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/JettyThreadPool.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.web.embedded;
+
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.SynchronousQueue;
+
+import org.eclipse.jetty.util.BlockingArrayQueue;
+import org.eclipse.jetty.util.thread.QueuedThreadPool;
+import org.eclipse.jetty.util.thread.ThreadPool;
+
+import org.springframework.boot.autoconfigure.web.ServerProperties;
+
+/**
+ * Creates a {@link ThreadPool} for Jetty, applying
+ * {@link org.springframework.boot.autoconfigure.web.ServerProperties.Jetty.Threads
+ * ServerProperties.Jetty.Threads Jetty thread properties}.
+ *
+ * @author Moritz Halbritter
+ */
+final class JettyThreadPool {
+
+	private JettyThreadPool() {
+	}
+
+	static QueuedThreadPool create(ServerProperties.Jetty.Threads properties) {
+		BlockingQueue<Runnable> queue = determineBlockingQueue(properties.getMaxQueueCapacity());
+		int maxThreadCount = (properties.getMax() > 0) ? properties.getMax() : 200;
+		int minThreadCount = (properties.getMin() > 0) ? properties.getMin() : 8;
+		int threadIdleTimeout = (properties.getIdleTimeout() != null) ? (int) properties.getIdleTimeout().toMillis()
+				: 60000;
+		return new QueuedThreadPool(maxThreadCount, minThreadCount, threadIdleTimeout, queue);
+	}
+
+	private static BlockingQueue<Runnable> determineBlockingQueue(Integer maxQueueCapacity) {
+		if (maxQueueCapacity == null) {
+			return null;
+		}
+		if (maxQueueCapacity == 0) {
+			return new SynchronousQueue<>();
+		}
+		return new BlockingArrayQueue<>(maxQueueCapacity);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/JettyVirtualThreadsWebServerFactoryCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/JettyVirtualThreadsWebServerFactoryCustomizer.java
new file mode 100644
index 000000000000..05720b3c82dc
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/JettyVirtualThreadsWebServerFactoryCustomizer.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.web.embedded;
+
+import org.eclipse.jetty.util.VirtualThreads;
+import org.eclipse.jetty.util.thread.QueuedThreadPool;
+
+import org.springframework.boot.autoconfigure.web.ServerProperties;
+import org.springframework.boot.web.embedded.jetty.ConfigurableJettyWebServerFactory;
+import org.springframework.boot.web.server.WebServerFactoryCustomizer;
+import org.springframework.core.Ordered;
+import org.springframework.util.Assert;
+
+/**
+ * Activates virtual threads on the {@link ConfigurableJettyWebServerFactory}.
+ *
+ * @author Moritz Halbritter
+ * @since 3.2.0
+ */
+public class JettyVirtualThreadsWebServerFactoryCustomizer
+		implements WebServerFactoryCustomizer<ConfigurableJettyWebServerFactory>, Ordered {
+
+	private final ServerProperties serverProperties;
+
+	public JettyVirtualThreadsWebServerFactoryCustomizer(ServerProperties serverProperties) {
+		this.serverProperties = serverProperties;
+	}
+
+	@Override
+	public void customize(ConfigurableJettyWebServerFactory factory) {
+		Assert.state(VirtualThreads.areSupported(), "Virtual threads are not supported");
+		QueuedThreadPool threadPool = JettyThreadPool.create(this.serverProperties.getJetty().getThreads());
+		threadPool.setVirtualThreadsExecutor(VirtualThreads.getDefaultVirtualThreadsExecutor());
+		factory.setThreadPool(threadPool);
+	}
+
+	@Override
+	public int getOrder() {
+		return JettyWebServerFactoryCustomizer.ORDER + 1;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/JettyWebServerFactoryCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/JettyWebServerFactoryCustomizer.java
index 2248f1a72039..c12333dc9cfc 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/JettyWebServerFactoryCustomizer.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/JettyWebServerFactoryCustomizer.java
@@ -18,9 +18,9 @@
 
 import java.time.Duration;
 import java.util.Arrays;
-import java.util.concurrent.BlockingQueue;
-import java.util.concurrent.SynchronousQueue;
+import java.util.List;
 
+import org.eclipse.jetty.ee10.servlet.ServletContextHandler;
 import org.eclipse.jetty.server.AbstractConnector;
 import org.eclipse.jetty.server.ConnectionFactory;
 import org.eclipse.jetty.server.CustomRequestLog;
@@ -28,12 +28,6 @@
 import org.eclipse.jetty.server.HttpConfiguration;
 import org.eclipse.jetty.server.RequestLogWriter;
 import org.eclipse.jetty.server.Server;
-import org.eclipse.jetty.server.handler.ContextHandler;
-import org.eclipse.jetty.server.handler.HandlerCollection;
-import org.eclipse.jetty.server.handler.HandlerWrapper;
-import org.eclipse.jetty.util.BlockingArrayQueue;
-import org.eclipse.jetty.util.thread.QueuedThreadPool;
-import org.eclipse.jetty.util.thread.ThreadPool;
 
 import org.springframework.boot.autoconfigure.web.ServerProperties;
 import org.springframework.boot.cloud.CloudPlatform;
@@ -60,6 +54,8 @@
 public class JettyWebServerFactoryCustomizer
 		implements WebServerFactoryCustomizer<ConfigurableJettyWebServerFactory>, Ordered {
 
+	static final int ORDER = 0;
+
 	private final Environment environment;
 
 	private final ServerProperties serverProperties;
@@ -71,7 +67,7 @@ public JettyWebServerFactoryCustomizer(Environment environment, ServerProperties
 
 	@Override
 	public int getOrder() {
-		return 0;
+		return ORDER;
 	}
 
 	@Override
@@ -79,8 +75,9 @@ public void customize(ConfigurableJettyWebServerFactory factory) {
 		ServerProperties.Jetty properties = this.serverProperties.getJetty();
 		factory.setUseForwardHeaders(getOrDeduceUseForwardHeaders());
 		ServerProperties.Jetty.Threads threadProperties = properties.getThreads();
-		factory.setThreadPool(determineThreadPool(properties.getThreads()));
+		factory.setThreadPool(JettyThreadPool.create(properties.getThreads()));
 		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+		map.from(properties::getMaxConnections).to(factory::setMaxConnections);
 		map.from(threadProperties::getAcceptors).to(factory::setAcceptors);
 		map.from(threadProperties::getSelectors).to(factory::setSelectors);
 		map.from(this.serverProperties::getMaxHttpRequestHeaderSize)
@@ -133,42 +130,25 @@ public void customize(Server server) {
 				setHandlerMaxHttpFormPostSize(server.getHandlers());
 			}
 
-			private void setHandlerMaxHttpFormPostSize(Handler... handlers) {
+			private void setHandlerMaxHttpFormPostSize(List<Handler> handlers) {
 				for (Handler handler : handlers) {
-					if (handler instanceof ContextHandler contextHandler) {
-						contextHandler.setMaxFormContentSize(maxHttpFormPostSize);
-					}
-					else if (handler instanceof HandlerWrapper wrapper) {
-						setHandlerMaxHttpFormPostSize(wrapper.getHandler());
-					}
-					else if (handler instanceof HandlerCollection collection) {
-						setHandlerMaxHttpFormPostSize(collection.getHandlers());
-					}
+					setHandlerMaxHttpFormPostSize(handler);
 				}
 			}
 
-		});
-	}
-
-	private ThreadPool determineThreadPool(ServerProperties.Jetty.Threads properties) {
-		BlockingQueue<Runnable> queue = determineBlockingQueue(properties.getMaxQueueCapacity());
-		int maxThreadCount = (properties.getMax() > 0) ? properties.getMax() : 200;
-		int minThreadCount = (properties.getMin() > 0) ? properties.getMin() : 8;
-		int threadIdleTimeout = (properties.getIdleTimeout() != null) ? (int) properties.getIdleTimeout().toMillis()
-				: 60000;
-		return new QueuedThreadPool(maxThreadCount, minThreadCount, threadIdleTimeout, queue);
-	}
+			private void setHandlerMaxHttpFormPostSize(Handler handler) {
+				if (handler instanceof ServletContextHandler contextHandler) {
+					contextHandler.setMaxFormContentSize(maxHttpFormPostSize);
+				}
+				else if (handler instanceof Handler.Wrapper wrapper) {
+					setHandlerMaxHttpFormPostSize(wrapper.getHandler());
+				}
+				else if (handler instanceof Handler.Collection collection) {
+					setHandlerMaxHttpFormPostSize(collection.getHandlers());
+				}
+			}
 
-	private BlockingQueue<Runnable> determineBlockingQueue(Integer maxQueueCapacity) {
-		if (maxQueueCapacity == null) {
-			return null;
-		}
-		if (maxQueueCapacity == 0) {
-			return new SynchronousQueue<>();
-		}
-		else {
-			return new BlockingArrayQueue<>(maxQueueCapacity);
-		}
+		});
 	}
 
 	private void customizeAccessLog(ConfigurableJettyWebServerFactory factory,
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/NettyWebServerFactoryCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/NettyWebServerFactoryCustomizer.java
index c1d6ba684b8d..6dc657b3d7cb 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/NettyWebServerFactoryCustomizer.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/NettyWebServerFactoryCustomizer.java
@@ -19,7 +19,6 @@
 import java.time.Duration;
 
 import io.netty.channel.ChannelOption;
-import reactor.netty.http.server.HttpRequestDecoderSpec;
 
 import org.springframework.boot.autoconfigure.web.ServerProperties;
 import org.springframework.boot.cloud.CloudPlatform;
@@ -64,6 +63,10 @@ public void customize(NettyReactiveWebServerFactory factory) {
 		map.from(nettyProperties::getIdleTimeout).to((idleTimeout) -> customizeIdleTimeout(factory, idleTimeout));
 		map.from(nettyProperties::getMaxKeepAliveRequests)
 			.to((maxKeepAliveRequests) -> customizeMaxKeepAliveRequests(factory, maxKeepAliveRequests));
+		if (this.serverProperties.getHttp2() != null && this.serverProperties.getHttp2().isEnabled()) {
+			map.from(this.serverProperties.getMaxHttpRequestHeaderSize())
+				.to((size) -> customizeHttp2MaxHeaderSize(factory, size.toBytes()));
+		}
 		customizeRequestDecoder(factory, map);
 	}
 
@@ -83,37 +86,22 @@ private void customizeConnectionTimeout(NettyReactiveWebServerFactory factory, D
 	private void customizeRequestDecoder(NettyReactiveWebServerFactory factory, PropertyMapper propertyMapper) {
 		factory.addServerCustomizers((httpServer) -> httpServer.httpRequestDecoder((httpRequestDecoderSpec) -> {
 			propertyMapper.from(this.serverProperties.getMaxHttpRequestHeaderSize())
-				.whenNonNull()
 				.to((maxHttpRequestHeader) -> httpRequestDecoderSpec
 					.maxHeaderSize((int) maxHttpRequestHeader.toBytes()));
 			ServerProperties.Netty nettyProperties = this.serverProperties.getNetty();
-			maxChunkSize(propertyMapper, httpRequestDecoderSpec, nettyProperties);
 			propertyMapper.from(nettyProperties.getMaxInitialLineLength())
-				.whenNonNull()
 				.to((maxInitialLineLength) -> httpRequestDecoderSpec
 					.maxInitialLineLength((int) maxInitialLineLength.toBytes()));
 			propertyMapper.from(nettyProperties.getH2cMaxContentLength())
-				.whenNonNull()
 				.to((h2cMaxContentLength) -> httpRequestDecoderSpec
 					.h2cMaxContentLength((int) h2cMaxContentLength.toBytes()));
 			propertyMapper.from(nettyProperties.getInitialBufferSize())
-				.whenNonNull()
 				.to((initialBufferSize) -> httpRequestDecoderSpec.initialBufferSize((int) initialBufferSize.toBytes()));
-			propertyMapper.from(nettyProperties.isValidateHeaders())
-				.whenNonNull()
-				.to(httpRequestDecoderSpec::validateHeaders);
+			propertyMapper.from(nettyProperties.isValidateHeaders()).to(httpRequestDecoderSpec::validateHeaders);
 			return httpRequestDecoderSpec;
 		}));
 	}
 
-	@SuppressWarnings({ "deprecation", "removal" })
-	private void maxChunkSize(PropertyMapper propertyMapper, HttpRequestDecoderSpec httpRequestDecoderSpec,
-			ServerProperties.Netty nettyProperties) {
-		propertyMapper.from(nettyProperties.getMaxChunkSize())
-			.whenNonNull()
-			.to((maxChunkSize) -> httpRequestDecoderSpec.maxChunkSize((int) maxChunkSize.toBytes()));
-	}
-
 	private void customizeIdleTimeout(NettyReactiveWebServerFactory factory, Duration idleTimeout) {
 		factory.addServerCustomizers((httpServer) -> httpServer.idleTimeout(idleTimeout));
 	}
@@ -122,4 +110,9 @@ private void customizeMaxKeepAliveRequests(NettyReactiveWebServerFactory factory
 		factory.addServerCustomizers((httpServer) -> httpServer.maxKeepAliveRequests(maxKeepAliveRequests));
 	}
 
+	private void customizeHttp2MaxHeaderSize(NettyReactiveWebServerFactory factory, long size) {
+		factory.addServerCustomizers(
+				((httpServer) -> httpServer.http2Settings((settings) -> settings.maxHeaderListSize(size))));
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/TomcatVirtualThreadsWebServerFactoryCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/TomcatVirtualThreadsWebServerFactoryCustomizer.java
new file mode 100644
index 000000000000..54ba36c7e67f
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/TomcatVirtualThreadsWebServerFactoryCustomizer.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.web.embedded;
+
+import org.apache.coyote.ProtocolHandler;
+import org.apache.tomcat.util.threads.VirtualThreadExecutor;
+
+import org.springframework.boot.web.embedded.tomcat.ConfigurableTomcatWebServerFactory;
+import org.springframework.boot.web.server.WebServerFactoryCustomizer;
+import org.springframework.core.Ordered;
+
+/**
+ * Activates {@link VirtualThreadExecutor} on {@link ProtocolHandler Tomcat's protocol
+ * handler}.
+ *
+ * @author Moritz Halbritter
+ * @since 3.2.0
+ */
+public class TomcatVirtualThreadsWebServerFactoryCustomizer
+		implements WebServerFactoryCustomizer<ConfigurableTomcatWebServerFactory>, Ordered {
+
+	@Override
+	public void customize(ConfigurableTomcatWebServerFactory factory) {
+		factory.addProtocolHandlerCustomizers(
+				(protocolHandler) -> protocolHandler.setExecutor(new VirtualThreadExecutor("tomcat-handler-")));
+	}
+
+	@Override
+	public int getOrder() {
+		return TomcatWebServerFactoryCustomizer.ORDER + 1;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizer.java
index e47b7cccfe59..1c357d2d0ac0 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizer.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizer.java
@@ -67,6 +67,8 @@
 public class TomcatWebServerFactoryCustomizer
 		implements WebServerFactoryCustomizer<ConfigurableTomcatWebServerFactory>, Ordered {
 
+	static final int ORDER = 0;
+
 	private final Environment environment;
 
 	private final ServerProperties serverProperties;
@@ -78,10 +80,11 @@ public TomcatWebServerFactoryCustomizer(Environment environment, ServerPropertie
 
 	@Override
 	public int getOrder() {
-		return 0;
+		return ORDER;
 	}
 
 	@Override
+	@SuppressWarnings("removal")
 	public void customize(ConfigurableTomcatWebServerFactory factory) {
 		ServerProperties.Tomcat properties = this.serverProperties.getTomcat();
 		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfiguration.java
index af302e459deb..45ba63007162 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfiguration.java
@@ -62,7 +62,7 @@ CodecCustomizer defaultPartHttpMessageReaderCustomizer(ReactiveMultipartProperti
 					.asInt(DataSize::toBytes)
 					.to(defaultPartHttpMessageReader::setMaxHeadersSize);
 				map.from(multipartProperties::getMaxDiskUsagePerPart)
-					.asInt(DataSize::toBytes)
+					.as(DataSize::toBytes)
 					.to(defaultPartHttpMessageReader::setMaxDiskUsagePerPart);
 				map.from(multipartProperties::getMaxParts).to(defaultPartHttpMessageReader::setMaxParts);
 				map.from(multipartProperties::getFileStorageDirectory)
@@ -78,6 +78,10 @@ else if (codec instanceof PartEventHttpMessageReader partEventHttpMessageReader)
 				map.from(multipartProperties::getMaxHeadersSize)
 					.asInt(DataSize::toBytes)
 					.to(partEventHttpMessageReader::setMaxHeadersSize);
+				map.from(multipartProperties::getMaxDiskUsagePerPart)
+					.as(DataSize::toBytes)
+					.to(partEventHttpMessageReader::setMaxPartSize);
+				map.from(multipartProperties::getMaxParts).to(partEventHttpMessageReader::setMaxParts);
 				map.from(multipartProperties::getHeadersCharset).to(partEventHttpMessageReader::setHeadersCharset);
 			}
 		});
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartProperties.java
index b1bd6d7854d9..01b4e005a2eb 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartProperties.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -37,7 +37,7 @@ public class ReactiveMultipartProperties {
 
 	/**
 	 * Maximum amount of memory allowed per part before it's written to disk. Set to -1 to
-	 * store all contents in memory. Ignored when streaming is enabled.
+	 * store all contents in memory.
 	 */
 	private DataSize maxInMemorySize = DataSize.ofKilobytes(256);
 
@@ -49,7 +49,7 @@ public class ReactiveMultipartProperties {
 
 	/**
 	 * Maximum amount of disk space allowed per part. Default is -1 which enforces no
-	 * limits. Ignored when streaming is enabled.
+	 * limits.
 	 */
 	private DataSize maxDiskUsagePerPart = DataSize.ofBytes(-1);
 
@@ -62,7 +62,7 @@ public class ReactiveMultipartProperties {
 	/**
 	 * Directory used to store file parts larger than 'maxInMemorySize'. Default is a
 	 * directory named 'spring-multipart' created under the system temporary directory.
-	 * Ignored when streaming is enabled.
+	 * Ignored when using the PartEvent streaming support.
 	 */
 	private String fileStorageDirectory;
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryConfiguration.java
index 2d92ced3d6e8..74880e24d3c3 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryConfiguration.java
@@ -17,7 +17,7 @@
 package org.springframework.boot.autoconfigure.web.reactive;
 
 import io.undertow.Undertow;
-import org.eclipse.jetty.servlet.ServletHolder;
+import org.eclipse.jetty.ee10.servlet.ServletHolder;
 import reactor.netty.http.server.HttpServer;
 
 import org.springframework.beans.factory.ObjectProvider;
@@ -39,8 +39,7 @@
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Import;
-import org.springframework.http.client.reactive.JettyResourceFactory;
-import org.springframework.http.client.reactive.ReactorResourceFactory;
+import org.springframework.http.client.ReactorResourceFactory;
 
 /**
  * Configuration classes for reactive web servers
@@ -97,17 +96,10 @@ TomcatReactiveWebServerFactory tomcatReactiveWebServerFactory(
 	static class EmbeddedJetty {
 
 		@Bean
-		@ConditionalOnMissingBean
-		JettyResourceFactory jettyServerResourceFactory() {
-			return new JettyResourceFactory();
-		}
-
-		@Bean
-		JettyReactiveWebServerFactory jettyReactiveWebServerFactory(JettyResourceFactory resourceFactory,
+		JettyReactiveWebServerFactory jettyReactiveWebServerFactory(
 				ObjectProvider<JettyServerCustomizer> serverCustomizers) {
 			JettyReactiveWebServerFactory serverFactory = new JettyReactiveWebServerFactory();
 			serverFactory.getServerCustomizers().addAll(serverCustomizers.orderedStream().toList());
-			serverFactory.setResourceFactory(resourceFactory);
 			return serverFactory;
 		}
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfiguration.java
index 54b1effcb9cb..e9ebef362499 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfiguration.java
@@ -32,6 +32,7 @@
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
 import org.springframework.boot.autoconfigure.http.codec.CodecsAutoConfiguration;
+import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration;
 import org.springframework.boot.autoconfigure.template.TemplateAvailabilityProviders;
 import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration;
 import org.springframework.boot.autoconfigure.validation.ValidatorAdapter;
@@ -54,12 +55,14 @@
 import org.springframework.context.annotation.ImportRuntimeHints;
 import org.springframework.core.Ordered;
 import org.springframework.core.annotation.Order;
+import org.springframework.core.task.AsyncTaskExecutor;
 import org.springframework.format.FormatterRegistry;
 import org.springframework.format.support.FormattingConversionService;
 import org.springframework.http.codec.ServerCodecConfigurer;
 import org.springframework.util.ClassUtils;
 import org.springframework.validation.Validator;
 import org.springframework.web.filter.reactive.HiddenHttpMethodFilter;
+import org.springframework.web.reactive.config.BlockingExecutionConfigurer;
 import org.springframework.web.reactive.config.DelegatingWebFluxConfiguration;
 import org.springframework.web.reactive.config.EnableWebFlux;
 import org.springframework.web.reactive.config.ResourceHandlerRegistration;
@@ -184,6 +187,17 @@ public void configureHttpMessageCodecs(ServerCodecConfigurer configurer) {
 			this.codecCustomizers.orderedStream().forEach((customizer) -> customizer.customize(configurer));
 		}
 
+		@Override
+		public void configureBlockingExecution(BlockingExecutionConfigurer configurer) {
+			if (this.beanFactory.containsBean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)) {
+				Object taskExecutor = this.beanFactory
+					.getBean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME);
+				if (taskExecutor instanceof AsyncTaskExecutor asyncTaskExecutor) {
+					configurer.setExecutor(asyncTaskExecutor);
+				}
+			}
+		}
+
 		@Override
 		public void addResourceHandlers(ResourceHandlerRegistry registry) {
 			if (!this.resourceProperties.isAddMappings()) {
@@ -343,6 +357,7 @@ static class ProblemDetailsErrorHandlingConfiguration {
 
 		@Bean
 		@ConditionalOnMissingBean(ResponseEntityExceptionHandler.class)
+		@Order(0)
 		ProblemDetailsExceptionHandler problemDetailsExceptionHandler() {
 			return new ProblemDetailsExceptionHandler();
 		}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxProperties.java
index 0b9c5ccc74bb..0d0f2359c2fc 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxProperties.java
@@ -20,7 +20,7 @@
 import org.springframework.util.StringUtils;
 
 /**
- * {@link ConfigurationProperties properties} for Spring WebFlux.
+ * {@link ConfigurationProperties Properties} for Spring WebFlux.
  *
  * @author Brian Clozel
  * @author Vedran Pavic
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/AbstractErrorWebExceptionHandler.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/AbstractErrorWebExceptionHandler.java
index 11c1bf536cc9..8b7daafd5c9c 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/AbstractErrorWebExceptionHandler.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/AbstractErrorWebExceptionHandler.java
@@ -62,13 +62,12 @@
 public abstract class AbstractErrorWebExceptionHandler implements ErrorWebExceptionHandler, InitializingBean {
 
 	/**
-	 * Currently duplicated from Spring WebFlux HttpWebHandlerAdapter.
+	 * Currently duplicated from Spring Web's DisconnectedClientHelper.
 	 */
 	private static final Set<String> DISCONNECTED_CLIENT_EXCEPTIONS;
 
 	static {
 		Set<String> exceptions = new HashSet<>();
-		exceptions.add("AbortedException");
 		exceptions.add("ClientAbortException");
 		exceptions.add("EOFException");
 		exceptions.add("EofException");
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfiguration.java
index 34bf67e605a8..308ac80f5031 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfiguration.java
@@ -46,7 +46,6 @@
 @ConditionalOnClass(WebClient.class)
 @AutoConfigureAfter(SslAutoConfiguration.class)
 @Import({ ClientHttpConnectorFactoryConfiguration.ReactorNetty.class,
-		ClientHttpConnectorFactoryConfiguration.JettyClient.class,
 		ClientHttpConnectorFactoryConfiguration.HttpClient5.class,
 		ClientHttpConnectorFactoryConfiguration.JdkClient.class })
 public class ClientHttpConnectorAutoConfiguration {
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfiguration.java
index cf0769bc0cda..d3f9aa1b0d8b 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfiguration.java
@@ -27,8 +27,7 @@
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Import;
-import org.springframework.http.client.reactive.JettyResourceFactory;
-import org.springframework.http.client.reactive.ReactorResourceFactory;
+import org.springframework.http.client.ReactorResourceFactory;
 
 /**
  * Configuration classes for WebClient client connectors.
@@ -55,24 +54,6 @@ ReactorClientHttpConnectorFactory reactorClientHttpConnectorFactory(
 
 	}
 
-	@Configuration(proxyBeanMethods = false)
-	@ConditionalOnClass(org.eclipse.jetty.reactive.client.ReactiveRequest.class)
-	@ConditionalOnMissingBean(ClientHttpConnectorFactory.class)
-	static class JettyClient {
-
-		@Bean
-		@ConditionalOnMissingBean
-		JettyResourceFactory jettyClientResourceFactory() {
-			return new JettyResourceFactory();
-		}
-
-		@Bean
-		JettyClientHttpConnectorFactory jettyClientHttpConnectorFactory(JettyResourceFactory jettyResourceFactory) {
-			return new JettyClientHttpConnectorFactory(jettyResourceFactory);
-		}
-
-	}
-
 	@Configuration(proxyBeanMethods = false)
 	@ConditionalOnClass({ HttpAsyncClients.class, AsyncRequestProducer.class, ReactiveResponseConsumer.class })
 	@ConditionalOnMissingBean(ClientHttpConnectorFactory.class)
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JettyClientHttpConnectorFactory.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JettyClientHttpConnectorFactory.java
deleted file mode 100644
index 5824abf1ca92..000000000000
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JettyClientHttpConnectorFactory.java
+++ /dev/null
@@ -1,64 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.autoconfigure.web.reactive.function.client;
-
-import org.eclipse.jetty.client.HttpClient;
-import org.eclipse.jetty.client.http.HttpClientTransportOverHTTP;
-import org.eclipse.jetty.io.ClientConnector;
-import org.eclipse.jetty.util.ssl.SslContextFactory;
-
-import org.springframework.boot.ssl.SslBundle;
-import org.springframework.boot.ssl.SslOptions;
-import org.springframework.http.client.reactive.JettyClientHttpConnector;
-import org.springframework.http.client.reactive.JettyResourceFactory;
-
-/**
- * {@link ClientHttpConnectorFactory} for {@link JettyClientHttpConnector}.
- *
- * @author Phillip Webb
- */
-class JettyClientHttpConnectorFactory implements ClientHttpConnectorFactory<JettyClientHttpConnector> {
-
-	private final JettyResourceFactory jettyResourceFactory;
-
-	JettyClientHttpConnectorFactory(JettyResourceFactory jettyResourceFactory) {
-		this.jettyResourceFactory = jettyResourceFactory;
-	}
-
-	@Override
-	public JettyClientHttpConnector createClientHttpConnector(SslBundle sslBundle) {
-		SslContextFactory.Client sslContextFactory = new SslContextFactory.Client();
-		if (sslBundle != null) {
-			SslOptions options = sslBundle.getOptions();
-			if (options.getCiphers() != null) {
-				sslContextFactory.setIncludeCipherSuites(options.getCiphers());
-				sslContextFactory.setExcludeCipherSuites();
-			}
-			if (options.getEnabledProtocols() != null) {
-				sslContextFactory.setIncludeProtocols(options.getEnabledProtocols());
-				sslContextFactory.setExcludeProtocols();
-			}
-			sslContextFactory.setSslContext(sslBundle.createSslContext());
-		}
-		ClientConnector connector = new ClientConnector();
-		connector.setSslContextFactory(sslContextFactory);
-		HttpClientTransportOverHTTP transport = new HttpClientTransportOverHTTP(connector);
-		HttpClient httpClient = new HttpClient(transport);
-		return new JettyClientHttpConnector(httpClient, this.jettyResourceFactory);
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactory.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactory.java
index b5dcd6b136a1..3f596126ac51 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactory.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactory.java
@@ -31,8 +31,8 @@
 import org.springframework.boot.ssl.SslBundle;
 import org.springframework.boot.ssl.SslManagerBundle;
 import org.springframework.boot.ssl.SslOptions;
+import org.springframework.http.client.ReactorResourceFactory;
 import org.springframework.http.client.reactive.ReactorClientHttpConnector;
-import org.springframework.http.client.reactive.ReactorResourceFactory;
 import org.springframework.util.function.ThrowingConsumer;
 
 /**
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientSsl.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientSsl.java
index ba33e619fb41..0fe531e92984 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientSsl.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientSsl.java
@@ -31,7 +31,7 @@
  * Typically used as follows: <pre class="code">
  * &#064;Bean
  * public MyBean myBean(WebClient.Builder webClientBuilder, WebClientSsl ssl) {
- *     WebClient webClient = webClientBuilder.apply(ssl.forBundle("mybundle")).build();
+ *     WebClient webClient = webClientBuilder.apply(ssl.fromBundle("mybundle")).build();
  *     return new MyBean(webClient);
  * }
  * </pre> NOTE: Apply SSL configuration will replace any previously
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/DispatcherServletAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/DispatcherServletAutoConfiguration.java
index 5575bde20a9c..5aafbfb0091e 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/DispatcherServletAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/DispatcherServletAutoConfiguration.java
@@ -89,12 +89,18 @@ public DispatcherServlet dispatcherServlet(WebMvcProperties webMvcProperties) {
 			DispatcherServlet dispatcherServlet = new DispatcherServlet();
 			dispatcherServlet.setDispatchOptionsRequest(webMvcProperties.isDispatchOptionsRequest());
 			dispatcherServlet.setDispatchTraceRequest(webMvcProperties.isDispatchTraceRequest());
-			dispatcherServlet.setThrowExceptionIfNoHandlerFound(webMvcProperties.isThrowExceptionIfNoHandlerFound());
+			configureThrowExceptionIfNoHandlerFound(webMvcProperties, dispatcherServlet);
 			dispatcherServlet.setPublishEvents(webMvcProperties.isPublishRequestHandledEvents());
 			dispatcherServlet.setEnableLoggingRequestDetails(webMvcProperties.isLogRequestDetails());
 			return dispatcherServlet;
 		}
 
+		@SuppressWarnings({ "deprecation", "removal" })
+		private void configureThrowExceptionIfNoHandlerFound(WebMvcProperties webMvcProperties,
+				DispatcherServlet dispatcherServlet) {
+			dispatcherServlet.setThrowExceptionIfNoHandlerFound(webMvcProperties.isThrowExceptionIfNoHandlerFound());
+		}
+
 		@Bean
 		@ConditionalOnBean(MultipartResolver.class)
 		@ConditionalOnMissingBean(name = DispatcherServlet.MULTIPART_RESOLVER_BEAN_NAME)
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/MultipartAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/MultipartAutoConfiguration.java
index f91fa1ec8ac2..9bac3910ac20 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/MultipartAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/MultipartAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -46,6 +46,7 @@
  * @author Greg Turnquist
  * @author Josh Long
  * @author Toshiaki Maki
+ * @author Yanming Zhou
  * @since 2.0.0
  */
 @AutoConfiguration
@@ -72,6 +73,7 @@ public MultipartConfigElement multipartConfigElement() {
 	public StandardServletMultipartResolver multipartResolver() {
 		StandardServletMultipartResolver multipartResolver = new StandardServletMultipartResolver();
 		multipartResolver.setResolveLazily(this.multipartProperties.isResolveLazily());
+		multipartResolver.setStrictServletCompliance(this.multipartProperties.isStrictServletCompliance());
 		return multipartResolver;
 	}
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/MultipartProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/MultipartProperties.java
index 1388ae73bbb1..6e38a93927dd 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/MultipartProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/MultipartProperties.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -43,6 +43,7 @@
  * @author Josh Long
  * @author Toshiaki Maki
  * @author Stephane Nicoll
+ * @author Yanming Zhou
  * @since 2.0.0
  */
 @ConfigurationProperties(prefix = "spring.servlet.multipart", ignoreUnknownFields = false)
@@ -79,6 +80,12 @@ public class MultipartProperties {
 	 */
 	private boolean resolveLazily = false;
 
+	/**
+	 * Whether to resolve the multipart request strictly complying with the Servlet
+	 * specification, only to be used for "multipart/form-data" requests.
+	 */
+	private boolean strictServletCompliance = false;
+
 	public boolean getEnabled() {
 		return this.enabled;
 	}
@@ -127,6 +134,14 @@ public void setResolveLazily(boolean resolveLazily) {
 		this.resolveLazily = resolveLazily;
 	}
 
+	public boolean isStrictServletCompliance() {
+		return this.strictServletCompliance;
+	}
+
+	public void setStrictServletCompliance(boolean strictServletCompliance) {
+		this.strictServletCompliance = strictServletCompliance;
+	}
+
 	/**
 	 * Create a new {@link MultipartConfigElement} using the properties.
 	 * @return a new {@link MultipartConfigElement} configured using there properties
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryConfiguration.java
index b33e8eaf4ced..266b298eeb25 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryConfiguration.java
@@ -20,9 +20,9 @@
 import jakarta.servlet.Servlet;
 import org.apache.catalina.startup.Tomcat;
 import org.apache.coyote.UpgradeProtocol;
+import org.eclipse.jetty.ee10.webapp.WebAppContext;
 import org.eclipse.jetty.server.Server;
 import org.eclipse.jetty.util.Loader;
-import org.eclipse.jetty.webapp.WebAppContext;
 import org.xnio.SslClientAuthMode;
 
 import org.springframework.beans.factory.ObjectProvider;
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java
index e92256d6004a..a6df6386d7e3 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java
@@ -31,7 +31,6 @@
 import org.springframework.beans.factory.ListableBeanFactory;
 import org.springframework.beans.factory.NoSuchBeanDefinitionException;
 import org.springframework.beans.factory.ObjectProvider;
-import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.AutoConfigureOrder;
 import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
@@ -403,24 +402,6 @@ public EnableWebMvcConfiguration(WebMvcProperties mvcProperties, WebProperties w
 			this.beanFactory = beanFactory;
 		}
 
-		@Bean
-		@Override
-		public RequestMappingHandlerAdapter requestMappingHandlerAdapter(
-				@Qualifier("mvcContentNegotiationManager") ContentNegotiationManager contentNegotiationManager,
-				@Qualifier("mvcConversionService") FormattingConversionService conversionService,
-				@Qualifier("mvcValidator") Validator validator) {
-			RequestMappingHandlerAdapter adapter = super.requestMappingHandlerAdapter(contentNegotiationManager,
-					conversionService, validator);
-			setIgnoreDefaultModelOnRedirect(adapter);
-			return adapter;
-		}
-
-		@SuppressWarnings({ "deprecation", "removal" })
-		private void setIgnoreDefaultModelOnRedirect(RequestMappingHandlerAdapter adapter) {
-			adapter.setIgnoreDefaultModelOnRedirect(
-					this.mvcProperties == null || this.mvcProperties.isIgnoreDefaultModelOnRedirect());
-		}
-
 		@Override
 		protected RequestMappingHandlerAdapter createRequestMappingHandlerAdapter() {
 			if (this.mvcRegistrations != null) {
@@ -681,6 +662,7 @@ static class ProblemDetailsErrorHandlingConfiguration {
 
 		@Bean
 		@ConditionalOnMissingBean(ResponseEntityExceptionHandler.class)
+		@Order(0)
 		ProblemDetailsExceptionHandler problemDetailsExceptionHandler() {
 			return new ProblemDetailsExceptionHandler();
 		}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcProperties.java
index 6037fa974a84..9945241d3e45 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcProperties.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcProperties.java
@@ -27,7 +27,7 @@
 import org.springframework.validation.DefaultMessageCodesResolver;
 
 /**
- * {@link ConfigurationProperties properties} for Spring MVC.
+ * {@link ConfigurationProperties Properties} for Spring MVC.
  *
  * @author Phillip Webb
  * @author Sébastien Deleuze
@@ -57,12 +57,6 @@ public class WebMvcProperties {
 	 */
 	private boolean dispatchOptionsRequest = true;
 
-	/**
-	 * Whether the content of the "default" model should be ignored during redirect
-	 * scenarios.
-	 */
-	private boolean ignoreDefaultModelOnRedirect = true;
-
 	/**
 	 * Whether to publish a ServletRequestHandledEvent at the end of each request.
 	 */
@@ -71,8 +65,10 @@ public class WebMvcProperties {
 	/**
 	 * Whether a "NoHandlerFoundException" should be thrown if no Handler was found to
 	 * process a request.
+	 * @deprecated since 3.2.0 for removal in 3.4.0
 	 */
-	private boolean throwExceptionIfNoHandlerFound = false;
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	private boolean throwExceptionIfNoHandlerFound = true;
 
 	/**
 	 * Whether logging of (potentially sensitive) request details at DEBUG and TRACE level
@@ -120,17 +116,6 @@ public Format getFormat() {
 		return this.format;
 	}
 
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	@DeprecatedConfigurationProperty(reason = "Deprecated for removal in Spring MVC")
-	public boolean isIgnoreDefaultModelOnRedirect() {
-		return this.ignoreDefaultModelOnRedirect;
-	}
-
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	public void setIgnoreDefaultModelOnRedirect(boolean ignoreDefaultModelOnRedirect) {
-		this.ignoreDefaultModelOnRedirect = ignoreDefaultModelOnRedirect;
-	}
-
 	public boolean isPublishRequestHandledEvents() {
 		return this.publishRequestHandledEvents;
 	}
@@ -139,10 +124,15 @@ public void setPublishRequestHandledEvents(boolean publishRequestHandledEvents)
 		this.publishRequestHandledEvents = publishRequestHandledEvents;
 	}
 
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	@DeprecatedConfigurationProperty(
+			reason = "DispatcherServlet property is deprecated for removal and should no longer need to be configured",
+			since = "3.2.0")
 	public boolean isThrowExceptionIfNoHandlerFound() {
 		return this.throwExceptionIfNoHandlerFound;
 	}
 
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	public void setThrowExceptionIfNoHandlerFound(boolean throwExceptionIfNoHandlerFound) {
 		this.throwExceptionIfNoHandlerFound = throwExceptionIfNoHandlerFound;
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcRegistrations.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcRegistrations.java
index 5edf41e887f1..e214aba223a3 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcRegistrations.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcRegistrations.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -17,6 +17,7 @@
 package org.springframework.boot.autoconfigure.web.servlet;
 
 import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
 import org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver;
 import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;
 import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
@@ -25,9 +26,12 @@
  * Interface to register key components of the {@link WebMvcConfigurationSupport} in place
  * of the default ones provided by Spring MVC.
  * <p>
- * All custom instances are later processed by Boot and Spring MVC configurations. A
- * single instance of this component should be registered, otherwise making it impossible
- * to choose from redundant MVC components.
+ * All custom instances are later processed by Boot and Spring MVC configurations. To
+ * participate in, and if desired, override that subsequent processing,
+ * {@link WebMvcConfigurer} should be used.
+ * <p>
+ * A single instance of this component should be registered, otherwise making it
+ * impossible to choose from redundant MVC components.
  *
  * @author Brian Clozel
  * @since 2.0.0
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WelcomePageHandlerMapping.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WelcomePageHandlerMapping.java
index 16661f20dbe4..fd669855a299 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WelcomePageHandlerMapping.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WelcomePageHandlerMapping.java
@@ -28,6 +28,7 @@
 import org.springframework.core.io.Resource;
 import org.springframework.core.log.LogMessage;
 import org.springframework.http.HttpHeaders;
+import org.springframework.http.InvalidMediaTypeException;
 import org.springframework.http.MediaType;
 import org.springframework.util.StringUtils;
 import org.springframework.web.servlet.handler.AbstractUrlHandlerMapping;
@@ -40,6 +41,7 @@
  *
  * @author Andy Wilkinson
  * @author Bruce Brouwer
+ * @author Moritz Halbritter
  * @see WelcomePageNotAcceptableHandlerMapping
  */
 final class WelcomePageHandlerMapping extends AbstractUrlHandlerMapping {
@@ -79,7 +81,13 @@ private boolean isHtmlTextAccepted(HttpServletRequest request) {
 	private List<MediaType> getAcceptedMediaTypes(HttpServletRequest request) {
 		String acceptHeader = request.getHeader(HttpHeaders.ACCEPT);
 		if (StringUtils.hasText(acceptHeader)) {
-			return MediaType.parseMediaTypes(acceptHeader);
+			try {
+				return MediaType.parseMediaTypes(acceptHeader);
+			}
+			catch (InvalidMediaTypeException ex) {
+				logger.warn("Received invalid Accept header. Assuming all media types are accepted",
+						logger.isDebugEnabled() ? ex : null);
+			}
 		}
 		return MEDIA_TYPES_ALL;
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/reactive/JettyWebSocketReactiveWebServerCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/reactive/JettyWebSocketReactiveWebServerCustomizer.java
index 4c050c4237ed..00ca570b9d6c 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/reactive/JettyWebSocketReactiveWebServerCustomizer.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/reactive/JettyWebSocketReactiveWebServerCustomizer.java
@@ -17,15 +17,13 @@
 package org.springframework.boot.autoconfigure.websocket.reactive;
 
 import jakarta.servlet.ServletContext;
+import org.eclipse.jetty.ee10.servlet.ServletContextHandler;
+import org.eclipse.jetty.ee10.websocket.jakarta.server.JakartaWebSocketServerContainer;
+import org.eclipse.jetty.ee10.websocket.server.JettyWebSocketServerContainer;
+import org.eclipse.jetty.ee10.websocket.servlet.WebSocketUpgradeFilter;
 import org.eclipse.jetty.server.Handler;
-import org.eclipse.jetty.server.handler.HandlerCollection;
-import org.eclipse.jetty.server.handler.HandlerWrapper;
-import org.eclipse.jetty.servlet.ServletContextHandler;
 import org.eclipse.jetty.websocket.core.server.WebSocketMappings;
 import org.eclipse.jetty.websocket.core.server.WebSocketServerComponents;
-import org.eclipse.jetty.websocket.jakarta.server.internal.JakartaWebSocketServerContainer;
-import org.eclipse.jetty.websocket.server.JettyWebSocketServerContainer;
-import org.eclipse.jetty.websocket.servlet.WebSocketUpgradeFilter;
 
 import org.springframework.boot.web.embedded.jetty.JettyReactiveWebServerFactory;
 import org.springframework.boot.web.server.WebServerFactoryCustomizer;
@@ -47,13 +45,13 @@ public void customize(JettyReactiveWebServerFactory factory) {
 			if (servletContextHandler != null) {
 				ServletContext servletContext = servletContextHandler.getServletContext();
 				if (JettyWebSocketServerContainer.getContainer(servletContext) == null) {
-					WebSocketServerComponents.ensureWebSocketComponents(server, servletContext);
+					WebSocketServerComponents.ensureWebSocketComponents(server, servletContextHandler);
 					JettyWebSocketServerContainer.ensureContainer(servletContext);
 				}
 				if (JakartaWebSocketServerContainer.getContainer(servletContext) == null) {
-					WebSocketServerComponents.ensureWebSocketComponents(server, servletContext);
+					WebSocketServerComponents.ensureWebSocketComponents(server, servletContextHandler);
 					WebSocketUpgradeFilter.ensureFilter(servletContext);
-					WebSocketMappings.ensureMappings(servletContext);
+					WebSocketMappings.ensureMappings(servletContextHandler);
 					JakartaWebSocketServerContainer.ensureContainer(servletContext);
 				}
 			}
@@ -64,10 +62,10 @@ private ServletContextHandler findServletContextHandler(Handler handler) {
 		if (handler instanceof ServletContextHandler servletContextHandler) {
 			return servletContextHandler;
 		}
-		if (handler instanceof HandlerWrapper handlerWrapper) {
+		if (handler instanceof Handler.Wrapper handlerWrapper) {
 			return findServletContextHandler(handlerWrapper.getHandler());
 		}
-		if (handler instanceof HandlerCollection handlerCollection) {
+		if (handler instanceof Handler.Collection handlerCollection) {
 			for (Handler contained : handlerCollection.getHandlers()) {
 				ServletContextHandler servletContextHandler = findServletContextHandler(contained);
 				if (servletContextHandler != null) {
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/reactive/WebSocketReactiveAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/reactive/WebSocketReactiveAutoConfiguration.java
index 2f26b1698582..3592ba14b3cd 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/reactive/WebSocketReactiveAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/reactive/WebSocketReactiveAutoConfiguration.java
@@ -20,7 +20,7 @@
 import jakarta.websocket.server.ServerContainer;
 import org.apache.catalina.startup.Tomcat;
 import org.apache.tomcat.websocket.server.WsSci;
-import org.eclipse.jetty.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer;
+import org.eclipse.jetty.ee10.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer;
 
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/JettyWebSocketServletWebServerCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/JettyWebSocketServletWebServerCustomizer.java
index 8ee41bc966ca..ccf0ef8f379e 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/JettyWebSocketServletWebServerCustomizer.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/JettyWebSocketServletWebServerCustomizer.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,13 +16,13 @@
 
 package org.springframework.boot.autoconfigure.websocket.servlet;
 
-import org.eclipse.jetty.webapp.AbstractConfiguration;
-import org.eclipse.jetty.webapp.WebAppContext;
+import org.eclipse.jetty.ee10.webapp.AbstractConfiguration;
+import org.eclipse.jetty.ee10.webapp.WebAppContext;
+import org.eclipse.jetty.ee10.websocket.jakarta.server.JakartaWebSocketServerContainer;
+import org.eclipse.jetty.ee10.websocket.server.JettyWebSocketServerContainer;
+import org.eclipse.jetty.ee10.websocket.servlet.WebSocketUpgradeFilter;
 import org.eclipse.jetty.websocket.core.server.WebSocketMappings;
 import org.eclipse.jetty.websocket.core.server.WebSocketServerComponents;
-import org.eclipse.jetty.websocket.jakarta.server.internal.JakartaWebSocketServerContainer;
-import org.eclipse.jetty.websocket.server.JettyWebSocketServerContainer;
-import org.eclipse.jetty.websocket.servlet.WebSocketUpgradeFilter;
 
 import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory;
 import org.springframework.boot.web.server.WebServerFactoryCustomizer;
@@ -41,20 +41,20 @@ public class JettyWebSocketServletWebServerCustomizer
 
 	@Override
 	public void customize(JettyServletWebServerFactory factory) {
-		factory.addConfigurations(new AbstractConfiguration() {
+		factory.addConfigurations(new AbstractConfiguration(new AbstractConfiguration.Builder()) {
 
 			@Override
 			public void configure(WebAppContext context) throws Exception {
 				if (JettyWebSocketServerContainer.getContainer(context.getServletContext()) == null) {
 					WebSocketServerComponents.ensureWebSocketComponents(context.getServer(),
-							context.getServletContext());
+							context.getContext().getContextHandler());
 					JettyWebSocketServerContainer.ensureContainer(context.getServletContext());
 				}
 				if (JakartaWebSocketServerContainer.getContainer(context.getServletContext()) == null) {
 					WebSocketServerComponents.ensureWebSocketComponents(context.getServer(),
-							context.getServletContext());
+							context.getContext().getContextHandler());
 					WebSocketUpgradeFilter.ensureFilter(context.getServletContext());
-					WebSocketMappings.ensureMappings(context.getServletContext());
+					WebSocketMappings.ensureMappings(context.getContext().getContextHandler());
 					JakartaWebSocketServerContainer.ensureContainer(context.getServletContext());
 				}
 			}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketMessagingAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketMessagingAutoConfiguration.java
index 939827774f3f..b65ed91535ed 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketMessagingAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketMessagingAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -64,8 +64,7 @@ static class WebSocketMessageConverterConfiguration implements WebSocketMessageB
 
 		@Override
 		public boolean configureMessageConverters(List<MessageConverter> messageConverters) {
-			MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter();
-			converter.setObjectMapper(this.objectMapper);
+			MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter(this.objectMapper);
 			DefaultContentTypeResolver resolver = new DefaultContentTypeResolver();
 			resolver.setDefaultMimeType(MimeTypeUtils.APPLICATION_JSON);
 			converter.setContentTypeResolver(resolver);
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketServletAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketServletAutoConfiguration.java
index 781f61309f7c..71dda5859aef 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketServletAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketServletAutoConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,20 +16,30 @@
 
 package org.springframework.boot.autoconfigure.websocket.servlet;
 
+import java.util.EnumSet;
+
+import jakarta.servlet.DispatcherType;
+import jakarta.servlet.FilterRegistration.Dynamic;
 import jakarta.servlet.Servlet;
 import jakarta.websocket.server.ServerContainer;
 import org.apache.catalina.startup.Tomcat;
 import org.apache.tomcat.websocket.server.WsSci;
-import org.eclipse.jetty.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer;
+import org.eclipse.jetty.ee10.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer;
+import org.eclipse.jetty.ee10.websocket.servlet.WebSocketUpgradeFilter;
 
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnNotWarDeployment;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type;
 import org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryAutoConfiguration;
+import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory;
+import org.springframework.boot.web.server.WebServerFactoryCustomizer;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
 
 /**
  * Auto configuration for WebSocket servlet server in embedded Tomcat, Jetty or Undertow.
@@ -79,6 +89,21 @@ JettyWebSocketServletWebServerCustomizer websocketServletWebServerCustomizer() {
 			return new JettyWebSocketServletWebServerCustomizer();
 		}
 
+		@Bean
+		@ConditionalOnNotWarDeployment
+		@Order(Ordered.LOWEST_PRECEDENCE)
+		@ConditionalOnMissingBean(name = "websocketUpgradeFilterWebServerCustomizer")
+		WebServerFactoryCustomizer<JettyServletWebServerFactory> websocketUpgradeFilterWebServerCustomizer() {
+			return (factory) -> {
+				factory.addInitializers((servletContext) -> {
+					Dynamic registration = servletContext.addFilter(WebSocketUpgradeFilter.class.getName(),
+							new WebSocketUpgradeFilter());
+					registration.setAsyncSupported(true);
+					registration.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/*");
+				});
+			};
+		}
+
 	}
 
 	@Configuration(proxyBeanMethods = false)
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json
index d4c480df6479..7d21dfd05e2d 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json
@@ -115,6 +115,13 @@
         "level": "error"
       }
     },
+    {
+      "name": "server.max-http-header-size",
+      "deprecation": {
+        "replacement": "server.max-http-request-header-size",
+        "level": "error"
+      }
+    },
     {
       "name": "server.max-http-post-size",
       "type": "java.lang.Integer",
@@ -125,6 +132,13 @@
         "level": "error"
       }
     },
+    {
+      "name": "server.netty.max-chunk-size",
+      "deprecation": {
+        "reason": "Deprecated for removal in Reactor Netty.",
+        "level": "error"
+      }
+    },
     {
       "name": "server.port",
       "defaultValue": 8080
@@ -210,7 +224,10 @@
     },
     {
       "name": "server.servlet.session.cookie.comment",
-      "description": "Comment for the cookie."
+      "description": "Comment for the cookie.",
+      "deprecation": {
+        "level": "error"
+      }
     },
     {
       "name": "server.servlet.session.cookie.domain",
@@ -1542,6 +1559,10 @@
       "name": "spring.jackson.constructor-detector",
       "defaultValue": "default"
     },
+    {
+      "name": "spring.jackson.datatype.enum",
+      "description": "Jackson on/off features for enums."
+    },
     {
       "name": "spring.jackson.joda-date-time-format",
       "type": "java.lang.String",
@@ -1555,178 +1576,29 @@
       "defaultValue": "servlet"
     },
     {
-      "name": "spring.jpa.hibernate.use-new-id-generator-mappings",
-      "type": "java.lang.Boolean",
-      "description": "Whether to use Hibernate's newer IdentifierGenerator for AUTO, TABLE and SEQUENCE. This is actually a shortcut for the \"hibernate.id.new_generator_mappings\" property. When not specified will default to \"true\".",
-      "deprecation": {
-        "level": "error",
-        "reason": "Hibernate no longer supports disabling the use of new ID generator mappings."
-      }
-    },
-    {
-      "name": "spring.jpa.open-in-view",
-      "defaultValue": true
-    },
-    {
-      "name": "spring.jta.bitronix.properties.allow-multiple-lrc",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.properties.asynchronous2-pc",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.properties.background-recovery-interval",
-      "type": "java.lang.Integer",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.properties.background-recovery-interval-seconds",
-      "type": "java.lang.Integer",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.properties.current-node-only-recovery",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.properties.debug-zero-resource-transaction",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.properties.default-transaction-timeout",
-      "type": "java.lang.Integer",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.properties.disable-jmx",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.properties.exception-analyzer",
-      "type": "java.lang.String",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.properties.filter-log-status",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.properties.force-batching-enabled",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.properties.forced-write-enabled",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.properties.graceful-shutdown-interval",
-      "type": "java.lang.String",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.properties.jndi-transaction-synchronization-registry-name",
-      "type": "java.lang.String",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.properties.jndi-user-transaction-name",
-      "type": "java.lang.String",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.properties.journal",
-      "type": "java.lang.String",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.properties.log-part1-filename",
-      "type": "java.lang.String",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.properties.log-part2-filename",
-      "type": "java.lang.String",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.properties.max-log-size-in-mb",
-      "type": "java.lang.Integer",
-      "deprecation": {
-        "level": "error"
-      }
+      "name": "spring.jms.listener.session.acknowledge-mode",
+      "defaultValue": "auto"
     },
     {
-      "name": "spring.jta.bitronix.properties.resource-configuration-filename",
-      "type": "java.lang.String",
-      "deprecation": {
-        "level": "error"
-      }
+      "name": "spring.jms.template.session.acknowledge-mode",
+      "defaultValue": "auto"
     },
     {
-      "name": "spring.jta.bitronix.properties.server-id",
-      "type": "java.lang.String",
-      "deprecation": {
-        "level": "error"
-      }
+      "name": "spring.jmx.registration-policy",
+      "defaultValue": "fail-on-existing"
     },
     {
-      "name": "spring.jta.bitronix.properties.skip-corrupted-logs",
+      "name": "spring.jpa.hibernate.use-new-id-generator-mappings",
       "type": "java.lang.Boolean",
+      "description": "Whether to use Hibernate's newer IdentifierGenerator for AUTO, TABLE and SEQUENCE. This is actually a shortcut for the \"hibernate.id.new_generator_mappings\" property. When not specified will default to \"true\".",
       "deprecation": {
-        "level": "error"
+        "level": "error",
+        "reason": "Hibernate no longer supports disabling the use of new ID generator mappings."
       }
     },
     {
-      "name": "spring.jta.bitronix.properties.warn-about-zero-resource-transaction",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
+      "name": "spring.jpa.open-in-view",
+      "defaultValue": true
     },
     {
       "name": "spring.jta.enabled",
@@ -2043,6 +1915,13 @@
         "level": "error"
       }
     },
+    {
+      "name": "spring.liquibase.labels",
+      "deprecation": {
+        "replacement": "spring.liquibase.label-filter",
+        "level": "error"
+      }
+    },
     {
       "name": "spring.mail.test-connection",
       "description": "Whether to test that the mail server is available on startup.",
@@ -2105,6 +1984,13 @@
       "description": "Whether to enable Spring's HiddenHttpMethodFilter.",
       "defaultValue": false
     },
+    {
+      "name": "spring.mvc.ignore-default-model-on-redirect",
+      "deprecation": {
+        "reason": "Deprecated for removal in Spring MVC.",
+        "level": "error"
+      }
+    },
     {
       "name": "spring.mvc.locale",
       "type": "java.util.Locale",
@@ -2133,6 +2019,18 @@
       "name": "spring.neo4j.uri",
       "defaultValue": "bolt://localhost:7687"
     },
+    {
+      "name": "spring.pulsar.function.enabled",
+      "type": "java.lang.Boolean",
+      "description": "Whether to enable function support.",
+      "defaultValue": true
+    },
+    {
+      "name": "spring.pulsar.producer.cache.enabled",
+      "type": "java.lang.Boolean",
+      "description": "Whether to enable caching in the PulsarProducerFactory.",
+      "defaultValue": true
+    },
     {
       "name": "spring.quartz.jdbc.comment-prefix",
       "defaultValue": [
@@ -2196,6 +2094,10 @@
         "level": "error"
       }
     },
+    {
+      "name": "spring.reactor.context-propagation",
+      "defaultValue": "limited"
+    },
     {
       "name": "spring.reactor.stacktrace-mode.enabled",
       "description": "Whether Reactor should collect stacktrace information at runtime.",
@@ -2825,6 +2727,12 @@
       "name": "spring.sql.init.mode",
       "defaultValue": "embedded"
     },
+    {
+      "name": "spring.threads.virtual.enabled",
+      "type": "java.lang.Boolean",
+      "description": "Whether to use virtual threads.",
+      "defaultValue": false
+    },
     {
       "name": "spring.thymeleaf.prefix",
       "defaultValue": "classpath:/templates/"
@@ -2862,6 +2770,14 @@
       "description": "Whether to enable Spring's HiddenHttpMethodFilter.",
       "defaultValue": false
     },
+    {
+      "name": "spring.webflux.multipart.streaming",
+      "type": "java.lang.Boolean",
+      "deprecation": {
+        "reason": "Replaced by the PartEventHttpMessageReader and the PartEvent API.",
+        "level": "error"
+      }
+    },
     {
       "name": "spring.webservices.wsdl-locations",
       "type": "java.util.List<java.lang.String>",
@@ -3100,8 +3016,38 @@
       ]
     },
     {
-      "name": "spring.jmx.registration-policy",
-      "defaultValue": "fail-on-existing"
+      "name": "spring.jms.listener.session.acknowledge-mode",
+      "values": [
+        {
+          "value": "auto",
+          "description": "Messages sent or received from the session are automatically acknowledged. This is the simplest mode and enables once-only message delivery guarantee."
+        },
+        {
+          "value": "client",
+          "description": "Messages are acknowledged once the message listener implementation has called \"jakarta.jms.Message#acknowledge()\". This mode gives the application (rather than the JMS provider) complete control over message acknowledgement."
+        },
+        {
+          "value": "dups_ok",
+          "description": "Similar to auto acknowledgment except that said acknowledgment is lazy. As a consequence, the messages might be delivered more than once. This mode enables at-least-once message delivery guarantee."
+        }
+      ]
+    },
+    {
+      "name": "spring.jms.template.session.acknowledge-mode",
+      "values": [
+        {
+          "value": "auto",
+          "description": "Messages sent or received from the session are automatically acknowledged. This is the simplest mode and enables once-only message delivery guarantee."
+        },
+        {
+          "value": "client",
+          "description": "Messages are acknowledged once the message listener implementation has called \"jakarta.jms.Message#acknowledge()\". This mode gives the application (rather than the JMS provider) complete control over message acknowledgement."
+        },
+        {
+          "value": "dups_ok",
+          "description": "Similar to auto acknowledgment except that said acknowledgment is lazy. As a consequence, the messages might be delivered more than once. This mode enables at-least-once message delivery guarantee."
+        }
+      ]
     },
     {
       "name": "spring.jmx.server",
@@ -3406,14 +3352,6 @@
           "name": "any"
         }
       ]
-    },
-    {
-      "name": "spring.webflux.multipart.streaming",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "reason": "Replaced by the PartEventHttpMessageReader and the PartEvent API.",
-        "level": "error"
-      }
     }
   ]
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
index f0018406978d..38fe003d37fd 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -68,6 +68,7 @@ org.springframework.boot.autoconfigure.info.ProjectInfoAutoConfiguration
 org.springframework.boot.autoconfigure.integration.IntegrationAutoConfiguration
 org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration
 org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
+org.springframework.boot.autoconfigure.jdbc.JdbcClientAutoConfiguration
 org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration
 org.springframework.boot.autoconfigure.jdbc.JndiDataSourceAutoConfiguration
 org.springframework.boot.autoconfigure.jdbc.XADataSourceAutoConfiguration
@@ -93,9 +94,12 @@ org.springframework.boot.autoconfigure.mustache.MustacheAutoConfiguration
 org.springframework.boot.autoconfigure.neo4j.Neo4jAutoConfiguration
 org.springframework.boot.autoconfigure.netty.NettyAutoConfiguration
 org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
+org.springframework.boot.autoconfigure.pulsar.PulsarAutoConfiguration
+org.springframework.boot.autoconfigure.pulsar.PulsarReactiveAutoConfiguration
 org.springframework.boot.autoconfigure.quartz.QuartzAutoConfiguration
 org.springframework.boot.autoconfigure.r2dbc.R2dbcAutoConfiguration
 org.springframework.boot.autoconfigure.r2dbc.R2dbcTransactionManagerAutoConfiguration
+org.springframework.boot.autoconfigure.reactor.ReactorAutoConfiguration
 org.springframework.boot.autoconfigure.rsocket.RSocketMessagingAutoConfiguration
 org.springframework.boot.autoconfigure.rsocket.RSocketRequesterAutoConfiguration
 org.springframework.boot.autoconfigure.rsocket.RSocketServerAutoConfiguration
@@ -121,8 +125,10 @@ org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration
 org.springframework.boot.autoconfigure.task.TaskSchedulingAutoConfiguration
 org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration
 org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration
+org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizationAutoConfiguration
 org.springframework.boot.autoconfigure.transaction.jta.JtaAutoConfiguration
 org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration
+org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration
 org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration
 org.springframework.boot.autoconfigure.web.embedded.EmbeddedWebServerFactoryCustomizerAutoConfiguration
 org.springframework.boot.autoconfigure.web.reactive.HttpHandlerAutoConfiguration
@@ -143,4 +149,4 @@ org.springframework.boot.autoconfigure.websocket.reactive.WebSocketReactiveAutoC
 org.springframework.boot.autoconfigure.websocket.servlet.WebSocketServletAutoConfiguration
 org.springframework.boot.autoconfigure.websocket.servlet.WebSocketMessagingAutoConfiguration
 org.springframework.boot.autoconfigure.webservices.WebServicesAutoConfiguration
-org.springframework.boot.autoconfigure.webservices.client.WebServiceTemplateAutoConfiguration
\ No newline at end of file
+org.springframework.boot.autoconfigure.webservices.client.WebServiceTemplateAutoConfiguration
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ImportAutoConfigurationImportSelectorTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ImportAutoConfigurationImportSelectorTests.java
index d08cbc2eb2b2..7a794fd3c84f 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ImportAutoConfigurationImportSelectorTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ImportAutoConfigurationImportSelectorTests.java
@@ -74,6 +74,25 @@ void importsAreSelectedUsingClassesAttribute() throws Exception {
 		assertThat(imports).containsExactly(FreeMarkerAutoConfiguration.class.getName());
 	}
 
+	@Test
+	void importsAreSelectedFromImportsFile() throws Exception {
+		AnnotationMetadata annotationMetadata = getAnnotationMetadata(FromImportsFile.class);
+		String[] imports = this.importSelector.selectImports(annotationMetadata);
+		assertThat(imports).containsExactly(
+				"org.springframework.boot.autoconfigure.freemarker.FreeMarkerAutoConfiguration",
+				"org.springframework.boot.autoconfigure.missing.MissingAutoConfiguration");
+	}
+
+	@Test
+	void importsSelectedFromImportsFileIgnoreMissingOptionalClasses() throws Exception {
+		AnnotationMetadata annotationMetadata = getAnnotationMetadata(
+				FromImportsFileIgnoresMissingOptionalClasses.class);
+		String[] imports = this.importSelector.selectImports(annotationMetadata);
+		assertThat(imports).containsExactly(
+				"org.springframework.boot.autoconfigure.freemarker.FreeMarkerAutoConfiguration",
+				"org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration");
+	}
+
 	@Test
 	void propertyExclusionsAreApplied() throws IOException {
 		this.environment.setProperty("spring.autoconfigure.exclude", FreeMarkerAutoConfiguration.class.getName());
@@ -312,6 +331,18 @@ Class<?>[] excludeAutoConfiguration() default {
 
 	}
 
+	@Retention(RetentionPolicy.RUNTIME)
+	@ImportAutoConfiguration
+	@interface FromImportsFile {
+
+	}
+
+	@Retention(RetentionPolicy.RUNTIME)
+	@ImportAutoConfiguration
+	@interface FromImportsFileIgnoresMissingOptionalClasses {
+
+	}
+
 	static class TestImportAutoConfigurationImportSelector extends ImportAutoConfigurationImportSelector {
 
 		@Override
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/admin/SpringApplicationAdminJmxAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/admin/SpringApplicationAdminJmxAutoConfigurationTests.java
index cbf6b84edf7a..3e08cc6530c3 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/admin/SpringApplicationAdminJmxAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/admin/SpringApplicationAdminJmxAutoConfigurationTests.java
@@ -43,7 +43,7 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
-import static org.junit.jupiter.api.Assertions.fail;
+import static org.assertj.core.api.Assertions.fail;
 
 /**
  * Tests for {@link SpringApplicationAdminJmxAutoConfiguration}.
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/PropertiesRabbitConnectionDetailsTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/PropertiesRabbitConnectionDetailsTests.java
new file mode 100644
index 000000000000..fb4f65d9cf32
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/PropertiesRabbitConnectionDetailsTests.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.amqp;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.autoconfigure.amqp.RabbitConnectionDetails.Address;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link PropertiesRabbitConnectionDetails}.
+ *
+ * @author Jonas FĂŒgedi
+ */
+class PropertiesRabbitConnectionDetailsTests {
+
+	private static final int DEFAULT_PORT = 5672;
+
+	@Test
+	void getAddresses() {
+		RabbitProperties properties = new RabbitProperties();
+		properties.setAddresses("localhost,localhost:1234,[::1],[::1]:32863");
+		PropertiesRabbitConnectionDetails propertiesRabbitConnectionDetails = new PropertiesRabbitConnectionDetails(
+				properties);
+		List<Address> addresses = propertiesRabbitConnectionDetails.getAddresses();
+		assertThat(addresses.size()).isEqualTo(4);
+		assertThat(addresses.get(0).host()).isEqualTo("localhost");
+		assertThat(addresses.get(0).port()).isEqualTo(DEFAULT_PORT);
+		assertThat(addresses.get(1).host()).isEqualTo("localhost");
+		assertThat(addresses.get(1).port()).isEqualTo(1234);
+		assertThat(addresses.get(2).host()).isEqualTo("[::1]");
+		assertThat(addresses.get(2).port()).isEqualTo(DEFAULT_PORT);
+		assertThat(addresses.get(3).host()).isEqualTo("[::1]");
+		assertThat(addresses.get(3).port()).isEqualTo(32863);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitAutoConfigurationTests.java
index a53b482e60ca..e97613b99b89 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitAutoConfigurationTests.java
@@ -30,6 +30,8 @@
 import com.rabbitmq.client.impl.DefaultCredentialsProvider;
 import org.aopalliance.aop.Advice;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.api.condition.JRE;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.InOrder;
 
@@ -58,6 +60,7 @@
 import org.springframework.amqp.support.converter.MessageConverter;
 import org.springframework.beans.factory.NoSuchBeanDefinitionException;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration;
 import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.boot.test.system.CapturedOutput;
@@ -68,6 +71,7 @@
 import org.springframework.context.annotation.Primary;
 import org.springframework.core.Ordered;
 import org.springframework.core.annotation.Order;
+import org.springframework.core.task.VirtualThreadTaskExecutor;
 import org.springframework.retry.RetryPolicy;
 import org.springframework.retry.backoff.BackOffPolicy;
 import org.springframework.retry.backoff.ExponentialBackOffPolicy;
@@ -99,12 +103,13 @@
  * @author Moritz Halbritter
  * @author Andy Wilkinson
  * @author Phillip Webb
+ * @author Scott Frederick
  */
 @ExtendWith(OutputCaptureExtension.class)
 class RabbitAutoConfigurationTests {
 
 	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
-		.withConfiguration(AutoConfigurations.of(RabbitAutoConfiguration.class));
+		.withConfiguration(AutoConfigurations.of(RabbitAutoConfiguration.class, SslAutoConfiguration.class));
 
 	@Test
 	void testDefaultRabbitConfiguration() {
@@ -147,6 +152,8 @@ void testDefaultConnectionFactoryConfiguration() {
 			com.rabbitmq.client.ConnectionFactory rabbitConnectionFactory = getTargetConnectionFactory(context);
 			assertThat(rabbitConnectionFactory.getUsername()).isEqualTo(properties.getUsername());
 			assertThat(rabbitConnectionFactory.getPassword()).isEqualTo(properties.getPassword());
+			assertThat(rabbitConnectionFactory).extracting("maxInboundMessageBodySize")
+				.isEqualTo((int) properties.getMaxInboundMessageBodySize().toBytes());
 		});
 	}
 
@@ -157,7 +164,8 @@ void testConnectionFactoryWithOverrides() {
 			.withPropertyValues("spring.rabbitmq.host:remote-server", "spring.rabbitmq.port:9000",
 					"spring.rabbitmq.address-shuffle-mode=random", "spring.rabbitmq.username:alice",
 					"spring.rabbitmq.password:secret", "spring.rabbitmq.virtual_host:/vhost",
-					"spring.rabbitmq.connection-timeout:123", "spring.rabbitmq.channel-rpc-timeout:140")
+					"spring.rabbitmq.connection-timeout:123", "spring.rabbitmq.channel-rpc-timeout:140",
+					"spring.rabbitmq.max-inbound-message-body-size:128MB")
 			.run((context) -> {
 				CachingConnectionFactory connectionFactory = context.getBean(CachingConnectionFactory.class);
 				assertThat(connectionFactory.getHost()).isEqualTo("remote-server");
@@ -169,6 +177,7 @@ void testConnectionFactoryWithOverrides() {
 				assertThat(rcf.getConnectionTimeout()).isEqualTo(123);
 				assertThat(rcf.getChannelRpcTimeout()).isEqualTo(140);
 				assertThat((List<Address>) ReflectionTestUtils.getField(connectionFactory, "addresses")).hasSize(1);
+				assertThat(rcf).hasFieldOrPropertyWithValue("maxInboundMessageBodySize", 1024 * 1024 * 128);
 			});
 	}
 
@@ -519,7 +528,8 @@ void testSimpleRabbitListenerContainerFactoryWithCustomSettings() {
 					"spring.rabbitmq.listener.simple.defaultRequeueRejected:false",
 					"spring.rabbitmq.listener.simple.idleEventInterval:5",
 					"spring.rabbitmq.listener.simple.batchSize:20",
-					"spring.rabbitmq.listener.simple.missingQueuesFatal:false")
+					"spring.rabbitmq.listener.simple.missingQueuesFatal:false",
+					"spring.rabbitmq.listener.simple.force-stop:true")
 			.run((context) -> {
 				SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory = context
 					.getBean("rabbitListenerContainerFactory", SimpleRabbitListenerContainerFactory.class);
@@ -531,6 +541,28 @@ void testSimpleRabbitListenerContainerFactoryWithCustomSettings() {
 			});
 	}
 
+	@Test
+	@EnabledForJreRange(min = JRE.JAVA_21)
+	void shouldConfigureVirtualThreads() {
+		this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true").run((context) -> {
+			SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory = context
+				.getBean("rabbitListenerContainerFactory", SimpleRabbitListenerContainerFactory.class);
+			assertThat(rabbitListenerContainerFactory).extracting("taskExecutor")
+				.isInstanceOf(VirtualThreadTaskExecutor.class);
+		});
+	}
+
+	@Test
+	void testSimpleRabbitListenerContainerFactoryWithDefaultForceStop() {
+		this.contextRunner
+			.withUserConfiguration(MessageConvertersConfiguration.class, MessageRecoverersConfiguration.class)
+			.run((context) -> {
+				SimpleRabbitListenerContainerFactory containerFactory = context
+					.getBean("rabbitListenerContainerFactory", SimpleRabbitListenerContainerFactory.class);
+				assertThat(containerFactory).hasFieldOrPropertyWithValue("forceStop", false);
+			});
+	}
+
 	@Test
 	void testDirectRabbitListenerContainerFactoryWithCustomSettings() {
 		this.contextRunner
@@ -547,7 +579,8 @@ void testDirectRabbitListenerContainerFactoryWithCustomSettings() {
 					"spring.rabbitmq.listener.direct.prefetch:40",
 					"spring.rabbitmq.listener.direct.defaultRequeueRejected:false",
 					"spring.rabbitmq.listener.direct.idleEventInterval:5",
-					"spring.rabbitmq.listener.direct.missingQueuesFatal:true")
+					"spring.rabbitmq.listener.direct.missingQueuesFatal:true",
+					"spring.rabbitmq.listener.direct.force-stop:true")
 			.run((context) -> {
 				DirectRabbitListenerContainerFactory rabbitListenerContainerFactory = context
 					.getBean("rabbitListenerContainerFactory", DirectRabbitListenerContainerFactory.class);
@@ -557,6 +590,18 @@ void testDirectRabbitListenerContainerFactoryWithCustomSettings() {
 			});
 	}
 
+	@Test
+	void testDirectRabbitListenerContainerFactoryWithDefaultForceStop() {
+		this.contextRunner
+			.withUserConfiguration(MessageConvertersConfiguration.class, MessageRecoverersConfiguration.class)
+			.withPropertyValues("spring.rabbitmq.listener.type:direct")
+			.run((context) -> {
+				DirectRabbitListenerContainerFactory containerFactory = context
+					.getBean("rabbitListenerContainerFactory", DirectRabbitListenerContainerFactory.class);
+				assertThat(containerFactory).hasFieldOrPropertyWithValue("forceStop", false);
+			});
+	}
+
 	@Test
 	void testSimpleRabbitListenerContainerFactoryRetryWithCustomizer() {
 		this.contextRunner.withUserConfiguration(RabbitRetryTemplateCustomizerConfiguration.class)
@@ -662,6 +707,7 @@ private void checkCommonProps(AssertableApplicationContext context,
 				context.getBean("myMessageConverter"));
 		assertThat(containerFactory).hasFieldOrPropertyWithValue("defaultRequeueRejected", Boolean.FALSE);
 		assertThat(containerFactory).hasFieldOrPropertyWithValue("idleEventInterval", 5L);
+		assertThat(containerFactory).hasFieldOrPropertyWithValue("forceStop", true);
 		Advice[] adviceChain = containerFactory.getAdviceChain();
 		assertThat(adviceChain).isNotNull();
 		assertThat(adviceChain).hasSize(1);
@@ -733,6 +779,16 @@ void enableSsl() {
 			});
 	}
 
+	@Test
+	void enableSslWithInvalidSslBundleFails() {
+		this.contextRunner.withUserConfiguration(TestConfiguration.class)
+			.withPropertyValues("spring.rabbitmq.ssl.bundle=invalid")
+			.run((context) -> {
+				assertThat(context).hasFailed();
+				assertThat(context).getFailure().hasMessageContaining("SSL bundle name 'invalid' cannot be found");
+			});
+	}
+
 	@Test
 	// Make sure that we at least attempt to load the store
 	void enableSslWithNonExistingKeystoreShouldFail() {
@@ -783,6 +839,19 @@ void enableSslWithInvalidTrustStoreTypeShouldFail() {
 			});
 	}
 
+	@Test
+	void enableSslWithBundle() {
+		this.contextRunner.withUserConfiguration(TestConfiguration.class)
+			.withPropertyValues("spring.rabbitmq.ssl.bundle=test-bundle",
+					"spring.ssl.bundle.jks.test-bundle.keystore.location=classpath:test.jks",
+					"spring.ssl.bundle.jks.test-bundle.keystore.password=secret",
+					"spring.ssl.bundle.jks.test-bundle.key.password=password")
+			.run((context) -> {
+				com.rabbitmq.client.ConnectionFactory rabbitConnectionFactory = getTargetConnectionFactory(context);
+				assertThat(rabbitConnectionFactory.isSSL()).isTrue();
+			});
+	}
+
 	@Test
 	void enableSslWithKeystoreTypeAndTrustStoreTypeShouldWork() {
 		this.contextRunner.withUserConfiguration(TestConfiguration.class)
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitPropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitPropertiesTests.java
index 998f5a88d9c6..06708ae217d0 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitPropertiesTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitPropertiesTests.java
@@ -16,6 +16,7 @@
 
 package org.springframework.boot.autoconfigure.amqp;
 
+import com.rabbitmq.client.ConnectionFactory;
 import org.junit.jupiter.api.Test;
 
 import org.springframework.amqp.rabbit.config.DirectRabbitListenerContainerFactory;
@@ -25,7 +26,7 @@
 import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
 
 /**
  * Tests for {@link RabbitProperties}.
@@ -34,6 +35,7 @@
  * @author Andy Wilkinson
  * @author Stephane Nicoll
  * @author Rafael Carvalho
+ * @author Scott Frederick
  */
 class RabbitPropertiesTests {
 
@@ -281,6 +283,13 @@ void determineAddressesUsesHostAndPortPropertiesWhenNoAddressesSet() {
 		assertThat(this.properties.determineAddresses()).isEqualTo("rabbit.example.com:1234");
 	}
 
+	@Test
+	void determineAddressesUsesIpv6HostAndPortPropertiesWhenNoAddressesSet() {
+		this.properties.setHost("[::1]");
+		this.properties.setPort(32863);
+		assertThat(this.properties.determineAddresses()).isEqualTo("[::1]:32863");
+	}
+
 	@Test
 	void determineSslUsingAmqpsReturnsStateOfFirstAddress() {
 		this.properties.setAddresses("amqps://root:password@otherhost,amqp://root:password2@otherhost2");
@@ -313,6 +322,19 @@ void determineSslReturnFlagPropertyWhenNoAddresses() {
 		assertThat(this.properties.getSsl().determineEnabled()).isTrue();
 	}
 
+	@Test
+	void determineSslEnabledIsTrueWhenBundleIsSetAndNoAddresses() {
+		this.properties.getSsl().setBundle("test");
+		assertThat(this.properties.getSsl().determineEnabled()).isTrue();
+	}
+
+	@Test
+	void propertiesUseConsistentDefaultValues() {
+		ConnectionFactory connectionFactory = new ConnectionFactory();
+		assertThat(connectionFactory).hasFieldOrPropertyWithValue("maxInboundMessageBodySize",
+				(int) this.properties.getMaxInboundMessageBodySize().toBytes());
+	}
+
 	@Test
 	void simpleContainerUseConsistentDefaultValues() {
 		SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
@@ -322,6 +344,7 @@ void simpleContainerUseConsistentDefaultValues() {
 		assertThat(container).hasFieldOrPropertyWithValue("missingQueuesFatal", simple.isMissingQueuesFatal());
 		assertThat(container).hasFieldOrPropertyWithValue("deBatchingEnabled", simple.isDeBatchingEnabled());
 		assertThat(container).hasFieldOrPropertyWithValue("consumerBatchEnabled", simple.isConsumerBatchEnabled());
+		assertThat(container).hasFieldOrPropertyWithValue("forceStop", simple.isForceStop());
 	}
 
 	@Test
@@ -332,6 +355,7 @@ void directContainerUseConsistentDefaultValues() {
 		assertThat(direct.isAutoStartup()).isEqualTo(container.isAutoStartup());
 		assertThat(container).hasFieldOrPropertyWithValue("missingQueuesFatal", direct.isMissingQueuesFatal());
 		assertThat(container).hasFieldOrPropertyWithValue("deBatchingEnabled", direct.isDeBatchingEnabled());
+		assertThat(container).hasFieldOrPropertyWithValue("forceStop", direct.isForceStop());
 	}
 
 	@Test
@@ -345,9 +369,9 @@ void determineUsernameWithoutPassword() {
 	void hostPropertyMustBeSingleHost() {
 		this.properties.setHost("my-rmq-host.net,my-rmq-host-2.net");
 		assertThat(this.properties.getHost()).isEqualTo("my-rmq-host.net,my-rmq-host-2.net");
-		assertThatThrownBy(this.properties::determineAddresses)
-			.isInstanceOf(InvalidConfigurationPropertyValueException.class)
-			.hasMessageContaining("spring.rabbitmq.host");
+		assertThatExceptionOfType(InvalidConfigurationPropertyValueException.class)
+			.isThrownBy(this.properties::determineAddresses)
+			.withMessageContaining("spring.rabbitmq.host");
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitStreamConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitStreamConfigurationTests.java
index eec28db57a32..95549628d1e7 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitStreamConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitStreamConfigurationTests.java
@@ -143,6 +143,24 @@ void whenStreamHostIsSetThenEnvironmentUsesCustomHost() {
 		then(builder).should().host("stream.rabbit.example.com");
 	}
 
+	@Test
+	void whenStreamVirtualHostIsSetThenEnvironmentUsesCustomVirtualHost() {
+		EnvironmentBuilder builder = mock(EnvironmentBuilder.class);
+		RabbitProperties properties = new RabbitProperties();
+		properties.getStream().setVirtualHost("stream-virtual-host");
+		RabbitStreamConfiguration.configure(builder, properties);
+		then(builder).should().virtualHost("stream-virtual-host");
+	}
+
+	@Test
+	void whenStreamVirtualHostIsNotSetButDefaultVirtualHostIsSetThenEnvironmentUsesDefaultVirtualHost() {
+		EnvironmentBuilder builder = mock(EnvironmentBuilder.class);
+		RabbitProperties properties = new RabbitProperties();
+		properties.setVirtualHost("default-virtual-host");
+		RabbitStreamConfiguration.configure(builder, properties);
+		then(builder).should().virtualHost("default-virtual-host");
+	}
+
 	@Test
 	void whenStreamCredentialsAreNotSetThenEnvironmentUsesRabbitCredentials() {
 		EnvironmentBuilder builder = mock(EnvironmentBuilder.class);
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/batch/BatchAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/batch/BatchAutoConfigurationTests.java
index 19a02f1f1d33..3c2c63d781a0 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/batch/BatchAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/batch/BatchAutoConfigurationTests.java
@@ -16,6 +16,7 @@
 
 package org.springframework.boot.autoconfigure.batch;
 
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 
@@ -38,10 +39,10 @@
 import org.springframework.batch.core.configuration.JobRegistry;
 import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing;
 import org.springframework.batch.core.configuration.support.DefaultBatchConfiguration;
-import org.springframework.batch.core.configuration.support.JobRegistryBeanPostProcessor;
 import org.springframework.batch.core.explore.JobExplorer;
 import org.springframework.batch.core.job.AbstractJob;
 import org.springframework.batch.core.launch.JobLauncher;
+import org.springframework.batch.core.launch.JobOperator;
 import org.springframework.batch.core.repository.JobRepository;
 import org.springframework.beans.BeansException;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -61,6 +62,7 @@
 import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration;
 import org.springframework.boot.autoconfigure.orm.jpa.test.City;
 import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration;
+import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizationAutoConfiguration;
 import org.springframework.boot.jdbc.DataSourceBuilder;
 import org.springframework.boot.jdbc.init.DataSourceScriptDatabaseInitializer;
 import org.springframework.boot.logging.LogLevel;
@@ -84,6 +86,8 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.BDDMockito.given;
 import static org.mockito.Mockito.mock;
 
 /**
@@ -93,21 +97,25 @@
  * @author Stephane Nicoll
  * @author Vedran Pavic
  * @author Kazuki Shimizu
+ * @author Mahmoud Ben Hassine
  */
 @ExtendWith(OutputCaptureExtension.class)
 class BatchAutoConfigurationTests {
 
-	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
-		.withConfiguration(AutoConfigurations.of(BatchAutoConfiguration.class, TransactionAutoConfiguration.class,
-				DataSourceTransactionManagerAutoConfiguration.class));
+	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withConfiguration(
+			AutoConfigurations.of(BatchAutoConfiguration.class, TransactionManagerCustomizationAutoConfiguration.class,
+					TransactionAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class));
 
 	@Test
 	void testDefaultContext() {
 		this.contextRunner.withInitializer(ConditionEvaluationReportLoggingListener.forLogLevel(LogLevel.INFO))
 			.withUserConfiguration(TestConfiguration.class, EmbeddedDataSourceConfiguration.class)
 			.run((context) -> {
+				assertThat(context).hasSingleBean(JobRepository.class);
 				assertThat(context).hasSingleBean(JobLauncher.class);
 				assertThat(context).hasSingleBean(JobExplorer.class);
+				assertThat(context).hasSingleBean(JobRegistry.class);
+				assertThat(context).hasSingleBean(JobOperator.class);
 				assertThat(context.getBean(BatchProperties.class).getJdbc().getInitializeSchema())
 					.isEqualTo(DatabaseInitializationMode.EMBEDDED);
 				assertThat(new JdbcTemplate(context.getBean(DataSource.class))
@@ -422,6 +430,53 @@ void conversionServiceCustomizersAreCalled() {
 			});
 	}
 
+	@Test
+	void whenTheUserDefinesAJobNameAsJobInstanceValidates() {
+		JobLauncherApplicationRunner runner = createInstance("another");
+		runner.setJobs(Collections.singletonList(mockJob("test")));
+		runner.setJobName("test");
+		runner.afterPropertiesSet();
+	}
+
+	@Test
+	void whenTheUserDefinesAJobNameAsRegisteredJobValidates() {
+		JobLauncherApplicationRunner runner = createInstance("test");
+		runner.setJobName("test");
+		runner.afterPropertiesSet();
+	}
+
+	@Test
+	void whenTheUserDefinesAJobNameThatDoesNotExistWithJobInstancesFailsFast() {
+		JobLauncherApplicationRunner runner = createInstance();
+		runner.setJobs(Arrays.asList(mockJob("one"), mockJob("two")));
+		runner.setJobName("three");
+		assertThatIllegalArgumentException().isThrownBy(runner::afterPropertiesSet)
+			.withMessage("No job found with name 'three'");
+	}
+
+	@Test
+	void whenTheUserDefinesAJobNameThatDoesNotExistWithRegisteredJobFailsFast() {
+		JobLauncherApplicationRunner runner = createInstance("one", "two");
+		runner.setJobName("three");
+		assertThatIllegalArgumentException().isThrownBy(runner::afterPropertiesSet)
+			.withMessage("No job found with name 'three'");
+	}
+
+	private JobLauncherApplicationRunner createInstance(String... registeredJobNames) {
+		JobLauncherApplicationRunner runner = new JobLauncherApplicationRunner(mock(JobLauncher.class),
+				mock(JobExplorer.class), mock(JobRepository.class));
+		JobRegistry jobRegistry = mock(JobRegistry.class);
+		given(jobRegistry.getJobNames()).willReturn(Arrays.asList(registeredJobNames));
+		runner.setJobRegistry(jobRegistry);
+		return runner;
+	}
+
+	private Job mockJob(String name) {
+		Job job = mock(Job.class);
+		given(job.getName()).willReturn(name);
+		return job;
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	protected static class BatchDataSourceConfiguration {
 
@@ -465,13 +520,6 @@ static class NamedJobConfigurationWithRegisteredAndLocalJob {
 		@Autowired
 		private JobRepository jobRepository;
 
-		@Bean
-		static JobRegistryBeanPostProcessor registryProcessor(JobRegistry jobRegistry) {
-			JobRegistryBeanPostProcessor processor = new JobRegistryBeanPostProcessor();
-			processor.setJobRegistry(jobRegistry);
-			return processor;
-		}
-
 		@Bean
 		Job discreteJob() {
 			AbstractJob job = new AbstractJob("discreteRegisteredJob") {
@@ -635,7 +683,17 @@ protected void doExecute(JobExecution execution) {
 
 		@Bean
 		Job job2() {
-			return mock(Job.class);
+			return new Job() {
+				@Override
+				public String getName() {
+					return "discreteLocalJob2";
+				}
+
+				@Override
+				public void execute(JobExecution execution) {
+					execution.setStatus(BatchStatus.COMPLETED);
+				}
+			};
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/batch/BatchDataSourceScriptDatabaseInitializerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/batch/BatchDataSourceScriptDatabaseInitializerTests.java
index 6b04e877c619..87a87f90c460 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/batch/BatchDataSourceScriptDatabaseInitializerTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/batch/BatchDataSourceScriptDatabaseInitializerTests.java
@@ -33,6 +33,7 @@
 import org.springframework.boot.jdbc.DatabaseDriver;
 import org.springframework.boot.sql.init.DatabaseInitializationSettings;
 import org.springframework.core.io.DefaultResourceLoader;
+import org.springframework.core.io.Resource;
 import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
 import org.springframework.test.util.ReflectionTestUtils;
 
@@ -76,7 +77,7 @@ void batchSchemaCanBeLocated(DatabaseDriver driver) throws SQLException {
 		DatabaseInitializationSettings settings = BatchDataSourceScriptDatabaseInitializer.getSettings(dataSource,
 				properties.getJdbc());
 		List<String> schemaLocations = settings.getSchemaLocations();
-		assertThat(schemaLocations)
+		assertThat(schemaLocations).isNotEmpty()
 			.allSatisfy((location) -> assertThat(resourceLoader.getResource(location).exists()).isTrue());
 	}
 
@@ -85,7 +86,7 @@ void batchHasExpectedBuiltInSchemas() throws IOException {
 		PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
 		List<String> schemaNames = Stream
 			.of(resolver.getResources("classpath:org/springframework/batch/core/schema-*.sql"))
-			.map((resource) -> resource.getFilename())
+			.map(Resource::getFilename)
 			.filter((resourceName) -> !resourceName.contains("-drop-"))
 			.toList();
 		assertThat(schemaNames).containsExactlyInAnyOrder("schema-derby.sql", "schema-sqlserver.sql",
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/cache/CacheAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/cache/CacheAutoConfigurationTests.java
index 2c887c890bd2..9a84e99eb3d3 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/cache/CacheAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/cache/CacheAutoConfigurationTests.java
@@ -69,6 +69,7 @@
 import org.springframework.data.couchbase.cache.CouchbaseCache;
 import org.springframework.data.couchbase.cache.CouchbaseCacheConfiguration;
 import org.springframework.data.couchbase.cache.CouchbaseCacheManager;
+import org.springframework.data.redis.cache.FixedDurationTtlFunction;
 import org.springframework.data.redis.cache.RedisCacheConfiguration;
 import org.springframework.data.redis.cache.RedisCacheManager;
 import org.springframework.data.redis.connection.RedisConnectionFactory;
@@ -273,7 +274,10 @@ void redisCacheExplicit() {
 				RedisCacheManager cacheManager = getCacheManager(context, RedisCacheManager.class);
 				assertThat(cacheManager.getCacheNames()).isEmpty();
 				RedisCacheConfiguration redisCacheConfiguration = getDefaultRedisCacheConfiguration(cacheManager);
-				assertThat(redisCacheConfiguration.getTtl()).isEqualTo(java.time.Duration.ofSeconds(15));
+				assertThat(redisCacheConfiguration).extracting(RedisCacheConfiguration::getTtlFunction)
+					.isInstanceOf(FixedDurationTtlFunction.class)
+					.extracting("duration")
+					.isEqualTo(java.time.Duration.ofSeconds(15));
 				assertThat(redisCacheConfiguration.getAllowCacheNullValues()).isFalse();
 				assertThat(redisCacheConfiguration.getKeyPrefixFor("MyCache")).isEqualTo("prefixMyCache::");
 				assertThat(redisCacheConfiguration.usePrefix()).isTrue();
@@ -289,7 +293,10 @@ void redisCacheWithRedisCacheConfiguration() {
 				RedisCacheManager cacheManager = getCacheManager(context, RedisCacheManager.class);
 				assertThat(cacheManager.getCacheNames()).isEmpty();
 				RedisCacheConfiguration redisCacheConfiguration = getDefaultRedisCacheConfiguration(cacheManager);
-				assertThat(redisCacheConfiguration.getTtl()).isEqualTo(java.time.Duration.ofSeconds(30));
+				assertThat(redisCacheConfiguration).extracting(RedisCacheConfiguration::getTtlFunction)
+					.isInstanceOf(FixedDurationTtlFunction.class)
+					.extracting("duration")
+					.isEqualTo(java.time.Duration.ofSeconds(30));
 				assertThat(redisCacheConfiguration.getKeyPrefixFor("")).isEqualTo("bar::");
 			});
 	}
@@ -301,7 +308,10 @@ void redisCacheWithRedisCacheManagerBuilderCustomizer() {
 			.run((context) -> {
 				RedisCacheManager cacheManager = getCacheManager(context, RedisCacheManager.class);
 				RedisCacheConfiguration redisCacheConfiguration = getDefaultRedisCacheConfiguration(cacheManager);
-				assertThat(redisCacheConfiguration.getTtl()).isEqualTo(java.time.Duration.ofSeconds(10));
+				assertThat(redisCacheConfiguration).extracting(RedisCacheConfiguration::getTtlFunction)
+					.isInstanceOf(FixedDurationTtlFunction.class)
+					.extracting("duration")
+					.isEqualTo(java.time.Duration.ofSeconds(10));
 			});
 	}
 
@@ -321,7 +331,10 @@ void redisCacheExplicitWithCaches() {
 				RedisCacheManager cacheManager = getCacheManager(context, RedisCacheManager.class);
 				assertThat(cacheManager.getCacheNames()).containsOnly("foo", "bar");
 				RedisCacheConfiguration redisCacheConfiguration = getDefaultRedisCacheConfiguration(cacheManager);
-				assertThat(redisCacheConfiguration.getTtl()).isEqualTo(java.time.Duration.ofMinutes(0));
+				assertThat(redisCacheConfiguration).extracting(RedisCacheConfiguration::getTtlFunction)
+					.isInstanceOf(FixedDurationTtlFunction.class)
+					.extracting("duration")
+					.isEqualTo(java.time.Duration.ofSeconds(0));
 				assertThat(redisCacheConfiguration.getAllowCacheNullValues()).isTrue();
 				assertThat(redisCacheConfiguration.getKeyPrefixFor("test")).isEqualTo("test::");
 				assertThat(redisCacheConfiguration.usePrefix()).isTrue();
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/cassandra/CassandraAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/cassandra/CassandraAutoConfigurationTests.java
index 7942acf74c3a..4a276d96bd10 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/cassandra/CassandraAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/cassandra/CassandraAutoConfigurationTests.java
@@ -32,6 +32,7 @@
 import com.datastax.oss.driver.internal.core.session.throttling.RateLimitingRequestThrottler;
 import org.junit.jupiter.api.Test;
 
+import org.springframework.beans.factory.BeanCreationException;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.cassandra.CassandraAutoConfiguration.PropertiesCassandraConnectionDetails;
 import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration;
@@ -42,7 +43,7 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatException;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
 
 /**
  * Tests for {@link CassandraAutoConfiguration}
@@ -299,9 +300,10 @@ void driverConfigLoaderUsePassThroughLimitingRequestThrottlerByDefault() {
 	@Test
 	void driverConfigLoaderWithRateLimitingRequiresExtraConfiguration() {
 		this.contextRunner.withPropertyValues("spring.cassandra.request.throttler.type=rate-limiting")
-			.run((context) -> assertThatThrownBy(() -> context.getBean(CqlSession.class))
-				.hasMessageContaining("Error instantiating class RateLimitingRequestThrottler")
-				.hasMessageContaining("No configuration setting found for key"));
+			.run((context) -> assertThatExceptionOfType(BeanCreationException.class)
+				.isThrownBy(() -> context.getBean(CqlSession.class))
+				.withMessageContaining("Error instantiating class RateLimitingRequestThrottler")
+				.withMessageContaining("No configuration setting found for key"));
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/cassandra/CassandraAutoConfigurationWithPasswordAuthenticationIntegrationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/cassandra/CassandraAutoConfigurationWithPasswordAuthenticationIntegrationTests.java
index cf8cc2686888..4880ea1dedbc 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/cassandra/CassandraAutoConfigurationWithPasswordAuthenticationIntegrationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/cassandra/CassandraAutoConfigurationWithPasswordAuthenticationIntegrationTests.java
@@ -34,13 +34,14 @@
 import org.testcontainers.junit.jupiter.Container;
 import org.testcontainers.junit.jupiter.Testcontainers;
 
+import org.springframework.beans.factory.BeanCreationException;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.boot.testsupport.testcontainers.CassandraContainer;
 import org.springframework.util.StreamUtils;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
 
 /**
  * Tests for {@link CassandraAutoConfiguration} that only uses password authentication.
@@ -77,8 +78,9 @@ void authenticationWithValidUsernameAndPassword() {
 	void authenticationWithInvalidCredentials() {
 		this.contextRunner
 			.withPropertyValues("spring.cassandra.username=not-a-user", "spring.cassandra.password=invalid-password")
-			.run((context) -> assertThatThrownBy(() -> context.getBean(CqlSession.class))
-				.hasMessageContaining("Authentication error"));
+			.run((context) -> assertThatExceptionOfType(BeanCreationException.class)
+				.isThrownBy(() -> context.getBean(CqlSession.class))
+				.withMessageContaining("Authentication error"));
 	}
 
 	static final class PasswordAuthenticatorCassandraContainer extends CassandraContainer {
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionEvaluationReportTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionEvaluationReportTests.java
index 2e130ff8c955..fc1df6ace290 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionEvaluationReportTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionEvaluationReportTests.java
@@ -164,7 +164,7 @@ private void prepareMatches(boolean m1, boolean m2, boolean m3) {
 	void springBootConditionPopulatesReport() {
 		ConditionEvaluationReport report = ConditionEvaluationReport
 			.get(new AnnotationConfigApplicationContext(Config.class).getBeanFactory());
-		assertThat(report.getConditionAndOutcomesBySource().size()).isNotZero();
+		assertThat(report.getConditionAndOutcomesBySource()).isNotEmpty();
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnBeanTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnBeanTests.java
index f77c3e5f9aca..06a49b1e6184 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnBeanTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnBeanTests.java
@@ -28,11 +28,13 @@
 import org.junit.jupiter.api.Test;
 
 import org.springframework.beans.factory.FactoryBean;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
 import org.springframework.beans.factory.support.BeanDefinitionRegistry;
 import org.springframework.beans.factory.support.RootBeanDefinition;
 import org.springframework.boot.autoconfigure.condition.ConditionEvaluationReport.ConditionAndOutcomes;
 import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.ApplicationContext;
 import org.springframework.context.ConfigurableApplicationContext;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
@@ -131,6 +133,19 @@ void beanProducedByFactoryBeanIsConsideredWhenMatchingOnAnnotation() {
 			});
 	}
 
+	@Test
+	void beanProducedByFactoryBeanIsConsideredWhenMatchingOnAnnotation2() {
+		this.contextRunner
+			.withUserConfiguration(EarlyInitializationFactoryBeanConfiguration.class,
+					EarlyInitializationOnAnnotationFactoryBeanConfiguration.class)
+			.run((context) -> {
+				assertThat(EarlyInitializationFactoryBeanConfiguration.calledWhenNoFrozen).as("calledWhenNoFrozen")
+					.isFalse();
+				assertThat(context).hasBean("bar");
+				assertThat(context).hasSingleBean(ExampleBean.class);
+			});
+	}
+
 	private void hasBarBean(AssertableApplicationContext context) {
 		assertThat(context).hasBean("bar");
 		assertThat(context.getBean("bar")).isEqualTo("bar");
@@ -352,6 +367,35 @@ String bar() {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class EarlyInitializationFactoryBeanConfiguration {
+
+		static boolean calledWhenNoFrozen;
+
+		@Bean
+		@TestAnnotation
+		static FactoryBean<?> exampleBeanFactoryBean(ApplicationContext applicationContext) {
+			// NOTE: must be static and return raw FactoryBean and not the subclass so
+			// Spring can't guess type
+			ConfigurableListableBeanFactory beanFactory = ((ConfigurableApplicationContext) applicationContext)
+				.getBeanFactory();
+			calledWhenNoFrozen = calledWhenNoFrozen || !beanFactory.isConfigurationFrozen();
+			return new ExampleFactoryBean();
+		}
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	@ConditionalOnBean(annotation = TestAnnotation.class)
+	static class EarlyInitializationOnAnnotationFactoryBeanConfiguration {
+
+		@Bean
+		String bar() {
+			return "bar";
+		}
+
+	}
+
 	static class WithPropertyPlaceholderClassNameRegistrar implements ImportBeanDefinitionRegistrar {
 
 		@Override
@@ -518,7 +562,7 @@ static class OtherExampleBean extends ExampleBean {
 
 	}
 
-	@Target(ElementType.TYPE)
+	@Target({ ElementType.TYPE, ElementType.METHOD })
 	@Retention(RetentionPolicy.RUNTIME)
 	@Documented
 	@interface TestAnnotation {
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnCheckpointRestoreTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnCheckpointRestoreTests.java
new file mode 100644
index 000000000000..7e50b6423e69
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnCheckpointRestoreTests.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.condition;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.boot.testsupport.classpath.ClassPathOverrides;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ConditionalOnCheckpointRestore @ConditionalOnCheckpointRestore}.
+ *
+ * @author Andy Wilkinson
+ */
+class ConditionalOnCheckpointRestoreTests {
+
+	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+		.withUserConfiguration(BasicConfiguration.class);
+
+	@Test
+	void whenCracIsUnavailableThenConditionDoesNotMatch() {
+		this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean("someBean"));
+	}
+
+	@Test
+	@ClassPathOverrides("org.crac:crac:1.3.0")
+	void whenCracIsAvailableThenConditionMatches() {
+		this.contextRunner.run((context) -> assertThat(context).hasBean("someBean"));
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	static class BasicConfiguration {
+
+		@Bean
+		@ConditionalOnCheckpointRestore
+		String someBean() {
+			return "someBean";
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnMissingBeanTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnMissingBeanTests.java
index 06072ca23151..9ca96314e9fa 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnMissingBeanTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnMissingBeanTests.java
@@ -155,7 +155,7 @@ void testOnMissingBeanConditionWithFactoryBean() {
 		this.contextRunner
 			.withUserConfiguration(FactoryBeanConfiguration.class, ConditionalOnFactoryBean.class,
 					PropertyPlaceholderAutoConfiguration.class)
-			.run((context) -> assertThat(context.getBean(ExampleBean.class).toString()).isEqualTo("fromFactory"));
+			.run((context) -> assertThat(context.getBean(ExampleBean.class)).hasToString("fromFactory"));
 	}
 
 	@Test
@@ -163,7 +163,7 @@ void testOnMissingBeanConditionWithComponentScannedFactoryBean() {
 		this.contextRunner
 			.withUserConfiguration(ComponentScannedFactoryBeanBeanMethodConfiguration.class,
 					ConditionalOnFactoryBean.class, PropertyPlaceholderAutoConfiguration.class)
-			.run((context) -> assertThat(context.getBean(ScanBean.class).toString()).isEqualTo("fromFactory"));
+			.run((context) -> assertThat(context.getBean(ScanBean.class)).hasToString("fromFactory"));
 	}
 
 	@Test
@@ -171,7 +171,7 @@ void testOnMissingBeanConditionWithComponentScannedFactoryBeanWithBeanMethodArgu
 		this.contextRunner
 			.withUserConfiguration(ComponentScannedFactoryBeanBeanMethodWithArgumentsConfiguration.class,
 					ConditionalOnFactoryBean.class, PropertyPlaceholderAutoConfiguration.class)
-			.run((context) -> assertThat(context.getBean(ScanBean.class).toString()).isEqualTo("fromFactory"));
+			.run((context) -> assertThat(context.getBean(ScanBean.class)).hasToString("fromFactory"));
 	}
 
 	@Test
@@ -180,7 +180,7 @@ void testOnMissingBeanConditionWithFactoryBeanWithBeanMethodArguments() {
 			.withUserConfiguration(FactoryBeanWithBeanMethodArgumentsConfiguration.class,
 					ConditionalOnFactoryBean.class, PropertyPlaceholderAutoConfiguration.class)
 			.withPropertyValues("theValue=foo")
-			.run((context) -> assertThat(context.getBean(ExampleBean.class).toString()).isEqualTo("fromFactory"));
+			.run((context) -> assertThat(context.getBean(ExampleBean.class)).hasToString("fromFactory"));
 	}
 
 	@Test
@@ -188,7 +188,7 @@ void testOnMissingBeanConditionWithConcreteFactoryBean() {
 		this.contextRunner
 			.withUserConfiguration(ConcreteFactoryBeanConfiguration.class, ConditionalOnFactoryBean.class,
 					PropertyPlaceholderAutoConfiguration.class)
-			.run((context) -> assertThat(context.getBean(ExampleBean.class).toString()).isEqualTo("fromFactory"));
+			.run((context) -> assertThat(context.getBean(ExampleBean.class)).hasToString("fromFactory"));
 	}
 
 	@Test
@@ -205,7 +205,7 @@ void testOnMissingBeanConditionWithRegisteredFactoryBean() {
 		this.contextRunner
 			.withUserConfiguration(RegisteredFactoryBeanConfiguration.class, ConditionalOnFactoryBean.class,
 					PropertyPlaceholderAutoConfiguration.class)
-			.run((context) -> assertThat(context.getBean(ExampleBean.class).toString()).isEqualTo("fromFactory"));
+			.run((context) -> assertThat(context.getBean(ExampleBean.class)).hasToString("fromFactory"));
 	}
 
 	@Test
@@ -213,7 +213,7 @@ void testOnMissingBeanConditionWithNonspecificFactoryBeanWithClassAttribute() {
 		this.contextRunner
 			.withUserConfiguration(NonspecificFactoryBeanClassAttributeConfiguration.class,
 					ConditionalOnFactoryBean.class, PropertyPlaceholderAutoConfiguration.class)
-			.run((context) -> assertThat(context.getBean(ExampleBean.class).toString()).isEqualTo("fromFactory"));
+			.run((context) -> assertThat(context.getBean(ExampleBean.class)).hasToString("fromFactory"));
 	}
 
 	@Test
@@ -221,7 +221,7 @@ void testOnMissingBeanConditionWithNonspecificFactoryBeanWithStringAttribute() {
 		this.contextRunner
 			.withUserConfiguration(NonspecificFactoryBeanStringAttributeConfiguration.class,
 					ConditionalOnFactoryBean.class, PropertyPlaceholderAutoConfiguration.class)
-			.run((context) -> assertThat(context.getBean(ExampleBean.class).toString()).isEqualTo("fromFactory"));
+			.run((context) -> assertThat(context.getBean(ExampleBean.class)).hasToString("fromFactory"));
 	}
 
 	@Test
@@ -229,7 +229,7 @@ void testOnMissingBeanConditionWithFactoryBeanInXml() {
 		this.contextRunner
 			.withUserConfiguration(FactoryBeanXmlConfiguration.class, ConditionalOnFactoryBean.class,
 					PropertyPlaceholderAutoConfiguration.class)
-			.run((context) -> assertThat(context.getBean(ExampleBean.class).toString()).isEqualTo("fromFactory"));
+			.run((context) -> assertThat(context.getBean(ExampleBean.class)).hasToString("fromFactory"));
 	}
 
 	@Test
@@ -468,18 +468,6 @@ static class NonspecificFactoryBeanStringAttributeConfiguration {
 
 	}
 
-	static class NonspecificFactoryBeanStringAttributeRegistrar implements ImportBeanDefinitionRegistrar {
-
-		@Override
-		public void registerBeanDefinitions(AnnotationMetadata meta, BeanDefinitionRegistry registry) {
-			BeanDefinitionBuilder builder = BeanDefinitionBuilder.rootBeanDefinition(NonspecificFactoryBean.class);
-			builder.addConstructorArgValue("foo");
-			builder.getBeanDefinition().setAttribute(FactoryBean.OBJECT_TYPE_ATTRIBUTE, ExampleBean.class.getName());
-			registry.registerBeanDefinition("exampleBeanFactoryBean", builder.getBeanDefinition());
-		}
-
-	}
-
 	@Configuration(proxyBeanMethods = false)
 	@Import(FactoryBeanRegistrar.class)
 	static class RegisteredFactoryBeanConfiguration {
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnThreadingTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnThreadingTests.java
new file mode 100644
index 000000000000..a455b22f0f91
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnThreadingTests.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.condition;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.api.condition.JRE;
+
+import org.springframework.boot.autoconfigure.thread.Threading;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ConditionalOnThreading}.
+ *
+ * @author Moritz Halbritter
+ */
+class ConditionalOnThreadingTests {
+
+	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+		.withUserConfiguration(BasicConfiguration.class);
+
+	@Test
+	@EnabledForJreRange(max = JRE.JAVA_20)
+	void platformThreadsOnJdkBelow21IfVirtualThreadsPropertyIsEnabled() {
+		this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true")
+			.run((context) -> assertThat(context.getBean(ThreadType.class)).isEqualTo(ThreadType.PLATFORM));
+	}
+
+	@Test
+	@EnabledForJreRange(max = JRE.JAVA_20)
+	void platformThreadsOnJdkBelow21IfVirtualThreadsPropertyIsDisabled() {
+		this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=false")
+			.run((context) -> assertThat(context.getBean(ThreadType.class)).isEqualTo(ThreadType.PLATFORM));
+	}
+
+	@Test
+	@EnabledForJreRange(min = JRE.JAVA_21)
+	void virtualThreadsOnJdk21IfVirtualThreadsPropertyIsEnabled() {
+		this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true")
+			.run((context) -> assertThat(context.getBean(ThreadType.class)).isEqualTo(ThreadType.VIRTUAL));
+	}
+
+	@Test
+	@EnabledForJreRange(min = JRE.JAVA_21)
+	void platformThreadsOnJdk21IfVirtualThreadsPropertyIsDisabled() {
+		this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=false")
+			.run((context) -> assertThat(context.getBean(ThreadType.class)).isEqualTo(ThreadType.PLATFORM));
+	}
+
+	private enum ThreadType {
+
+		PLATFORM, VIRTUAL
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	static class BasicConfiguration {
+
+		@Bean
+		@ConditionalOnThreading(Threading.VIRTUAL)
+		ThreadType virtual() {
+			return ThreadType.VIRTUAL;
+		}
+
+		@Bean
+		@ConditionalOnThreading(Threading.PLATFORM)
+		ThreadType platform() {
+			return ThreadType.PLATFORM;
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/OnBeanConditionTypeDeductionFailureTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/OnBeanConditionTypeDeductionFailureTests.java
index e02352350888..991d7e708770 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/OnBeanConditionTypeDeductionFailureTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/OnBeanConditionTypeDeductionFailureTests.java
@@ -29,7 +29,7 @@
 import org.springframework.core.type.AnnotationMetadata;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatException;
 
 /**
  * Tests for {@link OnBeanCondition} when deduction of the bean's type fails
@@ -41,7 +41,7 @@ class OnBeanConditionTypeDeductionFailureTests {
 
 	@Test
 	void conditionalOnMissingBeanWithDeducedTypeThatIsPartiallyMissingFromClassPath() {
-		assertThatExceptionOfType(Exception.class)
+		assertThatException()
 			.isThrownBy(() -> new AnnotationConfigApplicationContext(ImportingConfiguration.class).close())
 			.satisfies((ex) -> {
 				Throwable beanTypeDeductionException = findNestedCause(ex, BeanTypeDeductionException.class);
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/context/MessageSourceAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/context/MessageSourceAutoConfigurationTests.java
index 313624d8779f..e30983164136 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/context/MessageSourceAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/context/MessageSourceAutoConfigurationTests.java
@@ -21,7 +21,10 @@
 import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration.MessageSourceRuntimeHints;
 import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.boot.test.context.runner.ContextConsumer;
@@ -40,6 +43,7 @@
  * @author EddĂș MelĂ©ndez
  * @author Stephane Nicoll
  * @author Kedar Joshi
+ * @author Marc Becker
  */
 class MessageSourceAutoConfigurationTests {
 
@@ -180,6 +184,15 @@ void messageSourceWithNonStandardBeanNameIsIgnored() {
 			.run((context) -> assertThat(context.getMessage("foo", null, Locale.US)).isEqualTo("bar"));
 	}
 
+	@Test
+	void shouldRegisterDefaultHints() {
+		RuntimeHints hints = new RuntimeHints();
+		new MessageSourceRuntimeHints().registerHints(hints, getClass().getClassLoader());
+		assertThat(RuntimeHintsPredicates.resource().forResource("messages.properties")).accepts(hints);
+		assertThat(RuntimeHintsPredicates.resource().forResource("messages_de.properties")).accepts(hints);
+		assertThat(RuntimeHintsPredicates.resource().forResource("messages_zh-CN.properties")).accepts(hints);
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	@PropertySource("classpath:/switch-messages.properties")
 	static class Config {
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseAutoConfigurationIntegrationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseAutoConfigurationIntegrationTests.java
index 82ef9350db84..5fcd8bb95857 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseAutoConfigurationIntegrationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseAutoConfigurationIntegrationTests.java
@@ -29,6 +29,7 @@
 import org.junit.jupiter.api.Test;
 import org.testcontainers.couchbase.BucketDefinition;
 import org.testcontainers.couchbase.CouchbaseContainer;
+import org.testcontainers.couchbase.CouchbaseService;
 import org.testcontainers.junit.jupiter.Container;
 import org.testcontainers.junit.jupiter.Testcontainers;
 
@@ -51,6 +52,7 @@ class CouchbaseAutoConfigurationIntegrationTests {
 
 	@Container
 	static final CouchbaseContainer couchbase = new CouchbaseContainer(DockerImageNames.couchbase())
+		.withEnabledServices(CouchbaseService.KV)
 		.withCredentials("spring", "password")
 		.withStartupAttempts(5)
 		.withStartupTimeout(Duration.ofMinutes(10))
@@ -60,7 +62,8 @@ class CouchbaseAutoConfigurationIntegrationTests {
 		.withConfiguration(AutoConfigurations.of(CouchbaseAutoConfiguration.class))
 		.withPropertyValues("spring.couchbase.connection-string: " + couchbase.getConnectionString(),
 				"spring.couchbase.username:spring", "spring.couchbase.password:password",
-				"spring.couchbase.bucket.name:" + BUCKET_NAME);
+				"spring.couchbase.bucket.name:" + BUCKET_NAME, "spring.couchbase.env.timeouts.connect=2m",
+				"spring.couchbase.env.timeouts.key-value=1m");
 
 	@Test
 	void defaultConfiguration() {
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/cassandra/CassandraDataAutoConfigurationIntegrationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/cassandra/CassandraDataAutoConfigurationIntegrationTests.java
index aacd79fe8d6d..cd65a45a6421 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/cassandra/CassandraDataAutoConfigurationIntegrationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/cassandra/CassandraDataAutoConfigurationIntegrationTests.java
@@ -79,7 +79,7 @@ static class KeyspaceTestConfiguration {
 		CqlSession cqlSession(CqlSessionBuilder cqlSessionBuilder) {
 			try (CqlSession session = cqlSessionBuilder.build()) {
 				session.execute("CREATE KEYSPACE IF NOT EXISTS boot_test"
-						+ "  WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 };");
+						+ " WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 };");
 			}
 			return cqlSessionBuilder.withKeyspace("boot_test").build();
 		}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/jdbc/JdbcRepositoriesAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/jdbc/JdbcRepositoriesAutoConfigurationTests.java
index 0ed97c82ab28..064d5d34c5aa 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/jdbc/JdbcRepositoriesAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/jdbc/JdbcRepositoriesAutoConfigurationTests.java
@@ -232,7 +232,7 @@ static class JdbcMappingContextConfiguration {
 
 		@Bean
 		JdbcMappingContext customJdbcMappingContext() {
-			return mock(JdbcMappingContext.class);
+			return mock(JdbcMappingContext.class, Answers.RETURNS_MOCKS);
 		}
 
 	}
@@ -242,7 +242,7 @@ static class JdbcConverterConfiguration {
 
 		@Bean
 		JdbcConverter customJdbcConverter() {
-			return mock(JdbcConverter.class);
+			return mock(JdbcConverter.class, Answers.RETURNS_MOCKS);
 		}
 
 	}
@@ -262,7 +262,7 @@ static class JdbcAggregateTemplateConfiguration {
 
 		@Bean
 		JdbcAggregateTemplate customJdbcAggregateTemplate() {
-			return mock(JdbcAggregateTemplate.class);
+			return mock(JdbcAggregateTemplate.class, Answers.RETURNS_MOCKS);
 		}
 
 	}
@@ -272,7 +272,7 @@ static class DataAccessStrategyConfiguration {
 
 		@Bean
 		DataAccessStrategy customDataAccessStrategy() {
-			return mock(DataAccessStrategy.class);
+			return mock(DataAccessStrategy.class, Answers.RETURNS_MOCKS);
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/mongo/MongoDataAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/mongo/MongoDataAutoConfigurationTests.java
index 590fb36bdf1f..caff745e56f1 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/mongo/MongoDataAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/mongo/MongoDataAutoConfigurationTests.java
@@ -18,10 +18,14 @@
 
 import java.time.LocalDateTime;
 import java.util.Arrays;
+import java.util.function.Supplier;
 
 import com.mongodb.ConnectionString;
 import com.mongodb.client.MongoClient;
 import com.mongodb.client.MongoClients;
+import com.mongodb.client.MongoCollection;
+import com.mongodb.client.gridfs.GridFSBucket;
+import org.assertj.core.api.InstanceOfAssertFactories;
 import org.junit.jupiter.api.Test;
 
 import org.springframework.beans.factory.BeanCreationException;
@@ -78,32 +82,43 @@ void templateExists() {
 	}
 
 	@Test
+	@SuppressWarnings("unchecked")
 	void whenGridFsDatabaseIsConfiguredThenGridFsTemplateIsAutoConfiguredAndUsesIt() {
 		this.contextRunner.withPropertyValues("spring.data.mongodb.gridfs.database:grid").run((context) -> {
 			assertThat(context).hasSingleBean(GridFsTemplate.class);
 			GridFsTemplate template = context.getBean(GridFsTemplate.class);
-			MongoDatabaseFactory factory = (MongoDatabaseFactory) ReflectionTestUtils.getField(template, "dbFactory");
-			assertThat(factory.getMongoDatabase().getName()).isEqualTo("grid");
+			GridFSBucket bucket = ((Supplier<GridFSBucket>) ReflectionTestUtils.getField(template, "bucketSupplier"))
+				.get();
+			assertThat(bucket).extracting("filesCollection", InstanceOfAssertFactories.type(MongoCollection.class))
+				.extracting((collection) -> collection.getNamespace().getDatabaseName())
+				.isEqualTo("grid");
 		});
 	}
 
 	@Test
+	@SuppressWarnings("unchecked")
 	void usesMongoConnectionDetailsIfAvailable() {
 		this.contextRunner.withUserConfiguration(ConnectionDetailsConfiguration.class).run((context) -> {
 			assertThat(context).hasSingleBean(GridFsTemplate.class);
 			GridFsTemplate template = context.getBean(GridFsTemplate.class);
-			assertThat(template).hasFieldOrPropertyWithValue("bucket", "connection-details-bucket");
-			MongoDatabaseFactory factory = (MongoDatabaseFactory) ReflectionTestUtils.getField(template, "dbFactory");
-			assertThat(factory.getMongoDatabase().getName()).isEqualTo("grid-database-1");
+			GridFSBucket bucket = ((Supplier<GridFSBucket>) ReflectionTestUtils.getField(template, "bucketSupplier"))
+				.get();
+			assertThat(bucket.getBucketName()).isEqualTo("connection-details-bucket");
+			assertThat(bucket).extracting("filesCollection", InstanceOfAssertFactories.type(MongoCollection.class))
+				.extracting((collection) -> collection.getNamespace().getDatabaseName())
+				.isEqualTo("grid-database-1");
 		});
 	}
 
 	@Test
+	@SuppressWarnings("unchecked")
 	void whenGridFsBucketIsConfiguredThenGridFsTemplateIsAutoConfiguredAndUsesIt() {
 		this.contextRunner.withPropertyValues("spring.data.mongodb.gridfs.bucket:test-bucket").run((context) -> {
 			assertThat(context).hasSingleBean(GridFsTemplate.class);
 			GridFsTemplate template = context.getBean(GridFsTemplate.class);
-			assertThat(template).hasFieldOrPropertyWithValue("bucket", "test-bucket");
+			GridFSBucket bucket = ((Supplier<GridFSBucket>) ReflectionTestUtils.getField(template, "bucketSupplier"))
+				.get();
+			assertThat(bucket.getBucketName()).isEqualTo("test-bucket");
 		});
 	}
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/mongo/MongoReactiveDataAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/mongo/MongoReactiveDataAutoConfigurationTests.java
index df9a5f264ee2..a64fe5b7031d 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/mongo/MongoReactiveDataAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/mongo/MongoReactiveDataAutoConfigurationTests.java
@@ -16,8 +16,13 @@
 
 package org.springframework.boot.autoconfigure.data.mongo;
 
+import java.time.Duration;
+
 import com.mongodb.ConnectionString;
+import com.mongodb.reactivestreams.client.MongoCollection;
+import com.mongodb.reactivestreams.client.gridfs.GridFSBucket;
 import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
 
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
@@ -68,20 +73,26 @@ void whenGridFsDatabaseIsConfiguredThenGridFsTemplateUsesIt() {
 	}
 
 	@Test
+	@SuppressWarnings("unchecked")
 	void usesMongoConnectionDetailsIfAvailable() {
 		this.contextRunner.withUserConfiguration(ConnectionDetailsConfiguration.class).run((context) -> {
 			assertThat(grisFsTemplateDatabaseName(context)).isEqualTo("grid-database-1");
 			ReactiveGridFsTemplate template = context.getBean(ReactiveGridFsTemplate.class);
-			assertThat(template).hasFieldOrPropertyWithValue("bucket", "connection-details-bucket");
+			GridFSBucket bucket = ((Mono<GridFSBucket>) ReflectionTestUtils.getField(template, "bucketSupplier"))
+				.block(Duration.ofSeconds(30));
+			assertThat(bucket.getBucketName()).isEqualTo("connection-details-bucket");
 		});
 	}
 
 	@Test
+	@SuppressWarnings("unchecked")
 	void whenGridFsBucketIsConfiguredThenGridFsTemplateUsesIt() {
 		this.contextRunner.withPropertyValues("spring.data.mongodb.gridfs.bucket:test-bucket").run((context) -> {
 			assertThat(context).hasSingleBean(ReactiveGridFsTemplate.class);
 			ReactiveGridFsTemplate template = context.getBean(ReactiveGridFsTemplate.class);
-			assertThat(template).hasFieldOrPropertyWithValue("bucket", "test-bucket");
+			GridFSBucket bucket = ((Mono<GridFSBucket>) ReflectionTestUtils.getField(template, "bucketSupplier"))
+				.block(Duration.ofSeconds(30));
+			assertThat(bucket.getBucketName()).isEqualTo("test-bucket");
 		});
 	}
 
@@ -150,12 +161,14 @@ void contextFailsWhenDatabaseNotSet() {
 			.run((context) -> assertThat(context).getFailure().hasMessageContaining("Database name must not be empty"));
 	}
 
+	@SuppressWarnings("unchecked")
 	private String grisFsTemplateDatabaseName(AssertableApplicationContext context) {
 		assertThat(context).hasSingleBean(ReactiveGridFsTemplate.class);
 		ReactiveGridFsTemplate template = context.getBean(ReactiveGridFsTemplate.class);
-		ReactiveMongoDatabaseFactory factory = (ReactiveMongoDatabaseFactory) ReflectionTestUtils.getField(template,
-				"dbFactory");
-		return factory.getMongoDatabase().block().getName();
+		GridFSBucket bucket = ((Mono<GridFSBucket>) ReflectionTestUtils.getField(template, "bucketSupplier"))
+			.block(Duration.ofSeconds(30));
+		MongoCollection<?> collection = (MongoCollection<?>) ReflectionTestUtils.getField(bucket, "filesCollection");
+		return collection.getNamespace().getDatabaseName();
 	}
 
 	@Configuration(proxyBeanMethods = false)
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/neo4j/Neo4jDataAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/neo4j/Neo4jDataAutoConfigurationTests.java
index 2b3aeb38d491..76dc81f42e76 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/neo4j/Neo4jDataAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/neo4j/Neo4jDataAutoConfigurationTests.java
@@ -29,6 +29,7 @@
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.data.neo4j.aot.Neo4jManagedTypes;
 import org.springframework.data.neo4j.core.DatabaseSelection;
 import org.springframework.data.neo4j.core.DatabaseSelectionProvider;
 import org.springframework.data.neo4j.core.Neo4jClient;
@@ -37,6 +38,7 @@
 import org.springframework.data.neo4j.core.convert.Neo4jConversions;
 import org.springframework.data.neo4j.core.mapping.Neo4jMappingContext;
 import org.springframework.data.neo4j.core.transaction.Neo4jTransactionManager;
+import org.springframework.test.util.ReflectionTestUtils;
 import org.springframework.transaction.PlatformTransactionManager;
 import org.springframework.transaction.ReactiveTransactionManager;
 import org.springframework.transaction.TransactionManager;
@@ -162,6 +164,29 @@ void shouldFilterInitialEntityScanWithKnownAnnotations() {
 		});
 	}
 
+	@Test
+	void shouldProvideManagedTypes() {
+		this.contextRunner.run((context) -> {
+			assertThat(context).hasSingleBean(Neo4jManagedTypes.class);
+			assertThat(context.getBean(Neo4jMappingContext.class))
+				.extracting((mappingContext) -> ReflectionTestUtils.getField(mappingContext, "managedTypes"))
+				.isEqualTo(context.getBean(Neo4jManagedTypes.class));
+		});
+	}
+
+	@Test
+	void shouldReuseExistingManagedTypes() {
+		Neo4jManagedTypes managedTypes = Neo4jManagedTypes.from();
+		this.contextRunner.withBean("customManagedTypes", Neo4jManagedTypes.class, () -> managedTypes)
+			.run((context) -> {
+				assertThat(context).hasSingleBean(Neo4jManagedTypes.class);
+				assertThat(context).doesNotHaveBean("neo4jManagedTypes");
+				assertThat(context.getBean(Neo4jMappingContext.class))
+					.extracting((mappingContext) -> ReflectionTestUtils.getField(mappingContext, "managedTypes"))
+					.isSameAs(managedTypes);
+			});
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class CustomDatabaseSelectionProviderConfiguration {
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/redis/RedisAutoConfigurationJedisTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/redis/RedisAutoConfigurationJedisTests.java
index 164e222fd800..517b4d5a3bef 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/redis/RedisAutoConfigurationJedisTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/redis/RedisAutoConfigurationJedisTests.java
@@ -19,14 +19,18 @@
 import java.time.Duration;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.api.condition.JRE;
 
 import org.springframework.beans.factory.config.BeanPostProcessor;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.boot.testsupport.assertj.SimpleAsyncTaskExecutorAssert;
 import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.core.task.SimpleAsyncTaskExecutor;
 import org.springframework.data.redis.connection.RedisConnectionFactory;
 import org.springframework.data.redis.connection.jedis.JedisClientConfiguration.JedisClientConfigurationBuilder;
 import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
@@ -270,6 +274,25 @@ void testRedisConfigurationWithSslDisabledAndBundle() {
 			});
 	}
 
+	@Test
+	void shouldUsePlatformThreadsByDefault() {
+		this.contextRunner.run((context) -> {
+			JedisConnectionFactory factory = context.getBean(JedisConnectionFactory.class);
+			assertThat(factory).extracting("executor").isNull();
+		});
+	}
+
+	@Test
+	@EnabledForJreRange(min = JRE.JAVA_21)
+	void shouldUseVirtualThreadsIfEnabled() {
+		this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true").run((context) -> {
+			JedisConnectionFactory factory = context.getBean(JedisConnectionFactory.class);
+			assertThat(factory).extracting("executor")
+				.satisfies((executor) -> SimpleAsyncTaskExecutorAssert.assertThat((SimpleAsyncTaskExecutor) executor)
+					.usesVirtualThreads());
+		});
+	}
+
 	private String getUserName(JedisConnectionFactory factory) {
 		return ReflectionTestUtils.invokeMethod(factory, "getRedisUsername");
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/redis/RedisAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/redis/RedisAutoConfigurationTests.java
index 47e3e86d9548..79d33e99f302 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/redis/RedisAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/redis/RedisAutoConfigurationTests.java
@@ -31,6 +31,8 @@
 import io.lettuce.core.tracing.Tracing;
 import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.api.condition.JRE;
 
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.data.redis.RedisProperties.Pool;
@@ -38,8 +40,10 @@
 import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.boot.test.context.runner.ContextConsumer;
+import org.springframework.boot.testsupport.assertj.SimpleAsyncTaskExecutorAssert;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.core.task.SimpleAsyncTaskExecutor;
 import org.springframework.data.redis.connection.RedisClusterConfiguration;
 import org.springframework.data.redis.connection.RedisConnectionFactory;
 import org.springframework.data.redis.connection.RedisNode;
@@ -143,9 +147,9 @@ void testRedisUrlConfiguration() {
 	@Test
 	void testOverrideUrlRedisConfiguration() {
 		this.contextRunner
-			.withPropertyValues("spring.data.redis.host:foo", "spring.data.redis.password:xyz",
-					"spring.data.redis.port:1000", "spring.data.redis.ssl.enabled:false",
-					"spring.data.redis.url:rediss://user:password@example:33")
+			.withPropertyValues("spring.data.redis.host:foo", "spring.redis.data.user:alice",
+					"spring.data.redis.password:xyz", "spring.data.redis.port:1000",
+					"spring.data.redis.ssl.enabled:false", "spring.data.redis.url:rediss://user:password@example:33")
 			.run((context) -> {
 				LettuceConnectionFactory cf = context.getBean(LettuceConnectionFactory.class);
 				assertThat(cf.getHostName()).isEqualTo("example");
@@ -582,6 +586,25 @@ void testRedisConfigurationWithSslDisabledBundle() {
 			});
 	}
 
+	@Test
+	void shouldUsePlatformThreadsByDefault() {
+		this.contextRunner.run((context) -> {
+			LettuceConnectionFactory factory = context.getBean(LettuceConnectionFactory.class);
+			assertThat(factory).extracting("executor").isNull();
+		});
+	}
+
+	@Test
+	@EnabledForJreRange(min = JRE.JAVA_21)
+	void shouldUseVirtualThreadsIfEnabled() {
+		this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true").run((context) -> {
+			LettuceConnectionFactory factory = context.getBean(LettuceConnectionFactory.class);
+			assertThat(factory).extracting("executor")
+				.satisfies((executor) -> SimpleAsyncTaskExecutorAssert.assertThat((SimpleAsyncTaskExecutor) executor)
+					.usesVirtualThreads());
+		});
+	}
+
 	private <T extends ClientOptions> ContextConsumer<AssertableApplicationContext> assertClientOptions(
 			Class<T> expectedType, Consumer<T> options) {
 		return (context) -> {
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchClientAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchClientAutoConfigurationTests.java
index 3dbaaa80100e..1f1d84333dcc 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchClientAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchClientAutoConfigurationTests.java
@@ -154,7 +154,7 @@ JsonpMapper customJsonpMapper() {
 	static class TransportConfiguration {
 
 		@Bean
-		ElasticsearchTransport customElasticsearchTransport() {
+		ElasticsearchTransport customElasticsearchTransport(JsonpMapper mapper) {
 			return mock(ElasticsearchTransport.class);
 		}
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway10xAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway10xAutoConfigurationTests.java
new file mode 100644
index 000000000000..e1e8ebdddbe3
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway10xAutoConfigurationTests.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.flyway;
+
+import org.flywaydb.core.Flyway;
+import org.flywaydb.core.api.Location;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.api.condition.JRE;
+
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.jdbc.EmbeddedDataSourceConfiguration;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
+import org.springframework.boot.testsupport.classpath.ClassPathOverrides;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link FlywayAutoConfiguration} with Flyway 10.x.
+ *
+ * @author Andy Wilkinson
+ */
+@ClassPathExclusions({ "flyway-core-*.jar", "flyway-sqlserver-*.jar" })
+@ClassPathOverrides({ "org.flywaydb:flyway-core:10.0.0", "com.h2database:h2:2.1.210" })
+@EnabledForJreRange(min = JRE.JAVA_17)
+class Flyway10xAutoConfigurationTests {
+
+	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+		.withConfiguration(AutoConfigurations.of(FlywayAutoConfiguration.class))
+		.withPropertyValues("spring.datasource.generate-unique-name=true");
+
+	@Test
+	void defaultFlyway() {
+		this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class).run((context) -> {
+			assertThat(context).hasSingleBean(Flyway.class);
+			Flyway flyway = context.getBean(Flyway.class);
+			assertThat(flyway.getConfiguration().getLocations())
+				.containsExactly(new Location("classpath:db/migration"));
+		});
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway90AutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway90AutoConfigurationTests.java
index 6acaa1851ecb..97fd9ad3e6d9 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway90AutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway90AutoConfigurationTests.java
@@ -25,6 +25,7 @@
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
 import org.springframework.boot.testsupport.classpath.ClassPathOverrides;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -34,6 +35,7 @@
  *
  * @author Andy Wilkinson
  */
+@ClassPathExclusions("flyway-*.jar")
 @ClassPathOverrides("org.flywaydb:flyway-core:9.0.4")
 class Flyway90AutoConfigurationTests {
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway91AutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway91AutoConfigurationTests.java
new file mode 100644
index 000000000000..ee7cb7ff751a
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway91AutoConfigurationTests.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.flyway;
+
+import org.flywaydb.core.Flyway;
+import org.flywaydb.core.api.Location;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.jdbc.EmbeddedDataSourceConfiguration;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
+import org.springframework.boot.testsupport.classpath.ClassPathOverrides;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link FlywayAutoConfiguration} with Flyway 9.19.
+ *
+ * @author Andy Wilkinson
+ */
+@ClassPathExclusions("flyway-*.jar")
+@ClassPathOverrides("org.flywaydb:flyway-core:9.19.4")
+class Flyway91AutoConfigurationTests {
+
+	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+		.withConfiguration(AutoConfigurations.of(FlywayAutoConfiguration.class))
+		.withPropertyValues("spring.datasource.generate-unique-name=true");
+
+	@Test
+	void defaultFlyway() {
+		this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class).run((context) -> {
+			assertThat(context).hasSingleBean(Flyway.class);
+			Flyway flyway = context.getBean(Flyway.class);
+			assertThat(flyway.getConfiguration().getLocations())
+				.containsExactly(new Location("classpath:db/migration"));
+		});
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway920AutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway920AutoConfigurationTests.java
deleted file mode 100644
index 770a618e4725..000000000000
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway920AutoConfigurationTests.java
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.autoconfigure.flyway;
-
-import org.flywaydb.core.Flyway;
-import org.flywaydb.core.api.Location;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.boot.autoconfigure.AutoConfigurations;
-import org.springframework.boot.autoconfigure.jdbc.EmbeddedDataSourceConfiguration;
-import org.springframework.boot.test.context.runner.ApplicationContextRunner;
-import org.springframework.boot.testsupport.classpath.ClassPathOverrides;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Tests for {@link FlywayAutoConfiguration} with Flyway 9.20.
- *
- * @author Andy Wilkinson
- */
-@ClassPathOverrides({ "org.flywaydb:flyway-core:9.20.0", "org.flywaydb:flyway-sqlserver:9.20.0",
-		"com.h2database:h2:2.1.210" })
-class Flyway920AutoConfigurationTests {
-
-	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
-		.withConfiguration(AutoConfigurations.of(FlywayAutoConfiguration.class))
-		.withPropertyValues("spring.datasource.generate-unique-name=true");
-
-	@Test
-	void defaultFlyway() {
-		this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class).run((context) -> {
-			assertThat(context).hasSingleBean(Flyway.class);
-			Flyway flyway = context.getBean(Flyway.class);
-			assertThat(flyway.getConfiguration().getLocations())
-				.containsExactly(new Location("classpath:db/migration"));
-		});
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/FlywayAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/FlywayAutoConfigurationTests.java
index b2148d973e5d..c462bc02716a 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/FlywayAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/FlywayAutoConfigurationTests.java
@@ -31,8 +31,12 @@
 import org.flywaydb.core.api.callback.Callback;
 import org.flywaydb.core.api.callback.Context;
 import org.flywaydb.core.api.callback.Event;
+import org.flywaydb.core.api.configuration.FluentConfiguration;
 import org.flywaydb.core.api.migration.JavaMigration;
+import org.flywaydb.core.internal.database.postgresql.PostgreSQLConfigurationExtension;
 import org.flywaydb.core.internal.license.FlywayTeamsUpgradeRequiredException;
+import org.flywaydb.database.oracle.OracleConfigurationExtension;
+import org.flywaydb.database.sqlserver.SQLServerConfigurationExtension;
 import org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform;
 import org.jooq.DSLContext;
 import org.jooq.SQLDialect;
@@ -48,6 +52,9 @@
 import org.springframework.beans.factory.config.BeanDefinition;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration.FlywayAutoConfigurationRuntimeHints;
+import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration.OracleFlywayConfigurationCustomizer;
+import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration.PostgresqlFlywayConfigurationCustomizer;
+import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration.SqlServerFlywayConfigurationCustomizer;
 import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
 import org.springframework.boot.autoconfigure.jdbc.EmbeddedDataSourceConfiguration;
 import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails;
@@ -83,6 +90,7 @@
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
 import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.mock;
 
@@ -601,18 +609,105 @@ void licenseKeyIsCorrectlyMapped(CapturedOutput output) {
 					+ "Enterprise features, download Flyway Teams Edition & Flyway Enterprise Edition"));
 	}
 
+	@Test
+	void oracleExtensionIsNotLoadedByDefault() {
+		FluentConfiguration configuration = mock(FluentConfiguration.class);
+		new OracleFlywayConfigurationCustomizer(new FlywayProperties()).customize(configuration);
+		then(configuration).shouldHaveNoInteractions();
+	}
+
 	@Test
 	void oracleSqlplusIsCorrectlyMapped() {
+		this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class)
+			.withPropertyValues("spring.flyway.oracle.sqlplus=true")
+			.run((context) -> assertThat(context.getBean(Flyway.class)
+				.getConfiguration()
+				.getPluginRegister()
+				.getPlugin(OracleConfigurationExtension.class)
+				.getSqlplus()).isTrue());
+
+	}
+
+	@Test
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	void oracleSqlplusIsCorrectlyMappedWithDeprecatedProperty() {
 		this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class)
 			.withPropertyValues("spring.flyway.oracle-sqlplus=true")
-			.run(validateFlywayTeamsPropertyOnly("oracle.sqlplus"));
+			.run((context) -> assertThat(context.getBean(Flyway.class)
+				.getConfiguration()
+				.getPluginRegister()
+				.getPlugin(OracleConfigurationExtension.class)
+				.getSqlplus()).isTrue());
+
 	}
 
 	@Test
 	void oracleSqlplusWarnIsCorrectlyMapped() {
+		this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class)
+			.withPropertyValues("spring.flyway.oracle.sqlplus-warn=true")
+			.run((context) -> assertThat(context.getBean(Flyway.class)
+				.getConfiguration()
+				.getPluginRegister()
+				.getPlugin(OracleConfigurationExtension.class)
+				.getSqlplusWarn()).isTrue());
+	}
+
+	@Test
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	void oracleSqlplusWarnIsCorrectlyMappedWithDeprecatedProperty() {
 		this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class)
 			.withPropertyValues("spring.flyway.oracle-sqlplus-warn=true")
-			.run(validateFlywayTeamsPropertyOnly("oracle.sqlplusWarn"));
+			.run((context) -> assertThat(context.getBean(Flyway.class)
+				.getConfiguration()
+				.getPluginRegister()
+				.getPlugin(OracleConfigurationExtension.class)
+				.getSqlplusWarn()).isTrue());
+	}
+
+	@Test
+	void oracleWallerLocationIsCorrectlyMapped() {
+		this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class)
+			.withPropertyValues("spring.flyway.oracle.wallet-location=/tmp/my.wallet")
+			.run((context) -> assertThat(context.getBean(Flyway.class)
+				.getConfiguration()
+				.getPluginRegister()
+				.getPlugin(OracleConfigurationExtension.class)
+				.getWalletLocation()).isEqualTo("/tmp/my.wallet"));
+	}
+
+	@Test
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	void oracleWallerLocationIsCorrectlyMappedWithDeprecatedProperty() {
+		this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class)
+			.withPropertyValues("spring.flyway.oracle-wallet-location=/tmp/my.wallet")
+			.run((context) -> assertThat(context.getBean(Flyway.class)
+				.getConfiguration()
+				.getPluginRegister()
+				.getPlugin(OracleConfigurationExtension.class)
+				.getWalletLocation()).isEqualTo("/tmp/my.wallet"));
+	}
+
+	@Test
+	void oracleKerberosCacheFileIsCorrectlyMapped() {
+		this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class)
+			.withPropertyValues("spring.flyway.oracle.kerberos-cache-file=/tmp/cache")
+			.run((context) -> assertThat(context.getBean(Flyway.class)
+				.getConfiguration()
+				.getPluginRegister()
+				.getPlugin(OracleConfigurationExtension.class)
+				.getKerberosCacheFile()).isEqualTo("/tmp/cache"));
+	}
+
+	@Test
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	void oracleKerberosCacheFileIsCorrectlyMappedWithDeprecatedProperty() {
+		this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class)
+			.withPropertyValues("spring.flyway.oracle-kerberos-cache-file=/tmp/cache")
+			.run((context) -> assertThat(context.getBean(Flyway.class)
+				.getConfiguration()
+				.getPluginRegister()
+				.getPlugin(OracleConfigurationExtension.class)
+				.getKerberosCacheFile()).isEqualTo("/tmp/cache"));
 	}
 
 	@Test
@@ -683,24 +778,62 @@ void kerberosConfigFileIsCorrectlyMapped() {
 	}
 
 	@Test
-	void oracleKerberosCacheFileIsCorrectlyMapped() {
+	void outputQueryResultsIsCorrectlyMapped() {
 		this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class)
-			.withPropertyValues("spring.flyway.oracle-kerberos-cache-file=/tmp/cache")
-			.run(validateFlywayTeamsPropertyOnly("oracle.kerberosCacheFile"));
+			.withPropertyValues("spring.flyway.output-query-results=false")
+			.run(validateFlywayTeamsPropertyOnly("outputQueryResults"));
 	}
 
 	@Test
-	void outputQueryResultsIsCorrectlyMapped() {
+	void postgresqlExtensionIsNotLoadedByDefault() {
+		FluentConfiguration configuration = mock(FluentConfiguration.class);
+		new PostgresqlFlywayConfigurationCustomizer(new FlywayProperties()).customize(configuration);
+		then(configuration).shouldHaveNoInteractions();
+	}
+
+	@Test
+	void postgresqlTransactionalLockIsCorrectlyMapped() {
 		this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class)
-			.withPropertyValues("spring.flyway.output-query-results=false")
-			.run(validateFlywayTeamsPropertyOnly("outputQueryResults"));
+			.withPropertyValues("spring.flyway.postgresql.transactional-lock=false")
+			.run((context) -> assertThat(context.getBean(Flyway.class)
+				.getConfiguration()
+				.getPluginRegister()
+				.getPlugin(PostgreSQLConfigurationExtension.class)
+				.isTransactionalLock()).isFalse());
+	}
+
+	@Test
+	void sqlServerExtensionIsNotLoadedByDefault() {
+		FluentConfiguration configuration = mock(FluentConfiguration.class);
+		new SqlServerFlywayConfigurationCustomizer(new FlywayProperties()).customize(configuration);
+		then(configuration).shouldHaveNoInteractions();
 	}
 
 	@Test
 	void sqlServerKerberosLoginFileIsCorrectlyMapped() {
+		this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class)
+			.withPropertyValues("spring.flyway.sqlserver.kerberos-login-file=/tmp/config")
+			.run((context) -> assertThat(context.getBean(Flyway.class)
+				.getConfiguration()
+				.getPluginRegister()
+				.getPlugin(SQLServerConfigurationExtension.class)
+				.getKerberos()
+				.getLogin()
+				.getFile()).isEqualTo("/tmp/config"));
+	}
+
+	@Test
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	void sqlServerKerberosLoginFileIsCorrectlyMappedWithDeprecatedProperty() {
 		this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class)
 			.withPropertyValues("spring.flyway.sql-server-kerberos-login-file=/tmp/config")
-			.run(validateFlywayTeamsPropertyOnly("sqlserver.kerberos.login.file"));
+			.run((context) -> assertThat(context.getBean(Flyway.class)
+				.getConfiguration()
+				.getPluginRegister()
+				.getPlugin(SQLServerConfigurationExtension.class)
+				.getKerberos()
+				.getLogin()
+				.getFile()).isEqualTo("/tmp/config"));
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/FlywayPropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/FlywayPropertiesTests.java
index 98c3454b227f..0562b6c48e9a 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/FlywayPropertiesTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/FlywayPropertiesTests.java
@@ -109,14 +109,19 @@ void expectedPropertiesAreManaged() {
 				PropertyAccessorFactory.forBeanPropertyAccess(new ClassicConfiguration()));
 		// Properties specific settings
 		ignoreProperties(properties, "url", "driverClassName", "user", "password", "enabled");
-		// Property that moved to a separate SQL plugin
-		ignoreProperties(properties, "sqlServerKerberosLoginFile");
+		// Deprecated properties
+		ignoreProperties(properties, "oracleKerberosCacheFile", "oracleSqlplus", "oracleSqlplusWarn",
+				"oracleWalletLocation", "sqlServerKerberosLoginFile");
+		// Properties that are managed by specific extensions
+		ignoreProperties(properties, "oracle", "postgresql", "sqlserver");
+		// https://github.com/flyway/flyway/issues/3732
+		ignoreProperties(configuration, "environment");
 		// High level object we can't set with properties
 		ignoreProperties(configuration, "callbacks", "classLoader", "dataSource", "javaMigrations",
 				"javaMigrationClassProvider", "pluginRegister", "resourceProvider", "resolvers");
 		// Properties we don't want to expose
 		ignoreProperties(configuration, "resolversAsClassNames", "callbacksAsClassNames", "driver", "modernConfig",
-				"currentResolvedEnvironment", "reportFilename");
+				"currentResolvedEnvironment", "reportFilename", "reportEnabled", "workingDirectory");
 		// Handled by the conversion service
 		ignoreProperties(configuration, "baselineVersionAsString", "encodingAsString", "locationsAsStrings",
 				"targetAsString");
@@ -128,7 +133,7 @@ void expectedPropertiesAreManaged() {
 		// Handled as createSchemas
 		ignoreProperties(configuration, "shouldCreateSchemas");
 		// Getters for the DataSource settings rather than actual properties
-		ignoreProperties(configuration, "password", "url", "user");
+		ignoreProperties(configuration, "databaseType", "password", "url", "user");
 		// Properties not exposed by Flyway
 		ignoreProperties(configuration, "failOnMissingTarget");
 		List<String> configurationKeys = new ArrayList<>(configuration.keySet());
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfigurationTests.java
index 2719747656e1..4d121314b5ff 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfigurationTests.java
@@ -17,6 +17,7 @@
 package org.springframework.boot.autoconfigure.graphql;
 
 import java.nio.charset.StandardCharsets;
+import java.util.concurrent.Executor;
 
 import graphql.GraphQL;
 import graphql.execution.instrumentation.ChainedInstrumentation;
@@ -29,12 +30,16 @@
 import graphql.schema.visibility.NoIntrospectionGraphqlFieldVisibility;
 import org.assertj.core.api.InstanceOfAssertFactories;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
 
 import org.springframework.aot.hint.RuntimeHints;
 import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration.GraphQlResourcesRuntimeHints;
+import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.core.io.ByteArrayResource;
@@ -54,6 +59,7 @@
 /**
  * Tests for {@link GraphQlAutoConfiguration}.
  */
+@ExtendWith(OutputCaptureExtension.class)
 class GraphQlAutoConfigurationTests {
 
 	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
@@ -156,6 +162,11 @@ void shouldApplyGraphQlSourceBuilderCustomizer() {
 		});
 	}
 
+	@Test
+	void schemaInspectionShouldBeEnabledByDefault(CapturedOutput output) {
+		this.contextRunner.run((context) -> assertThat(output).contains("GraphQL schema inspection"));
+	}
+
 	@Test
 	void fieldIntrospectionShouldBeEnabledByDefault() {
 		this.contextRunner.run((context) -> {
@@ -203,12 +214,32 @@ void shouldContributeConnectionTypeDefinitionConfigurer() {
 			GraphQlSource graphQlSource = context.getBean(GraphQlSource.class);
 			GraphQLSchema schema = graphQlSource.schema();
 			GraphQLOutputType bookConnection = schema.getQueryType().getField("books").getType();
-			assertThat(bookConnection).isNotNull().isInstanceOf(GraphQLObjectType.class);
+			assertThat(bookConnection).isInstanceOf(GraphQLObjectType.class);
 			assertThat((GraphQLObjectType) bookConnection)
 				.satisfies((connection) -> assertThat(connection.getFieldDefinition("edges")).isNotNull());
 		});
 	}
 
+	@Test
+	void whenApplicationTaskExecutorIsDefinedThenAnnotatedControllerConfigurerShouldUseIt() {
+		this.contextRunner.withConfiguration(AutoConfigurations.of(TaskExecutionAutoConfiguration.class))
+			.run((context) -> {
+				AnnotatedControllerConfigurer annotatedControllerConfigurer = context
+					.getBean(AnnotatedControllerConfigurer.class);
+				assertThat(annotatedControllerConfigurer).extracting("executor")
+					.isSameAs(context.getBean("applicationTaskExecutor"));
+			});
+	}
+
+	@Test
+	void whenCustomExecutorIsDefinedThenAnnotatedControllerConfigurerDoesNotUseIt() {
+		this.contextRunner.withUserConfiguration(CustomExecutorConfiguration.class).run((context) -> {
+			AnnotatedControllerConfigurer annotatedControllerConfigurer = context
+				.getBean(AnnotatedControllerConfigurer.class);
+			assertThat(annotatedControllerConfigurer).extracting("executor").isNull();
+		});
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class CustomGraphQlBuilderConfiguration {
 
@@ -294,4 +325,14 @@ public void customize(GraphQlSource.SchemaResourceBuilder builder) {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class CustomExecutorConfiguration {
+
+		@Bean
+		Executor customExecutor() {
+			return mock(Executor.class);
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfigurationTests.java
index 0998bfe10a92..964bf0ebaff6 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfigurationTests.java
@@ -49,7 +49,7 @@ class GraphQlQueryByExampleAutoConfigurationTests {
 		.withConfiguration(
 				AutoConfigurations.of(GraphQlAutoConfiguration.class, GraphQlQueryByExampleAutoConfiguration.class))
 		.withUserConfiguration(MockRepositoryConfig.class)
-		.withPropertyValues("spring.main.web-application-type=reactive");
+		.withPropertyValues("spring.main.web-application-type=servlet");
 
 	@Test
 	void shouldRegisterDataFetcherForQueryByExampleRepositories() {
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfigurationTests.java
index 3bbb3df8a03b..ff2624099b2d 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfigurationTests.java
@@ -23,6 +23,7 @@
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.graphql.Book;
 import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration;
+import org.springframework.boot.test.context.FilteredClassLoader;
 import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
@@ -33,6 +34,7 @@
 import org.springframework.graphql.test.tester.ExecutionGraphQlServiceTester;
 import org.springframework.graphql.test.tester.GraphQlTester;
 
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.BDDMockito.given;
 import static org.mockito.Mockito.mock;
@@ -50,7 +52,7 @@ class GraphQlQuerydslAutoConfigurationTests {
 		.withConfiguration(
 				AutoConfigurations.of(GraphQlAutoConfiguration.class, GraphQlQuerydslAutoConfiguration.class))
 		.withUserConfiguration(MockRepositoryConfig.class)
-		.withPropertyValues("spring.main.web-application-type=reactive");
+		.withPropertyValues("spring.main.web-application-type=servlet");
 
 	@Test
 	void shouldRegisterDataFetcherForQueryDslRepositories() {
@@ -65,6 +67,13 @@ void shouldRegisterDataFetcherForQueryDslRepositories() {
 		});
 	}
 
+	@Test
+	void shouldBackOffWithoutQueryDsl() {
+		this.contextRunner.withClassLoader(new FilteredClassLoader("com.querydsl.core"))
+			.run((context) -> assertThat(context).doesNotHaveBean("querydslRegistrar")
+				.doesNotHaveBean(GraphQlQuerydslAutoConfiguration.class));
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class MockRepositoryConfig {
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfigurationTests.java
index 9901d096cb75..8c807fe98984 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfigurationTests.java
@@ -22,6 +22,7 @@
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.graphql.Book;
 import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration;
+import org.springframework.boot.test.context.FilteredClassLoader;
 import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
@@ -32,6 +33,7 @@
 import org.springframework.graphql.test.tester.ExecutionGraphQlServiceTester;
 import org.springframework.graphql.test.tester.GraphQlTester;
 
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.BDDMockito.given;
 import static org.mockito.Mockito.mock;
@@ -64,6 +66,13 @@ void shouldRegisterDataFetcherForQueryDslRepositories() {
 		});
 	}
 
+	@Test
+	void shouldBackOffWithoutQueryDsl() {
+		this.contextRunner.withClassLoader(new FilteredClassLoader("com.querydsl.core"))
+			.run((context) -> assertThat(context).doesNotHaveBean("querydslRegistrar")
+				.doesNotHaveBean(GraphQlReactiveQuerydslAutoConfiguration.class));
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class MockRepositoryConfig {
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java
index e99c778746a8..6df0fdff742a 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java
@@ -45,7 +45,11 @@
 import org.springframework.test.web.servlet.MockMvc;
 import org.springframework.test.web.servlet.MvcResult;
 import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.servlet.HandlerMapping;
 import org.springframework.web.servlet.function.RouterFunction;
+import org.springframework.web.servlet.function.support.RouterFunctionMapping;
+import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
+import org.springframework.web.socket.server.support.WebSocketHandlerMapping;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch;
@@ -162,8 +166,12 @@ void shouldSupportCors() {
 
 	@Test
 	void shouldConfigureWebSocketBeans() {
-		this.contextRunner.withPropertyValues("spring.graphql.websocket.path=/ws")
-			.run((context) -> assertThat(context).hasSingleBean(GraphQlWebSocketHandler.class));
+		this.contextRunner.withPropertyValues("spring.graphql.websocket.path=/ws").run((context) -> {
+			assertThat(context).hasSingleBean(GraphQlWebSocketHandler.class);
+			assertThat(context.getBeanProvider(HandlerMapping.class).orderedStream().toList()).containsSubsequence(
+					context.getBean(WebSocketHandlerMapping.class), context.getBean(RouterFunctionMapping.class),
+					context.getBean(RequestMappingHandlerMapping.class));
+		});
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/influx/InfluxDbAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/influx/InfluxDbAutoConfigurationTests.java
index 7d37ee994cc4..fade2e3b2816 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/influx/InfluxDbAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/influx/InfluxDbAutoConfigurationTests.java
@@ -42,6 +42,8 @@
  * @author Andy Wilkinson
  * @author Phillip Webb
  */
+@SuppressWarnings("removal")
+@Deprecated(since = "3.2.0", forRemoval = true)
 class InfluxDbAutoConfigurationTests {
 
 	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
@@ -102,6 +104,7 @@ private int getReadTimeoutProperty(AssertableApplicationContext context) {
 	static class CustomOkHttpClientBuilderProviderConfig {
 
 		@Bean
+		@SuppressWarnings("removal")
 		InfluxDbOkHttpClientBuilderProvider influxDbOkHttpClientBuilderProvider() {
 			return () -> new OkHttpClient.Builder().readTimeout(40, TimeUnit.SECONDS);
 		}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/info/ProjectInfoAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/info/ProjectInfoAutoConfigurationTests.java
index 9d8680999811..7a65682b9306 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/info/ProjectInfoAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/info/ProjectInfoAutoConfigurationTests.java
@@ -119,7 +119,7 @@ void buildPropertiesCustomLocation() {
 	@Test
 	void buildPropertiesCustomInvalidLocation() {
 		this.contextRunner.withPropertyValues("spring.info.build.location=classpath:/org/acme/no-build-info.properties")
-			.run((context) -> assertThat(context.getBeansOfType(BuildProperties.class)).hasSize(0));
+			.run((context) -> assertThat(context.getBeansOfType(BuildProperties.class)).isEmpty());
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/integration/IntegrationPropertiesEnvironmentPostProcessorTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/integration/IntegrationPropertiesEnvironmentPostProcessorTests.java
index d6076db26026..a7bf3aa28749 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/integration/IntegrationPropertiesEnvironmentPostProcessorTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/integration/IntegrationPropertiesEnvironmentPostProcessorTests.java
@@ -34,7 +34,7 @@
 import org.springframework.core.io.Resource;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
 import static org.mockito.Mockito.mock;
 
 /**
@@ -69,11 +69,11 @@ void postProcessEnvironmentAddPropertySourceLast() {
 	void registerIntegrationPropertiesPropertySourceWithUnknownResourceThrowsException() {
 		ConfigurableEnvironment environment = new StandardEnvironment();
 		ClassPathResource unknown = new ClassPathResource("does-not-exist.properties", getClass());
-		assertThatThrownBy(() -> new IntegrationPropertiesEnvironmentPostProcessor()
-			.registerIntegrationPropertiesPropertySource(environment, unknown))
-			.isInstanceOf(IllegalStateException.class)
-			.hasCauseInstanceOf(FileNotFoundException.class)
-			.hasMessageContaining(unknown.toString());
+		assertThatIllegalStateException()
+			.isThrownBy(() -> new IntegrationPropertiesEnvironmentPostProcessor()
+				.registerIntegrationPropertiesPropertySource(environment, unknown))
+			.withCauseInstanceOf(FileNotFoundException.class)
+			.withMessageContaining(unknown.toString());
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfigurationTests.java
index 3c14adc3ef3f..3e8dcf01c8f3 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfigurationTests.java
@@ -45,6 +45,8 @@
 import com.fasterxml.jackson.databind.SerializerProvider;
 import com.fasterxml.jackson.databind.cfg.ConstructorDetector;
 import com.fasterxml.jackson.databind.cfg.ConstructorDetector.SingleArgConstructor;
+import com.fasterxml.jackson.databind.cfg.EnumFeature;
+import com.fasterxml.jackson.databind.cfg.JsonNodeFeature;
 import com.fasterxml.jackson.databind.exc.InvalidFormatException;
 import com.fasterxml.jackson.databind.module.SimpleModule;
 import com.fasterxml.jackson.databind.util.StdDateFormat;
@@ -73,7 +75,7 @@
 import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
 import static org.assertj.core.api.Assertions.entry;
 import static org.mockito.Mockito.mock;
 
@@ -88,6 +90,7 @@
  * @author Johannes Edmeier
  * @author Grzegorz Poznachowski
  * @author Ralf Ueberfuhr
+ * @author EddĂș MelĂ©ndez
  */
 class JacksonAutoConfigurationTests {
 
@@ -289,6 +292,27 @@ void defaultObjectMapperBuilder() {
 		});
 	}
 
+	@Test
+	void enableEnumFeature() {
+		this.contextRunner.withPropertyValues("spring.jackson.datatype.enum.write-enums-to-lowercase=true")
+			.run((context) -> {
+				ObjectMapper mapper = context.getBean(ObjectMapper.class);
+				assertThat(EnumFeature.WRITE_ENUMS_TO_LOWERCASE.enabledByDefault()).isFalse();
+				assertThat(mapper.getSerializationConfig().isEnabled(EnumFeature.WRITE_ENUMS_TO_LOWERCASE)).isTrue();
+			});
+	}
+
+	@Test
+	void disableJsonNodeFeature() {
+		this.contextRunner.withPropertyValues("spring.jackson.datatype.json-node.write-null-properties:false")
+			.run((context) -> {
+				ObjectMapper mapper = context.getBean(ObjectMapper.class);
+				assertThat(JsonNodeFeature.WRITE_NULL_PROPERTIES.enabledByDefault()).isTrue();
+				assertThat(mapper.getDeserializationConfig().isEnabled(JsonNodeFeature.WRITE_NULL_PROPERTIES))
+					.isFalse();
+			});
+	}
+
 	@Test
 	void moduleBeansAndWellKnownModulesAreRegisteredWithTheObjectMapperBuilder() {
 		this.contextRunner.withUserConfiguration(ModuleConfig.class).run((context) -> {
@@ -339,10 +363,10 @@ void enableDefaultLeniency() {
 	void disableDefaultLeniency() {
 		this.contextRunner.withPropertyValues("spring.jackson.default-leniency:false").run((context) -> {
 			ObjectMapper mapper = context.getBean(ObjectMapper.class);
-			assertThatThrownBy(() -> mapper.readValue("{\"birthDate\": \"2010-12-30\"}", Person.class))
-				.isInstanceOf(InvalidFormatException.class)
-				.hasMessageContaining("expected format")
-				.hasMessageContaining("yyyyMMdd");
+			assertThatExceptionOfType(InvalidFormatException.class)
+				.isThrownBy(() -> mapper.readValue("{\"birthDate\": \"2010-12-30\"}", Person.class))
+				.withMessageContaining("expected format")
+				.withMessageContaining("yyyyMMdd");
 		});
 	}
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/DataSourceAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/DataSourceAutoConfigurationTests.java
index 75cf07536762..1e2cbca31086 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/DataSourceAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/DataSourceAutoConfigurationTests.java
@@ -152,10 +152,10 @@ void oracleUcpIsFallback() {
 	}
 
 	@Test
-	void oracleUcpValidatesConnectionByDefault() {
+	void oracleUcpDoesNotValidateConnectionByDefault() {
 		assertDataSource(PoolDataSourceImpl.class,
 				Arrays.asList("com.zaxxer.hikari", "org.apache.tomcat", "org.apache.commons.dbcp2"), (dataSource) -> {
-					assertThat(dataSource.getValidateConnectionOnBorrow()).isTrue();
+					assertThat(dataSource.getValidateConnectionOnBorrow()).isFalse();
 					// Use an internal ping when using an Oracle JDBC driver
 					assertThat(dataSource.getSQLForValidateConnection()).isNull();
 				});
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/DataSourceTransactionManagerAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/DataSourceTransactionManagerAutoConfigurationTests.java
index 90044440432a..a289503b0240 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/DataSourceTransactionManagerAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/DataSourceTransactionManagerAutoConfigurationTests.java
@@ -24,6 +24,7 @@
 
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration;
+import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizationAutoConfiguration;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.jdbc.datasource.DataSourceTransactionManager;
 import org.springframework.jdbc.support.JdbcTransactionManager;
@@ -44,6 +45,7 @@ class DataSourceTransactionManagerAutoConfigurationTests {
 
 	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
 		.withConfiguration(AutoConfigurations.of(TransactionAutoConfiguration.class,
+				TransactionManagerCustomizationAutoConfiguration.class,
 				DataSourceTransactionManagerAutoConfiguration.class))
 		.withPropertyValues("spring.datasource.url:jdbc:hsqldb:mem:test-" + UUID.randomUUID());
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/HikariDataSourceConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/HikariDataSourceConfigurationTests.java
index 653d59cb8f48..3019be543757 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/HikariDataSourceConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/HikariDataSourceConfigurationTests.java
@@ -22,10 +22,16 @@
 import org.assertj.core.api.InstanceOfAssertFactories;
 import org.junit.jupiter.api.Test;
 
+import org.springframework.beans.BeansException;
+import org.springframework.beans.factory.config.BeanPostProcessor;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.jdbc.DataSourceBuilder;
+import org.springframework.boot.jdbc.HikariCheckpointRestoreLifecycle;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.boot.testsupport.classpath.ClassPathOverrides;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.jdbc.datasource.DelegatingDataSource;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -37,6 +43,7 @@
  * @author Moritz Halbritter
  * @author Andy Wilkinson
  * @author Phillip Webb
+ * @author Olga Maciaszek-Sharma
  */
 class HikariDataSourceConfigurationTests {
 
@@ -122,6 +129,33 @@ void usesCustomConnectionDetailsWhenDefined() {
 			});
 	}
 
+	@Test
+	@ClassPathOverrides("org.crac:crac:1.3.0")
+	void whenCheckpointRestoreIsAvailableHikariAutoConfigRegistersLifecycleBean() {
+		this.contextRunner.withPropertyValues("spring.datasource.type=" + HikariDataSource.class.getName())
+			.run((context) -> assertThat(context).hasSingleBean(HikariCheckpointRestoreLifecycle.class));
+	}
+
+	@Test
+	@ClassPathOverrides("org.crac:crac:1.3.0")
+	void whenCheckpointRestoreIsAvailableAndDataSourceHasBeenWrappedHikariAutoConfigRegistersLifecycleBean() {
+		this.contextRunner.withUserConfiguration(DataSourceWrapperConfiguration.class)
+			.run((context) -> assertThat(context).hasSingleBean(HikariCheckpointRestoreLifecycle.class));
+	}
+
+	@Test
+	void whenCheckpointRestoreIsNotAvailableHikariAutoConfigDoesNotRegisterLifecycleBean() {
+		this.contextRunner
+			.run((context) -> assertThat(context).doesNotHaveBean(HikariCheckpointRestoreLifecycle.class));
+	}
+
+	@Test
+	@ClassPathOverrides("org.crac:crac:1.3.0")
+	void whenCheckpointRestoreIsAvailableAndDataSourceIsFromUserConfigurationHikariAutoConfigRegistersLifecycleBean() {
+		this.contextRunner.withUserConfiguration(UserDataSourceConfiguration.class)
+			.run((context) -> assertThat(context).hasSingleBean(HikariCheckpointRestoreLifecycle.class));
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class ConnectionDetailsConfiguration {
 
@@ -132,4 +166,39 @@ JdbcConnectionDetails sqlConnectionDetails() {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class DataSourceWrapperConfiguration {
+
+		@Bean
+		static BeanPostProcessor dataSourceWrapper() {
+			return new BeanPostProcessor() {
+
+				@Override
+				public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
+					if (bean instanceof DataSource dataSource) {
+						return new DelegatingDataSource(dataSource);
+					}
+					return bean;
+				}
+
+			};
+		}
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	static class UserDataSourceConfiguration {
+
+		@Bean
+		DataSource dataSource() {
+			return DataSourceBuilder.create()
+				.driverClassName("org.postgresql.Driver")
+				.url("jdbc:postgresql://localhost:5432/database")
+				.username("user")
+				.password("password")
+				.build();
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/JdbcClientAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/JdbcClientAutoConfigurationTests.java
new file mode 100644
index 000000000000..6c4035dc79e8
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/JdbcClientAutoConfigurationTests.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.jdbc;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
+import org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.jdbc.core.simple.JdbcClient;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link JdbcClientAutoConfiguration}.
+ *
+ * @author Stephane Nicoll
+ */
+class JdbcClientAutoConfigurationTests {
+
+	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+		.withPropertyValues("spring.datasource.generate-unique-name=true")
+		.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class, JdbcTemplateAutoConfiguration.class,
+				JdbcClientAutoConfiguration.class));
+
+	@Test
+	void jdbcClientWhenNoAvailableJdbcTemplateIsNotCreated() {
+		new ApplicationContextRunner()
+			.withConfiguration(
+					AutoConfigurations.of(DataSourceAutoConfiguration.class, JdbcClientAutoConfiguration.class))
+			.run((context) -> assertThat(context).doesNotHaveBean(JdbcClient.class));
+	}
+
+	@Test
+	void jdbcClientWhenExistingJdbcTemplateIsCreated() {
+		this.contextRunner.run((context) -> {
+			assertThat(context).hasSingleBean(JdbcClient.class);
+			NamedParameterJdbcTemplate namedParameterJdbcTemplate = context.getBean(NamedParameterJdbcTemplate.class);
+			assertThat(namedParameterJdbcTemplate.getJdbcOperations()).isEqualTo(context.getBean(JdbcOperations.class));
+		});
+	}
+
+	@Test
+	void jdbcClientWithCustomJdbcClientIsNotCreated() {
+		this.contextRunner.withBean("customJdbcClient", JdbcClient.class, () -> mock(JdbcClient.class))
+			.run((context) -> {
+				assertThat(context).hasSingleBean(JdbcClient.class);
+				assertThat(context.getBean(JdbcClient.class)).isEqualTo(context.getBean("customJdbcClient"));
+			});
+	}
+
+	@Test
+	void jdbcClientIsOrderedAfterFlywayMigration() {
+		this.contextRunner.withUserConfiguration(JdbcClientDataSourceMigrationValidator.class)
+			.withPropertyValues("spring.flyway.locations:classpath:db/city")
+			.withConfiguration(AutoConfigurations.of(FlywayAutoConfiguration.class))
+			.run((context) -> {
+				assertThat(context).hasNotFailed().hasSingleBean(JdbcClient.class);
+				assertThat(context.getBean(JdbcClientDataSourceMigrationValidator.class).count).isZero();
+			});
+	}
+
+	@Test
+	void jdbcClientIsOrderedAfterLiquibaseMigration() {
+		this.contextRunner.withUserConfiguration(JdbcClientDataSourceMigrationValidator.class)
+			.withPropertyValues("spring.liquibase.change-log:classpath:db/changelog/db.changelog-city.yaml")
+			.withConfiguration(AutoConfigurations.of(LiquibaseAutoConfiguration.class))
+			.run((context) -> {
+				assertThat(context).hasNotFailed().hasSingleBean(JdbcClient.class);
+				assertThat(context.getBean(JdbcClientDataSourceMigrationValidator.class).count).isZero();
+			});
+	}
+
+	static class JdbcClientDataSourceMigrationValidator {
+
+		private final Long count;
+
+		JdbcClientDataSourceMigrationValidator(JdbcClient jdbcClient) {
+			this.count = jdbcClient.sql("SELECT COUNT(*) from CITY").query(Long.class).single();
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/JdbcTemplateAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/JdbcTemplateAutoConfigurationTests.java
index e9424bd2bd36..7154f618dd49 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/JdbcTemplateAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/JdbcTemplateAutoConfigurationTests.java
@@ -184,7 +184,7 @@ void testDependencyToFlywayWithJdbcTemplateMixed() {
 	@Test
 	void testDependencyToLiquibase() {
 		this.contextRunner.withUserConfiguration(DataSourceMigrationValidator.class)
-			.withPropertyValues("spring.liquibase.changeLog:classpath:db/changelog/db.changelog-city.yaml")
+			.withPropertyValues("spring.liquibase.change-log:classpath:db/changelog/db.changelog-city.yaml")
 			.withConfiguration(AutoConfigurations.of(LiquibaseAutoConfiguration.class))
 			.run((context) -> {
 				assertThat(context).hasNotFailed();
@@ -195,7 +195,7 @@ void testDependencyToLiquibase() {
 	@Test
 	void testDependencyToLiquibaseWithJdbcTemplateMixed() {
 		this.contextRunner.withUserConfiguration(NamedParameterDataSourceMigrationValidator.class)
-			.withPropertyValues("spring.liquibase.changeLog:classpath:db/changelog/db.changelog-city.yaml")
+			.withPropertyValues("spring.liquibase.change-log:classpath:db/changelog/db.changelog-city.yaml")
 			.withConfiguration(AutoConfigurations.of(LiquibaseAutoConfiguration.class))
 			.run((context) -> {
 				assertThat(context).hasNotFailed();
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/OracleUcpDataSourceConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/OracleUcpDataSourceConfigurationTests.java
index 234374540943..971ab21d37a5 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/OracleUcpDataSourceConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/OracleUcpDataSourceConfigurationTests.java
@@ -17,6 +17,7 @@
 package org.springframework.boot.autoconfigure.jdbc;
 
 import java.sql.Connection;
+import java.time.Duration;
 
 import javax.sql.DataSource;
 
@@ -82,10 +83,10 @@ void testDataSourceDefaultsPreserved() {
 		this.contextRunner.run((context) -> {
 			PoolDataSourceImpl ds = context.getBean(PoolDataSourceImpl.class);
 			assertThat(ds.getInitialPoolSize()).isZero();
-			assertThat(ds.getMinPoolSize()).isZero();
+			assertThat(ds.getMinPoolSize()).isEqualTo(1);
 			assertThat(ds.getMaxPoolSize()).isEqualTo(Integer.MAX_VALUE);
 			assertThat(ds.getInactiveConnectionTimeout()).isZero();
-			assertThat(ds.getConnectionWaitTimeout()).isEqualTo(3);
+			assertThat(ds.getConnectionWaitDuration()).isEqualTo(Duration.ofSeconds(3));
 			assertThat(ds.getTimeToLiveConnectionTimeout()).isZero();
 			assertThat(ds.getAbandonedConnectionTimeout()).isZero();
 			assertThat(ds.getTimeoutCheckInterval()).isEqualTo(30);
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/TomcatDataSourceConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/TomcatDataSourceConfigurationTests.java
index 9c27204f79e2..fc4ef5e00238 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/TomcatDataSourceConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/TomcatDataSourceConfigurationTests.java
@@ -37,7 +37,7 @@
 import org.springframework.context.annotation.EnableMBeanExport;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.fail;
+import static org.assertj.core.api.Assertions.fail;
 
 /**
  * Tests for {@link TomcatDataSourceConfiguration}.
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jersey/JerseyAutoConfigurationCustomObjectMapperProviderTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jersey/JerseyAutoConfigurationCustomObjectMapperProviderTests.java
index 7cddc14bfe79..fe823f095482 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jersey/JerseyAutoConfigurationCustomObjectMapperProviderTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jersey/JerseyAutoConfigurationCustomObjectMapperProviderTests.java
@@ -60,8 +60,8 @@ class JerseyAutoConfigurationCustomObjectMapperProviderTests {
 	@Test
 	void contextLoads() {
 		ResponseEntity<String> response = this.restTemplate.getForEntity("/rest/message", String.class);
-		assertThat(HttpStatus.OK).isEqualTo(response.getStatusCode());
-		assertThat(response.getBody()).isEqualTo("{\"subject\":\"Jersey\"}");
+		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+		assertThat("{\"subject\":\"Jersey\"}").isEqualTo(response.getBody());
 	}
 
 	@MinimalWebConfiguration
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jersey/JerseyAutoConfigurationObjectMapperProviderTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jersey/JerseyAutoConfigurationObjectMapperProviderTests.java
index e1336263463c..59cc5167b36e 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jersey/JerseyAutoConfigurationObjectMapperProviderTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jersey/JerseyAutoConfigurationObjectMapperProviderTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -62,7 +62,7 @@ class JerseyAutoConfigurationObjectMapperProviderTests {
 	@Test
 	void responseIsSerializedUsingAutoConfiguredObjectMapper() {
 		ResponseEntity<String> response = this.restTemplate.getForEntity("/rest/message", String.class);
-		assertThat(HttpStatus.OK).isEqualTo(response.getStatusCode());
+		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
 		assertThat(response.getBody()).isEqualTo("{\"subject\":\"Jersey\"}");
 	}
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/AcknowledgeModeTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/AcknowledgeModeTests.java
new file mode 100644
index 000000000000..77957f5a9674
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/AcknowledgeModeTests.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.jms;
+
+import jakarta.jms.Session;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link AcknowledgeMode}.
+ *
+ * @author Andy Wilkinson
+ */
+class AcknowledgeModeTests {
+
+	@ParameterizedTest
+	@EnumSource(Mapping.class)
+	void stringIsMappedToInt(Mapping mapping) {
+		assertThat(AcknowledgeMode.of(mapping.actual)).extracting(AcknowledgeMode::getMode).isEqualTo(mapping.expected);
+	}
+
+	@Test
+	void mapShouldThrowWhenMapIsCalledWithUnknownNonIntegerString() {
+		assertThatIllegalArgumentException().isThrownBy(() -> AcknowledgeMode.of("some-string"))
+			.withMessage(
+					"'some-string' is neither a known acknowledge mode (auto, client, or dups_ok) nor an integer value");
+	}
+
+	private enum Mapping {
+
+		AUTO_LOWER_CASE("auto", Session.AUTO_ACKNOWLEDGE),
+
+		CLIENT_LOWER_CASE("client", Session.CLIENT_ACKNOWLEDGE),
+
+		DUPS_OK_LOWER_CASE("dups_ok", Session.DUPS_OK_ACKNOWLEDGE),
+
+		AUTO_UPPER_CASE("AUTO", Session.AUTO_ACKNOWLEDGE),
+
+		CLIENT_UPPER_CASE("CLIENT", Session.CLIENT_ACKNOWLEDGE),
+
+		DUPS_OK_UPPER_CASE("DUPS_OK", Session.DUPS_OK_ACKNOWLEDGE),
+
+		AUTO_MIXED_CASE("AuTo", Session.AUTO_ACKNOWLEDGE),
+
+		CLIENT_MIXED_CASE("CliEnT", Session.CLIENT_ACKNOWLEDGE),
+
+		DUPS_OK_MIXED_CASE("dUPs_Ok", Session.DUPS_OK_ACKNOWLEDGE),
+
+		DUPS_OK_KEBAB_CASE("DUPS-OK", Session.DUPS_OK_ACKNOWLEDGE),
+
+		DUPS_OK_NO_SEPARATOR_UPPER_CASE("DUPSOK", Session.DUPS_OK_ACKNOWLEDGE),
+
+		DUPS_OK_NO_SEPARATOR_LOWER_CASE("dupsok", Session.DUPS_OK_ACKNOWLEDGE),
+
+		DUPS_OK_NO_SEPARATOR_MIXED_CASE("duPSok", Session.DUPS_OK_ACKNOWLEDGE),
+
+		INTEGER("36", 36);
+
+		private final String actual;
+
+		private final int expected;
+
+		Mapping(String actual, int expected) {
+			this.actual = actual;
+			this.expected = expected;
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/JmsAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/JmsAutoConfigurationTests.java
index 17692a1b7c62..b9b6025bae46 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/JmsAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/JmsAutoConfigurationTests.java
@@ -24,14 +24,18 @@
 import org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory;
 import org.junit.jupiter.api.Test;
 
+import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
+import org.springframework.aot.test.generate.TestGenerationContext;
 import org.springframework.beans.factory.config.BeanPostProcessor;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.jms.artemis.ArtemisAutoConfiguration;
 import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Primary;
+import org.springframework.context.aot.ApplicationContextAotGenerator;
 import org.springframework.jdbc.datasource.DataSourceTransactionManager;
 import org.springframework.jms.annotation.EnableJms;
 import org.springframework.jms.config.DefaultJmsListenerContainerFactory;
@@ -57,6 +61,7 @@
  * @author Stephane Nicoll
  * @author Aurélien Leboulanger
  * @author EddĂș MelĂ©ndez
+ * @author Vedran Pavic
  */
 class JmsAutoConfigurationTests {
 
@@ -142,9 +147,10 @@ void jmsListenerContainerFactoryWhenMultipleConnectionFactoryBeansShouldBackOff(
 	@Test
 	void testJmsListenerContainerFactoryWithCustomSettings() {
 		this.contextRunner.withUserConfiguration(EnableJmsConfiguration.class)
-			.withPropertyValues("spring.jms.listener.autoStartup=false", "spring.jms.listener.acknowledgeMode=client",
-					"spring.jms.listener.concurrency=2", "spring.jms.listener.receiveTimeout=2s",
-					"spring.jms.listener.maxConcurrency=10")
+			.withPropertyValues("spring.jms.listener.autoStartup=false",
+					"spring.jms.listener.session.acknowledgeMode=client",
+					"spring.jms.listener.session.transacted=false", "spring.jms.listener.minConcurrency=2",
+					"spring.jms.listener.receiveTimeout=2s", "spring.jms.listener.maxConcurrency=10")
 			.run(this::testJmsListenerContainerFactoryWithCustomSettings);
 	}
 
@@ -152,11 +158,22 @@ private void testJmsListenerContainerFactoryWithCustomSettings(AssertableApplica
 		DefaultMessageListenerContainer container = getContainer(loaded, "jmsListenerContainerFactory");
 		assertThat(container.isAutoStartup()).isFalse();
 		assertThat(container.getSessionAcknowledgeMode()).isEqualTo(Session.CLIENT_ACKNOWLEDGE);
+		assertThat(container.isSessionTransacted()).isFalse();
 		assertThat(container.getConcurrentConsumers()).isEqualTo(2);
 		assertThat(container.getMaxConcurrentConsumers()).isEqualTo(10);
 		assertThat(container).hasFieldOrPropertyWithValue("receiveTimeout", 2000L);
 	}
 
+	@Test
+	void testJmsListenerContainerFactoryWithNonStandardAcknowledgeMode() {
+		this.contextRunner.withUserConfiguration(EnableJmsConfiguration.class)
+			.withPropertyValues("spring.jms.listener.session.acknowledge-mode=9")
+			.run((context) -> {
+				DefaultMessageListenerContainer container = getContainer(context, "jmsListenerContainerFactory");
+				assertThat(container.getSessionAcknowledgeMode()).isEqualTo(9);
+			});
+	}
+
 	@Test
 	void testJmsListenerContainerFactoryWithDefaultSettings() {
 		this.contextRunner.withUserConfiguration(EnableJmsConfiguration.class)
@@ -179,6 +196,18 @@ void testDefaultContainerFactoryWithJtaTransactionManager() {
 			});
 	}
 
+	@Test
+	void testDefaultContainerFactoryWithJtaTransactionManagerAndSessionTransactedEnabled() {
+		this.contextRunner.withUserConfiguration(TestConfiguration7.class, EnableJmsConfiguration.class)
+			.withPropertyValues("spring.jms.listener.session.transacted=true")
+			.run((context) -> {
+				DefaultMessageListenerContainer container = getContainer(context, "jmsListenerContainerFactory");
+				assertThat(container.isSessionTransacted()).isTrue();
+				assertThat(container).hasFieldOrPropertyWithValue("transactionManager",
+						context.getBean(JtaTransactionManager.class));
+			});
+	}
+
 	@Test
 	void testDefaultContainerFactoryNonJtaTransactionManager() {
 		this.contextRunner.withUserConfiguration(TestConfiguration8.class, EnableJmsConfiguration.class)
@@ -198,6 +227,17 @@ void testDefaultContainerFactoryNoTransactionManager() {
 		});
 	}
 
+	@Test
+	void testDefaultContainerFactoryNoTransactionManagerAndSessionTransactedDisabled() {
+		this.contextRunner.withUserConfiguration(EnableJmsConfiguration.class)
+			.withPropertyValues("spring.jms.listener.session.transacted=false")
+			.run((context) -> {
+				DefaultMessageListenerContainer container = getContainer(context, "jmsListenerContainerFactory");
+				assertThat(container.isSessionTransacted()).isFalse();
+				assertThat(container).hasFieldOrPropertyWithValue("transactionManager", null);
+			});
+	}
+
 	@Test
 	void testDefaultContainerFactoryWithMessageConverters() {
 		this.contextRunner.withUserConfiguration(MessageConvertersConfiguration.class, EnableJmsConfiguration.class)
@@ -253,7 +293,8 @@ void testJmsTemplateWithDestinationResolver() {
 	@Test
 	void testJmsTemplateFullCustomization() {
 		this.contextRunner.withUserConfiguration(MessageConvertersConfiguration.class)
-			.withPropertyValues("spring.jms.template.default-destination=testQueue",
+			.withPropertyValues("spring.jms.template.session.acknowledge-mode=client",
+					"spring.jms.template.session.transacted=true", "spring.jms.template.default-destination=testQueue",
 					"spring.jms.template.delivery-delay=500", "spring.jms.template.delivery-mode=non-persistent",
 					"spring.jms.template.priority=6", "spring.jms.template.time-to-live=6000",
 					"spring.jms.template.receive-timeout=2000")
@@ -261,6 +302,8 @@ void testJmsTemplateFullCustomization() {
 				JmsTemplate jmsTemplate = context.getBean(JmsTemplate.class);
 				assertThat(jmsTemplate.getMessageConverter()).isSameAs(context.getBean("myMessageConverter"));
 				assertThat(jmsTemplate.isPubSubDomain()).isFalse();
+				assertThat(jmsTemplate.getSessionAcknowledgeMode()).isEqualTo(Session.CLIENT_ACKNOWLEDGE);
+				assertThat(jmsTemplate.isSessionTransacted()).isTrue();
 				assertThat(jmsTemplate.getDefaultDestinationName()).isEqualTo("testQueue");
 				assertThat(jmsTemplate.getDeliveryDelay()).isEqualTo(500);
 				assertThat(jmsTemplate.getDeliveryMode()).isOne();
@@ -271,6 +314,16 @@ void testJmsTemplateFullCustomization() {
 			});
 	}
 
+	@Test
+	void testJmsTemplateWithNonStandardAcknowledgeMode() {
+		this.contextRunner.withUserConfiguration(EnableJmsConfiguration.class)
+			.withPropertyValues("spring.jms.template.session.acknowledge-mode=7")
+			.run((context) -> {
+				JmsTemplate jmsTemplate = context.getBean(JmsTemplate.class);
+				assertThat(jmsTemplate.getSessionAcknowledgeMode()).isEqualTo(7);
+			});
+	}
+
 	@Test
 	void testJmsMessagingTemplateUseConfiguredDefaultDestination() {
 		this.contextRunner.withPropertyValues("spring.jms.template.default-destination=testQueue").run((context) -> {
@@ -338,6 +391,17 @@ void enableJmsAutomatically() {
 				.hasBean(JmsListenerConfigUtils.JMS_LISTENER_ENDPOINT_REGISTRY_BEAN_NAME));
 	}
 
+	@Test
+	void runtimeHintsAreRegisteredForBindingOfAcknowledgeMode() {
+		try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
+			context.register(ArtemisAutoConfiguration.class, JmsAutoConfiguration.class);
+			TestGenerationContext generationContext = new TestGenerationContext();
+			new ApplicationContextAotGenerator().processAheadOfTime(context, generationContext);
+			assertThat(RuntimeHintsPredicates.reflection().onMethod(AcknowledgeMode.class, "of").invoke())
+				.accepts(generationContext.getRuntimeHints());
+		}
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class TestConfiguration {
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/JmsPropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/JmsPropertiesTests.java
index 7ddbecd5c8b2..32b93708dcae 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/JmsPropertiesTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/JmsPropertiesTests.java
@@ -40,8 +40,8 @@ void formatConcurrencyNull() {
 	@Test
 	void formatConcurrencyOnlyLowerBound() {
 		JmsProperties properties = new JmsProperties();
-		properties.getListener().setConcurrency(2);
-		assertThat(properties.getListener().formatConcurrency()).isEqualTo("2");
+		properties.getListener().setMinConcurrency(2);
+		assertThat(properties.getListener().formatConcurrency()).isEqualTo("2-2");
 	}
 
 	@Test
@@ -54,7 +54,7 @@ void formatConcurrencyOnlyHigherBound() {
 	@Test
 	void formatConcurrencyBothBounds() {
 		JmsProperties properties = new JmsProperties();
-		properties.getListener().setConcurrency(2);
+		properties.getListener().setMinConcurrency(2);
 		properties.getListener().setMaxConcurrency(10);
 		assertThat(properties.getListener().formatConcurrency()).isEqualTo("2-10");
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQAutoConfigurationTests.java
index 0edd4270c7ee..2815e3e2ff50 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQAutoConfigurationTests.java
@@ -40,6 +40,7 @@
  * @author Andy Wilkinson
  * @author Aurélien Leboulanger
  * @author Stephane Nicoll
+ * @author EddĂș MelĂ©ndez
  */
 class ActiveMQAutoConfigurationTests {
 
@@ -233,6 +234,27 @@ void cachingConnectionFactoryNotOnTheClasspathAndCacheEnabledThenSimpleConnectio
 				.doesNotHaveBean("jmsConnectionFactory"));
 	}
 
+	@Test
+	void definesPropertiesBasedConnectionDetailsByDefault() {
+		this.contextRunner.run((context) -> assertThat(context)
+			.hasSingleBean(ActiveMQAutoConfiguration.PropertiesActiveMQConnectionDetails.class));
+	}
+
+	@Test
+	void testConnectionFactoryWithOverridesWhenUsingCustomConnectionDetails() {
+		this.contextRunner.withClassLoader(new FilteredClassLoader(CachingConnectionFactory.class))
+			.withPropertyValues("spring.activemq.pool.enabled=false", "spring.jms.cache.enabled=false")
+			.withUserConfiguration(TestConnectionDetailsConfiguration.class)
+			.run((context) -> {
+				assertThat(context).hasSingleBean(ActiveMQConnectionDetails.class)
+					.doesNotHaveBean(ActiveMQAutoConfiguration.PropertiesActiveMQConnectionDetails.class);
+				ActiveMQConnectionFactory connectionFactory = context.getBean(ActiveMQConnectionFactory.class);
+				assertThat(connectionFactory.getBrokerURL()).isEqualTo("tcp://localhost:12345");
+				assertThat(connectionFactory.getUserName()).isEqualTo("springuser");
+				assertThat(connectionFactory.getPassword()).isEqualTo("spring");
+			});
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class EmptyConfiguration {
 
@@ -261,4 +283,31 @@ ActiveMQConnectionFactoryCustomizer activeMQConnectionFactoryCustomizer() {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class TestConnectionDetailsConfiguration {
+
+		@Bean
+		ActiveMQConnectionDetails activemqConnectionDetails() {
+			return new ActiveMQConnectionDetails() {
+
+				@Override
+				public String getBrokerUrl() {
+					return "tcp://localhost:12345";
+				}
+
+				@Override
+				public String getUser() {
+					return "springuser";
+				}
+
+				@Override
+				public String getPassword() {
+					return "spring";
+				}
+
+			};
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQPropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQPropertiesTests.java
index d6b66bc12390..a839b4d03d89 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQPropertiesTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQPropertiesTests.java
@@ -29,6 +29,7 @@
  * @author Stephane Nicoll
  * @author Aurélien Leboulanger
  * @author Venil Noronha
+ * @author EddĂș MelĂ©ndez
  */
 class ActiveMQPropertiesTests {
 
@@ -38,13 +39,13 @@ class ActiveMQPropertiesTests {
 
 	@Test
 	void getBrokerUrlIsLocalhostByDefault() {
-		assertThat(createFactory(this.properties).determineBrokerUrl()).isEqualTo(DEFAULT_NETWORK_BROKER_URL);
+		assertThat(this.properties.determineBrokerUrl()).isEqualTo(DEFAULT_NETWORK_BROKER_URL);
 	}
 
 	@Test
 	void getBrokerUrlUseExplicitBrokerUrl() {
 		this.properties.setBrokerUrl("tcp://activemq.example.com:71717");
-		assertThat(createFactory(this.properties).determineBrokerUrl()).isEqualTo("tcp://activemq.example.com:71717");
+		assertThat(this.properties.determineBrokerUrl()).isEqualTo("tcp://activemq.example.com:71717");
 	}
 
 	@Test
@@ -61,12 +62,13 @@ void setTrustedPackages() {
 		ActiveMQConnectionFactory factory = createFactory(this.properties)
 			.createConnectionFactory(ActiveMQConnectionFactory.class);
 		assertThat(factory.isTrustAllPackages()).isFalse();
-		assertThat(factory.getTrustedPackages().size()).isEqualTo(1);
+		assertThat(factory.getTrustedPackages()).hasSize(1);
 		assertThat(factory.getTrustedPackages().get(0)).isEqualTo("trusted.package");
 	}
 
 	private ActiveMQConnectionFactoryFactory createFactory(ActiveMQProperties properties) {
-		return new ActiveMQConnectionFactoryFactory(properties, Collections.emptyList());
+		return new ActiveMQConnectionFactoryFactory(properties, Collections.emptyList(),
+				new ActiveMQAutoConfiguration.PropertiesActiveMQConnectionDetails(properties));
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/artemis/ArtemisAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/artemis/ArtemisAutoConfigurationTests.java
index 2b362358b7f2..ec0c32031e73 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/artemis/ArtemisAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/artemis/ArtemisAutoConfigurationTests.java
@@ -46,6 +46,7 @@
 
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.jms.JmsAutoConfiguration;
+import org.springframework.boot.test.context.FilteredClassLoader;
 import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.context.ApplicationContext;
@@ -356,6 +357,20 @@ void poolConnectionFactoryConfiguration() {
 		});
 	}
 
+	@Test
+	void cachingConnectionFactoryNotOnTheClasspathThenSimpleConnectionFactoryAutoConfigured() {
+		this.contextRunner.withClassLoader(new FilteredClassLoader(CachingConnectionFactory.class))
+			.withPropertyValues("spring.artemis.pool.enabled=false", "spring.jms.cache.enabled=false")
+			.run((context) -> assertThat(context).hasSingleBean(ActiveMQConnectionFactory.class));
+	}
+
+	@Test
+	void cachingConnectionFactoryNotOnTheClasspathAndCacheEnabledThenSimpleConnectionFactoryNotConfigured() {
+		this.contextRunner.withClassLoader(new FilteredClassLoader(CachingConnectionFactory.class))
+			.withPropertyValues("spring.artemis.pool.enabled=false", "spring.jms.cache.enabled=true")
+			.run((context) -> assertThat(context).doesNotHaveBean(ActiveMQConnectionFactory.class));
+	}
+
 	private ConnectionFactory getConnectionFactory(AssertableApplicationContext context) {
 		assertThat(context).hasSingleBean(ConnectionFactory.class).hasBean("jmsConnectionFactory");
 		ConnectionFactory connectionFactory = context.getBean(ConnectionFactory.class);
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/ConcurrentKafkaListenerContainerFactoryConfigurerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/ConcurrentKafkaListenerContainerFactoryConfigurerTests.java
new file mode 100644
index 000000000000..779d9974d3f1
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/ConcurrentKafkaListenerContainerFactoryConfigurerTests.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.kafka;
+
+import java.util.function.Function;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.core.task.SimpleAsyncTaskExecutor;
+import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
+import org.springframework.kafka.core.ConsumerFactory;
+import org.springframework.kafka.listener.MessageListenerContainer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+
+/**
+ * Tests for {@link ConcurrentKafkaListenerContainerFactoryConfigurer}.
+ *
+ * @author Moritz Halbritter
+ */
+class ConcurrentKafkaListenerContainerFactoryConfigurerTests {
+
+	private ConcurrentKafkaListenerContainerFactoryConfigurer configurer;
+
+	private ConcurrentKafkaListenerContainerFactory<Object, Object> factory;
+
+	private ConsumerFactory<Object, Object> consumerFactory;
+
+	private KafkaProperties properties;
+
+	@BeforeEach
+	@SuppressWarnings("unchecked")
+	void setUp() {
+		this.configurer = new ConcurrentKafkaListenerContainerFactoryConfigurer();
+		this.properties = new KafkaProperties();
+		this.configurer.setKafkaProperties(this.properties);
+		this.factory = spy(new ConcurrentKafkaListenerContainerFactory<>());
+		this.consumerFactory = mock(ConsumerFactory.class);
+
+	}
+
+	@Test
+	void shouldApplyThreadNameSupplier() {
+		Function<MessageListenerContainer, String> function = (container) -> "thread-1";
+		this.configurer.setThreadNameSupplier(function);
+		this.configurer.configure(this.factory, this.consumerFactory);
+		then(this.factory).should().setThreadNameSupplier(function);
+	}
+
+	@Test
+	void shouldApplyChangeConsumerThreadName() {
+		this.properties.getListener().setChangeConsumerThreadName(true);
+		this.configurer.configure(this.factory, this.consumerFactory);
+		then(this.factory).should().setChangeConsumerThreadName(true);
+	}
+
+	@Test
+	void shouldApplyListenerTaskExecutor() {
+		SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor();
+		this.configurer.setListenerTaskExecutor(executor);
+		this.configurer.configure(this.factory, this.consumerFactory);
+		assertThat(this.factory.getContainerProperties().getListenerTaskExecutor()).isEqualTo(executor);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfigurationIntegrationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfigurationIntegrationTests.java
index 2b6d6e849a17..8b0668ffe8dd 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfigurationIntegrationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfigurationIntegrationTests.java
@@ -33,6 +33,7 @@
 import org.junit.jupiter.api.condition.DisabledOnOs;
 import org.junit.jupiter.api.condition.OS;
 
+import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration;
 import org.springframework.boot.test.util.TestPropertyValues;
 import org.springframework.context.annotation.AnnotationConfigApplicationContext;
 import org.springframework.context.annotation.Bean;
@@ -58,6 +59,7 @@
  * @author Gary Russell
  * @author Stephane Nicoll
  * @author Tomaz Fernandes
+ * @author Andy Wilkinson
  */
 @DisabledOnOs(OS.WINDOWS)
 @EmbeddedKafka(topics = KafkaAutoConfigurationIntegrationTests.TEST_TOPIC)
@@ -133,6 +135,7 @@ private void load(Class<?> config, String... environment) {
 	private AnnotationConfigApplicationContext doLoad(Class<?>[] configs, String... environment) {
 		AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext();
 		applicationContext.register(configs);
+		applicationContext.register(SslAutoConfiguration.class);
 		applicationContext.register(KafkaAutoConfiguration.class);
 		TestPropertyValues.of(environment).applyTo(applicationContext);
 		applicationContext.refresh();
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfigurationTests.java
index f274a3b51779..4adc774e8d7f 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfigurationTests.java
@@ -40,15 +40,21 @@
 import org.apache.kafka.streams.StreamsConfig;
 import org.assertj.core.api.InstanceOfAssertFactories;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.api.condition.JRE;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
 
 import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration;
 import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.boot.test.context.runner.ContextConsumer;
+import org.springframework.boot.testsupport.assertj.SimpleAsyncTaskExecutorAssert;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.core.task.AsyncTaskExecutor;
+import org.springframework.core.task.SimpleAsyncTaskExecutor;
 import org.springframework.kafka.annotation.EnableKafkaStreams;
 import org.springframework.kafka.annotation.KafkaStreamsDefaultConfiguration;
 import org.springframework.kafka.config.AbstractKafkaListenerContainerFactory;
@@ -102,11 +108,12 @@
  * @author Moritz Halbritter
  * @author Andy Wilkinson
  * @author Phillip Webb
+ * @author Scott Frederick
  */
 class KafkaAutoConfigurationTests {
 
 	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
-		.withConfiguration(AutoConfigurations.of(KafkaAutoConfiguration.class));
+		.withConfiguration(AutoConfigurations.of(KafkaAutoConfiguration.class, SslAutoConfiguration.class));
 
 	@Test
 	void consumerProperties() {
@@ -570,6 +577,31 @@ void streamsApplicationIdIsNotMandatoryIfEnableKafkaStreamsIsNotSet() {
 		});
 	}
 
+	@Test
+	void shouldUsePlatformThreadsByDefault() {
+		this.contextRunner.run((context) -> {
+			ConcurrentKafkaListenerContainerFactory<?, ?> factory = context
+				.getBean(ConcurrentKafkaListenerContainerFactory.class);
+			assertThat(factory).isNotNull();
+			AsyncTaskExecutor listenerTaskExecutor = factory.getContainerProperties().getListenerTaskExecutor();
+			assertThat(listenerTaskExecutor).isNull();
+		});
+	}
+
+	@Test
+	@EnabledForJreRange(min = JRE.JAVA_21)
+	void shouldUseVirtualThreadsIfEnabled() {
+		this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true").run((context) -> {
+			ConcurrentKafkaListenerContainerFactory<?, ?> factory = context
+				.getBean(ConcurrentKafkaListenerContainerFactory.class);
+			assertThat(factory).isNotNull();
+			AsyncTaskExecutor listenerTaskExecutor = factory.getContainerProperties().getListenerTaskExecutor();
+			assertThat(listenerTaskExecutor).isInstanceOf(SimpleAsyncTaskExecutor.class);
+			SimpleAsyncTaskExecutorAssert.assertThat((SimpleAsyncTaskExecutor) listenerTaskExecutor)
+				.usesVirtualThreads();
+		});
+	}
+
 	@SuppressWarnings("unchecked")
 	@Test
 	void listenerProperties() {
@@ -586,7 +618,8 @@ void listenerProperties() {
 					"spring.kafka.listener.missing-topics-fatal=true", "spring.kafka.jaas.enabled=true",
 					"spring.kafka.listener.immediate-stop=true", "spring.kafka.producer.transaction-id-prefix=foo",
 					"spring.kafka.jaas.login-module=foo", "spring.kafka.jaas.control-flag=REQUISITE",
-					"spring.kafka.jaas.options.useKeyTab=true", "spring.kafka.listener.async-acks=true")
+					"spring.kafka.jaas.options.useKeyTab=true", "spring.kafka.listener.async-acks=true",
+					"spring.kafka.template.observation-enabled=true", "spring.kafka.listener.observation-enabled=true")
 			.run((context) -> {
 				DefaultKafkaProducerFactory<?, ?> producerFactory = context.getBean(DefaultKafkaProducerFactory.class);
 				DefaultKafkaConsumerFactory<?, ?> consumerFactory = context.getBean(DefaultKafkaConsumerFactory.class);
@@ -597,6 +630,7 @@ void listenerProperties() {
 				assertThat(kafkaTemplate).hasFieldOrPropertyWithValue("producerFactory", producerFactory);
 				assertThat(kafkaTemplate.getDefaultTopic()).isEqualTo("testTopic");
 				assertThat(kafkaTemplate).hasFieldOrPropertyWithValue("transactionIdPrefix", "txOverride");
+				assertThat(kafkaTemplate).hasFieldOrPropertyWithValue("observationEnabled", true);
 				assertThat(kafkaListenerContainerFactory.getConsumerFactory()).isEqualTo(consumerFactory);
 				ContainerProperties containerProperties = kafkaListenerContainerFactory.getContainerProperties();
 				assertThat(containerProperties.getAckMode()).isEqualTo(AckMode.MANUAL);
@@ -613,6 +647,7 @@ void listenerProperties() {
 				assertThat(containerProperties.isLogContainerConfig()).isTrue();
 				assertThat(containerProperties.isMissingTopicsFatal()).isTrue();
 				assertThat(containerProperties.isStopImmediate()).isTrue();
+				assertThat(containerProperties.isObservationEnabled()).isTrue();
 				assertThat(kafkaListenerContainerFactory).extracting("concurrency").isEqualTo(3);
 				assertThat(kafkaListenerContainerFactory.isBatchListener()).isTrue();
 				assertThat(kafkaListenerContainerFactory).hasFieldOrPropertyWithValue("autoStartup", true);
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaPropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaPropertiesTests.java
index 8ee0486d857b..dbd53fd59d81 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaPropertiesTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaPropertiesTests.java
@@ -27,6 +27,8 @@
 import org.springframework.boot.autoconfigure.kafka.KafkaProperties.IsolationLevel;
 import org.springframework.boot.autoconfigure.kafka.KafkaProperties.Listener;
 import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException;
+import org.springframework.boot.ssl.DefaultSslBundleRegistry;
+import org.springframework.boot.ssl.SslBundle;
 import org.springframework.core.io.ClassPathResource;
 import org.springframework.kafka.core.CleanupConfig;
 import org.springframework.kafka.core.KafkaAdmin;
@@ -34,16 +36,19 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.Mockito.mock;
 
 /**
  * Tests for {@link KafkaProperties}.
  *
  * @author Stephane Nicoll
  * @author Madhura Bhave
+ * @author Scott Frederick
  */
 class KafkaPropertiesTests {
 
-	@SuppressWarnings("rawtypes")
+	private final SslBundle sslBundle = mock(SslBundle.class);
+
 	@Test
 	void isolationLevelEnumConsistentWithKafkaVersion() {
 		org.apache.kafka.common.IsolationLevel[] original = org.apache.kafka.common.IsolationLevel.values();
@@ -75,20 +80,30 @@ void sslPemConfiguration() {
 		properties.getSsl().setKeyStoreKey("-----BEGINkey");
 		properties.getSsl().setTrustStoreCertificates("-----BEGINtrust");
 		properties.getSsl().setKeyStoreCertificateChain("-----BEGINchain");
-		Map<String, Object> consumerProperties = properties.buildConsumerProperties();
+		Map<String, Object> consumerProperties = properties.buildConsumerProperties(null);
 		assertThat(consumerProperties).containsEntry(SslConfigs.SSL_KEYSTORE_KEY_CONFIG, "-----BEGINkey");
 		assertThat(consumerProperties).containsEntry(SslConfigs.SSL_TRUSTSTORE_CERTIFICATES_CONFIG, "-----BEGINtrust");
 		assertThat(consumerProperties).containsEntry(SslConfigs.SSL_KEYSTORE_CERTIFICATE_CHAIN_CONFIG,
 				"-----BEGINchain");
 	}
 
+	@Test
+	void sslBundleConfiguration() {
+		KafkaProperties properties = new KafkaProperties();
+		properties.getSsl().setBundle("myBundle");
+		Map<String, Object> consumerProperties = properties
+			.buildConsumerProperties(new DefaultSslBundleRegistry("myBundle", this.sslBundle));
+		assertThat(consumerProperties).containsEntry(SslConfigs.SSL_ENGINE_FACTORY_CLASS_CONFIG,
+				SslBundleSslEngineFactory.class.getName());
+	}
+
 	@Test
 	void sslPropertiesWhenKeyStoreLocationAndKeySetShouldThrowException() {
 		KafkaProperties properties = new KafkaProperties();
 		properties.getSsl().setKeyStoreKey("-----BEGIN");
 		properties.getSsl().setKeyStoreLocation(new ClassPathResource("ksLoc"));
 		assertThatExceptionOfType(MutuallyExclusiveConfigurationPropertiesException.class)
-			.isThrownBy(properties::buildConsumerProperties);
+			.isThrownBy(() -> properties.buildConsumerProperties(null));
 	}
 
 	@Test
@@ -97,7 +112,43 @@ void sslPropertiesWhenTrustStoreLocationAndCertificatesSetShouldThrowException()
 		properties.getSsl().setTrustStoreLocation(new ClassPathResource("tsLoc"));
 		properties.getSsl().setTrustStoreCertificates("-----BEGIN");
 		assertThatExceptionOfType(MutuallyExclusiveConfigurationPropertiesException.class)
-			.isThrownBy(properties::buildConsumerProperties);
+			.isThrownBy(() -> properties.buildConsumerProperties(null));
+	}
+
+	@Test
+	void sslPropertiesWhenKeyStoreLocationAndBundleSetShouldThrowException() {
+		KafkaProperties properties = new KafkaProperties();
+		properties.getSsl().setBundle("myBundle");
+		properties.getSsl().setKeyStoreLocation(new ClassPathResource("ksLoc"));
+		assertThatExceptionOfType(MutuallyExclusiveConfigurationPropertiesException.class).isThrownBy(
+				() -> properties.buildConsumerProperties(new DefaultSslBundleRegistry("myBundle", this.sslBundle)));
+	}
+
+	@Test
+	void sslPropertiesWhenKeyStoreKeyAndBundleSetShouldThrowException() {
+		KafkaProperties properties = new KafkaProperties();
+		properties.getSsl().setBundle("myBundle");
+		properties.getSsl().setKeyStoreKey("-----BEGIN");
+		assertThatExceptionOfType(MutuallyExclusiveConfigurationPropertiesException.class).isThrownBy(
+				() -> properties.buildConsumerProperties(new DefaultSslBundleRegistry("myBundle", this.sslBundle)));
+	}
+
+	@Test
+	void sslPropertiesWhenTrustStoreLocationAndBundleSetShouldThrowException() {
+		KafkaProperties properties = new KafkaProperties();
+		properties.getSsl().setBundle("myBundle");
+		properties.getSsl().setTrustStoreLocation(new ClassPathResource("tsLoc"));
+		assertThatExceptionOfType(MutuallyExclusiveConfigurationPropertiesException.class).isThrownBy(
+				() -> properties.buildConsumerProperties(new DefaultSslBundleRegistry("myBundle", this.sslBundle)));
+	}
+
+	@Test
+	void sslPropertiesWhenTrustStoreCertificatesAndBundleSetShouldThrowException() {
+		KafkaProperties properties = new KafkaProperties();
+		properties.getSsl().setBundle("myBundle");
+		properties.getSsl().setTrustStoreCertificates("-----BEGIN");
+		assertThatExceptionOfType(MutuallyExclusiveConfigurationPropertiesException.class).isThrownBy(
+				() -> properties.buildConsumerProperties(new DefaultSslBundleRegistry("myBundle", this.sslBundle)));
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseAutoConfigurationTests.java
index b05e9b0f010d..6a9490f5dd5b 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseAutoConfigurationTests.java
@@ -29,6 +29,8 @@
 import javax.sql.DataSource;
 
 import com.zaxxer.hikari.HikariDataSource;
+import liquibase.UpdateSummaryEnum;
+import liquibase.UpdateSummaryOutputEnum;
 import liquibase.integration.spring.SpringLiquibase;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -117,6 +119,9 @@ void defaultSpringLiquibase() {
 				assertThat(liquibase.getDefaultSchema()).isNull();
 				assertThat(liquibase.isDropFirst()).isFalse();
 				assertThat(liquibase.isClearCheckSums()).isFalse();
+				UpdateSummaryOutputEnum showSummaryOutput = (UpdateSummaryOutputEnum) ReflectionTestUtils
+					.getField(liquibase, "showSummaryOutput");
+				assertThat(showSummaryOutput).isEqualTo(UpdateSummaryOutputEnum.LOG);
 			}));
 	}
 
@@ -266,8 +271,12 @@ void overrideDropFirst() {
 
 	@Test
 	void overrideClearChecksums() {
+		String jdbcUrl = "jdbc:hsqldb:mem:liquibase" + UUID.randomUUID();
+		this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class)
+			.withPropertyValues("spring.liquibase.url:" + jdbcUrl)
+			.run((context) -> assertThat(context).hasNotFailed());
 		this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class)
-			.withPropertyValues("spring.liquibase.clear-checksums:true")
+			.withPropertyValues("spring.liquibase.clear-checksums:true", "spring.liquibase.url:" + jdbcUrl)
 			.run(assertLiquibase((liquibase) -> assertThat(liquibase.isClearCheckSums()).isTrue()));
 	}
 
@@ -380,11 +389,25 @@ void overrideLabelFilter() {
 	}
 
 	@Test
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	void overrideLabelFilterWithDeprecatedLabelsProperty() {
+	void overrideShowSummary() {
 		this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class)
-			.withPropertyValues("spring.liquibase.labels:test, production")
-			.run(assertLiquibase((liquibase) -> assertThat(liquibase.getLabelFilter()).isEqualTo("test, production")));
+			.withPropertyValues("spring.liquibase.show-summary=off")
+			.run(assertLiquibase((liquibase) -> {
+				UpdateSummaryEnum showSummary = (UpdateSummaryEnum) ReflectionTestUtils.getField(liquibase,
+						"showSummary");
+				assertThat(showSummary).isEqualTo(UpdateSummaryEnum.OFF);
+			}));
+	}
+
+	@Test
+	void overrideShowSummaryOutput() {
+		this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class)
+			.withPropertyValues("spring.liquibase.show-summary-output=all")
+			.run(assertLiquibase((liquibase) -> {
+				UpdateSummaryOutputEnum showSummaryOutput = (UpdateSummaryOutputEnum) ReflectionTestUtils
+					.getField(liquibase, "showSummaryOutput");
+				assertThat(showSummaryOutput).isEqualTo(UpdateSummaryOutputEnum.ALL);
+			}));
 	}
 
 	@Test
@@ -404,7 +427,7 @@ void testOverrideParameters() {
 	void rollbackFile(@TempDir Path temp) throws IOException {
 		File file = Files.createTempFile(temp, "rollback-file", "sql").toFile();
 		this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class)
-			.withPropertyValues("spring.liquibase.rollbackFile:" + file.getAbsolutePath())
+			.withPropertyValues("spring.liquibase.rollback-file:" + file.getAbsolutePath())
 			.run((context) -> {
 				SpringLiquibase liquibase = context.getBean(SpringLiquibase.class);
 				File actualFile = (File) ReflectionTestUtils.getField(liquibase, "rollbackFile");
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/logging/ConditionEvaluationReportLoggingListenerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/logging/ConditionEvaluationReportLoggingListenerTests.java
index 87b56eae87ef..ebeba985c06a 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/logging/ConditionEvaluationReportLoggingListenerTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/logging/ConditionEvaluationReportLoggingListenerTests.java
@@ -39,7 +39,7 @@
 import org.springframework.mock.web.MockServletContext;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatException;
 
 /**
  * Tests for {@link ConditionEvaluationReportLoggingListener}.
@@ -67,7 +67,7 @@ void logsDebugOnApplicationFailedEvent(CapturedOutput output) {
 		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
 		this.initializer.initialize(context);
 		context.register(ErrorConfig.class);
-		assertThatExceptionOfType(Exception.class).isThrownBy(context::refresh)
+		assertThatException().isThrownBy(context::refresh)
 			.satisfies((ex) -> withDebugLogging(() -> context
 				.publishEvent(new ApplicationFailedEvent(new SpringApplication(), new String[0], context, ex))));
 		assertThat(output).contains("CONDITIONS EVALUATION REPORT");
@@ -78,7 +78,7 @@ void logsInfoGuidanceToEnableDebugLoggingOnApplicationFailedEvent(CapturedOutput
 		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
 		this.initializer.initialize(context);
 		context.register(ErrorConfig.class);
-		assertThatExceptionOfType(Exception.class).isThrownBy(context::refresh)
+		assertThatException().isThrownBy(context::refresh)
 			.satisfies((ex) -> withInfoLogging(() -> context
 				.publishEvent(new ApplicationFailedEvent(new SpringApplication(), new String[0], context, ex))));
 		assertThat(output).doesNotContain("CONDITIONS EVALUATION REPORT")
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mongo/MongoClientFactorySupportTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mongo/MongoClientFactorySupportTests.java
index 87861cf2d0b7..d596a1ff92c8 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mongo/MongoClientFactorySupportTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mongo/MongoClientFactorySupportTests.java
@@ -43,7 +43,7 @@
  * @author Mark Paluch
  * @author Artsiom Yudovin
  * @author Scott Frederick
- * @author Mortiz Halbritter
+ * @author Moritz Halbritter
  */
 abstract class MongoClientFactorySupportTests<T> {
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mongo/MongoReactiveAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mongo/MongoReactiveAutoConfigurationTests.java
index 7f3fe1ef0b12..df46e4b45933 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mongo/MongoReactiveAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mongo/MongoReactiveAutoConfigurationTests.java
@@ -23,11 +23,9 @@
 import com.mongodb.MongoClientSettings;
 import com.mongodb.MongoCredential;
 import com.mongodb.ReadPreference;
-import com.mongodb.connection.AsynchronousSocketChannelStreamFactoryFactory;
+import com.mongodb.connection.NettyTransportSettings;
 import com.mongodb.connection.SslSettings;
-import com.mongodb.connection.StreamFactory;
-import com.mongodb.connection.StreamFactoryFactory;
-import com.mongodb.connection.netty.NettyStreamFactoryFactory;
+import com.mongodb.connection.TransportSettings;
 import com.mongodb.reactivestreams.client.MongoClient;
 import com.mongodb.reactivestreams.client.internal.MongoClientImpl;
 import io.netty.channel.EventLoopGroup;
@@ -39,12 +37,8 @@
 import org.springframework.context.ApplicationContext;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
-import org.springframework.test.util.ReflectionTestUtils;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.BDDMockito.given;
-import static org.mockito.Mockito.mock;
 
 /**
  * Tests for {@link MongoReactiveAutoConfiguration}.
@@ -85,7 +79,7 @@ void settingsSslConfig() {
 				assertThat(context).hasSingleBean(MongoClient.class);
 				MongoClientSettings settings = getSettings(context);
 				assertThat(settings.getApplicationName()).isEqualTo("test-config");
-				assertThat(settings.getStreamFactoryFactory()).isSameAs(context.getBean("myStreamFactoryFactory"));
+				assertThat(settings.getTransportSettings()).isSameAs(context.getBean("myTransportSettings"));
 			});
 	}
 
@@ -212,13 +206,13 @@ void configuresCredentialsFromUriPropertyWithAuthDatabase() {
 	}
 
 	@Test
-	void nettyStreamFactoryFactoryIsConfiguredAutomatically() {
+	void nettyTransportSettingsAreConfiguredAutomatically() {
 		AtomicReference<EventLoopGroup> eventLoopGroupReference = new AtomicReference<>();
 		this.contextRunner.run((context) -> {
 			assertThat(context).hasSingleBean(MongoClient.class);
-			StreamFactoryFactory factory = getSettings(context).getStreamFactoryFactory();
-			assertThat(factory).isInstanceOf(NettyStreamFactoryFactory.class);
-			EventLoopGroup eventLoopGroup = (EventLoopGroup) ReflectionTestUtils.getField(factory, "eventLoopGroup");
+			TransportSettings transportSettings = getSettings(context).getTransportSettings();
+			assertThat(transportSettings).isInstanceOf(NettyTransportSettings.class);
+			EventLoopGroup eventLoopGroup = ((NettyTransportSettings) transportSettings).getEventLoopGroup();
 			assertThat(eventLoopGroup.isShutdown()).isFalse();
 			eventLoopGroupReference.set(eventLoopGroup);
 		});
@@ -226,14 +220,17 @@ void nettyStreamFactoryFactoryIsConfiguredAutomatically() {
 	}
 
 	@Test
-	void customizerOverridesAutoConfig() {
+	@SuppressWarnings("deprecation")
+	void customizerWithTransportSettingsOverridesAutoConfig() {
 		this.contextRunner.withPropertyValues("spring.data.mongodb.uri:mongodb://localhost/test?appname=auto-config")
-			.withUserConfiguration(SimpleCustomizerConfig.class)
+			.withUserConfiguration(SimpleTransportSettingsCustomizerConfig.class)
 			.run((context) -> {
 				assertThat(context).hasSingleBean(MongoClient.class);
 				MongoClientSettings settings = getSettings(context);
-				assertThat(settings.getApplicationName()).isEqualTo("overridden-name");
-				assertThat(settings.getStreamFactoryFactory()).isEqualTo(SimpleCustomizerConfig.streamFactoryFactory);
+				assertThat(settings.getApplicationName()).isEqualTo("custom-transport-settings");
+				assertThat(settings.getTransportSettings())
+					.isSameAs(SimpleTransportSettingsCustomizerConfig.transportSettings);
+				assertThat(settings.getStreamFactoryFactory()).isNull();
 			});
 	}
 
@@ -278,32 +275,29 @@ MongoClientSettings mongoClientSettings() {
 	static class SslSettingsConfig {
 
 		@Bean
-		MongoClientSettings mongoClientSettings(StreamFactoryFactory streamFactoryFactory) {
+		MongoClientSettings mongoClientSettings(TransportSettings transportSettings) {
 			return MongoClientSettings.builder()
 				.applicationName("test-config")
-				.streamFactoryFactory(streamFactoryFactory)
+				.transportSettings(transportSettings)
 				.build();
 		}
 
 		@Bean
-		StreamFactoryFactory myStreamFactoryFactory() {
-			StreamFactoryFactory streamFactoryFactory = mock(StreamFactoryFactory.class);
-			given(streamFactoryFactory.create(any(), any())).willReturn(mock(StreamFactory.class));
-			return streamFactoryFactory;
+		TransportSettings myTransportSettings() {
+			return TransportSettings.nettyBuilder().build();
 		}
 
 	}
 
 	@Configuration(proxyBeanMethods = false)
-	static class SimpleCustomizerConfig {
+	static class SimpleTransportSettingsCustomizerConfig {
 
-		private static final StreamFactoryFactory streamFactoryFactory = new AsynchronousSocketChannelStreamFactoryFactory.Builder()
-			.build();
+		private static final TransportSettings transportSettings = TransportSettings.nettyBuilder().build();
 
 		@Bean
 		MongoClientSettingsBuilderCustomizer customizer() {
-			return (clientSettingsBuilder) -> clientSettingsBuilder.applicationName("overridden-name")
-				.streamFactoryFactory(streamFactoryFactory);
+			return (clientSettingsBuilder) -> clientSettingsBuilder.applicationName("custom-transport-settings")
+				.transportSettings(transportSettings);
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfigurationIntegrationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfigurationIntegrationTests.java
index c8febbbe6056..e6cbbe3605eb 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfigurationIntegrationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfigurationIntegrationTests.java
@@ -16,9 +16,15 @@
 
 package org.springframework.boot.autoconfigure.neo4j;
 
+import java.net.URI;
 import java.time.Duration;
 
+import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
+import org.neo4j.driver.AuthToken;
+import org.neo4j.driver.AuthTokenManager;
+import org.neo4j.driver.AuthTokenManagers;
+import org.neo4j.driver.AuthTokens;
 import org.neo4j.driver.Driver;
 import org.neo4j.driver.Result;
 import org.neo4j.driver.Session;
@@ -31,6 +37,7 @@
 import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
 import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
+import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.test.context.DynamicPropertyRegistry;
 import org.springframework.test.context.DynamicPropertySource;
@@ -43,7 +50,6 @@
  * @author Michael J. Simons
  * @author Stephane Nicoll
  */
-@SpringBootTest
 @Testcontainers(disabledWithoutDocker = true)
 class Neo4jAutoConfigurationIntegrationTests {
 
@@ -52,28 +58,125 @@ class Neo4jAutoConfigurationIntegrationTests {
 		.withStartupAttempts(5)
 		.withStartupTimeout(Duration.ofMinutes(10));
 
-	@DynamicPropertySource
-	static void neo4jProperties(DynamicPropertyRegistry registry) {
-		registry.add("spring.neo4j.uri", neo4jServer::getBoltUrl);
-		registry.add("spring.neo4j.authentication.username", () -> "neo4j");
-		registry.add("spring.neo4j.authentication.password", neo4jServer::getAdminPassword);
+	@SpringBootTest
+	@Nested
+	class DriverWithDefaultAuthToken {
+
+		@DynamicPropertySource
+		static void neo4jProperties(DynamicPropertyRegistry registry) {
+			registry.add("spring.neo4j.uri", neo4jServer::getBoltUrl);
+			registry.add("spring.neo4j.authentication.username", () -> "neo4j");
+			registry.add("spring.neo4j.authentication.password", neo4jServer::getAdminPassword);
+		}
+
+		@Autowired
+		private Driver driver;
+
+		@Test
+		void driverCanHandleRequest() {
+			try (Session session = this.driver.session(); Transaction tx = session.beginTransaction()) {
+				Result statementResult = tx.run("MATCH (n:Thing) RETURN n LIMIT 1");
+				assertThat(statementResult.hasNext()).isFalse();
+				tx.commit();
+			}
+		}
+
+		@Configuration(proxyBeanMethods = false)
+		@ImportAutoConfiguration(Neo4jAutoConfiguration.class)
+		static class TestConfiguration {
+
+		}
+
 	}
 
-	@Autowired
-	private Driver driver;
+	@SpringBootTest
+	@Nested
+	class DriverWithDynamicAuthToken {
+
+		@DynamicPropertySource
+		static void neo4jProperties(DynamicPropertyRegistry registry) {
+			registry.add("spring.neo4j.uri", neo4jServer::getBoltUrl);
+			registry.add("spring.neo4j.authentication.username", () -> "wrong");
+			registry.add("spring.neo4j.authentication.password", () -> "alsowrong");
+		}
+
+		@Autowired
+		private Driver driver;
+
+		@Test
+		void driverCanHandleRequest() {
+			try (Session session = this.driver.session(); Transaction tx = session.beginTransaction()) {
+				Result statementResult = tx.run("MATCH (n:Thing) RETURN n LIMIT 1");
+				assertThat(statementResult.hasNext()).isFalse();
+				tx.commit();
+			}
+		}
+
+		@Configuration(proxyBeanMethods = false)
+		@ImportAutoConfiguration(Neo4jAutoConfiguration.class)
+		static class TestConfiguration {
+
+			@Bean
+			AuthTokenManager authTokenManager() {
+				return AuthTokenManagers.bearer(() -> AuthTokens.basic("neo4j", neo4jServer.getAdminPassword())
+					.expiringAt(System.currentTimeMillis() + 5_000));
+			}
 
-	@Test
-	void driverCanHandleRequest() {
-		try (Session session = this.driver.session(); Transaction tx = session.beginTransaction()) {
-			Result statementResult = tx.run("MATCH (n:Thing) RETURN n LIMIT 1");
-			assertThat(statementResult.hasNext()).isFalse();
-			tx.commit();
 		}
+
 	}
 
-	@Configuration(proxyBeanMethods = false)
-	@ImportAutoConfiguration(Neo4jAutoConfiguration.class)
-	static class TestConfiguration {
+	@SpringBootTest
+	@Nested
+	class DriverWithCustomConnectionDetailsIgnoresAuthTokenManager {
+
+		@DynamicPropertySource
+		static void neo4jProperties(DynamicPropertyRegistry registry) {
+			registry.add("spring.neo4j.uri", neo4jServer::getBoltUrl);
+			registry.add("spring.neo4j.authentication.username", () -> "wrong");
+			registry.add("spring.neo4j.authentication.password", () -> "alsowrong");
+		}
+
+		@Autowired
+		private Driver driver;
+
+		@Test
+		void driverCanHandleRequest() {
+			try (Session session = this.driver.session(); Transaction tx = session.beginTransaction()) {
+				Result statementResult = tx.run("MATCH (n:Thing) RETURN n LIMIT 1");
+				assertThat(statementResult.hasNext()).isFalse();
+				tx.commit();
+			}
+		}
+
+		@Configuration(proxyBeanMethods = false)
+		@ImportAutoConfiguration(Neo4jAutoConfiguration.class)
+		static class TestConfiguration {
+
+			@Bean
+			AuthTokenManager authTokenManager() {
+				return AuthTokenManagers.bearer(() -> AuthTokens.basic("wrongagain", "stillwrong")
+					.expiringAt(System.currentTimeMillis() + 5_000));
+			}
+
+			@Bean
+			Neo4jConnectionDetails connectionDetails() {
+				return new Neo4jConnectionDetails() {
+
+					@Override
+					public URI getUri() {
+						return URI.create(neo4jServer.getBoltUrl());
+					}
+
+					@Override
+					public AuthToken getAuthToken() {
+						return AuthTokens.basic("neo4j", neo4jServer.getAdminPassword());
+					}
+
+				};
+			}
+
+		}
 
 	}
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfigurationTests.java
index 0bb4a6e25015..1a05936d3c65 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfigurationTests.java
@@ -26,6 +26,7 @@
 import org.junit.jupiter.api.io.TempDir;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
+import org.neo4j.driver.AuthTokenManagers;
 import org.neo4j.driver.AuthTokens;
 import org.neo4j.driver.Config;
 import org.neo4j.driver.Config.ConfigBuilder;
@@ -38,7 +39,6 @@
 import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException;
 import org.springframework.boot.test.context.FilteredClassLoader;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
-import org.springframework.context.annotation.Bean;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -110,7 +110,7 @@ void definesPropertiesBasedConnectionDetailsByDefault() {
 		this.contextRunner.run((context) -> assertThat(context).hasSingleBean(PropertiesNeo4jConnectionDetails.class));
 	}
 
-	@Bean
+	@Test
 	void shouldUseCustomConnectionDetailsWhenDefined() {
 		this.contextRunner.withBean(Neo4jConnectionDetails.class, () -> new Neo4jConnectionDetails() {
 
@@ -144,7 +144,7 @@ void maxTransactionRetryTime() {
 
 	@Test
 	void uriShouldDefaultToLocalhost() {
-		assertThat(new PropertiesNeo4jConnectionDetails(new Neo4jProperties()).getUri())
+		assertThat(new PropertiesNeo4jConnectionDetails(new Neo4jProperties(), null).getUri())
 			.isEqualTo(URI.create("bolt://localhost:7687"));
 	}
 
@@ -153,12 +153,12 @@ void determineServerUriWithCustomUriShouldOverrideDefault() {
 		URI customUri = URI.create("bolt://localhost:4242");
 		Neo4jProperties properties = new Neo4jProperties();
 		properties.setUri(customUri);
-		assertThat(new PropertiesNeo4jConnectionDetails(properties).getUri()).isEqualTo(customUri);
+		assertThat(new PropertiesNeo4jConnectionDetails(properties, null).getUri()).isEqualTo(customUri);
 	}
 
 	@Test
 	void authenticationShouldDefaultToNone() {
-		assertThat(new PropertiesNeo4jConnectionDetails(new Neo4jProperties()).getAuthToken())
+		assertThat(new PropertiesNeo4jConnectionDetails(new Neo4jProperties(), null).getAuthToken())
 			.isEqualTo(AuthTokens.none());
 	}
 
@@ -167,8 +167,9 @@ void authenticationWithUsernameShouldEnableBasicAuth() {
 		Neo4jProperties properties = new Neo4jProperties();
 		properties.getAuthentication().setUsername("Farin");
 		properties.getAuthentication().setPassword("Urlaub");
-		assertThat(new PropertiesNeo4jConnectionDetails(properties).getAuthToken())
-			.isEqualTo(AuthTokens.basic("Farin", "Urlaub"));
+		PropertiesNeo4jConnectionDetails connectionDetails = new PropertiesNeo4jConnectionDetails(properties, null);
+		assertThat(connectionDetails.getAuthToken()).isEqualTo(AuthTokens.basic("Farin", "Urlaub"));
+		assertThat(connectionDetails.getAuthTokenManager()).isNull();
 	}
 
 	@Test
@@ -178,8 +179,22 @@ void authenticationWithUsernameAndRealmShouldEnableBasicAuth() {
 		authentication.setUsername("Farin");
 		authentication.setPassword("Urlaub");
 		authentication.setRealm("Test Realm");
-		assertThat(new PropertiesNeo4jConnectionDetails(properties).getAuthToken())
-			.isEqualTo(AuthTokens.basic("Farin", "Urlaub", "Test Realm"));
+		PropertiesNeo4jConnectionDetails connectionDetails = new PropertiesNeo4jConnectionDetails(properties, null);
+		assertThat(connectionDetails.getAuthToken()).isEqualTo(AuthTokens.basic("Farin", "Urlaub", "Test Realm"));
+		assertThat(connectionDetails.getAuthTokenManager()).isNull();
+	}
+
+	@Test
+	void authenticationWithAuthTokenManagerAndUsernameShouldProvideAuthTokenManger() {
+		Neo4jProperties properties = new Neo4jProperties();
+		Authentication authentication = properties.getAuthentication();
+		authentication.setUsername("Farin");
+		authentication.setPassword("Urlaub");
+		authentication.setRealm("Test Realm");
+		assertThat(new PropertiesNeo4jConnectionDetails(properties,
+				AuthTokenManagers.bearer(
+						() -> AuthTokens.basic("username", "password").expiringAt(System.currentTimeMillis() + 5000)))
+			.getAuthTokenManager()).isNotNull();
 	}
 
 	@Test
@@ -187,7 +202,7 @@ void authenticationWithKerberosTicketShouldEnableKerberos() {
 		Neo4jProperties properties = new Neo4jProperties();
 		Authentication authentication = properties.getAuthentication();
 		authentication.setKerberosTicket("AABBCCDDEE");
-		assertThat(new PropertiesNeo4jConnectionDetails(properties).getAuthToken())
+		assertThat(new PropertiesNeo4jConnectionDetails(properties, null).getAuthToken())
 			.isEqualTo(AuthTokens.kerberos("AABBCCDDEE"));
 	}
 
@@ -198,7 +213,7 @@ void authenticationWithBothUsernameAndKerberosShouldNotBeAllowed() {
 		authentication.setUsername("Farin");
 		authentication.setKerberosTicket("AABBCCDDEE");
 		assertThatIllegalStateException()
-			.isThrownBy(() -> new PropertiesNeo4jConnectionDetails(properties).getAuthToken())
+			.isThrownBy(() -> new PropertiesNeo4jConnectionDetails(properties, null).getAuthToken())
 			.withMessage("Cannot specify both username ('Farin') and kerberos ticket ('AABBCCDDEE')");
 	}
 
@@ -314,7 +329,7 @@ void driverConfigShouldBeConfiguredToUseUseSpringJclLogging() {
 
 	private Config mapDriverConfig(Neo4jProperties properties, ConfigBuilderCustomizer... customizers) {
 		return new Neo4jAutoConfiguration().mapDriverConfig(properties,
-				new PropertiesNeo4jConnectionDetails(properties), Arrays.asList(customizers));
+				new PropertiesNeo4jConnectionDetails(properties, null), Arrays.asList(customizers));
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/orm/jpa/AbstractJpaAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/orm/jpa/AbstractJpaAutoConfigurationTests.java
index 38909c284b3a..869607e7e875 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/orm/jpa/AbstractJpaAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/orm/jpa/AbstractJpaAutoConfigurationTests.java
@@ -39,6 +39,7 @@
 import org.springframework.boot.autoconfigure.orm.jpa.test.City;
 import org.springframework.boot.autoconfigure.sql.init.SqlInitializationAutoConfiguration;
 import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration;
+import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizationAutoConfiguration;
 import org.springframework.boot.jdbc.DataSourceBuilder;
 import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
@@ -82,7 +83,8 @@ protected AbstractJpaAutoConfigurationTests(Class<?> autoConfiguredClass) {
 					"spring.jta.log-dir=" + new File(new BuildOutput(getClass()).getRootLocation(), "transaction-logs"))
 			.withUserConfiguration(TestConfiguration.class)
 			.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class,
-					TransactionAutoConfiguration.class, SqlInitializationAutoConfiguration.class, autoConfiguredClass));
+					TransactionAutoConfiguration.class, TransactionManagerCustomizationAutoConfiguration.class,
+					SqlInitializationAutoConfiguration.class, autoConfiguredClass));
 	}
 
 	protected ApplicationContextRunner contextRunner() {
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaAutoConfigurationTests.java
index d1ddd7e00972..b5c385699f95 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaAutoConfigurationTests.java
@@ -40,7 +40,8 @@
 import org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy;
 import org.hibernate.boot.model.naming.ImplicitNamingStrategy;
 import org.hibernate.boot.model.naming.PhysicalNamingStrategy;
-import org.hibernate.cfg.AvailableSettings;
+import org.hibernate.cfg.ManagedBeanSettings;
+import org.hibernate.cfg.SchemaToolingSettings;
 import org.hibernate.dialect.H2Dialect;
 import org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform;
 import org.hibernate.engine.transaction.jta.platform.spi.JtaPlatform;
@@ -129,7 +130,8 @@ void testDmlScript() {
 	void testDmlScriptRunsEarly() {
 		contextRunner().withUserConfiguration(TestInitializedJpaConfiguration.class)
 			.withClassLoader(new HideDataScriptClassLoader())
-			.withPropertyValues("spring.jpa.show-sql=true", "spring.jpa.hibernate.ddl-auto:create-drop",
+			.withPropertyValues("spring.jpa.show-sql=true", "spring.jpa.properties.hibernate.format_sql=true",
+					"spring.jpa.properties.hibernate.highlight_sql=true", "spring.jpa.hibernate.ddl-auto:create-drop",
 					"spring.sql.init.data-locations:/city.sql", "spring.jpa.defer-datasource-initialization=true")
 			.run((context) -> assertThat(context.getBean(TestInitializedJpaConfiguration.class).called).isTrue());
 	}
@@ -153,7 +155,7 @@ void testFlywayPlusValidation() {
 	@Test
 	void testLiquibasePlusValidation() {
 		contextRunner()
-			.withPropertyValues("spring.liquibase.changeLog:classpath:db/changelog/db.changelog-city.yaml",
+			.withPropertyValues("spring.liquibase.change-log:classpath:db/changelog/db.changelog-city.yaml",
 					"spring.jpa.hibernate.ddl-auto:validate")
 			.withConfiguration(AutoConfigurations.of(LiquibaseAutoConfiguration.class))
 			.run((context) -> assertThat(context).hasNotFailed());
@@ -386,8 +388,8 @@ void hibernatePropertiesCustomizerCanDisableBeanContainer() {
 	@Test
 	void vendorPropertiesWithEmbeddedDatabaseAndNoDdlProperty() {
 		contextRunner().run(vendorProperties((vendorProperties) -> {
-			assertThat(vendorProperties).doesNotContainKeys(AvailableSettings.JAKARTA_HBM2DDL_DATABASE_ACTION);
-			assertThat(vendorProperties).containsEntry(AvailableSettings.HBM2DDL_AUTO, "create-drop");
+			assertThat(vendorProperties).doesNotContainKeys(SchemaToolingSettings.JAKARTA_HBM2DDL_DATABASE_ACTION);
+			assertThat(vendorProperties).containsEntry(SchemaToolingSettings.HBM2DDL_AUTO, "create-drop");
 		}));
 	}
 
@@ -395,8 +397,8 @@ void vendorPropertiesWithEmbeddedDatabaseAndNoDdlProperty() {
 	void vendorPropertiesWhenDdlAutoPropertyIsSet() {
 		contextRunner().withPropertyValues("spring.jpa.hibernate.ddl-auto=update")
 			.run(vendorProperties((vendorProperties) -> {
-				assertThat(vendorProperties).doesNotContainKeys(AvailableSettings.JAKARTA_HBM2DDL_DATABASE_ACTION);
-				assertThat(vendorProperties).containsEntry(AvailableSettings.HBM2DDL_AUTO, "update");
+				assertThat(vendorProperties).doesNotContainKeys(SchemaToolingSettings.JAKARTA_HBM2DDL_DATABASE_ACTION);
+				assertThat(vendorProperties).containsEntry(SchemaToolingSettings.HBM2DDL_AUTO, "update");
 			}));
 	}
 
@@ -406,8 +408,8 @@ void vendorPropertiesWhenDdlAutoPropertyAndHibernatePropertiesAreSet() {
 			.withPropertyValues("spring.jpa.hibernate.ddl-auto=update",
 					"spring.jpa.properties.hibernate.hbm2ddl.auto=create-drop")
 			.run(vendorProperties((vendorProperties) -> {
-				assertThat(vendorProperties).doesNotContainKeys(AvailableSettings.JAKARTA_HBM2DDL_DATABASE_ACTION);
-				assertThat(vendorProperties).containsEntry(AvailableSettings.HBM2DDL_AUTO, "create-drop");
+				assertThat(vendorProperties).doesNotContainKeys(SchemaToolingSettings.JAKARTA_HBM2DDL_DATABASE_ACTION);
+				assertThat(vendorProperties).containsEntry(SchemaToolingSettings.HBM2DDL_AUTO, "create-drop");
 			}));
 	}
 
@@ -415,7 +417,7 @@ void vendorPropertiesWhenDdlAutoPropertyAndHibernatePropertiesAreSet() {
 	void vendorPropertiesWhenDdlAutoPropertyIsSetToNone() {
 		contextRunner().withPropertyValues("spring.jpa.hibernate.ddl-auto=none")
 			.run(vendorProperties((vendorProperties) -> assertThat(vendorProperties).doesNotContainKeys(
-					AvailableSettings.JAKARTA_HBM2DDL_DATABASE_ACTION, AvailableSettings.HBM2DDL_AUTO)));
+					SchemaToolingSettings.JAKARTA_HBM2DDL_DATABASE_ACTION, SchemaToolingSettings.HBM2DDL_AUTO)));
 	}
 
 	@Test
@@ -423,8 +425,9 @@ void vendorPropertiesWhenJpaDdlActionIsSet() {
 		contextRunner()
 			.withPropertyValues("spring.jpa.properties.jakarta.persistence.schema-generation.database.action=create")
 			.run(vendorProperties((vendorProperties) -> {
-				assertThat(vendorProperties).containsEntry(AvailableSettings.JAKARTA_HBM2DDL_DATABASE_ACTION, "create");
-				assertThat(vendorProperties).doesNotContainKeys(AvailableSettings.HBM2DDL_AUTO);
+				assertThat(vendorProperties).containsEntry(SchemaToolingSettings.JAKARTA_HBM2DDL_DATABASE_ACTION,
+						"create");
+				assertThat(vendorProperties).doesNotContainKeys(SchemaToolingSettings.HBM2DDL_AUTO);
 			}));
 	}
 
@@ -434,8 +437,9 @@ void vendorPropertiesWhenBothDdlAutoPropertiesAreSet() {
 			.withPropertyValues("spring.jpa.properties.jakarta.persistence.schema-generation.database.action=create",
 					"spring.jpa.hibernate.ddl-auto=create-only")
 			.run(vendorProperties((vendorProperties) -> {
-				assertThat(vendorProperties).containsEntry(AvailableSettings.JAKARTA_HBM2DDL_DATABASE_ACTION, "create");
-				assertThat(vendorProperties).containsEntry(AvailableSettings.HBM2DDL_AUTO, "create-only");
+				assertThat(vendorProperties).containsEntry(SchemaToolingSettings.JAKARTA_HBM2DDL_DATABASE_ACTION,
+						"create");
+				assertThat(vendorProperties).containsEntry(SchemaToolingSettings.HBM2DDL_AUTO, "create-only");
 			}));
 	}
 
@@ -570,7 +574,7 @@ static class DisableBeanContainerConfiguration {
 
 		@Bean
 		HibernatePropertiesCustomizer disableBeanContainerHibernatePropertiesCustomizer() {
-			return (hibernateProperties) -> hibernateProperties.remove(AvailableSettings.BEAN_CONTAINER);
+			return (hibernateProperties) -> hibernateProperties.remove(ManagedBeanSettings.BEAN_CONTAINER);
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/Customizers.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/Customizers.java
new file mode 100644
index 000000000000..7f8de3556175
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/Customizers.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.pulsar;
+
+import java.util.List;
+import java.util.function.BiConsumer;
+
+import org.assertj.core.api.AssertDelegateTarget;
+import org.mockito.InOrder;
+
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Test utility used to check customizers are called correctly.
+ *
+ * @param <C> the customizer type
+ * @param <T> the target class that is customized
+ * @author Phillip Webb
+ * @author Chris Bono
+ */
+final class Customizers<C, T> {
+
+	private final BiConsumer<C, T> customizeAction;
+
+	private final Class<T> targetClass;
+
+	@SuppressWarnings("unchecked")
+	private Customizers(Class<?> targetClass, BiConsumer<C, T> customizeAction) {
+		this.customizeAction = customizeAction;
+		this.targetClass = (Class<T>) targetClass;
+	}
+
+	/**
+	 * Create an instance by getting the value from a field.
+	 * @param source the source to extract the customizers from
+	 * @param fieldName the field name
+	 * @return a new {@link CustomizersAssert} instance
+	 */
+	@SuppressWarnings("unchecked")
+	CustomizersAssert fromField(Object source, String fieldName) {
+		return new CustomizersAssert(ReflectionTestUtils.getField(source, fieldName));
+	}
+
+	/**
+	 * Create a new {@link Customizers} instance.
+	 * @param <C> the customizer class
+	 * @param <T> the target class that is customized
+	 * @param targetClass the target class that is customized
+	 * @param customizeAction the customizer action to take
+	 * @return a new {@link Customizers} instance
+	 */
+	static <C, T> Customizers<C, T> of(Class<?> targetClass, BiConsumer<C, T> customizeAction) {
+		return new Customizers<>(targetClass, customizeAction);
+	}
+
+	/**
+	 * Assertions that can be applied to customizers.
+	 */
+	final class CustomizersAssert implements AssertDelegateTarget {
+
+		private final List<C> customizers;
+
+		@SuppressWarnings("unchecked")
+		private CustomizersAssert(Object customizers) {
+			this.customizers = (customizers instanceof List) ? (List<C>) customizers : List.of((C) customizers);
+		}
+
+		/**
+		 * Assert that the customize method is called in a specified order. It is expected
+		 * that each customizer has set a unique value so the expected values can be used
+		 * as a verify step.
+		 * @param <V> the value type
+		 * @param call the call the customizer makes
+		 * @param expectedValues the expected values
+		 */
+		@SuppressWarnings("unchecked")
+		<V> void callsInOrder(BiConsumer<T, V> call, V... expectedValues) {
+			T target = mock(Customizers.this.targetClass);
+			BiConsumer<C, T> customizeAction = Customizers.this.customizeAction;
+			this.customizers.forEach((customizer) -> customizeAction.accept(customizer, target));
+			InOrder ordered = inOrder(target);
+			for (V expectedValue : expectedValues) {
+				call.accept(ordered.verify(target), expectedValue);
+			}
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/DeadLetterPolicyMapperTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/DeadLetterPolicyMapperTests.java
new file mode 100644
index 000000000000..afc18050f64b
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/DeadLetterPolicyMapperTests.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.pulsar;
+
+import org.apache.pulsar.client.api.DeadLetterPolicy;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+
+/**
+ * Tests for {@link DeadLetterPolicyMapper}.
+ *
+ * @author Chris Bono
+ * @author Phillip Webb
+ */
+class DeadLetterPolicyMapperTests {
+
+	@Test
+	void map() {
+		PulsarProperties.Consumer.DeadLetterPolicy properties = new PulsarProperties.Consumer.DeadLetterPolicy();
+		properties.setMaxRedeliverCount(100);
+		properties.setRetryLetterTopic("my-retry-topic");
+		properties.setDeadLetterTopic("my-dlt-topic");
+		properties.setInitialSubscriptionName("my-initial-subscription");
+		DeadLetterPolicy policy = DeadLetterPolicyMapper.map(properties);
+		assertThat(policy.getMaxRedeliverCount()).isEqualTo(100);
+		assertThat(policy.getRetryLetterTopic()).isEqualTo("my-retry-topic");
+		assertThat(policy.getDeadLetterTopic()).isEqualTo("my-dlt-topic");
+		assertThat(policy.getInitialSubscriptionName()).isEqualTo("my-initial-subscription");
+	}
+
+	@Test
+	void mapWhenMaxRedeliverCountIsNotPositiveThrowsException() {
+		PulsarProperties.Consumer.DeadLetterPolicy properties = new PulsarProperties.Consumer.DeadLetterPolicy();
+		properties.setMaxRedeliverCount(0);
+		assertThatIllegalStateException().isThrownBy(() -> DeadLetterPolicyMapper.map(properties))
+			.withMessage("Pulsar DeadLetterPolicy must have a positive 'max-redelivery-count' property value");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PropertiesPulsarConnectionDetailsTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PropertiesPulsarConnectionDetailsTests.java
new file mode 100644
index 000000000000..3abff9be7346
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PropertiesPulsarConnectionDetailsTests.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.pulsar;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link PropertiesPulsarConnectionDetails}.
+ *
+ * @author Chris Bono
+ */
+class PropertiesPulsarConnectionDetailsTests {
+
+	@Test
+	void getClientServiceUrlReturnsValueFromProperties() {
+		PulsarProperties properties = new PulsarProperties();
+		properties.getClient().setServiceUrl("foo");
+		PulsarConnectionDetails connectionDetails = new PropertiesPulsarConnectionDetails(properties);
+		assertThat(connectionDetails.getBrokerUrl()).isEqualTo("foo");
+	}
+
+	@Test
+	void getAdminServiceHttpUrlReturnsValueFromProperties() {
+		PulsarProperties properties = new PulsarProperties();
+		properties.getAdmin().setServiceUrl("foo");
+		PulsarConnectionDetails connectionDetails = new PropertiesPulsarConnectionDetails(properties);
+		assertThat(connectionDetails.getAdminUrl()).isEqualTo("foo");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfigurationIntegrationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfigurationIntegrationTests.java
new file mode 100644
index 000000000000..14c7a37baf5f
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfigurationIntegrationTests.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.pulsar;
+
+import java.time.Duration;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.pulsar.client.api.PulsarClientException;
+import org.junit.jupiter.api.Test;
+import org.testcontainers.containers.PulsarContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
+import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
+import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
+import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration;
+import org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryAutoConfiguration;
+import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.pulsar.annotation.PulsarListener;
+import org.springframework.pulsar.core.PulsarTemplate;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests for {@link PulsarAutoConfiguration}.
+ *
+ * @author Chris Bono
+ * @author Phillip Webb
+ */
+@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
+@Testcontainers(disabledWithoutDocker = true)
+class PulsarAutoConfigurationIntegrationTests {
+
+	@Container
+	private static final PulsarContainer PULSAR_CONTAINER = new PulsarContainer(DockerImageNames.pulsar())
+		.withStartupAttempts(2)
+		.withStartupTimeout(Duration.ofMinutes(3));
+
+	private static final CountDownLatch LISTEN_LATCH = new CountDownLatch(1);
+
+	private static final String TOPIC = "pacit-hello-topic";
+
+	@DynamicPropertySource
+	static void pulsarProperties(DynamicPropertyRegistry registry) {
+		registry.add("spring.pulsar.client.service-url", PULSAR_CONTAINER::getPulsarBrokerUrl);
+		registry.add("spring.pulsar.admin.service-url", PULSAR_CONTAINER::getHttpServiceUrl);
+	}
+
+	@Test
+	void appStartsWithAutoConfiguredSpringPulsarComponents(
+			@Autowired(required = false) PulsarTemplate<String> pulsarTemplate) {
+		assertThat(pulsarTemplate).isNotNull();
+	}
+
+	@Test
+	void templateCanBeAccessedDuringWebRequest(@Autowired TestRestTemplate restTemplate) throws InterruptedException {
+		assertThat(restTemplate.getForObject("/hello", String.class)).startsWith("Hello World -> ");
+		assertThat(LISTEN_LATCH.await(5, TimeUnit.SECONDS)).isTrue();
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	@ImportAutoConfiguration({ DispatcherServletAutoConfiguration.class, ServletWebServerFactoryAutoConfiguration.class,
+			WebMvcAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class, JacksonAutoConfiguration.class,
+			PulsarAutoConfiguration.class, PulsarReactiveAutoConfiguration.class })
+	@Import(TestWebController.class)
+	static class TestConfiguration {
+
+		@PulsarListener(subscriptionName = TOPIC + "-sub", topics = TOPIC)
+		void listen(String ignored) {
+			LISTEN_LATCH.countDown();
+		}
+
+	}
+
+	@RestController
+	static class TestWebController {
+
+		private final PulsarTemplate<String> pulsarTemplate;
+
+		TestWebController(PulsarTemplate<String> pulsarTemplate) {
+			this.pulsarTemplate = pulsarTemplate;
+		}
+
+		@GetMapping("/hello")
+		String sayHello() throws PulsarClientException {
+			return "Hello World -> " + this.pulsarTemplate.send(TOPIC, "hello");
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfigurationTests.java
new file mode 100644
index 000000000000..7e56f4129ead
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfigurationTests.java
@@ -0,0 +1,565 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.pulsar;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import com.github.benmanes.caffeine.cache.Caffeine;
+import org.apache.pulsar.client.api.ConsumerBuilder;
+import org.apache.pulsar.client.api.ProducerBuilder;
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.client.api.ReaderBuilder;
+import org.apache.pulsar.client.api.interceptor.ProducerInterceptor;
+import org.apache.pulsar.common.schema.SchemaType;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.api.condition.JRE;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.FilteredClassLoader;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.annotation.Order;
+import org.springframework.core.task.VirtualThreadTaskExecutor;
+import org.springframework.pulsar.annotation.PulsarBootstrapConfiguration;
+import org.springframework.pulsar.annotation.PulsarListenerAnnotationBeanPostProcessor;
+import org.springframework.pulsar.annotation.PulsarReaderAnnotationBeanPostProcessor;
+import org.springframework.pulsar.cache.provider.caffeine.CaffeineCacheProvider;
+import org.springframework.pulsar.config.ConcurrentPulsarListenerContainerFactory;
+import org.springframework.pulsar.config.DefaultPulsarReaderContainerFactory;
+import org.springframework.pulsar.config.PulsarListenerContainerFactory;
+import org.springframework.pulsar.config.PulsarListenerEndpointRegistry;
+import org.springframework.pulsar.config.PulsarReaderEndpointRegistry;
+import org.springframework.pulsar.core.CachingPulsarProducerFactory;
+import org.springframework.pulsar.core.ConsumerBuilderCustomizer;
+import org.springframework.pulsar.core.DefaultPulsarClientFactory;
+import org.springframework.pulsar.core.DefaultPulsarConsumerFactory;
+import org.springframework.pulsar.core.DefaultPulsarProducerFactory;
+import org.springframework.pulsar.core.DefaultPulsarReaderFactory;
+import org.springframework.pulsar.core.DefaultSchemaResolver;
+import org.springframework.pulsar.core.DefaultTopicResolver;
+import org.springframework.pulsar.core.ProducerBuilderCustomizer;
+import org.springframework.pulsar.core.PulsarAdministration;
+import org.springframework.pulsar.core.PulsarConsumerFactory;
+import org.springframework.pulsar.core.PulsarProducerFactory;
+import org.springframework.pulsar.core.PulsarReaderFactory;
+import org.springframework.pulsar.core.PulsarTemplate;
+import org.springframework.pulsar.core.ReaderBuilderCustomizer;
+import org.springframework.pulsar.core.SchemaResolver;
+import org.springframework.pulsar.core.TopicResolver;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link PulsarAutoConfiguration}.
+ *
+ * @author Chris Bono
+ * @author Alexander Preuß
+ * @author Soby Chacko
+ * @author Phillip Webb
+ */
+class PulsarAutoConfigurationTests {
+
+	private static final String INTERNAL_PULSAR_LISTENER_ANNOTATION_PROCESSOR = "org.springframework.pulsar.config.internalPulsarListenerAnnotationProcessor";
+
+	private static final String INTERNAL_PULSAR_READER_ANNOTATION_PROCESSOR = "org.springframework.pulsar.config.internalPulsarReaderAnnotationProcessor";
+
+	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+		.withConfiguration(AutoConfigurations.of(PulsarAutoConfiguration.class))
+		.withBean(PulsarClient.class, () -> mock(PulsarClient.class));
+
+	@Test
+	void whenPulsarNotOnClasspathAutoConfigurationIsSkipped() {
+		new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(PulsarAutoConfiguration.class))
+			.withClassLoader(new FilteredClassLoader(PulsarClient.class))
+			.run((context) -> assertThat(context).doesNotHaveBean(PulsarAutoConfiguration.class));
+	}
+
+	@Test
+	void whenSpringPulsarNotOnClasspathAutoConfigurationIsSkipped() {
+		this.contextRunner.withClassLoader(new FilteredClassLoader(PulsarTemplate.class))
+			.run((context) -> assertThat(context).doesNotHaveBean(PulsarAutoConfiguration.class));
+	}
+
+	@Test
+	void whenCustomPulsarListenerAnnotationProcessorDefinedAutoConfigurationIsSkipped() {
+		this.contextRunner.withBean(INTERNAL_PULSAR_LISTENER_ANNOTATION_PROCESSOR, String.class, () -> "bean")
+			.run((context) -> assertThat(context).doesNotHaveBean(PulsarBootstrapConfiguration.class));
+	}
+
+	@Test
+	void whenCustomPulsarReaderAnnotationProcessorDefinedAutoConfigurationIsSkipped() {
+		this.contextRunner.withBean(INTERNAL_PULSAR_READER_ANNOTATION_PROCESSOR, String.class, () -> "bean")
+			.run((context) -> assertThat(context).doesNotHaveBean(PulsarBootstrapConfiguration.class));
+	}
+
+	@Test
+	void autoConfiguresBeans() {
+		this.contextRunner.run((context) -> assertThat(context).hasSingleBean(PulsarConfiguration.class)
+			.hasSingleBean(PulsarConnectionDetails.class)
+			.hasSingleBean(DefaultPulsarClientFactory.class)
+			.hasSingleBean(PulsarClient.class)
+			.hasSingleBean(PulsarAdministration.class)
+			.hasSingleBean(DefaultSchemaResolver.class)
+			.hasSingleBean(DefaultTopicResolver.class)
+			.hasSingleBean(CachingPulsarProducerFactory.class)
+			.hasSingleBean(PulsarTemplate.class)
+			.hasSingleBean(DefaultPulsarConsumerFactory.class)
+			.hasSingleBean(ConcurrentPulsarListenerContainerFactory.class)
+			.hasSingleBean(DefaultPulsarReaderFactory.class)
+			.hasSingleBean(DefaultPulsarReaderContainerFactory.class)
+			.hasSingleBean(PulsarListenerAnnotationBeanPostProcessor.class)
+			.hasSingleBean(PulsarListenerEndpointRegistry.class)
+			.hasSingleBean(PulsarReaderAnnotationBeanPostProcessor.class)
+			.hasSingleBean(PulsarReaderEndpointRegistry.class));
+	}
+
+	@Nested
+	class ProducerFactoryTests {
+
+		private final ApplicationContextRunner contextRunner = PulsarAutoConfigurationTests.this.contextRunner;
+
+		@Test
+		@SuppressWarnings("unchecked")
+		void whenHasUserDefinedBeanDoesNotAutoConfigureBean() {
+			PulsarProducerFactory<String> producerFactory = mock(PulsarProducerFactory.class);
+			this.contextRunner
+				.withBean("customPulsarProducerFactory", PulsarProducerFactory.class, () -> producerFactory)
+				.run((context) -> assertThat(context).getBean(PulsarProducerFactory.class).isSameAs(producerFactory));
+		}
+
+		@Test
+		void whenNoPropertiesUsesCachingPulsarProducerFactory() {
+			this.contextRunner.run((context) -> assertThat(context).getBean(PulsarProducerFactory.class)
+				.isExactlyInstanceOf(CachingPulsarProducerFactory.class));
+		}
+
+		@Test
+		void whenCachingDisabledUsesDefaultPulsarProducerFactory() {
+			this.contextRunner.withPropertyValues("spring.pulsar.producer.cache.enabled=false")
+				.run((context) -> assertThat(context).getBean(PulsarProducerFactory.class)
+					.isExactlyInstanceOf(DefaultPulsarProducerFactory.class));
+		}
+
+		@Test
+		void whenCachingEnabledUsesCachingPulsarProducerFactory() {
+			this.contextRunner.withPropertyValues("spring.pulsar.producer.cache.enabled=true")
+				.run((context) -> assertThat(context).getBean(PulsarProducerFactory.class)
+					.isExactlyInstanceOf(CachingPulsarProducerFactory.class));
+		}
+
+		@Test
+		void whenCachingEnabledAndCaffeineNotOnClasspathStillUsesCaffeine() {
+			this.contextRunner.withClassLoader(new FilteredClassLoader(Caffeine.class))
+				.withPropertyValues("spring.pulsar.producer.cache.enabled=true")
+				.run((context) -> {
+					assertThat(context).getBean(CachingPulsarProducerFactory.class)
+						.extracting("producerCache")
+						.extracting(Object::getClass)
+						.isEqualTo(CaffeineCacheProvider.class);
+					assertThat(context).getBean(CachingPulsarProducerFactory.class)
+						.extracting("producerCache.cache")
+						.extracting(Object::getClass)
+						.extracting(Class::getName)
+						.asString()
+						.startsWith("org.springframework.pulsar.shade.com.github.benmanes.caffeine.cache.");
+				});
+		}
+
+		@Test
+		void whenCustomCachingPropertiesCreatesConfiguredBean() {
+			this.contextRunner
+				.withPropertyValues("spring.pulsar.producer.cache.expire-after-access=100s",
+						"spring.pulsar.producer.cache.maximum-size=5150",
+						"spring.pulsar.producer.cache.initial-capacity=200")
+				.run((context) -> assertThat(context).getBean(CachingPulsarProducerFactory.class)
+					.extracting("producerCache.cache.cache")
+					.hasFieldOrPropertyWithValue("maximum", 5150L)
+					.hasFieldOrPropertyWithValue("expiresAfterAccessNanos", TimeUnit.SECONDS.toNanos(100)));
+		}
+
+		@Test
+		void whenHasTopicNamePropertyCreatesConfiguredBean() {
+			this.contextRunner.withPropertyValues("spring.pulsar.producer.topic-name=my-topic")
+				.run((context) -> assertThat(context).getBean(DefaultPulsarProducerFactory.class)
+					.hasFieldOrPropertyWithValue("defaultTopic", "my-topic"));
+		}
+
+		@Test
+		void injectsExpectedBeans() {
+			this.contextRunner
+				.withPropertyValues("spring.pulsar.producer.topic-name=my-topic",
+						"spring.pulsar.producer.cache.enabled=false")
+				.run((context) -> assertThat(context).getBean(DefaultPulsarProducerFactory.class)
+					.hasFieldOrPropertyWithValue("pulsarClient", context.getBean(PulsarClient.class))
+					.hasFieldOrPropertyWithValue("topicResolver", context.getBean(TopicResolver.class)));
+		}
+
+		@ParameterizedTest
+		@ValueSource(booleans = { true, false })
+		<T> void whenHasUserDefinedCustomizersAppliesInCorrectOrder(boolean cachingEnabled) {
+			this.contextRunner
+				.withPropertyValues("spring.pulsar.producer.cache.enabled=" + cachingEnabled,
+						"spring.pulsar.producer.name=fromPropsCustomizer")
+				.withUserConfiguration(ProducerBuilderCustomizersConfig.class)
+				.run((context) -> {
+					DefaultPulsarProducerFactory<?> producerFactory = context
+						.getBean(DefaultPulsarProducerFactory.class);
+					Customizers<ProducerBuilderCustomizer<T>, ProducerBuilder<T>> customizers = Customizers
+						.of(ProducerBuilder.class, ProducerBuilderCustomizer::customize);
+					assertThat(customizers.fromField(producerFactory, "defaultConfigCustomizers")).callsInOrder(
+							ProducerBuilder::producerName, "fromPropsCustomizer", "fromCustomizer1", "fromCustomizer2");
+				});
+		}
+
+		@TestConfiguration(proxyBeanMethods = false)
+		static class ProducerBuilderCustomizersConfig {
+
+			@Bean
+			@Order(200)
+			ProducerBuilderCustomizer<?> customizerFoo() {
+				return (builder) -> builder.producerName("fromCustomizer2");
+			}
+
+			@Bean
+			@Order(100)
+			ProducerBuilderCustomizer<?> customizerBar() {
+				return (builder) -> builder.producerName("fromCustomizer1");
+			}
+
+		}
+
+	}
+
+	@Nested
+	class TemplateTests {
+
+		private final ApplicationContextRunner contextRunner = PulsarAutoConfigurationTests.this.contextRunner;
+
+		@Test
+		@SuppressWarnings("unchecked")
+		void whenHasUserDefinedBeanDoesNotAutoConfigureBean() {
+			PulsarTemplate<String> template = mock(PulsarTemplate.class);
+			this.contextRunner.withBean("customPulsarTemplate", PulsarTemplate.class, () -> template)
+				.run((context) -> assertThat(context).getBean(PulsarTemplate.class).isSameAs(template));
+		}
+
+		@Test
+		void injectsExpectedBeans() {
+			PulsarProducerFactory<?> producerFactory = mock(PulsarProducerFactory.class);
+			SchemaResolver schemaResolver = mock(SchemaResolver.class);
+			TopicResolver topicResolver = mock(TopicResolver.class);
+			this.contextRunner
+				.withBean("customPulsarProducerFactory", PulsarProducerFactory.class, () -> producerFactory)
+				.withBean("schemaResolver", SchemaResolver.class, () -> schemaResolver)
+				.withBean("topicResolver", TopicResolver.class, () -> topicResolver)
+				.run((context) -> assertThat(context).getBean(PulsarTemplate.class)
+					.hasFieldOrPropertyWithValue("producerFactory", producerFactory)
+					.hasFieldOrPropertyWithValue("schemaResolver", schemaResolver)
+					.hasFieldOrPropertyWithValue("topicResolver", topicResolver));
+		}
+
+		@Test
+		void whenHasUseDefinedProducerInterceptorInjectsBean() {
+			ProducerInterceptor interceptor = mock(ProducerInterceptor.class);
+			this.contextRunner.withBean("customProducerInterceptor", ProducerInterceptor.class, () -> interceptor)
+				.run((context) -> assertThat(context).getBean(PulsarTemplate.class)
+					.extracting("interceptors")
+					.asList()
+					.contains(interceptor));
+		}
+
+		@Test
+		void whenHasUseDefinedProducerInterceptorsInjectsBeansInCorrectOrder() {
+			this.contextRunner.withUserConfiguration(InterceptorTestConfiguration.class)
+				.run((context) -> assertThat(context).getBean(PulsarTemplate.class)
+					.extracting("interceptors")
+					.asList()
+					.containsExactly(context.getBean("interceptorBar"), context.getBean("interceptorFoo")));
+		}
+
+		@Test
+		void whenNoPropertiesEnablesObservation() {
+			this.contextRunner.run((context) -> assertThat(context).getBean(PulsarTemplate.class)
+				.hasFieldOrPropertyWithValue("observationEnabled", true));
+		}
+
+		@Test
+		void whenObservationsEnabledEnablesObservation() {
+			this.contextRunner.withPropertyValues("spring.pulsar.template.observations-enabled=true")
+				.run((context) -> assertThat(context).getBean(PulsarTemplate.class)
+					.hasFieldOrPropertyWithValue("observationEnabled", true));
+		}
+
+		@Test
+		void whenObservationsDisabledDoesNotEnableObservation() {
+			this.contextRunner.withPropertyValues("spring.pulsar.template.observations-enabled=false")
+				.run((context) -> assertThat(context).getBean(PulsarTemplate.class)
+					.hasFieldOrPropertyWithValue("observationEnabled", false));
+		}
+
+		@Configuration(proxyBeanMethods = false)
+		static class InterceptorTestConfiguration {
+
+			@Bean
+			@Order(200)
+			ProducerInterceptor interceptorFoo() {
+				return mock(ProducerInterceptor.class);
+			}
+
+			@Bean
+			@Order(100)
+			ProducerInterceptor interceptorBar() {
+				return mock(ProducerInterceptor.class);
+			}
+
+		}
+
+	}
+
+	@Nested
+	class ConsumerFactoryTests {
+
+		private final ApplicationContextRunner contextRunner = PulsarAutoConfigurationTests.this.contextRunner;
+
+		@Test
+		@SuppressWarnings("unchecked")
+		void whenHasUserDefinedBeanDoesNotAutoConfigureBean() {
+			PulsarConsumerFactory<String> consumerFactory = mock(PulsarConsumerFactory.class);
+			this.contextRunner
+				.withBean("customPulsarConsumerFactory", PulsarConsumerFactory.class, () -> consumerFactory)
+				.run((context) -> assertThat(context).getBean(PulsarConsumerFactory.class).isSameAs(consumerFactory));
+		}
+
+		@Test
+		void injectsExpectedBeans() {
+			this.contextRunner.run((context) -> assertThat(context).getBean(DefaultPulsarConsumerFactory.class)
+				.hasFieldOrPropertyWithValue("pulsarClient", context.getBean(PulsarClient.class)));
+		}
+
+		@Test
+		<T> void whenHasUserDefinedCustomizersAppliesInCorrectOrder() {
+			this.contextRunner.withPropertyValues("spring.pulsar.consumer.name=fromPropsCustomizer")
+				.withUserConfiguration(ConsumerBuilderCustomizersConfig.class)
+				.run((context) -> {
+					DefaultPulsarConsumerFactory<?> consumerFactory = context
+						.getBean(DefaultPulsarConsumerFactory.class);
+					Customizers<ConsumerBuilderCustomizer<T>, ConsumerBuilder<T>> customizers = Customizers
+						.of(ConsumerBuilder.class, ConsumerBuilderCustomizer::customize);
+					assertThat(customizers.fromField(consumerFactory, "defaultConfigCustomizers")).callsInOrder(
+							ConsumerBuilder::consumerName, "fromPropsCustomizer", "fromCustomizer1", "fromCustomizer2");
+				});
+		}
+
+		@TestConfiguration(proxyBeanMethods = false)
+		static class ConsumerBuilderCustomizersConfig {
+
+			@Bean
+			@Order(200)
+			ConsumerBuilderCustomizer<?> customizerFoo() {
+				return (builder) -> builder.consumerName("fromCustomizer2");
+			}
+
+			@Bean
+			@Order(100)
+			ConsumerBuilderCustomizer<?> customizerBar() {
+				return (builder) -> builder.consumerName("fromCustomizer1");
+			}
+
+		}
+
+	}
+
+	@Nested
+	class ListenerTests {
+
+		private final ApplicationContextRunner contextRunner = PulsarAutoConfigurationTests.this.contextRunner;
+
+		@Test
+		void whenHasUserDefinedListenerContainerFactoryBeanDoesNotAutoConfigureBean() {
+			PulsarListenerContainerFactory listenerContainerFactory = mock(PulsarListenerContainerFactory.class);
+			this.contextRunner
+				.withBean("pulsarListenerContainerFactory", PulsarListenerContainerFactory.class,
+						() -> listenerContainerFactory)
+				.run((context) -> assertThat(context).getBean(PulsarListenerContainerFactory.class)
+					.isSameAs(listenerContainerFactory));
+		}
+
+		@Test
+		@SuppressWarnings("rawtypes")
+		void injectsExpectedBeans() {
+			PulsarConsumerFactory<?> consumerFactory = mock(PulsarConsumerFactory.class);
+			SchemaResolver schemaResolver = mock(SchemaResolver.class);
+			TopicResolver topicResolver = mock(TopicResolver.class);
+			this.contextRunner.withBean("pulsarConsumerFactory", PulsarConsumerFactory.class, () -> consumerFactory)
+				.withBean("schemaResolver", SchemaResolver.class, () -> schemaResolver)
+				.withBean("topicResolver", TopicResolver.class, () -> topicResolver)
+				.run((context) -> assertThat(context).getBean(ConcurrentPulsarListenerContainerFactory.class)
+					.hasFieldOrPropertyWithValue("consumerFactory", consumerFactory)
+					.extracting(ConcurrentPulsarListenerContainerFactory::getContainerProperties)
+					.hasFieldOrPropertyWithValue("schemaResolver", schemaResolver)
+					.hasFieldOrPropertyWithValue("topicResolver", topicResolver));
+		}
+
+		@Test
+		@SuppressWarnings("unchecked")
+		void whenHasUserDefinedListenerAnnotationBeanPostProcessorBeanDoesNotAutoConfigureBean() {
+			PulsarListenerAnnotationBeanPostProcessor<String> listenerAnnotationBeanPostProcessor = mock(
+					PulsarListenerAnnotationBeanPostProcessor.class);
+			this.contextRunner
+				.withBean("org.springframework.pulsar.config.internalPulsarListenerAnnotationProcessor",
+						PulsarListenerAnnotationBeanPostProcessor.class, () -> listenerAnnotationBeanPostProcessor)
+				.run((context) -> assertThat(context).getBean(PulsarListenerAnnotationBeanPostProcessor.class)
+					.isSameAs(listenerAnnotationBeanPostProcessor));
+		}
+
+		@Test
+		void whenHasCustomProperties() {
+			List<String> properties = new ArrayList<>();
+			properties.add("spring.pulsar.listener.schema-type=avro");
+			this.contextRunner.withPropertyValues(properties.toArray(String[]::new)).run((context) -> {
+				ConcurrentPulsarListenerContainerFactory<?> factory = context
+					.getBean(ConcurrentPulsarListenerContainerFactory.class);
+				assertThat(factory.getContainerProperties().getSchemaType()).isEqualTo(SchemaType.AVRO);
+			});
+		}
+
+		@Test
+		void whenNoPropertiesEnablesObservation() {
+			this.contextRunner
+				.run((context) -> assertThat(context).getBean(ConcurrentPulsarListenerContainerFactory.class)
+					.hasFieldOrPropertyWithValue("containerProperties.observationEnabled", true));
+		}
+
+		@Test
+		void whenObservationsEnabledEnablesObservation() {
+			this.contextRunner.withPropertyValues("spring.pulsar.listener.observation-enabled=true")
+				.run((context) -> assertThat(context).getBean(ConcurrentPulsarListenerContainerFactory.class)
+					.hasFieldOrPropertyWithValue("containerProperties.observationEnabled", true));
+		}
+
+		@Test
+		void whenObservationsDisabledDoesNotEnableObservation() {
+			this.contextRunner.withPropertyValues("spring.pulsar.listener.observation-enabled=false")
+				.run((context) -> assertThat(context).getBean(ConcurrentPulsarListenerContainerFactory.class)
+					.hasFieldOrPropertyWithValue("containerProperties.observationEnabled", false));
+		}
+
+		@Test
+		@EnabledForJreRange(min = JRE.JAVA_21)
+		void whenVirtualThreadsAreEnabledOnJava21AndLaterListenerContainerShouldUseVirtualThreads() {
+			this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true").run((context) -> {
+				ConcurrentPulsarListenerContainerFactory<?> factory = context
+					.getBean(ConcurrentPulsarListenerContainerFactory.class);
+				assertThat(factory.getContainerProperties().getConsumerTaskExecutor())
+					.isInstanceOf(VirtualThreadTaskExecutor.class);
+			});
+		}
+
+		@Test
+		@EnabledForJreRange(max = JRE.JAVA_20)
+		void whenVirtualThreadsAreEnabledOnJava20AndEarlierListenerContainerShouldNotUseVirtualThreads() {
+			this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true").run((context) -> {
+				ConcurrentPulsarListenerContainerFactory<?> factory = context
+					.getBean(ConcurrentPulsarListenerContainerFactory.class);
+				assertThat(factory.getContainerProperties().getConsumerTaskExecutor()).isNull();
+			});
+		}
+
+	}
+
+	@Nested
+	class ReaderFactoryTests {
+
+		private final ApplicationContextRunner contextRunner = PulsarAutoConfigurationTests.this.contextRunner;
+
+		@Test
+		@SuppressWarnings("unchecked")
+		void whenHasUserDefinedBeanDoesNotAutoConfigureBean() {
+			PulsarReaderFactory<String> readerFactory = mock(PulsarReaderFactory.class);
+			this.contextRunner.withBean("customPulsarReaderFactory", PulsarReaderFactory.class, () -> readerFactory)
+				.run((context) -> assertThat(context).getBean(PulsarReaderFactory.class).isSameAs(readerFactory));
+		}
+
+		@Test
+		void injectsExpectedBeans() {
+			this.contextRunner.run((context) -> assertThat(context).getBean(DefaultPulsarReaderFactory.class)
+				.hasFieldOrPropertyWithValue("pulsarClient", context.getBean(PulsarClient.class)));
+		}
+
+		@Test
+		<T> void whenHasUserDefinedCustomizersAppliesInCorrectOrder() {
+			this.contextRunner.withPropertyValues("spring.pulsar.reader.name=fromPropsCustomizer")
+				.withUserConfiguration(ReaderBuilderCustomizersConfig.class)
+				.run((context) -> {
+					DefaultPulsarReaderFactory<?> readerFactory = context.getBean(DefaultPulsarReaderFactory.class);
+					Customizers<ReaderBuilderCustomizer<T>, ReaderBuilder<T>> customizers = Customizers
+						.of(ReaderBuilder.class, ReaderBuilderCustomizer::customize);
+					assertThat(customizers.fromField(readerFactory, "defaultConfigCustomizers")).callsInOrder(
+							ReaderBuilder::readerName, "fromPropsCustomizer", "fromCustomizer1", "fromCustomizer2");
+				});
+		}
+
+		@Test
+		@EnabledForJreRange(min = JRE.JAVA_21)
+		void whenVirtualThreadsAreEnabledOnJava21AndLaterReaderShouldUseVirtualThreads() {
+			this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true").run((context) -> {
+				DefaultPulsarReaderContainerFactory<?> factory = context
+					.getBean(DefaultPulsarReaderContainerFactory.class);
+				assertThat(factory.getContainerProperties().getReaderTaskExecutor())
+					.isInstanceOf(VirtualThreadTaskExecutor.class);
+			});
+		}
+
+		@Test
+		@EnabledForJreRange(max = JRE.JAVA_20)
+		void whenVirtualThreadsAreEnabledOnJava20AndEarlierReaderShouldNotUseVirtualThreads() {
+			this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true").run((context) -> {
+				DefaultPulsarReaderContainerFactory<?> factory = context
+					.getBean(DefaultPulsarReaderContainerFactory.class);
+				assertThat(factory.getContainerProperties().getReaderTaskExecutor()).isNull();
+			});
+		}
+
+		@TestConfiguration(proxyBeanMethods = false)
+		static class ReaderBuilderCustomizersConfig {
+
+			@Bean
+			@Order(200)
+			ReaderBuilderCustomizer<?> customizerFoo() {
+				return (builder) -> builder.readerName("fromCustomizer2");
+			}
+
+			@Bean
+			@Order(100)
+			ReaderBuilderCustomizer<?> customizerBar() {
+				return (builder) -> builder.readerName("fromCustomizer1");
+			}
+
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarConfigurationTests.java
new file mode 100644
index 000000000000..a1136b11ba29
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarConfigurationTests.java
@@ -0,0 +1,334 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.pulsar;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import org.apache.pulsar.client.admin.PulsarAdminBuilder;
+import org.apache.pulsar.client.api.ClientBuilder;
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.client.api.Schema;
+import org.apache.pulsar.common.schema.KeyValueEncodingType;
+import org.assertj.core.api.InstanceOfAssertFactories;
+import org.assertj.core.api.InstanceOfAssertFactory;
+import org.assertj.core.api.MapAssert;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.core.annotation.Order;
+import org.springframework.pulsar.core.DefaultPulsarClientFactory;
+import org.springframework.pulsar.core.DefaultSchemaResolver;
+import org.springframework.pulsar.core.DefaultTopicResolver;
+import org.springframework.pulsar.core.PulsarAdminBuilderCustomizer;
+import org.springframework.pulsar.core.PulsarAdministration;
+import org.springframework.pulsar.core.PulsarClientBuilderCustomizer;
+import org.springframework.pulsar.core.PulsarClientFactory;
+import org.springframework.pulsar.core.SchemaResolver;
+import org.springframework.pulsar.core.SchemaResolver.SchemaResolverCustomizer;
+import org.springframework.pulsar.core.TopicResolver;
+import org.springframework.pulsar.function.PulsarFunctionAdministration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.entry;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link PulsarConfiguration}.
+ *
+ * @author Chris Bono
+ * @author Alexander Preuß
+ * @author Soby Chacko
+ * @author Phillip Webb
+ */
+class PulsarConfigurationTests {
+
+	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+		.withConfiguration(AutoConfigurations.of(PulsarConfiguration.class))
+		.withBean(PulsarClient.class, () -> mock(PulsarClient.class));
+
+	@Test
+	void whenHasUserDefinedConnectionDetailsBeanDoesNotAutoConfigureBean() {
+		PulsarConnectionDetails customConnectionDetails = mock(PulsarConnectionDetails.class);
+		this.contextRunner
+			.withBean("customPulsarConnectionDetails", PulsarConnectionDetails.class, () -> customConnectionDetails)
+			.run((context) -> assertThat(context).getBean(PulsarConnectionDetails.class)
+				.isSameAs(customConnectionDetails));
+	}
+
+	@Nested
+	class ClientTests {
+
+		@Test
+		void whenHasUserDefinedClientFactoryBeanDoesNotAutoConfigureBean() {
+			PulsarClientFactory customFactory = mock(PulsarClientFactory.class);
+			new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(PulsarConfiguration.class))
+				.withBean("customPulsarClientFactory", PulsarClientFactory.class, () -> customFactory)
+				.run((context) -> assertThat(context).getBean(PulsarClientFactory.class).isSameAs(customFactory));
+		}
+
+		@Test
+		void whenHasUserDefinedClientBeanDoesNotAutoConfigureBean() {
+			PulsarClient customClient = mock(PulsarClient.class);
+			new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(PulsarConfiguration.class))
+				.withBean("customPulsarClient", PulsarClient.class, () -> customClient)
+				.run((context) -> assertThat(context).getBean(PulsarClient.class).isSameAs(customClient));
+		}
+
+		@Test
+		void whenHasUserDefinedCustomizersAppliesInCorrectOrder() {
+			PulsarConnectionDetails connectionDetails = mock(PulsarConnectionDetails.class);
+			given(connectionDetails.getBrokerUrl()).willReturn("connectiondetails");
+			PulsarConfigurationTests.this.contextRunner
+				.withUserConfiguration(PulsarClientBuilderCustomizersConfig.class)
+				.withBean(PulsarConnectionDetails.class, () -> connectionDetails)
+				.withPropertyValues("spring.pulsar.client.service-url=properties")
+				.run((context) -> {
+					DefaultPulsarClientFactory clientFactory = context.getBean(DefaultPulsarClientFactory.class);
+					Customizers<PulsarClientBuilderCustomizer, ClientBuilder> customizers = Customizers
+						.of(ClientBuilder.class, PulsarClientBuilderCustomizer::customize);
+					assertThat(customizers.fromField(clientFactory, "customizer")).callsInOrder(
+							ClientBuilder::serviceUrl, "connectiondetails", "fromCustomizer1", "fromCustomizer2");
+				});
+		}
+
+		@TestConfiguration(proxyBeanMethods = false)
+		static class PulsarClientBuilderCustomizersConfig {
+
+			@Bean
+			@Order(200)
+			PulsarClientBuilderCustomizer customizerFoo() {
+				return (builder) -> builder.serviceUrl("fromCustomizer2");
+			}
+
+			@Bean
+			@Order(100)
+			PulsarClientBuilderCustomizer customizerBar() {
+				return (builder) -> builder.serviceUrl("fromCustomizer1");
+			}
+
+		}
+
+	}
+
+	@Nested
+	class AdministrationTests {
+
+		private final ApplicationContextRunner contextRunner = PulsarConfigurationTests.this.contextRunner;
+
+		@Test
+		void whenHasUserDefinedBeanDoesNotAutoConfigureBean() {
+			PulsarAdministration pulsarAdministration = mock(PulsarAdministration.class);
+			this.contextRunner
+				.withBean("customPulsarAdministration", PulsarAdministration.class, () -> pulsarAdministration)
+				.run((context) -> assertThat(context).getBean(PulsarAdministration.class)
+					.isSameAs(pulsarAdministration));
+		}
+
+		@Test
+		void whenHasUserDefinedCustomizersAppliesInCorrectOrder() {
+			PulsarConnectionDetails connectionDetails = mock(PulsarConnectionDetails.class);
+			given(connectionDetails.getAdminUrl()).willReturn("connectiondetails");
+			this.contextRunner.withUserConfiguration(PulsarAdminBuilderCustomizersConfig.class)
+				.withBean(PulsarConnectionDetails.class, () -> connectionDetails)
+				.withPropertyValues("spring.pulsar.admin.service-url=property")
+				.run((context) -> {
+					PulsarAdministration pulsarAdmin = context.getBean(PulsarAdministration.class);
+					Customizers<PulsarAdminBuilderCustomizer, PulsarAdminBuilder> customizers = Customizers
+						.of(PulsarAdminBuilder.class, PulsarAdminBuilderCustomizer::customize);
+					assertThat(customizers.fromField(pulsarAdmin, "adminCustomizers")).callsInOrder(
+							PulsarAdminBuilder::serviceHttpUrl, "connectiondetails", "fromCustomizer1",
+							"fromCustomizer2");
+				});
+		}
+
+		@TestConfiguration(proxyBeanMethods = false)
+		static class PulsarAdminBuilderCustomizersConfig {
+
+			@Bean
+			@Order(200)
+			PulsarAdminBuilderCustomizer customizerFoo() {
+				return (builder) -> builder.serviceHttpUrl("fromCustomizer2");
+			}
+
+			@Bean
+			@Order(100)
+			PulsarAdminBuilderCustomizer customizerBar() {
+				return (builder) -> builder.serviceHttpUrl("fromCustomizer1");
+			}
+
+		}
+
+	}
+
+	@Nested
+	class SchemaResolverTests {
+
+		@SuppressWarnings("rawtypes")
+		private static final InstanceOfAssertFactory<Map, MapAssert<Class, Schema>> CLASS_SCHEMA_MAP = InstanceOfAssertFactories
+			.map(Class.class, Schema.class);
+
+		private final ApplicationContextRunner contextRunner = PulsarConfigurationTests.this.contextRunner;
+
+		@Test
+		void whenHasUserDefinedBeanDoesNotAutoConfigureBean() {
+			SchemaResolver schemaResolver = mock(SchemaResolver.class);
+			this.contextRunner.withBean("customSchemaResolver", SchemaResolver.class, () -> schemaResolver)
+				.run((context) -> assertThat(context).getBean(SchemaResolver.class).isSameAs(schemaResolver));
+		}
+
+		@Test
+		void whenHasUserDefinedSchemaResolverCustomizer() {
+			SchemaResolverCustomizer<DefaultSchemaResolver> customizer = (schemaResolver) -> schemaResolver
+				.addCustomSchemaMapping(TestRecord.class, Schema.STRING);
+			this.contextRunner.withBean("schemaResolverCustomizer", SchemaResolverCustomizer.class, () -> customizer)
+				.run((context) -> assertThat(context).getBean(DefaultSchemaResolver.class)
+					.extracting(DefaultSchemaResolver::getCustomSchemaMappings, InstanceOfAssertFactories.MAP)
+					.containsEntry(TestRecord.class, Schema.STRING));
+		}
+
+		@Test
+		void whenHasDefaultsTypeMappingForPrimitiveAddsToSchemaResolver() {
+			List<String> properties = new ArrayList<>();
+			properties.add("spring.pulsar.defaults.type-mappings[0].message-type=" + TestRecord.CLASS_NAME);
+			properties.add("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type=STRING");
+			this.contextRunner.withPropertyValues(properties.toArray(String[]::new))
+				.run((context) -> assertThat(context).getBean(DefaultSchemaResolver.class)
+					.extracting(DefaultSchemaResolver::getCustomSchemaMappings, InstanceOfAssertFactories.MAP)
+					.containsOnly(entry(TestRecord.class, Schema.STRING)));
+		}
+
+		@Test
+		void whenHasDefaultsTypeMappingForStructAddsToSchemaResolver() {
+			List<String> properties = new ArrayList<>();
+			properties.add("spring.pulsar.defaults.type-mappings[0].message-type=" + TestRecord.CLASS_NAME);
+			properties.add("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type=JSON");
+			Schema<?> expectedSchema = Schema.JSON(TestRecord.class);
+			this.contextRunner.withPropertyValues(properties.toArray(String[]::new))
+				.run((context) -> assertThat(context).getBean(DefaultSchemaResolver.class)
+					.extracting(DefaultSchemaResolver::getCustomSchemaMappings, CLASS_SCHEMA_MAP)
+					.hasEntrySatisfying(TestRecord.class, schemaEqualTo(expectedSchema)));
+		}
+
+		@Test
+		void whenHasDefaultsTypeMappingForKeyValueAddsToSchemaResolver() {
+			List<String> properties = new ArrayList<>();
+			properties.add("spring.pulsar.defaults.type-mappings[0].message-type=" + TestRecord.CLASS_NAME);
+			properties.add("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type=key-value");
+			properties.add("spring.pulsar.defaults.type-mappings[0].schema-info.message-key-type=java.lang.String");
+			Schema<?> expectedSchema = Schema.KeyValue(Schema.STRING, Schema.JSON(TestRecord.class),
+					KeyValueEncodingType.INLINE);
+			this.contextRunner.withPropertyValues(properties.toArray(String[]::new))
+				.run((context) -> assertThat(context).getBean(DefaultSchemaResolver.class)
+					.extracting(DefaultSchemaResolver::getCustomSchemaMappings, CLASS_SCHEMA_MAP)
+					.hasEntrySatisfying(TestRecord.class, schemaEqualTo(expectedSchema)));
+		}
+
+		@SuppressWarnings("rawtypes")
+		private Consumer<Schema> schemaEqualTo(Schema<?> expected) {
+			return (actual) -> assertThat(actual.getSchemaInfo()).isEqualTo(expected.getSchemaInfo());
+		}
+
+	}
+
+	@Nested
+	class TopicResolverTests {
+
+		private final ApplicationContextRunner contextRunner = PulsarConfigurationTests.this.contextRunner;
+
+		@Test
+		void whenHasUserDefinedBeanDoesNotAutoConfigureBean() {
+			TopicResolver topicResolver = mock(TopicResolver.class);
+			this.contextRunner.withBean("customTopicResolver", TopicResolver.class, () -> topicResolver)
+				.run((context) -> assertThat(context).getBean(TopicResolver.class).isSameAs(topicResolver));
+		}
+
+		@Test
+		void whenHasDefaultsTypeMappingAddsToSchemaResolver() {
+			List<String> properties = new ArrayList<>();
+			properties.add("spring.pulsar.defaults.type-mappings[0].message-type=" + TestRecord.CLASS_NAME);
+			properties.add("spring.pulsar.defaults.type-mappings[0].topic-name=foo-topic");
+			properties.add("spring.pulsar.defaults.type-mappings[1].message-type=java.lang.String");
+			properties.add("spring.pulsar.defaults.type-mappings[1].topic-name=string-topic");
+			this.contextRunner.withPropertyValues(properties.toArray(String[]::new))
+				.run((context) -> assertThat(context).getBean(TopicResolver.class)
+					.asInstanceOf(InstanceOfAssertFactories.type(DefaultTopicResolver.class))
+					.extracting(DefaultTopicResolver::getCustomTopicMappings, InstanceOfAssertFactories.MAP)
+					.containsOnly(entry(TestRecord.class, "foo-topic"), entry(String.class, "string-topic")));
+		}
+
+	}
+
+	@Nested
+	class FunctionAdministrationTests {
+
+		private final ApplicationContextRunner contextRunner = PulsarConfigurationTests.this.contextRunner;
+
+		@Test
+		void whenNoPropertiesAddsFunctionAdministrationBean() {
+			this.contextRunner.run((context) -> assertThat(context).getBean(PulsarFunctionAdministration.class)
+				.hasFieldOrPropertyWithValue("failFast", Boolean.TRUE)
+				.hasFieldOrPropertyWithValue("propagateFailures", Boolean.TRUE)
+				.hasFieldOrPropertyWithValue("propagateStopFailures", Boolean.FALSE)
+				.hasNoNullFieldsOrProperties() // ensures object providers set
+				.extracting("pulsarAdministration")
+				.isSameAs(context.getBean(PulsarAdministration.class)));
+		}
+
+		@Test
+		void whenHasFunctionPropertiesAppliesPropertiesToBean() {
+			List<String> properties = new ArrayList<>();
+			properties.add("spring.pulsar.function.fail-fast=false");
+			properties.add("spring.pulsar.function.propagate-failures=false");
+			properties.add("spring.pulsar.function.propagate-stop-failures=true");
+			this.contextRunner.withPropertyValues(properties.toArray(String[]::new))
+				.run((context) -> assertThat(context).getBean(PulsarFunctionAdministration.class)
+					.hasFieldOrPropertyWithValue("failFast", Boolean.FALSE)
+					.hasFieldOrPropertyWithValue("propagateFailures", Boolean.FALSE)
+					.hasFieldOrPropertyWithValue("propagateStopFailures", Boolean.TRUE));
+		}
+
+		@Test
+		void whenHasFunctionDisabledPropertyDoesNotCreateBean() {
+			this.contextRunner.withPropertyValues("spring.pulsar.function.enabled=false")
+				.run((context) -> assertThat(context).doesNotHaveBean(PulsarFunctionAdministration.class));
+		}
+
+		@Test
+		void whenHasCustomFunctionAdministrationBean() {
+			PulsarFunctionAdministration functionAdministration = mock(PulsarFunctionAdministration.class);
+			this.contextRunner.withBean(PulsarFunctionAdministration.class, () -> functionAdministration)
+				.run((context) -> assertThat(context).getBean(PulsarFunctionAdministration.class)
+					.isSameAs(functionAdministration));
+		}
+
+	}
+
+	record TestRecord() {
+
+		private static final String CLASS_NAME = TestRecord.class.getName();
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesMapperTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesMapperTests.java
new file mode 100644
index 000000000000..b168d4f71306
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesMapperTests.java
@@ -0,0 +1,217 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.pulsar;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+
+import org.apache.pulsar.client.admin.PulsarAdminBuilder;
+import org.apache.pulsar.client.api.ClientBuilder;
+import org.apache.pulsar.client.api.CompressionType;
+import org.apache.pulsar.client.api.ConsumerBuilder;
+import org.apache.pulsar.client.api.DeadLetterPolicy;
+import org.apache.pulsar.client.api.HashingScheme;
+import org.apache.pulsar.client.api.MessageRoutingMode;
+import org.apache.pulsar.client.api.ProducerAccessMode;
+import org.apache.pulsar.client.api.ProducerBuilder;
+import org.apache.pulsar.client.api.PulsarClientException.UnsupportedAuthenticationException;
+import org.apache.pulsar.client.api.ReaderBuilder;
+import org.apache.pulsar.client.api.SubscriptionType;
+import org.apache.pulsar.common.schema.SchemaType;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Consumer;
+import org.springframework.pulsar.listener.PulsarContainerProperties;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link PulsarPropertiesMapper}.
+ *
+ * @author Chris Bono
+ * @author Phillip Webb
+ */
+class PulsarPropertiesMapperTests {
+
+	@Test
+	void customizeClientBuilderWhenHasNoAuthentication() {
+		PulsarProperties properties = new PulsarProperties();
+		properties.getClient().setServiceUrl("https://example.com");
+		properties.getClient().setConnectionTimeout(Duration.ofSeconds(1));
+		properties.getClient().setOperationTimeout(Duration.ofSeconds(2));
+		properties.getClient().setLookupTimeout(Duration.ofSeconds(3));
+		ClientBuilder builder = mock(ClientBuilder.class);
+		new PulsarPropertiesMapper(properties).customizeClientBuilder(builder,
+				new PropertiesPulsarConnectionDetails(properties));
+		then(builder).should().serviceUrl("https://example.com");
+		then(builder).should().connectionTimeout(1000, TimeUnit.MILLISECONDS);
+		then(builder).should().operationTimeout(2000, TimeUnit.MILLISECONDS);
+		then(builder).should().lookupTimeout(3000, TimeUnit.MILLISECONDS);
+	}
+
+	@Test
+	void customizeClientBuilderWhenHasAuthentication() throws UnsupportedAuthenticationException {
+		PulsarProperties properties = new PulsarProperties();
+		Map<String, String> params = Map.of("param", "name");
+		properties.getClient().getAuthentication().setPluginClassName("myclass");
+		properties.getClient().getAuthentication().setParam(params);
+		ClientBuilder builder = mock(ClientBuilder.class);
+		new PulsarPropertiesMapper(properties).customizeClientBuilder(builder,
+				new PropertiesPulsarConnectionDetails(properties));
+		then(builder).should().authentication("myclass", params);
+	}
+
+	@Test
+	void customizeClientBuilderWhenHasConnectionDetails() {
+		PulsarProperties properties = new PulsarProperties();
+		properties.getClient().setServiceUrl("https://ignored.example.com");
+		ClientBuilder builder = mock(ClientBuilder.class);
+		PulsarConnectionDetails connectionDetails = mock(PulsarConnectionDetails.class);
+		given(connectionDetails.getBrokerUrl()).willReturn("https://used.example.com");
+		new PulsarPropertiesMapper(properties).customizeClientBuilder(builder, connectionDetails);
+		then(builder).should().serviceUrl("https://used.example.com");
+	}
+
+	@Test
+	void customizeAdminBuilderWhenHasNoAuthentication() {
+		PulsarProperties properties = new PulsarProperties();
+		properties.getAdmin().setServiceUrl("https://example.com");
+		properties.getAdmin().setConnectionTimeout(Duration.ofSeconds(1));
+		properties.getAdmin().setReadTimeout(Duration.ofSeconds(2));
+		properties.getAdmin().setRequestTimeout(Duration.ofSeconds(3));
+		PulsarAdminBuilder builder = mock(PulsarAdminBuilder.class);
+		new PulsarPropertiesMapper(properties).customizeAdminBuilder(builder,
+				new PropertiesPulsarConnectionDetails(properties));
+		then(builder).should().serviceHttpUrl("https://example.com");
+		then(builder).should().connectionTimeout(1000, TimeUnit.MILLISECONDS);
+		then(builder).should().readTimeout(2000, TimeUnit.MILLISECONDS);
+		then(builder).should().requestTimeout(3000, TimeUnit.MILLISECONDS);
+	}
+
+	@Test
+	void customizeAdminBuilderWhenHasAuthentication() throws UnsupportedAuthenticationException {
+		PulsarProperties properties = new PulsarProperties();
+		Map<String, String> params = Map.of("param", "name");
+		properties.getAdmin().getAuthentication().setPluginClassName("myclass");
+		properties.getAdmin().getAuthentication().setParam(params);
+		PulsarAdminBuilder builder = mock(PulsarAdminBuilder.class);
+		new PulsarPropertiesMapper(properties).customizeAdminBuilder(builder,
+				new PropertiesPulsarConnectionDetails(properties));
+		then(builder).should().authentication("myclass", params);
+	}
+
+	@Test
+	void customizeAdminBuilderWhenHasConnectionDetails() {
+		PulsarProperties properties = new PulsarProperties();
+		properties.getAdmin().setServiceUrl("https://ignored.example.com");
+		PulsarAdminBuilder builder = mock(PulsarAdminBuilder.class);
+		PulsarConnectionDetails connectionDetails = mock(PulsarConnectionDetails.class);
+		given(connectionDetails.getAdminUrl()).willReturn("https://used.example.com");
+		new PulsarPropertiesMapper(properties).customizeAdminBuilder(builder, connectionDetails);
+		then(builder).should().serviceHttpUrl("https://used.example.com");
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	void customizeProducerBuilder() {
+		PulsarProperties properties = new PulsarProperties();
+		properties.getProducer().setName("name");
+		properties.getProducer().setTopicName("topicname");
+		properties.getProducer().setSendTimeout(Duration.ofSeconds(1));
+		properties.getProducer().setMessageRoutingMode(MessageRoutingMode.RoundRobinPartition);
+		properties.getProducer().setHashingScheme(HashingScheme.JavaStringHash);
+		properties.getProducer().setBatchingEnabled(false);
+		properties.getProducer().setChunkingEnabled(true);
+		properties.getProducer().setCompressionType(CompressionType.SNAPPY);
+		properties.getProducer().setAccessMode(ProducerAccessMode.Exclusive);
+		ProducerBuilder<Object> builder = mock(ProducerBuilder.class);
+		new PulsarPropertiesMapper(properties).customizeProducerBuilder(builder);
+		then(builder).should().producerName("name");
+		then(builder).should().topic("topicname");
+		then(builder).should().sendTimeout(1000, TimeUnit.MILLISECONDS);
+		then(builder).should().messageRoutingMode(MessageRoutingMode.RoundRobinPartition);
+		then(builder).should().hashingScheme(HashingScheme.JavaStringHash);
+		then(builder).should().enableBatching(false);
+		then(builder).should().enableChunking(true);
+		then(builder).should().compressionType(CompressionType.SNAPPY);
+		then(builder).should().accessMode(ProducerAccessMode.Exclusive);
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	void customizeConsumerBuilder() {
+		PulsarProperties properties = new PulsarProperties();
+		List<String> topics = List.of("mytopic");
+		Pattern topisPattern = Pattern.compile("my-pattern");
+		properties.getConsumer().setName("name");
+		properties.getConsumer().setTopics(topics);
+		properties.getConsumer().setTopicsPattern(topisPattern);
+		properties.getConsumer().setPriorityLevel(123);
+		properties.getConsumer().setReadCompacted(true);
+		Consumer.DeadLetterPolicy deadLetterPolicy = new Consumer.DeadLetterPolicy();
+		deadLetterPolicy.setDeadLetterTopic("my-dlt");
+		deadLetterPolicy.setMaxRedeliverCount(1);
+		properties.getConsumer().setDeadLetterPolicy(deadLetterPolicy);
+		ConsumerBuilder<Object> builder = mock(ConsumerBuilder.class);
+		new PulsarPropertiesMapper(properties).customizeConsumerBuilder(builder);
+		then(builder).should().consumerName("name");
+		then(builder).should().topics(topics);
+		then(builder).should().topicsPattern(topisPattern);
+		then(builder).should().priorityLevel(123);
+		then(builder).should().readCompacted(true);
+		then(builder).should().deadLetterPolicy(new DeadLetterPolicy(1, null, "my-dlt", null));
+	}
+
+	@Test
+	void customizeContainerProperties() {
+		PulsarProperties properties = new PulsarProperties();
+		properties.getConsumer().getSubscription().setType(SubscriptionType.Shared);
+		properties.getListener().setSchemaType(SchemaType.AVRO);
+		properties.getListener().setObservationEnabled(false);
+		PulsarContainerProperties containerProperties = new PulsarContainerProperties("my-topic-pattern");
+		new PulsarPropertiesMapper(properties).customizeContainerProperties(containerProperties);
+		assertThat(containerProperties.getSubscriptionType()).isEqualTo(SubscriptionType.Shared);
+		assertThat(containerProperties.getSchemaType()).isEqualTo(SchemaType.AVRO);
+		assertThat(containerProperties.isObservationEnabled()).isFalse();
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	void customizeReaderBuilder() {
+		PulsarProperties properties = new PulsarProperties();
+		List<String> topics = List.of("mytopic");
+		properties.getReader().setName("name");
+		properties.getReader().setTopics(topics);
+		properties.getReader().setSubscriptionName("subname");
+		properties.getReader().setSubscriptionRolePrefix("subroleprefix");
+		properties.getReader().setReadCompacted(true);
+		ReaderBuilder<Object> builder = mock(ReaderBuilder.class);
+		new PulsarPropertiesMapper(properties).customizeReaderBuilder(builder);
+		then(builder).should().readerName("name");
+		then(builder).should().topics(topics);
+		then(builder).should().subscriptionName("subname");
+		then(builder).should().subscriptionRolePrefix("subroleprefix");
+		then(builder).should().readCompacted(true);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesTests.java
new file mode 100644
index 000000000000..48c17247a4f9
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesTests.java
@@ -0,0 +1,365 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.pulsar;
+
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.pulsar.client.api.CompressionType;
+import org.apache.pulsar.client.api.HashingScheme;
+import org.apache.pulsar.client.api.MessageRoutingMode;
+import org.apache.pulsar.client.api.ProducerAccessMode;
+import org.apache.pulsar.client.api.RegexSubscriptionMode;
+import org.apache.pulsar.client.api.SubscriptionInitialPosition;
+import org.apache.pulsar.client.api.SubscriptionMode;
+import org.apache.pulsar.client.api.SubscriptionType;
+import org.apache.pulsar.common.schema.SchemaType;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Defaults.SchemaInfo;
+import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Defaults.TypeMapping;
+import org.springframework.boot.context.properties.bind.BindException;
+import org.springframework.boot.context.properties.bind.Binder;
+import org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+/**
+ * Tests for {@link PulsarProperties}.
+ *
+ * @author Chris Bono
+ * @author Christophe Bornet
+ * @author Soby Chacko
+ * @author Phillip Webb
+ */
+class PulsarPropertiesTests {
+
+	private PulsarProperties bindPropeties(Map<String, String> map) {
+		return new Binder(new MapConfigurationPropertySource(map)).bind("spring.pulsar", PulsarProperties.class).get();
+	}
+
+	@Nested
+	class ClientProperties {
+
+		@Test
+		void bind() {
+			Map<String, String> map = new HashMap<>();
+			map.put("spring.pulsar.client.service-url", "my-service-url");
+			map.put("spring.pulsar.client.operation-timeout", "1s");
+			map.put("spring.pulsar.client.lookup-timeout", "2s");
+			map.put("spring.pulsar.client.connection-timeout", "12s");
+			PulsarProperties.Client properties = bindPropeties(map).getClient();
+			assertThat(properties.getServiceUrl()).isEqualTo("my-service-url");
+			assertThat(properties.getOperationTimeout()).isEqualTo(Duration.ofMillis(1000));
+			assertThat(properties.getLookupTimeout()).isEqualTo(Duration.ofMillis(2000));
+			assertThat(properties.getConnectionTimeout()).isEqualTo(Duration.ofMillis(12000));
+		}
+
+		@Test
+		void bindAuthentication() {
+			Map<String, String> map = new HashMap<>();
+			map.put("spring.pulsar.client.authentication.plugin-class-name", "com.example.MyAuth");
+			map.put("spring.pulsar.client.authentication.param.token", "1234");
+			PulsarProperties.Client properties = bindPropeties(map).getClient();
+			assertThat(properties.getAuthentication().getPluginClassName()).isEqualTo("com.example.MyAuth");
+			assertThat(properties.getAuthentication().getParam()).containsEntry("token", "1234");
+		}
+
+	}
+
+	@Nested
+	class AdminProperties {
+
+		private final String authPluginClassName = "org.apache.pulsar.client.impl.auth.AuthenticationToken";
+
+		private final String authToken = "1234";
+
+		@Test
+		void bind() {
+			Map<String, String> map = new HashMap<>();
+			map.put("spring.pulsar.admin.service-url", "my-service-url");
+			map.put("spring.pulsar.admin.connection-timeout", "12s");
+			map.put("spring.pulsar.admin.read-timeout", "13s");
+			map.put("spring.pulsar.admin.request-timeout", "14s");
+			PulsarProperties.Admin properties = bindPropeties(map).getAdmin();
+			assertThat(properties.getServiceUrl()).isEqualTo("my-service-url");
+			assertThat(properties.getConnectionTimeout()).isEqualTo(Duration.ofSeconds(12));
+			assertThat(properties.getReadTimeout()).isEqualTo(Duration.ofSeconds(13));
+			assertThat(properties.getRequestTimeout()).isEqualTo(Duration.ofSeconds(14));
+		}
+
+		@Test
+		void bindAuthentication() {
+			Map<String, String> map = new HashMap<>();
+			map.put("spring.pulsar.admin.authentication.plugin-class-name", this.authPluginClassName);
+			map.put("spring.pulsar.admin.authentication.param.token", this.authToken);
+			PulsarProperties.Admin properties = bindPropeties(map).getAdmin();
+			assertThat(properties.getAuthentication().getPluginClassName()).isEqualTo(this.authPluginClassName);
+			assertThat(properties.getAuthentication().getParam()).containsEntry("token", this.authToken);
+		}
+
+	}
+
+	@Nested
+	class DefaultsProperties {
+
+		@Test
+		void bindWhenNoTypeMappings() {
+			assertThat(new PulsarProperties().getDefaults().getTypeMappings()).isEmpty();
+		}
+
+		@Test
+		void bindWhenTypeMappingsWithTopicsOnly() {
+			Map<String, String> map = new HashMap<>();
+			map.put("spring.pulsar.defaults.type-mappings[0].message-type", TestMessage.class.getName());
+			map.put("spring.pulsar.defaults.type-mappings[0].topic-name", "foo-topic");
+			map.put("spring.pulsar.defaults.type-mappings[1].message-type", String.class.getName());
+			map.put("spring.pulsar.defaults.type-mappings[1].topic-name", "string-topic");
+			PulsarProperties.Defaults properties = bindPropeties(map).getDefaults();
+			TypeMapping expectedTopic1 = new TypeMapping(TestMessage.class, "foo-topic", null);
+			TypeMapping expectedTopic2 = new TypeMapping(String.class, "string-topic", null);
+			assertThat(properties.getTypeMappings()).containsExactly(expectedTopic1, expectedTopic2);
+		}
+
+		@Test
+		void bindWhenTypeMappingsWithSchemaOnly() {
+			Map<String, String> map = new HashMap<>();
+			map.put("spring.pulsar.defaults.type-mappings[0].message-type", TestMessage.class.getName());
+			map.put("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type", "JSON");
+			PulsarProperties.Defaults properties = bindPropeties(map).getDefaults();
+			TypeMapping expected = new TypeMapping(TestMessage.class, null, new SchemaInfo(SchemaType.JSON, null));
+			assertThat(properties.getTypeMappings()).containsExactly(expected);
+		}
+
+		@Test
+		void bindWhenTypeMappingsWithTopicAndSchema() {
+			Map<String, String> map = new HashMap<>();
+			map.put("spring.pulsar.defaults.type-mappings[0].message-type", TestMessage.class.getName());
+			map.put("spring.pulsar.defaults.type-mappings[0].topic-name", "foo-topic");
+			map.put("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type", "JSON");
+			PulsarProperties.Defaults properties = bindPropeties(map).getDefaults();
+			TypeMapping expected = new TypeMapping(TestMessage.class, "foo-topic",
+					new SchemaInfo(SchemaType.JSON, null));
+			assertThat(properties.getTypeMappings()).containsExactly(expected);
+		}
+
+		@Test
+		void bindWhenTypeMappingsWithKeyValueSchema() {
+			Map<String, String> map = new HashMap<>();
+			map.put("spring.pulsar.defaults.type-mappings[0].message-type", TestMessage.class.getName());
+			map.put("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type", "KEY_VALUE");
+			map.put("spring.pulsar.defaults.type-mappings[0].schema-info.message-key-type", String.class.getName());
+			PulsarProperties.Defaults properties = bindPropeties(map).getDefaults();
+			TypeMapping expected = new TypeMapping(TestMessage.class, null,
+					new SchemaInfo(SchemaType.KEY_VALUE, String.class));
+			assertThat(properties.getTypeMappings()).containsExactly(expected);
+		}
+
+		@Test
+		void bindWhenNoSchemaThrowsException() {
+			Map<String, String> map = new HashMap<>();
+			map.put("spring.pulsar.defaults.type-mappings[0].message-type", TestMessage.class.getName());
+			map.put("spring.pulsar.defaults.type-mappings[0].schema-info.message-key-type", String.class.getName());
+			assertThatExceptionOfType(BindException.class).isThrownBy(() -> bindPropeties(map))
+				.havingRootCause()
+				.withMessageContaining("schemaType must not be null");
+		}
+
+		@Test
+		void bindWhenSchemaTypeNoneThrowsException() {
+			Map<String, String> map = new HashMap<>();
+			map.put("spring.pulsar.defaults.type-mappings[0].message-type", TestMessage.class.getName());
+			map.put("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type", "NONE");
+			assertThatExceptionOfType(BindException.class).isThrownBy(() -> bindPropeties(map))
+				.havingRootCause()
+				.withMessageContaining("schemaType 'NONE' not supported");
+		}
+
+		@Test
+		void bindWhenMessageKeyTypeSetOnNonKeyValueSchemaThrowsException() {
+			Map<String, String> map = new HashMap<>();
+			map.put("spring.pulsar.defaults.type-mappings[0].message-type", TestMessage.class.getName());
+			map.put("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type", "JSON");
+			map.put("spring.pulsar.defaults.type-mappings[0].schema-info.message-key-type", String.class.getName());
+			assertThatExceptionOfType(BindException.class).isThrownBy(() -> bindPropeties(map))
+				.havingRootCause()
+				.withMessageContaining("messageKeyType can only be set when schemaType is KEY_VALUE");
+		}
+
+		record TestMessage(String value) {
+		}
+
+	}
+
+	@Nested
+	class FunctionProperties {
+
+		@Test
+		void defaults() {
+			PulsarProperties.Function properties = new PulsarProperties.Function();
+			assertThat(properties.isFailFast()).isTrue();
+			assertThat(properties.isPropagateFailures()).isTrue();
+			assertThat(properties.isPropagateStopFailures()).isFalse();
+		}
+
+		@Test
+		void bind() {
+			Map<String, String> props = new HashMap<>();
+			props.put("spring.pulsar.function.fail-fast", "false");
+			props.put("spring.pulsar.function.propagate-failures", "false");
+			props.put("spring.pulsar.function.propagate-stop-failures", "true");
+			PulsarProperties.Function properties = bindPropeties(props).getFunction();
+			assertThat(properties.isFailFast()).isFalse();
+			assertThat(properties.isPropagateFailures()).isFalse();
+			assertThat(properties.isPropagateStopFailures()).isTrue();
+		}
+
+	}
+
+	@Nested
+	class ProducerProperties {
+
+		@Test
+		void bind() {
+			Map<String, String> map = new HashMap<>();
+			map.put("spring.pulsar.producer.name", "my-producer");
+			map.put("spring.pulsar.producer.topic-name", "my-topic");
+			map.put("spring.pulsar.producer.send-timeout", "2s");
+			map.put("spring.pulsar.producer.message-routing-mode", "custompartition");
+			map.put("spring.pulsar.producer.hashing-scheme", "murmur3_32hash");
+			map.put("spring.pulsar.producer.batching-enabled", "false");
+			map.put("spring.pulsar.producer.chunking-enabled", "true");
+			map.put("spring.pulsar.producer.compression-type", "lz4");
+			map.put("spring.pulsar.producer.access-mode", "exclusive");
+			map.put("spring.pulsar.producer.cache.expire-after-access", "2s");
+			map.put("spring.pulsar.producer.cache.maximum-size", "3");
+			map.put("spring.pulsar.producer.cache.initial-capacity", "5");
+			PulsarProperties.Producer properties = bindPropeties(map).getProducer();
+			assertThat(properties.getName()).isEqualTo("my-producer");
+			assertThat(properties.getTopicName()).isEqualTo("my-topic");
+			assertThat(properties.getSendTimeout()).isEqualTo(Duration.ofSeconds(2));
+			assertThat(properties.getMessageRoutingMode()).isEqualTo(MessageRoutingMode.CustomPartition);
+			assertThat(properties.getHashingScheme()).isEqualTo(HashingScheme.Murmur3_32Hash);
+			assertThat(properties.isBatchingEnabled()).isFalse();
+			assertThat(properties.isChunkingEnabled()).isTrue();
+			assertThat(properties.getCompressionType()).isEqualTo(CompressionType.LZ4);
+			assertThat(properties.getAccessMode()).isEqualTo(ProducerAccessMode.Exclusive);
+			assertThat(properties.getCache().getExpireAfterAccess()).isEqualTo(Duration.ofSeconds(2));
+			assertThat(properties.getCache().getMaximumSize()).isEqualTo(3);
+			assertThat(properties.getCache().getInitialCapacity()).isEqualTo(5);
+		}
+
+	}
+
+	@Nested
+	class ConsumerPropertiesTests {
+
+		@Test
+		void bind() {
+			Map<String, String> map = new HashMap<>();
+			map.put("spring.pulsar.consumer.name", "my-consumer");
+			map.put("spring.pulsar.consumer.subscription.initial-position", "earliest");
+			map.put("spring.pulsar.consumer.subscription.mode", "nondurable");
+			map.put("spring.pulsar.consumer.subscription.name", "my-subscription");
+			map.put("spring.pulsar.consumer.subscription.topics-mode", "all-topics");
+			map.put("spring.pulsar.consumer.subscription.type", "shared");
+			map.put("spring.pulsar.consumer.topics[0]", "my-topic");
+			map.put("spring.pulsar.consumer.topics-pattern", "my-pattern");
+			map.put("spring.pulsar.consumer.priority-level", "8");
+			map.put("spring.pulsar.consumer.read-compacted", "true");
+			map.put("spring.pulsar.consumer.dead-letter-policy.max-redeliver-count", "4");
+			map.put("spring.pulsar.consumer.dead-letter-policy.retry-letter-topic", "my-retry-topic");
+			map.put("spring.pulsar.consumer.dead-letter-policy.dead-letter-topic", "my-dlt-topic");
+			map.put("spring.pulsar.consumer.dead-letter-policy.initial-subscription-name", "my-initial-subscription");
+			map.put("spring.pulsar.consumer.retry-enable", "true");
+			PulsarProperties.Consumer properties = bindPropeties(map).getConsumer();
+			assertThat(properties.getName()).isEqualTo("my-consumer");
+			assertThat(properties.getSubscription()).satisfies((subscription) -> {
+				assertThat(subscription.getName()).isEqualTo("my-subscription");
+				assertThat(subscription.getType()).isEqualTo(SubscriptionType.Shared);
+				assertThat(subscription.getMode()).isEqualTo(SubscriptionMode.NonDurable);
+				assertThat(subscription.getInitialPosition()).isEqualTo(SubscriptionInitialPosition.Earliest);
+				assertThat(subscription.getTopicsMode()).isEqualTo(RegexSubscriptionMode.AllTopics);
+			});
+			assertThat(properties.getTopics()).containsExactly("my-topic");
+			assertThat(properties.getTopicsPattern().toString()).isEqualTo("my-pattern");
+			assertThat(properties.getPriorityLevel()).isEqualTo(8);
+			assertThat(properties.isReadCompacted()).isTrue();
+			assertThat(properties.getDeadLetterPolicy()).satisfies((policy) -> {
+				assertThat(policy.getMaxRedeliverCount()).isEqualTo(4);
+				assertThat(policy.getRetryLetterTopic()).isEqualTo("my-retry-topic");
+				assertThat(policy.getDeadLetterTopic()).isEqualTo("my-dlt-topic");
+				assertThat(policy.getInitialSubscriptionName()).isEqualTo("my-initial-subscription");
+			});
+			assertThat(properties.isRetryEnable()).isTrue();
+		}
+
+	}
+
+	@Nested
+	class ListenerProperties {
+
+		@Test
+		void bind() {
+			Map<String, String> map = new HashMap<>();
+			map.put("spring.pulsar.listener.schema-type", "avro");
+			map.put("spring.pulsar.listener.observation-enabled", "false");
+			PulsarProperties.Listener properties = bindPropeties(map).getListener();
+			assertThat(properties.getSchemaType()).isEqualTo(SchemaType.AVRO);
+			assertThat(properties.isObservationEnabled()).isFalse();
+		}
+
+	}
+
+	@Nested
+	class ReaderProperties {
+
+		@Test
+		void bind() {
+			Map<String, String> map = new HashMap<>();
+			map.put("spring.pulsar.reader.name", "my-reader");
+			map.put("spring.pulsar.reader.topics", "my-topic");
+			map.put("spring.pulsar.reader.subscription-name", "my-subscription");
+			map.put("spring.pulsar.reader.subscription-role-prefix", "sub-role");
+			map.put("spring.pulsar.reader.read-compacted", "true");
+			PulsarProperties.Reader properties = bindPropeties(map).getReader();
+			assertThat(properties.getName()).isEqualTo("my-reader");
+			assertThat(properties.getTopics()).containsExactly("my-topic");
+			assertThat(properties.getSubscriptionName()).isEqualTo("my-subscription");
+			assertThat(properties.getSubscriptionRolePrefix()).isEqualTo("sub-role");
+			assertThat(properties.isReadCompacted()).isTrue();
+		}
+
+	}
+
+	@Nested
+	class TemplateProperties {
+
+		@Test
+		void bind() {
+			Map<String, String> map = new HashMap<>();
+			map.put("spring.pulsar.template.observations-enabled", "false");
+			PulsarProperties.Template properties = bindPropeties(map).getTemplate();
+			assertThat(properties.isObservationsEnabled()).isFalse();
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactiveAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactiveAutoConfigurationTests.java
new file mode 100644
index 000000000000..4f3ab011ea2e
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactiveAutoConfigurationTests.java
@@ -0,0 +1,473 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.pulsar;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Supplier;
+
+import com.github.benmanes.caffeine.cache.Caffeine;
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.common.schema.SchemaType;
+import org.apache.pulsar.reactive.client.adapter.ProducerCacheProvider;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageConsumerBuilder;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageReaderBuilder;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderBuilder;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderCache;
+import org.apache.pulsar.reactive.client.api.ReactivePulsarClient;
+import org.apache.pulsar.reactive.client.producercache.CaffeineShadedProducerCacheProvider;
+import org.assertj.core.api.AbstractObjectAssert;
+import org.assertj.core.api.InstanceOfAssertFactories;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.FilteredClassLoader;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.core.annotation.Order;
+import org.springframework.pulsar.core.DefaultSchemaResolver;
+import org.springframework.pulsar.core.DefaultTopicResolver;
+import org.springframework.pulsar.core.PulsarAdministration;
+import org.springframework.pulsar.core.SchemaResolver;
+import org.springframework.pulsar.core.TopicResolver;
+import org.springframework.pulsar.reactive.config.DefaultReactivePulsarListenerContainerFactory;
+import org.springframework.pulsar.reactive.config.ReactivePulsarListenerContainerFactory;
+import org.springframework.pulsar.reactive.config.ReactivePulsarListenerEndpointRegistry;
+import org.springframework.pulsar.reactive.config.annotation.ReactivePulsarBootstrapConfiguration;
+import org.springframework.pulsar.reactive.config.annotation.ReactivePulsarListenerAnnotationBeanPostProcessor;
+import org.springframework.pulsar.reactive.core.DefaultReactivePulsarConsumerFactory;
+import org.springframework.pulsar.reactive.core.DefaultReactivePulsarReaderFactory;
+import org.springframework.pulsar.reactive.core.DefaultReactivePulsarSenderFactory;
+import org.springframework.pulsar.reactive.core.ReactiveMessageConsumerBuilderCustomizer;
+import org.springframework.pulsar.reactive.core.ReactiveMessageReaderBuilderCustomizer;
+import org.springframework.pulsar.reactive.core.ReactiveMessageSenderBuilderCustomizer;
+import org.springframework.pulsar.reactive.core.ReactivePulsarConsumerFactory;
+import org.springframework.pulsar.reactive.core.ReactivePulsarReaderFactory;
+import org.springframework.pulsar.reactive.core.ReactivePulsarSenderFactory;
+import org.springframework.pulsar.reactive.core.ReactivePulsarTemplate;
+import org.springframework.pulsar.reactive.listener.ReactivePulsarContainerProperties;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link PulsarReactiveAutoConfiguration}.
+ *
+ * @author Chris Bono
+ * @author Christophe Bornet
+ * @author Phillip Webb
+ */
+class PulsarReactiveAutoConfigurationTests {
+
+	private static final String INTERNAL_PULSAR_LISTENER_ANNOTATION_PROCESSOR = "org.springframework.pulsar.config.internalReactivePulsarListenerAnnotationProcessor";
+
+	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+		.withConfiguration(AutoConfigurations.of(PulsarReactiveAutoConfiguration.class))
+		.withBean(PulsarClient.class, () -> mock(PulsarClient.class));
+
+	@Test
+	void whenPulsarNotOnClasspathAutoConfigurationIsSkipped() {
+		new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(PulsarReactiveAutoConfiguration.class))
+			.withClassLoader(new FilteredClassLoader(PulsarClient.class))
+			.run((context) -> assertThat(context).doesNotHaveBean(PulsarReactiveAutoConfiguration.class));
+	}
+
+	@Test
+	void whenReactivePulsarNotOnClasspathAutoConfigurationIsSkipped() {
+		this.contextRunner.withClassLoader(new FilteredClassLoader(ReactivePulsarClient.class))
+			.run((context) -> assertThat(context).doesNotHaveBean(PulsarReactiveAutoConfiguration.class));
+	}
+
+	@Test
+	void whenReactiveSpringPulsarNotOnClasspathAutoConfigurationIsSkipped() {
+		this.contextRunner.withClassLoader(new FilteredClassLoader(ReactivePulsarTemplate.class))
+			.run((context) -> assertThat(context).doesNotHaveBean(PulsarReactiveAutoConfiguration.class));
+	}
+
+	@Test
+	void whenCustomPulsarListenerAnnotationProcessorDefinedAutoConfigurationIsSkipped() {
+		this.contextRunner.withBean(INTERNAL_PULSAR_LISTENER_ANNOTATION_PROCESSOR, String.class, () -> "bean")
+			.run((context) -> assertThat(context).doesNotHaveBean(ReactivePulsarBootstrapConfiguration.class));
+	}
+
+	@Test
+	void autoConfiguresBeans() {
+		this.contextRunner.run((context) -> assertThat(context).hasSingleBean(PulsarConfiguration.class)
+			.hasSingleBean(PulsarClient.class)
+			.hasSingleBean(PulsarAdministration.class)
+			.hasSingleBean(DefaultSchemaResolver.class)
+			.hasSingleBean(DefaultTopicResolver.class)
+			.hasSingleBean(ReactivePulsarClient.class)
+			.hasSingleBean(CaffeineShadedProducerCacheProvider.class)
+			.hasSingleBean(ReactiveMessageSenderCache.class)
+			.hasSingleBean(DefaultReactivePulsarSenderFactory.class)
+			.hasSingleBean(ReactivePulsarTemplate.class)
+			.hasSingleBean(DefaultReactivePulsarConsumerFactory.class)
+			.hasSingleBean(DefaultReactivePulsarListenerContainerFactory.class)
+			.hasSingleBean(ReactivePulsarListenerAnnotationBeanPostProcessor.class)
+			.hasSingleBean(ReactivePulsarListenerEndpointRegistry.class));
+	}
+
+	@Test
+	@SuppressWarnings("rawtypes")
+	void injectsExpectedBeansIntoReactivePulsarClient() {
+		this.contextRunner.run((context) -> {
+			PulsarClient pulsarClient = context.getBean(PulsarClient.class);
+			assertThat(context).hasNotFailed()
+				.getBean(ReactivePulsarClient.class)
+				.extracting("reactivePulsarResourceAdapter")
+				.extracting("pulsarClientSupplier", InstanceOfAssertFactories.type(Supplier.class))
+				.extracting(Supplier::get)
+				.isSameAs(pulsarClient);
+		});
+	}
+
+	@ParameterizedTest
+	@ValueSource(classes = { ReactivePulsarClient.class, ProducerCacheProvider.class, ReactiveMessageSenderCache.class,
+			ReactivePulsarSenderFactory.class, ReactivePulsarConsumerFactory.class, ReactivePulsarReaderFactory.class,
+			ReactivePulsarTemplate.class })
+	<T> void whenHasUserDefinedBeanDoesNotAutoConfigureBean(Class<T> beanClass) {
+		T bean = mock(beanClass);
+		this.contextRunner.withBean(beanClass.getName(), beanClass, () -> bean)
+			.run((context) -> assertThat(context).getBean(beanClass).isSameAs(bean));
+	}
+
+	@Nested
+	class SenderFactoryTests {
+
+		private final ApplicationContextRunner contextRunner = PulsarReactiveAutoConfigurationTests.this.contextRunner;
+
+		@Test
+		void injectsExpectedBeans() {
+			ReactivePulsarClient client = mock(ReactivePulsarClient.class);
+			ReactiveMessageSenderCache cache = mock(ReactiveMessageSenderCache.class);
+			this.contextRunner.withPropertyValues("spring.pulsar.producer.topic-name=test-topic")
+				.withBean("customReactivePulsarClient", ReactivePulsarClient.class, () -> client)
+				.withBean("customReactiveMessageSenderCache", ReactiveMessageSenderCache.class, () -> cache)
+				.run((context) -> {
+					DefaultReactivePulsarSenderFactory<?> senderFactory = context
+						.getBean(DefaultReactivePulsarSenderFactory.class);
+					assertThat(senderFactory)
+						.extracting("reactivePulsarClient", InstanceOfAssertFactories.type(ReactivePulsarClient.class))
+						.isSameAs(client);
+					assertThat(senderFactory)
+						.extracting("reactiveMessageSenderCache",
+								InstanceOfAssertFactories.type(ReactiveMessageSenderCache.class))
+						.isSameAs(cache);
+					assertThat(senderFactory)
+						.extracting("topicResolver", InstanceOfAssertFactories.type(TopicResolver.class))
+						.isSameAs(context.getBean(TopicResolver.class));
+				});
+		}
+
+		@Test
+		void injectsExpectedBeansIntoReactiveMessageSenderCache() {
+			ProducerCacheProvider provider = mock(ProducerCacheProvider.class);
+			this.contextRunner.withBean("customProducerCacheProvider", ProducerCacheProvider.class, () -> provider)
+				.run((context) -> assertThat(context).getBean(ReactiveMessageSenderCache.class)
+					.extracting("cacheProvider", InstanceOfAssertFactories.type(ProducerCacheProvider.class))
+					.isSameAs(provider));
+		}
+
+		@Test
+		<T> void whenHasUserDefinedCustomizersAppliesInCorrectOrder() {
+			this.contextRunner.withPropertyValues("spring.pulsar.producer.name=fromPropsCustomizer")
+				.withUserConfiguration(ReactiveMessageSenderBuilderCustomizerConfig.class)
+				.run((context) -> {
+					DefaultReactivePulsarSenderFactory<?> producerFactory = context
+						.getBean(DefaultReactivePulsarSenderFactory.class);
+					Customizers<ReactiveMessageSenderBuilderCustomizer<T>, ReactiveMessageSenderBuilder<T>> customizers = Customizers
+						.of(ReactiveMessageSenderBuilder.class, ReactiveMessageSenderBuilderCustomizer::customize);
+					assertThat(customizers.fromField(producerFactory, "defaultConfigCustomizers")).callsInOrder(
+							ReactiveMessageSenderBuilder::producerName, "fromPropsCustomizer", "fromCustomizer1",
+							"fromCustomizer2");
+				});
+		}
+
+		@TestConfiguration(proxyBeanMethods = false)
+		static class ReactiveMessageSenderBuilderCustomizerConfig {
+
+			@Bean
+			@Order(200)
+			ReactiveMessageSenderBuilderCustomizer<?> customizerFoo() {
+				return (builder) -> builder.producerName("fromCustomizer2");
+			}
+
+			@Bean
+			@Order(100)
+			ReactiveMessageSenderBuilderCustomizer<?> customizerBar() {
+				return (builder) -> builder.producerName("fromCustomizer1");
+			}
+
+		}
+
+	}
+
+	@Nested
+	class TemplateTests {
+
+		private final ApplicationContextRunner contextRunner = PulsarReactiveAutoConfigurationTests.this.contextRunner;
+
+		@Test
+		@SuppressWarnings("rawtypes")
+		void injectsExpectedBeans() {
+			ReactivePulsarSenderFactory senderFactory = mock(ReactivePulsarSenderFactory.class);
+			SchemaResolver schemaResolver = mock(SchemaResolver.class);
+			this.contextRunner
+				.withBean("customReactivePulsarSenderFactory", ReactivePulsarSenderFactory.class, () -> senderFactory)
+				.withBean("schemaResolver", SchemaResolver.class, () -> schemaResolver)
+				.run((context) -> assertThat(context).getBean(ReactivePulsarTemplate.class).satisfies((template) -> {
+					assertThat(template).extracting("reactiveMessageSenderFactory").isSameAs(senderFactory);
+					assertThat(template).extracting("schemaResolver").isSameAs(schemaResolver);
+				}));
+		}
+
+	}
+
+	@Nested
+	class ConsumerFactoryTests {
+
+		private final ApplicationContextRunner contextRunner = PulsarReactiveAutoConfigurationTests.this.contextRunner;
+
+		@Test
+		void injectsExpectedBeans() {
+			ReactivePulsarClient client = mock(ReactivePulsarClient.class);
+			this.contextRunner.withBean("customReactivePulsarClient", ReactivePulsarClient.class, () -> client)
+				.run((context) -> {
+					ReactivePulsarConsumerFactory<?> consumerFactory = context
+						.getBean(DefaultReactivePulsarConsumerFactory.class);
+					assertThat(consumerFactory)
+						.extracting("reactivePulsarClient", InstanceOfAssertFactories.type(ReactivePulsarClient.class))
+						.isSameAs(client);
+				});
+		}
+
+		@Test
+		<T> void whenHasUserDefinedCustomizersAppliesInCorrectOrder() {
+			this.contextRunner.withPropertyValues("spring.pulsar.consumer.name=fromPropsCustomizer")
+				.withUserConfiguration(ReactiveMessageConsumerBuilderCustomizerConfig.class)
+				.run((context) -> {
+					DefaultReactivePulsarConsumerFactory<?> consumerFactory = context
+						.getBean(DefaultReactivePulsarConsumerFactory.class);
+					Customizers<ReactiveMessageConsumerBuilderCustomizer<T>, ReactiveMessageConsumerBuilder<T>> customizers = Customizers
+						.of(ReactiveMessageConsumerBuilder.class, ReactiveMessageConsumerBuilderCustomizer::customize);
+					assertThat(customizers.fromField(consumerFactory, "defaultConfigCustomizers")).callsInOrder(
+							ReactiveMessageConsumerBuilder::consumerName, "fromPropsCustomizer", "fromCustomizer1",
+							"fromCustomizer2");
+				});
+		}
+
+		@TestConfiguration(proxyBeanMethods = false)
+		static class ReactiveMessageConsumerBuilderCustomizerConfig {
+
+			@Bean
+			@Order(200)
+			ReactiveMessageConsumerBuilderCustomizer<?> customizerFoo() {
+				return (builder) -> builder.consumerName("fromCustomizer2");
+			}
+
+			@Bean
+			@Order(100)
+			ReactiveMessageConsumerBuilderCustomizer<?> customizerBar() {
+				return (builder) -> builder.consumerName("fromCustomizer1");
+			}
+
+		}
+
+	}
+
+	@Nested
+	class ListenerTests {
+
+		private final ApplicationContextRunner contextRunner = PulsarReactiveAutoConfigurationTests.this.contextRunner;
+
+		@Test
+		void whenHasUserDefinedBeanDoesNotAutoConfigureBean() {
+			ReactivePulsarListenerContainerFactory<?> listenerContainerFactory = mock(
+					ReactivePulsarListenerContainerFactory.class);
+			this.contextRunner
+				.withBean("reactivePulsarListenerContainerFactory", ReactivePulsarListenerContainerFactory.class,
+						() -> listenerContainerFactory)
+				.run((context) -> assertThat(context).getBean(ReactivePulsarListenerContainerFactory.class)
+					.isSameAs(listenerContainerFactory));
+		}
+
+		@Test
+		void whenHasUserDefinedReactivePulsarListenerAnnotationBeanPostProcessorDoesNotAutoConfigureBean() {
+			ReactivePulsarListenerAnnotationBeanPostProcessor<?> listenerAnnotationBeanPostProcessor = mock(
+					ReactivePulsarListenerAnnotationBeanPostProcessor.class);
+			this.contextRunner.withBean(INTERNAL_PULSAR_LISTENER_ANNOTATION_PROCESSOR,
+					ReactivePulsarListenerAnnotationBeanPostProcessor.class, () -> listenerAnnotationBeanPostProcessor)
+				.run((context) -> assertThat(context).getBean(ReactivePulsarListenerAnnotationBeanPostProcessor.class)
+					.isSameAs(listenerAnnotationBeanPostProcessor));
+		}
+
+		@Test
+		void whenHasCustomProperties() {
+			List<String> properties = new ArrayList<>();
+			properties.add("spring.pulsar.listener.schema-type=avro");
+			this.contextRunner.withPropertyValues(properties.toArray(String[]::new)).run((context) -> {
+				DefaultReactivePulsarListenerContainerFactory<?> factory = context
+					.getBean(DefaultReactivePulsarListenerContainerFactory.class);
+				assertThat(factory.getContainerProperties().getSchemaType()).isEqualTo(SchemaType.AVRO);
+			});
+		}
+
+		@Test
+		void injectsExpectedBeans() {
+			ReactivePulsarConsumerFactory<?> consumerFactory = mock(ReactivePulsarConsumerFactory.class);
+			SchemaResolver schemaResolver = mock(SchemaResolver.class);
+			this.contextRunner
+				.withBean("customReactivePulsarConsumerFactory", ReactivePulsarConsumerFactory.class,
+						() -> consumerFactory)
+				.withBean("schemaResolver", SchemaResolver.class, () -> schemaResolver)
+				.run((context) -> {
+					DefaultReactivePulsarListenerContainerFactory<?> containerFactory = context
+						.getBean(DefaultReactivePulsarListenerContainerFactory.class);
+					assertThat(containerFactory).extracting("consumerFactory").isSameAs(consumerFactory);
+					assertThat(containerFactory)
+						.extracting(DefaultReactivePulsarListenerContainerFactory::getContainerProperties)
+						.extracting(ReactivePulsarContainerProperties::getSchemaResolver)
+						.isSameAs(schemaResolver);
+				});
+		}
+
+	}
+
+	@Nested
+	class ReaderFactoryTests {
+
+		private final ApplicationContextRunner contextRunner = PulsarReactiveAutoConfigurationTests.this.contextRunner;
+
+		@Test
+		void injectsExpectedBeans() {
+			ReactivePulsarClient client = mock(ReactivePulsarClient.class);
+			this.contextRunner.withPropertyValues("spring.pulsar.reader.name=test-reader")
+				.withBean("customReactivePulsarClient", ReactivePulsarClient.class, () -> client)
+				.run((context) -> {
+					DefaultReactivePulsarReaderFactory<?> readerFactory = context
+						.getBean(DefaultReactivePulsarReaderFactory.class);
+					assertThat(readerFactory)
+						.extracting("reactivePulsarClient", InstanceOfAssertFactories.type(ReactivePulsarClient.class))
+						.isSameAs(client);
+				});
+		}
+
+		@Test
+		<T> void whenHasUserDefinedCustomizersAppliesInCorrectOrder() {
+			this.contextRunner.withPropertyValues("spring.pulsar.reader.name=fromPropsCustomizer")
+				.withUserConfiguration(ReactiveMessageReaderBuilderCustomizerConfig.class)
+				.run((context) -> {
+					DefaultReactivePulsarReaderFactory<?> readerFactory = context
+						.getBean(DefaultReactivePulsarReaderFactory.class);
+					Customizers<ReactiveMessageReaderBuilderCustomizer<T>, ReactiveMessageReaderBuilder<T>> customizers = Customizers
+						.of(ReactiveMessageReaderBuilder.class, ReactiveMessageReaderBuilderCustomizer::customize);
+					assertThat(customizers.fromField(readerFactory, "defaultConfigCustomizers")).callsInOrder(
+							ReactiveMessageReaderBuilder::readerName, "fromPropsCustomizer", "fromCustomizer1",
+							"fromCustomizer2");
+				});
+		}
+
+		@TestConfiguration(proxyBeanMethods = false)
+		static class ReactiveMessageReaderBuilderCustomizerConfig {
+
+			@Bean
+			@Order(200)
+			ReactiveMessageReaderBuilderCustomizer<?> customizerFoo() {
+				return (builder) -> builder.readerName("fromCustomizer2");
+			}
+
+			@Bean
+			@Order(100)
+			ReactiveMessageReaderBuilderCustomizer<?> customizerBar() {
+				return (builder) -> builder.readerName("fromCustomizer1");
+			}
+
+		}
+
+	}
+
+	@Nested
+	class SenderCacheAutoConfigurationTests {
+
+		private final ApplicationContextRunner contextRunner = PulsarReactiveAutoConfigurationTests.this.contextRunner;
+
+		@Test
+		void whenNoPropertiesEnablesCaching() {
+			this.contextRunner.run(this::assertCaffeineProducerCacheProvider);
+		}
+
+		@Test
+		void whenCachingEnabledEnablesCaching() {
+			this.contextRunner.withPropertyValues("spring.pulsar.producer.cache.enabled=true")
+				.run(this::assertCaffeineProducerCacheProvider);
+		}
+
+		@Test
+		void whenCachingDisabledDoesNotEnableCaching() {
+			this.contextRunner.withPropertyValues("spring.pulsar.producer.cache.enabled=false")
+				.run((context) -> assertThat(context).doesNotHaveBean(ProducerCacheProvider.class)
+					.doesNotHaveBean(ReactiveMessageSenderCache.class));
+		}
+
+		@Test
+		void whenCachingEnabledAndCaffeineNotOnClasspathStillUsesCaffeine() {
+			// The reactive client shades Caffeine - it should still be used
+			this.contextRunner.withClassLoader(new FilteredClassLoader(Caffeine.class))
+				.withPropertyValues("spring.pulsar.producer.cache.enabled=true")
+				.run(this::assertCaffeineProducerCacheProvider);
+		}
+
+		@Test
+		void whenCachingEnabledAndNoCacheProviderAvailable() {
+			// The reactive client uses a shaded caffeine cache provider as its internal
+			// cache
+			this.contextRunner.withClassLoader(new FilteredClassLoader(CaffeineShadedProducerCacheProvider.class))
+				.withPropertyValues("spring.pulsar.producer.cache.enabled=true")
+				.run((context) -> assertThat(context).doesNotHaveBean(ProducerCacheProvider.class)
+					.getBean(ReactiveMessageSenderCache.class)
+					.extracting("cacheProvider")
+					.isExactlyInstanceOf(CaffeineShadedProducerCacheProvider.class));
+		}
+
+		@Test
+		void whenCustomCachingPropertiesCreatesConfiguredBean() {
+			this.contextRunner
+				.withPropertyValues("spring.pulsar.producer.cache.expire-after-access=100s",
+						"spring.pulsar.producer.cache.maximum-size=5150",
+						"spring.pulsar.producer.cache.initial-capacity=200")
+				.run((context) -> assertCaffeineProducerCacheProvider(context).extracting("cache.cache")
+					.hasFieldOrPropertyWithValue("expiresAfterAccessNanos", Duration.ofSeconds(100).toNanos())
+					.hasFieldOrPropertyWithValue("maximum", 5150L));
+		}
+
+		private AbstractObjectAssert<?, ProducerCacheProvider> assertCaffeineProducerCacheProvider(
+				AssertableApplicationContext context) {
+			return assertThat(context).hasSingleBean(ReactiveMessageSenderCache.class)
+				.getBean(ProducerCacheProvider.class)
+				.isExactlyInstanceOf(CaffeineShadedProducerCacheProvider.class);
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactivePropertiesMapperTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactivePropertiesMapperTests.java
new file mode 100644
index 000000000000..df078b21a354
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactivePropertiesMapperTests.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.pulsar;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import org.apache.pulsar.client.api.CompressionType;
+import org.apache.pulsar.client.api.DeadLetterPolicy;
+import org.apache.pulsar.client.api.HashingScheme;
+import org.apache.pulsar.client.api.MessageRoutingMode;
+import org.apache.pulsar.client.api.ProducerAccessMode;
+import org.apache.pulsar.client.api.RegexSubscriptionMode;
+import org.apache.pulsar.client.api.SubscriptionInitialPosition;
+import org.apache.pulsar.client.api.SubscriptionMode;
+import org.apache.pulsar.client.api.SubscriptionType;
+import org.apache.pulsar.common.schema.SchemaType;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageConsumerBuilder;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageReaderBuilder;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderBuilder;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Consumer;
+import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Consumer.Subscription;
+import org.springframework.pulsar.reactive.listener.ReactivePulsarContainerProperties;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link PulsarReactivePropertiesMapper}.
+ *
+ * @author Chris Bono
+ * @author Phillip Webb
+ */
+class PulsarReactivePropertiesMapperTests {
+
+	@Test
+	@SuppressWarnings("unchecked")
+	void customizeMessageSenderBuilder() {
+		PulsarProperties properties = new PulsarProperties();
+		properties.getProducer().setName("name");
+		properties.getProducer().setTopicName("topicname");
+		properties.getProducer().setSendTimeout(Duration.ofSeconds(1));
+		properties.getProducer().setMessageRoutingMode(MessageRoutingMode.RoundRobinPartition);
+		properties.getProducer().setHashingScheme(HashingScheme.JavaStringHash);
+		properties.getProducer().setBatchingEnabled(false);
+		properties.getProducer().setChunkingEnabled(true);
+		properties.getProducer().setCompressionType(CompressionType.SNAPPY);
+		properties.getProducer().setAccessMode(ProducerAccessMode.Exclusive);
+		ReactiveMessageSenderBuilder<Object> builder = mock(ReactiveMessageSenderBuilder.class);
+		new PulsarReactivePropertiesMapper(properties).customizeMessageSenderBuilder(builder);
+		then(builder).should().producerName("name");
+		then(builder).should().topic("topicname");
+		then(builder).should().sendTimeout(Duration.ofSeconds(1));
+		then(builder).should().messageRoutingMode(MessageRoutingMode.RoundRobinPartition);
+		then(builder).should().hashingScheme(HashingScheme.JavaStringHash);
+		then(builder).should().batchingEnabled(false);
+		then(builder).should().chunkingEnabled(true);
+		then(builder).should().compressionType(CompressionType.SNAPPY);
+		then(builder).should().accessMode(ProducerAccessMode.Exclusive);
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	void customizeMessageConsumerBuilder() {
+		PulsarProperties properties = new PulsarProperties();
+		List<String> topics = List.of("mytopic");
+		Pattern topisPattern = Pattern.compile("my-pattern");
+		properties.getConsumer().setName("name");
+		properties.getConsumer().setTopics(topics);
+		properties.getConsumer().setTopicsPattern(topisPattern);
+		properties.getConsumer().setPriorityLevel(123);
+		properties.getConsumer().setReadCompacted(true);
+		Consumer.DeadLetterPolicy deadLetterPolicy = new Consumer.DeadLetterPolicy();
+		deadLetterPolicy.setDeadLetterTopic("my-dlt");
+		deadLetterPolicy.setMaxRedeliverCount(1);
+		properties.getConsumer().setDeadLetterPolicy(deadLetterPolicy);
+		properties.getConsumer().setRetryEnable(false);
+		Subscription subscriptionProperties = properties.getConsumer().getSubscription();
+		subscriptionProperties.setName("subname");
+		subscriptionProperties.setInitialPosition(SubscriptionInitialPosition.Earliest);
+		subscriptionProperties.setMode(SubscriptionMode.NonDurable);
+		subscriptionProperties.setTopicsMode(RegexSubscriptionMode.NonPersistentOnly);
+		subscriptionProperties.setType(SubscriptionType.Key_Shared);
+		ReactiveMessageConsumerBuilder<Object> builder = mock(ReactiveMessageConsumerBuilder.class);
+		new PulsarReactivePropertiesMapper(properties).customizeMessageConsumerBuilder(builder);
+		then(builder).should().consumerName("name");
+		then(builder).should().topics(topics);
+		then(builder).should().topicsPattern(topisPattern);
+		then(builder).should().priorityLevel(123);
+		then(builder).should().readCompacted(true);
+		then(builder).should().deadLetterPolicy(new DeadLetterPolicy(1, null, "my-dlt", null));
+		then(builder).should().retryLetterTopicEnable(false);
+		then(builder).should().subscriptionName("subname");
+		then(builder).should().subscriptionInitialPosition(SubscriptionInitialPosition.Earliest);
+		then(builder).should().subscriptionMode(SubscriptionMode.NonDurable);
+		then(builder).should().topicsPatternSubscriptionMode(RegexSubscriptionMode.NonPersistentOnly);
+		then(builder).should().subscriptionType(SubscriptionType.Key_Shared);
+	}
+
+	@Test
+	void customizeContainerProperties() {
+		PulsarProperties properties = new PulsarProperties();
+		properties.getConsumer().getSubscription().setType(SubscriptionType.Shared);
+		properties.getListener().setSchemaType(SchemaType.AVRO);
+		ReactivePulsarContainerProperties<Object> containerProperties = new ReactivePulsarContainerProperties<>();
+		new PulsarReactivePropertiesMapper(properties).customizeContainerProperties(containerProperties);
+		assertThat(containerProperties.getSubscriptionType()).isEqualTo(SubscriptionType.Shared);
+		assertThat(containerProperties.getSchemaType()).isEqualTo(SchemaType.AVRO);
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	void customizeMessageReaderBuilder() {
+		List<String> topics = List.of("my-topic");
+		PulsarProperties properties = new PulsarProperties();
+		properties.getReader().setName("name");
+		properties.getReader().setTopics(topics);
+		properties.getReader().setSubscriptionName("subname");
+		properties.getReader().setSubscriptionRolePrefix("srp");
+		ReactiveMessageReaderBuilder<Object> builder = mock(ReactiveMessageReaderBuilder.class);
+		new PulsarReactivePropertiesMapper(properties).customizeMessageReaderBuilder(builder);
+		then(builder).should().readerName("name");
+		then(builder).should().topics(topics);
+		then(builder).should().subscriptionName("subname");
+		then(builder).should().generatedSubscriptionNamePrefix("srp");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/reactor/ReactorAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/reactor/ReactorAutoConfigurationTests.java
new file mode 100644
index 000000000000..1587fc2050db
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/reactor/ReactorAutoConfigurationTests.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.reactor;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+import io.micrometer.context.ContextRegistry;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Hooks;
+import reactor.core.publisher.Mono;
+import reactor.util.context.Context;
+
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ReactorAutoConfiguration}.
+ *
+ * @author Brian Clozel
+ * @author Moritz Halbritter
+ */
+class ReactorAutoConfigurationTests {
+
+	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+		.withConfiguration(AutoConfigurations.of(ReactorAutoConfiguration.class));
+
+	private static final String THREADLOCAL_KEY = "ReactorAutoConfigurationTests";
+
+	private static final ThreadLocal<String> THREADLOCAL_VALUE = ThreadLocal.withInitial(() -> "initial");
+
+	@BeforeEach
+	@AfterEach
+	void resetStaticState() {
+		Hooks.disableAutomaticContextPropagation();
+	}
+
+	@BeforeAll
+	static void initializeThreadLocalAccessors() {
+		ContextRegistry globalRegistry = ContextRegistry.getInstance();
+		globalRegistry.registerThreadLocalAccessor(THREADLOCAL_KEY, THREADLOCAL_VALUE);
+	}
+
+	@AfterAll
+	static void removeThreadLocalAccessors() {
+		ContextRegistry globalRegistry = ContextRegistry.getInstance();
+		globalRegistry.removeThreadLocalAccessor(THREADLOCAL_KEY);
+	}
+
+	@Test
+	void shouldNotConfigurePropagationByDefault() {
+		AtomicReference<String> threadLocalValue = new AtomicReference<>();
+		this.contextRunner.run((applicationContext) -> {
+			Mono.just("test")
+				.doOnNext((element) -> threadLocalValue.set(THREADLOCAL_VALUE.get()))
+				.contextWrite(Context.of(THREADLOCAL_KEY, "updated"))
+				.block();
+			assertThat(threadLocalValue.get()).isEqualTo("initial");
+		});
+	}
+
+	@Test
+	void shouldConfigurePropagationIfSetToAuto() {
+		AtomicReference<String> threadLocalValue = new AtomicReference<>();
+		this.contextRunner.withPropertyValues("spring.reactor.context-propagation=auto").run((applicationContext) -> {
+			Mono.just("test")
+				.doOnNext((element) -> threadLocalValue.set(THREADLOCAL_VALUE.get()))
+				.contextWrite(Context.of(THREADLOCAL_KEY, "updated"))
+				.block();
+			assertThat(threadLocalValue.get()).isEqualTo("updated");
+		});
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/rsocket/RSocketPropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/rsocket/RSocketPropertiesTests.java
new file mode 100644
index 000000000000..eefd1b7212de
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/rsocket/RSocketPropertiesTests.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.rsocket;
+
+import org.junit.jupiter.api.Test;
+import reactor.netty.http.server.WebsocketServerSpec;
+
+import org.springframework.boot.autoconfigure.rsocket.RSocketProperties.Server.Spec;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link RSocketProperties}.
+ *
+ * @author Stephane Nicoll
+ */
+class RSocketPropertiesTests {
+
+	@Test
+	void defaultServerSpecValuesAreConsistent() {
+		WebsocketServerSpec spec = WebsocketServerSpec.builder().build();
+		Spec properties = new RSocketProperties().getServer().getSpec();
+		assertThat(properties.getProtocols()).isEqualTo(spec.protocols());
+		assertThat(properties.getMaxFramePayloadLength().toBytes()).isEqualTo(spec.maxFramePayloadLength());
+		assertThat(properties.isHandlePing()).isEqualTo(spec.handlePing());
+		assertThat(properties.isCompress()).isEqualTo(spec.compress());
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/rsocket/RSocketServerAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/rsocket/RSocketServerAutoConfigurationTests.java
index 0ddf63903d7b..62569319c380 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/rsocket/RSocketServerAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/rsocket/RSocketServerAutoConfigurationTests.java
@@ -34,7 +34,7 @@
 import org.springframework.context.annotation.Configuration;
 import org.springframework.core.codec.CharSequenceEncoder;
 import org.springframework.core.codec.StringDecoder;
-import org.springframework.http.client.reactive.ReactorResourceFactory;
+import org.springframework.http.client.ReactorResourceFactory;
 import org.springframework.messaging.rsocket.RSocketStrategies;
 import org.springframework.messaging.rsocket.annotation.support.RSocketMessageHandler;
 import org.springframework.util.unit.DataSize;
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerAutoConfigurationTests.java
index cc5a652d10c2..9583efcbc450 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerAutoConfigurationTests.java
@@ -17,14 +17,17 @@
 package org.springframework.boot.autoconfigure.security.oauth2.resource.reactive;
 
 import java.io.IOException;
-import java.net.MalformedURLException;
+import java.net.URI;
 import java.net.URL;
 import java.time.Duration;
 import java.time.Instant;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.function.Consumer;
 import java.util.stream.Stream;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
@@ -33,6 +36,7 @@
 import okhttp3.mockwebserver.MockResponse;
 import okhttp3.mockwebserver.MockWebServer;
 import org.assertj.core.api.InstanceOfAssertFactories;
+import org.assertj.core.api.ThrowingConsumer;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.InOrder;
@@ -87,6 +91,7 @@
  * @author HaiTao Zhang
  * @author Anastasiia Losieva
  * @author Mushtaq Ahmed
+ * @author Roman Golovin
  */
 class ReactiveOAuth2ResourceServerAutoConfigurationTests {
 
@@ -438,7 +443,6 @@ void autoConfigurationWhenIntrospectionUriAvailableShouldBeConditionalOnClass()
 			.run((context) -> assertThat(context).doesNotHaveBean(ReactiveOpaqueTokenIntrospector.class));
 	}
 
-	@SuppressWarnings("unchecked")
 	@Test
 	void autoConfigurationShouldConfigureResourceServerUsingJwkSetUriAndIssuerUri() throws Exception {
 		this.server = new MockWebServer();
@@ -454,15 +458,11 @@ void autoConfigurationShouldConfigureResourceServerUsingJwkSetUriAndIssuerUri()
 			.run((context) -> {
 				assertThat(context).hasSingleBean(ReactiveJwtDecoder.class);
 				ReactiveJwtDecoder reactiveJwtDecoder = context.getBean(ReactiveJwtDecoder.class);
-				DelegatingOAuth2TokenValidator<Jwt> jwtValidator = (DelegatingOAuth2TokenValidator<Jwt>) ReflectionTestUtils
-					.getField(reactiveJwtDecoder, "jwtValidator");
-				Collection<OAuth2TokenValidator<Jwt>> tokenValidators = (Collection<OAuth2TokenValidator<Jwt>>) ReflectionTestUtils
-					.getField(jwtValidator, "tokenValidators");
-				assertThat(tokenValidators).hasAtLeastOneElementOfType(JwtIssuerValidator.class);
+				validate(jwt().claim("iss", issuer), reactiveJwtDecoder,
+						(validators) -> assertThat(validators).hasAtLeastOneElementOfType(JwtIssuerValidator.class));
 			});
 	}
 
-	@SuppressWarnings("unchecked")
 	@Test
 	void autoConfigurationShouldNotConfigureIssuerUriAndAudienceJwtValidatorIfPropertyNotConfigured() throws Exception {
 		this.server = new MockWebServer();
@@ -476,13 +476,8 @@ void autoConfigurationShouldNotConfigureIssuerUriAndAudienceJwtValidatorIfProper
 			.run((context) -> {
 				assertThat(context).hasSingleBean(ReactiveJwtDecoder.class);
 				ReactiveJwtDecoder reactiveJwtDecoder = context.getBean(ReactiveJwtDecoder.class);
-				DelegatingOAuth2TokenValidator<Jwt> jwtValidator = (DelegatingOAuth2TokenValidator<Jwt>) ReflectionTestUtils
-					.getField(reactiveJwtDecoder, "jwtValidator");
-				Collection<OAuth2TokenValidator<Jwt>> tokenValidators = (Collection<OAuth2TokenValidator<Jwt>>) ReflectionTestUtils
-					.getField(jwtValidator, "tokenValidators");
-				assertThat(tokenValidators).hasExactlyElementsOfTypes(JwtTimestampValidator.class);
-				assertThat(tokenValidators).doesNotHaveAnyElementsOfTypes(JwtClaimValidator.class);
-				assertThat(tokenValidators).doesNotHaveAnyElementsOfTypes(JwtIssuerValidator.class);
+				validate(jwt(), reactiveJwtDecoder, (validators) -> assertThat(validators).singleElement()
+					.isInstanceOf(JwtTimestampValidator.class));
 			});
 	}
 
@@ -502,39 +497,15 @@ void autoConfigurationShouldConfigureIssuerAndAudienceJwtValidatorIfPropertyProv
 			.run((context) -> {
 				assertThat(context).hasSingleBean(ReactiveJwtDecoder.class);
 				ReactiveJwtDecoder reactiveJwtDecoder = context.getBean(ReactiveJwtDecoder.class);
-				validate(issuerUri, reactiveJwtDecoder);
+				validate(
+						jwt().claim("iss", URI.create(issuerUri).toURL())
+							.claim("aud", List.of("https://test-audience.com")),
+						reactiveJwtDecoder,
+						(validators) -> assertThat(validators).hasAtLeastOneElementOfType(JwtIssuerValidator.class)
+							.satisfiesOnlyOnce(audClaimValidator()));
 			});
 	}
 
-	@SuppressWarnings("unchecked")
-	private void validate(String issuerUri, ReactiveJwtDecoder jwtDecoder) throws MalformedURLException {
-		DelegatingOAuth2TokenValidator<Jwt> jwtValidator = (DelegatingOAuth2TokenValidator<Jwt>) ReflectionTestUtils
-			.getField(jwtDecoder, "jwtValidator");
-		Jwt.Builder builder = jwt().claim("aud", Collections.singletonList("https://test-audience.com"));
-		if (issuerUri != null) {
-			builder.claim("iss", new URL(issuerUri));
-		}
-		Jwt jwt = builder.build();
-		assertThat(jwtValidator.validate(jwt).hasErrors()).isFalse();
-		Collection<OAuth2TokenValidator<Jwt>> delegates = (Collection<OAuth2TokenValidator<Jwt>>) ReflectionTestUtils
-			.getField(jwtValidator, "tokenValidators");
-		validateDelegates(issuerUri, delegates);
-	}
-
-	@SuppressWarnings("unchecked")
-	private void validateDelegates(String issuerUri, Collection<OAuth2TokenValidator<Jwt>> delegates) {
-		assertThat(delegates).hasAtLeastOneElementOfType(JwtClaimValidator.class);
-		OAuth2TokenValidator<Jwt> delegatingValidator = delegates.stream()
-			.filter((v) -> v instanceof DelegatingOAuth2TokenValidator)
-			.findFirst()
-			.get();
-		Collection<OAuth2TokenValidator<Jwt>> nestedDelegates = (Collection<OAuth2TokenValidator<Jwt>>) ReflectionTestUtils
-			.getField(delegatingValidator, "tokenValidators");
-		if (issuerUri != null) {
-			assertThat(nestedDelegates).hasAtLeastOneElementOfType(JwtIssuerValidator.class);
-		}
-	}
-
 	@SuppressWarnings("unchecked")
 	@Test
 	void autoConfigurationShouldConfigureAudienceValidatorIfPropertyProvidedAndIssuerUri() throws Exception {
@@ -552,7 +523,12 @@ void autoConfigurationShouldConfigureAudienceValidatorIfPropertyProvidedAndIssue
 				Mono<ReactiveJwtDecoder> jwtDecoderSupplier = (Mono<ReactiveJwtDecoder>) ReflectionTestUtils
 					.getField(supplierJwtDecoderBean, "jwtDecoderMono");
 				ReactiveJwtDecoder jwtDecoder = jwtDecoderSupplier.block();
-				validate(issuerUri, jwtDecoder);
+				validate(
+						jwt().claim("iss", URI.create(issuerUri).toURL())
+							.claim("aud", List.of("https://test-audience.com")),
+						jwtDecoder,
+						(validators) -> assertThat(validators).hasAtLeastOneElementOfType(JwtIssuerValidator.class)
+							.satisfiesOnlyOnce(audClaimValidator()));
 			});
 	}
 
@@ -570,7 +546,33 @@ void autoConfigurationShouldConfigureAudienceValidatorIfPropertyProvidedAndPubli
 			.run((context) -> {
 				assertThat(context).hasSingleBean(ReactiveJwtDecoder.class);
 				ReactiveJwtDecoder jwtDecoder = context.getBean(ReactiveJwtDecoder.class);
-				validate(null, jwtDecoder);
+				validate(jwt().claim("aud", List.of("https://test-audience.com")), jwtDecoder,
+						(validators) -> assertThat(validators).satisfiesOnlyOnce(audClaimValidator()));
+			});
+	}
+
+	@SuppressWarnings("unchecked")
+	@Test
+	void autoConfigurationShouldConfigureCustomValidators() throws Exception {
+		this.server = new MockWebServer();
+		this.server.start();
+		String path = "test";
+		String issuer = this.server.url(path).toString();
+		String cleanIssuerPath = cleanIssuerPath(issuer);
+		setupMockResponse(cleanIssuerPath);
+		String issuerUri = "http://" + this.server.getHostName() + ":" + this.server.getPort() + "/" + path;
+		this.contextRunner
+			.withPropertyValues("spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://jwk-set-uri.com",
+					"spring.security.oauth2.resourceserver.jwt.issuer-uri=" + issuerUri)
+			.withUserConfiguration(CustomJwtClaimValidatorConfig.class)
+			.run((context) -> {
+				assertThat(context).hasSingleBean(ReactiveJwtDecoder.class);
+				ReactiveJwtDecoder reactiveJwtDecoder = context.getBean(ReactiveJwtDecoder.class);
+				OAuth2TokenValidator<Jwt> customValidator = (OAuth2TokenValidator<Jwt>) context
+					.getBean("customJwtClaimValidator");
+				validate(jwt().claim("iss", URI.create(issuerUri).toURL()).claim("custom_claim", "custom_claim_value"),
+						reactiveJwtDecoder, (validators) -> assertThat(validators).contains(customValidator)
+							.hasAtLeastOneElementOfType(JwtIssuerValidator.class));
 			});
 	}
 
@@ -600,6 +602,30 @@ void audienceValidatorWhenAudienceInvalid() throws Exception {
 			});
 	}
 
+	@SuppressWarnings("unchecked")
+	@Test
+	void customValidatorWhenInvalid() throws Exception {
+		this.server = new MockWebServer();
+		this.server.start();
+		String path = "test";
+		String issuer = this.server.url(path).toString();
+		String cleanIssuerPath = cleanIssuerPath(issuer);
+		setupMockResponse(cleanIssuerPath);
+		String issuerUri = "http://" + this.server.getHostName() + ":" + this.server.getPort() + "/" + path;
+		this.contextRunner
+			.withPropertyValues("spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://jwk-set-uri.com",
+					"spring.security.oauth2.resourceserver.jwt.issuer-uri=" + issuerUri)
+			.withUserConfiguration(CustomJwtClaimValidatorConfig.class)
+			.run((context) -> {
+				assertThat(context).hasSingleBean(ReactiveJwtDecoder.class);
+				ReactiveJwtDecoder jwtDecoder = context.getBean(ReactiveJwtDecoder.class);
+				DelegatingOAuth2TokenValidator<Jwt> jwtValidator = (DelegatingOAuth2TokenValidator<Jwt>) ReflectionTestUtils
+					.getField(jwtDecoder, "jwtValidator");
+				Jwt jwt = jwt().claim("iss", new URL(issuerUri)).claim("custom_claim", "invalid_value").build();
+				assertThat(jwtValidator.validate(jwt).hasErrors()).isTrue();
+			});
+	}
+
 	private void assertFilterConfiguredWithJwtAuthenticationManager(AssertableReactiveWebApplicationContext context) {
 		MatcherSecurityWebFilterChain filterChain = (MatcherSecurityWebFilterChain) context
 			.getBean(BeanIds.SPRING_SECURITY_FILTER_CHAIN);
@@ -683,7 +709,37 @@ static Jwt.Builder jwt() {
 			.subject("mock-test-subject");
 	}
 
-	@EnableWebFluxSecurity
+	@SuppressWarnings("unchecked")
+	private void validate(Jwt.Builder builder, ReactiveJwtDecoder jwtDecoder,
+			ThrowingConsumer<List<OAuth2TokenValidator<Jwt>>> validatorsConsumer) {
+		DelegatingOAuth2TokenValidator<Jwt> jwtValidator = (DelegatingOAuth2TokenValidator<Jwt>) ReflectionTestUtils
+			.getField(jwtDecoder, "jwtValidator");
+		assertThat(jwtValidator.validate(builder.build()).hasErrors()).isFalse();
+		validatorsConsumer.accept(extractValidators(jwtValidator));
+	}
+
+	@SuppressWarnings("unchecked")
+	private List<OAuth2TokenValidator<Jwt>> extractValidators(DelegatingOAuth2TokenValidator<Jwt> delegatingValidator) {
+		Collection<OAuth2TokenValidator<Jwt>> delegates = (Collection<OAuth2TokenValidator<Jwt>>) ReflectionTestUtils
+			.getField(delegatingValidator, "tokenValidators");
+		List<OAuth2TokenValidator<Jwt>> extracted = new ArrayList<>();
+		for (OAuth2TokenValidator<Jwt> delegate : delegates) {
+			if (delegate instanceof DelegatingOAuth2TokenValidator<Jwt> delegatingDelegate) {
+				extracted.addAll(extractValidators(delegatingDelegate));
+			}
+			else {
+				extracted.add(delegate);
+			}
+		}
+		return extracted;
+	}
+
+	private Consumer<OAuth2TokenValidator<Jwt>> audClaimValidator() {
+		return (validator) -> assertThat(validator).isInstanceOf(JwtClaimValidator.class)
+			.extracting("claim")
+			.isEqualTo("aud");
+	}
+
 	static class TestConfig {
 
 		@Bean
@@ -725,6 +781,7 @@ ReactiveOpaqueTokenIntrospector decoder() {
 
 	}
 
+	@EnableWebFluxSecurity
 	@Configuration(proxyBeanMethods = false)
 	static class SecurityWebFilterChainConfig {
 
@@ -740,4 +797,14 @@ SecurityWebFilterChain testSpringSecurityFilterChain(ServerHttpSecurity http) {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class CustomJwtClaimValidatorConfig {
+
+		@Bean
+		JwtClaimValidator<String> customJwtClaimValidator() {
+			return new JwtClaimValidator<>("custom_claim", "custom_claim_value"::equals);
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerAutoConfigurationTests.java
index 878415ec9f9f..b7aa1a5ec67b 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerAutoConfigurationTests.java
@@ -16,14 +16,16 @@
 
 package org.springframework.boot.autoconfigure.security.oauth2.resource.servlet;
 
-import java.net.MalformedURLException;
+import java.net.URI;
 import java.net.URL;
 import java.time.Instant;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Consumer;
 import java.util.function.Supplier;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
@@ -33,6 +35,7 @@
 import okhttp3.mockwebserver.MockResponse;
 import okhttp3.mockwebserver.MockWebServer;
 import org.assertj.core.api.InstanceOfAssertFactories;
+import org.assertj.core.api.ThrowingConsumer;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.InOrder;
@@ -80,6 +83,7 @@
  * @author Artsiom Yudovin
  * @author HaiTao Zhang
  * @author Mushtaq Ahmed
+ * @author Roman Golovin
  */
 class OAuth2ResourceServerAutoConfigurationTests {
 
@@ -190,8 +194,8 @@ void autoConfigurationUsingPublicKeyValueWithMultipleJwsAlgorithmsShouldFail() {
 			});
 	}
 
-	@Test
 	@SuppressWarnings("unchecked")
+	@Test
 	void autoConfigurationShouldConfigureResourceServerUsingOidcIssuerUri() throws Exception {
 		this.server = new MockWebServer();
 		this.server.start();
@@ -215,8 +219,8 @@ void autoConfigurationShouldConfigureResourceServerUsingOidcIssuerUri() throws E
 		assertThat(this.server.getRequestCount()).isEqualTo(2);
 	}
 
-	@Test
 	@SuppressWarnings("unchecked")
+	@Test
 	void autoConfigurationShouldConfigureResourceServerUsingOidcRfc8414IssuerUri() throws Exception {
 		this.server = new MockWebServer();
 		this.server.start();
@@ -240,8 +244,8 @@ void autoConfigurationShouldConfigureResourceServerUsingOidcRfc8414IssuerUri() t
 		assertThat(this.server.getRequestCount()).isEqualTo(3);
 	}
 
-	@Test
 	@SuppressWarnings("unchecked")
+	@Test
 	void autoConfigurationShouldConfigureResourceServerUsingOAuthIssuerUri() throws Exception {
 		this.server = new MockWebServer();
 		this.server.start();
@@ -472,9 +476,8 @@ void autoConfigurationShouldConfigureResourceServerUsingJwkSetUriAndIssuerUri()
 			.run((context) -> {
 				assertThat(context).hasSingleBean(JwtDecoder.class);
 				JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class);
-				assertThat(jwtDecoder).extracting("jwtValidator.tokenValidators")
-					.asInstanceOf(InstanceOfAssertFactories.collection(OAuth2TokenValidator.class))
-					.hasAtLeastOneElementOfType(JwtIssuerValidator.class);
+				validate(jwt().claim("iss", issuer), jwtDecoder,
+						(validators) -> assertThat(validators).hasAtLeastOneElementOfType(JwtIssuerValidator.class));
 			});
 	}
 
@@ -491,11 +494,8 @@ void autoConfigurationShouldNotConfigureIssuerUriAndAudienceJwtValidatorIfProper
 			.run((context) -> {
 				assertThat(context).hasSingleBean(JwtDecoder.class);
 				JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class);
-				assertThat(jwtDecoder).extracting("jwtValidator.tokenValidators")
-					.asInstanceOf(InstanceOfAssertFactories.collection(OAuth2TokenValidator.class))
-					.hasExactlyElementsOfTypes(JwtTimestampValidator.class)
-					.doesNotHaveAnyElementsOfTypes(JwtClaimValidator.class)
-					.doesNotHaveAnyElementsOfTypes(JwtIssuerValidator.class);
+				validate(jwt(), jwtDecoder, (validators) -> assertThat(validators).singleElement()
+					.isInstanceOf(JwtTimestampValidator.class));
 			});
 	}
 
@@ -515,7 +515,12 @@ void autoConfigurationShouldConfigureAudienceAndIssuerJwtValidatorIfPropertyProv
 			.run((context) -> {
 				assertThat(context).hasSingleBean(JwtDecoder.class);
 				JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class);
-				validate(issuerUri, jwtDecoder);
+				validate(
+						jwt().claim("iss", URI.create(issuerUri).toURL())
+							.claim("aud", List.of("https://test-audience.com")),
+						jwtDecoder,
+						(validators) -> assertThat(validators).hasAtLeastOneElementOfType(JwtIssuerValidator.class)
+							.satisfiesOnlyOnce(audClaimValidator()));
 			});
 	}
 
@@ -536,36 +541,39 @@ void autoConfigurationShouldConfigureAudienceValidatorIfPropertyProvidedAndIssue
 				Supplier<JwtDecoder> jwtDecoderSupplier = (Supplier<JwtDecoder>) ReflectionTestUtils
 					.getField(supplierJwtDecoderBean, "delegate");
 				JwtDecoder jwtDecoder = jwtDecoderSupplier.get();
-				validate(issuerUri, jwtDecoder);
+				validate(
+						jwt().claim("iss", URI.create(issuerUri).toURL())
+							.claim("aud", List.of("https://test-audience.com")),
+						jwtDecoder,
+						(validators) -> assertThat(validators).hasAtLeastOneElementOfType(JwtIssuerValidator.class)
+							.satisfiesOnlyOnce(audClaimValidator()));
 			});
 	}
 
 	@SuppressWarnings("unchecked")
-	private void validate(String issuerUri, JwtDecoder jwtDecoder) throws MalformedURLException {
-		DelegatingOAuth2TokenValidator<Jwt> jwtValidator = (DelegatingOAuth2TokenValidator<Jwt>) ReflectionTestUtils
-			.getField(jwtDecoder, "jwtValidator");
-		Jwt.Builder builder = jwt().claim("aud", Collections.singletonList("https://test-audience.com"));
-		if (issuerUri != null) {
-			builder.claim("iss", new URL(issuerUri));
-		}
-		Jwt jwt = builder.build();
-		assertThat(jwtValidator.validate(jwt).hasErrors()).isFalse();
-		Collection<OAuth2TokenValidator<Jwt>> delegates = (Collection<OAuth2TokenValidator<Jwt>>) ReflectionTestUtils
-			.getField(jwtValidator, "tokenValidators");
-		validateDelegates(issuerUri, delegates);
-	}
-
-	private void validateDelegates(String issuerUri, Collection<OAuth2TokenValidator<Jwt>> delegates) {
-		assertThat(delegates).hasAtLeastOneElementOfType(JwtClaimValidator.class);
-		OAuth2TokenValidator<Jwt> delegatingValidator = delegates.stream()
-			.filter((v) -> v instanceof DelegatingOAuth2TokenValidator)
-			.findFirst()
-			.get();
-		if (issuerUri != null) {
-			assertThat(delegatingValidator).extracting("tokenValidators")
-				.asInstanceOf(InstanceOfAssertFactories.collection(OAuth2TokenValidator.class))
-				.hasAtLeastOneElementOfType(JwtIssuerValidator.class);
-		}
+	@Test
+	void autoConfigurationShouldConfigureCustomValidators() throws Exception {
+		this.server = new MockWebServer();
+		this.server.start();
+		String path = "test";
+		String issuer = this.server.url(path).toString();
+		String cleanIssuerPath = cleanIssuerPath(issuer);
+		setupMockResponse(cleanIssuerPath);
+		String issuerUri = "http://" + this.server.getHostName() + ":" + this.server.getPort() + "/" + path;
+		this.contextRunner.withPropertyValues("spring.security.oauth2.resourceserver.jwt.issuer-uri=" + issuerUri)
+			.withUserConfiguration(CustomJwtClaimValidatorConfig.class)
+			.run((context) -> {
+				SupplierJwtDecoder supplierJwtDecoderBean = context.getBean(SupplierJwtDecoder.class);
+				Supplier<JwtDecoder> jwtDecoderSupplier = (Supplier<JwtDecoder>) ReflectionTestUtils
+					.getField(supplierJwtDecoderBean, "delegate");
+				JwtDecoder jwtDecoder = jwtDecoderSupplier.get();
+				assertThat(context).hasBean("customJwtClaimValidator");
+				OAuth2TokenValidator<Jwt> customValidator = (OAuth2TokenValidator<Jwt>) context
+					.getBean("customJwtClaimValidator");
+				validate(jwt().claim("iss", URI.create(issuerUri).toURL()).claim("custom_claim", "custom_claim_value"),
+						jwtDecoder, (validators) -> assertThat(validators).contains(customValidator)
+							.hasAtLeastOneElementOfType(JwtIssuerValidator.class));
+			});
 	}
 
 	@Test
@@ -582,7 +590,8 @@ void autoConfigurationShouldConfigureAudienceValidatorIfPropertyProvidedAndPubli
 			.run((context) -> {
 				assertThat(context).hasSingleBean(JwtDecoder.class);
 				JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class);
-				validate(null, jwtDecoder);
+				validate(jwt().claim("aud", List.of("https://test-audience.com")), jwtDecoder,
+						(validators) -> assertThat(validators).satisfiesOnlyOnce(audClaimValidator()));
 			});
 	}
 
@@ -692,6 +701,37 @@ static Jwt.Builder jwt() {
 			.subject("mock-test-subject");
 	}
 
+	@SuppressWarnings("unchecked")
+	private void validate(Jwt.Builder builder, JwtDecoder jwtDecoder,
+			ThrowingConsumer<List<OAuth2TokenValidator<Jwt>>> validatorsConsumer) {
+		DelegatingOAuth2TokenValidator<Jwt> jwtValidator = (DelegatingOAuth2TokenValidator<Jwt>) ReflectionTestUtils
+			.getField(jwtDecoder, "jwtValidator");
+		assertThat(jwtValidator.validate(builder.build()).hasErrors()).isFalse();
+		validatorsConsumer.accept(extractValidators(jwtValidator));
+	}
+
+	@SuppressWarnings("unchecked")
+	private List<OAuth2TokenValidator<Jwt>> extractValidators(DelegatingOAuth2TokenValidator<Jwt> delegatingValidator) {
+		Collection<OAuth2TokenValidator<Jwt>> delegates = (Collection<OAuth2TokenValidator<Jwt>>) ReflectionTestUtils
+			.getField(delegatingValidator, "tokenValidators");
+		List<OAuth2TokenValidator<Jwt>> extracted = new ArrayList<>();
+		for (OAuth2TokenValidator<Jwt> delegate : delegates) {
+			if (delegate instanceof DelegatingOAuth2TokenValidator<Jwt> delegatingDelegate) {
+				extracted.addAll(extractValidators(delegatingDelegate));
+			}
+			else {
+				extracted.add(delegate);
+			}
+		}
+		return extracted;
+	}
+
+	private Consumer<OAuth2TokenValidator<Jwt>> audClaimValidator() {
+		return (validator) -> assertThat(validator).isInstanceOf(JwtClaimValidator.class)
+			.extracting("claim")
+			.isEqualTo("aud");
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	@EnableWebSecurity
 	static class TestConfig {
@@ -745,4 +785,14 @@ SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class CustomJwtClaimValidatorConfig {
+
+		@Bean
+		JwtClaimValidator<String> customJwtClaimValidator() {
+			return new JwtClaimValidator<>("custom_claim", "custom_claim_value"::equals);
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/server/servlet/OAuth2AuthorizationServerAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/server/servlet/OAuth2AuthorizationServerAutoConfigurationTests.java
index 3f2a89e73769..c1c043eecdb2 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/server/servlet/OAuth2AuthorizationServerAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/server/servlet/OAuth2AuthorizationServerAutoConfigurationTests.java
@@ -23,6 +23,7 @@
 import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
 import org.springframework.boot.test.context.FilteredClassLoader;
 import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
+import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.security.oauth2.core.AuthorizationGrantType;
@@ -59,8 +60,11 @@ void autoConfigurationConditionalOnClassOauth2Authorization() {
 	}
 
 	@Test
+	@ClassPathExclusions({ "spring-security-oauth2-client-*.jar", "spring-security-oauth2-resource-server-*.jar",
+			"spring-security-saml2-service-provider-*.jar" })
 	void autoConfigurationDoesNotCauseUserDetailsServiceToBackOff() {
-		this.contextRunner.run((context) -> assertThat(context).hasBean("inMemoryUserDetailsManager"));
+		this.contextRunner.run((context) -> assertThat(context).hasSingleBean(UserDetailsServiceAutoConfiguration.class)
+			.hasBean("inMemoryUserDetailsManager"));
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveSecurityAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveSecurityAutoConfigurationTests.java
index 4436ab7360e0..dd5dd07ef32d 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveSecurityAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveSecurityAutoConfigurationTests.java
@@ -20,11 +20,16 @@
 import reactor.core.publisher.Flux;
 
 import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration.EnableWebFluxSecurityConfiguration;
 import org.springframework.boot.test.context.FilteredClassLoader;
 import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.security.authentication.ReactiveAuthenticationManager;
 import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
+import org.springframework.security.core.userdetails.MapReactiveUserDetailsService;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.web.server.SecurityWebFilterChain;
 import org.springframework.security.web.server.WebFilterChainProxy;
 import org.springframework.web.reactive.config.WebFluxConfigurer;
 
@@ -38,20 +43,37 @@
  */
 class ReactiveSecurityAutoConfigurationTests {
 
-	private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner();
+	private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner()
+		.withConfiguration(AutoConfigurations.of(ReactiveSecurityAutoConfiguration.class));
 
 	@Test
 	void backsOffWhenWebFilterChainProxyBeanPresent() {
-		this.contextRunner.withConfiguration(AutoConfigurations.of(ReactiveSecurityAutoConfiguration.class))
-			.withUserConfiguration(WebFilterChainProxyConfiguration.class)
+		this.contextRunner.withUserConfiguration(WebFilterChainProxyConfiguration.class)
 			.run((context) -> assertThat(context).hasSingleBean(WebFilterChainProxy.class));
 	}
 
 	@Test
-	void enablesWebFluxSecurity() {
+	void backsOffWhenReactiveAuthenticationManagerNotPresent() {
+		this.contextRunner.run((context) -> assertThat(context).hasSingleBean(ReactiveSecurityAutoConfiguration.class)
+			.doesNotHaveBean(EnableWebFluxSecurityConfiguration.class));
+	}
+
+	@Test
+	void enablesWebFluxSecurityWhenUserDetailsServiceIsPresent() {
+		this.contextRunner.withUserConfiguration(UserDetailsServiceConfiguration.class)
+			.run((context) -> assertThat(context).getBean(WebFilterChainProxy.class).isNotNull());
+	}
+
+	@Test
+	void enablesWebFluxSecurityWhenReactiveAuthenticationManagerIsPresent() {
 		this.contextRunner
-			.withConfiguration(AutoConfigurations.of(ReactiveSecurityAutoConfiguration.class,
-					ReactiveUserDetailsServiceAutoConfiguration.class))
+			.withBean(ReactiveAuthenticationManager.class, () -> mock(ReactiveAuthenticationManager.class))
+			.run((context) -> assertThat(context).getBean(WebFilterChainProxy.class).isNotNull());
+	}
+
+	@Test
+	void enablesWebFluxSecurityWhenSecurityWebFilterChainIsPresent() {
+		this.contextRunner.withBean(SecurityWebFilterChain.class, () -> mock(SecurityWebFilterChain.class))
 			.run((context) -> assertThat(context).getBean(WebFilterChainProxy.class).isNotNull());
 	}
 
@@ -60,8 +82,7 @@ void autoConfigurationIsConditionalOnClass() {
 		this.contextRunner
 			.withClassLoader(new FilteredClassLoader(Flux.class, EnableWebFluxSecurity.class, WebFilterChainProxy.class,
 					WebFluxConfigurer.class))
-			.withConfiguration(AutoConfigurations.of(ReactiveSecurityAutoConfiguration.class,
-					ReactiveUserDetailsServiceAutoConfiguration.class))
+			.withUserConfiguration(UserDetailsServiceConfiguration.class)
 			.run((context) -> assertThat(context).doesNotHaveBean(WebFilterChainProxy.class));
 	}
 
@@ -75,4 +96,15 @@ WebFilterChainProxy webFilterChainProxy() {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class UserDetailsServiceConfiguration {
+
+		@Bean
+		MapReactiveUserDetailsService userDetailsService() {
+			return new MapReactiveUserDetailsService(
+					User.withUsername("alice").password("secret").roles("admin").build());
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveUserDetailsServiceAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveUserDetailsServiceAutoConfigurationTests.java
index e2389c322a89..96efa632a667 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveUserDetailsServiceAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveUserDetailsServiceAutoConfigurationTests.java
@@ -25,6 +25,7 @@
 import org.springframework.boot.autoconfigure.rsocket.RSocketStrategiesAutoConfiguration;
 import org.springframework.boot.autoconfigure.security.SecurityProperties;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.test.context.FilteredClassLoader;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
 import org.springframework.context.annotation.Bean;
@@ -39,6 +40,7 @@
 import org.springframework.security.core.userdetails.User;
 import org.springframework.security.core.userdetails.UserDetails;
 import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
 import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
 import org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenIntrospector;
 
@@ -58,15 +60,21 @@ class ReactiveUserDetailsServiceAutoConfigurationTests {
 
 	@Test
 	void configuresADefaultUser() {
-		this.contextRunner.withUserConfiguration(TestSecurityConfiguration.class).run((context) -> {
-			ReactiveUserDetailsService userDetailsService = context.getBean(ReactiveUserDetailsService.class);
-			assertThat(userDetailsService.findByUsername("user").block(Duration.ofSeconds(30))).isNotNull();
-		});
+		this.contextRunner
+			.withClassLoader(
+					new FilteredClassLoader(ClientRegistrationRepository.class, ReactiveOpaqueTokenIntrospector.class))
+			.withUserConfiguration(TestSecurityConfiguration.class)
+			.run((context) -> {
+				ReactiveUserDetailsService userDetailsService = context.getBean(ReactiveUserDetailsService.class);
+				assertThat(userDetailsService.findByUsername("user").block(Duration.ofSeconds(30))).isNotNull();
+			});
 	}
 
 	@Test
 	void userDetailsServiceWhenRSocketConfigured() {
 		new ApplicationContextRunner()
+			.withClassLoader(
+					new FilteredClassLoader(ClientRegistrationRepository.class, ReactiveOpaqueTokenIntrospector.class))
 			.withConfiguration(AutoConfigurations.of(ReactiveUserDetailsServiceAutoConfiguration.class,
 					RSocketMessagingAutoConfiguration.class, RSocketStrategiesAutoConfiguration.class))
 			.withUserConfiguration(TestRSocketSecurityConfiguration.class)
@@ -109,20 +117,21 @@ void doesNotConfigureDefaultUserIfResourceServerWithJWTIsUsed() {
 	}
 
 	@Test
-	void doesNotConfigureDefaultUserIfResourceServerWithOpaqueIsUsed() {
-		this.contextRunner.withUserConfiguration(ReactiveOpaqueTokenIntrospectorConfiguration.class).run((context) -> {
-			assertThat(context).hasSingleBean(ReactiveOpaqueTokenIntrospector.class);
-			assertThat(context).doesNotHaveBean(ReactiveUserDetailsService.class);
-		});
+	void doesNotConfigureDefaultUserIfResourceServerIsPresent() {
+		this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean(ReactiveUserDetailsService.class));
 	}
 
 	@Test
 	void userDetailsServiceWhenPasswordEncoderAbsentAndDefaultPassword() {
-		this.contextRunner.withUserConfiguration(TestSecurityConfiguration.class).run(((context) -> {
-			MapReactiveUserDetailsService userDetailsService = context.getBean(MapReactiveUserDetailsService.class);
-			String password = userDetailsService.findByUsername("user").block(Duration.ofSeconds(30)).getPassword();
-			assertThat(password).startsWith("{noop}");
-		}));
+		this.contextRunner
+			.withClassLoader(
+					new FilteredClassLoader(ClientRegistrationRepository.class, ReactiveOpaqueTokenIntrospector.class))
+			.withUserConfiguration(TestSecurityConfiguration.class)
+			.run(((context) -> {
+				MapReactiveUserDetailsService userDetailsService = context.getBean(MapReactiveUserDetailsService.class);
+				String password = userDetailsService.findByUsername("user").block(Duration.ofSeconds(30)).getPassword();
+				assertThat(password).startsWith("{noop}");
+			}));
 	}
 
 	@Test
@@ -142,7 +151,10 @@ void userDetailsServiceWhenPasswordEncoderBeanPresent() {
 	}
 
 	private void testPasswordEncoding(Class<?> configClass, String providedPassword, String expectedPassword) {
-		this.contextRunner.withUserConfiguration(configClass)
+		this.contextRunner
+			.withClassLoader(
+					new FilteredClassLoader(ClientRegistrationRepository.class, ReactiveOpaqueTokenIntrospector.class))
+			.withUserConfiguration(configClass)
 			.withPropertyValues("spring.security.user.password=" + providedPassword)
 			.run(((context) -> {
 				MapReactiveUserDetailsService userDetailsService = context.getBean(MapReactiveUserDetailsService.class);
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/rsocket/RSocketSecurityAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/rsocket/RSocketSecurityAutoConfigurationTests.java
index 95d807269000..a479bb5d1da7 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/rsocket/RSocketSecurityAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/rsocket/RSocketSecurityAutoConfigurationTests.java
@@ -22,12 +22,15 @@
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.rsocket.RSocketMessagingAutoConfiguration;
 import org.springframework.boot.autoconfigure.rsocket.RSocketStrategiesAutoConfiguration;
-import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration;
 import org.springframework.boot.rsocket.server.RSocketServerCustomizer;
 import org.springframework.boot.test.context.FilteredClassLoader;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
 import org.springframework.messaging.rsocket.annotation.support.RSocketMessageHandler;
 import org.springframework.security.config.annotation.rsocket.RSocketSecurity;
+import org.springframework.security.core.userdetails.MapReactiveUserDetailsService;
+import org.springframework.security.core.userdetails.User;
 import org.springframework.security.messaging.handler.invocation.reactive.AuthenticationPrincipalArgumentResolver;
 import org.springframework.security.rsocket.core.SecuritySocketAcceptorInterceptor;
 
@@ -42,9 +45,9 @@
 class RSocketSecurityAutoConfigurationTests {
 
 	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
-		.withConfiguration(AutoConfigurations.of(ReactiveUserDetailsServiceAutoConfiguration.class,
-				RSocketSecurityAutoConfiguration.class, RSocketMessagingAutoConfiguration.class,
-				RSocketStrategiesAutoConfiguration.class));
+		.withConfiguration(AutoConfigurations.of(RSocketSecurityAutoConfiguration.class,
+				RSocketMessagingAutoConfiguration.class, RSocketStrategiesAutoConfiguration.class))
+		.withUserConfiguration(UserDetailsServiceConfiguration.class);
 
 	@Test
 	void autoConfigurationEnablesRSocketSecurity() {
@@ -81,4 +84,15 @@ void autoConfigurationAddsCustomizerForAuthenticationPrincipalArgumentResolver()
 		});
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class UserDetailsServiceConfiguration {
+
+		@Bean
+		MapReactiveUserDetailsService userDetailsService() {
+			return new MapReactiveUserDetailsService(
+					User.withUsername("alice").password("secret").roles("admin").build());
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyAutoConfigurationTests.java
index d3a2fb361db4..b3f51121b243 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyAutoConfigurationTests.java
@@ -16,7 +16,6 @@
 
 package org.springframework.boot.autoconfigure.security.saml2;
 
-import java.io.IOException;
 import java.io.InputStream;
 import java.util.List;
 
@@ -253,7 +252,26 @@ void autoconfigurationWhenMultipleProvidersAndSpecifiedEntityId() throws Excepti
 		testMultipleProviders("https://idp2.example.com/idp/shibboleth", "https://idp2.example.com/idp/shibboleth");
 	}
 
-	private void testMultipleProviders(String specifiedEntityId, String expected) throws IOException, Exception {
+	@Test
+	void signRequestShouldApplyIfMetadataUriIsSet() throws Exception {
+		try (MockWebServer server = new MockWebServer()) {
+			server.start();
+			String metadataUrl = server.url("").toString();
+			setupMockResponse(server, new ClassPathResource("saml/idp-metadata"));
+			this.contextRunner.withPropertyValues(PREFIX + ".foo.assertingparty.metadata-uri=" + metadataUrl,
+					PREFIX + ".foo.assertingparty.singlesignon.sign-request=true",
+					PREFIX + ".foo.signing.credentials[0].private-key-location=classpath:org/springframework/boot/autoconfigure/security/saml2/rsa.key",
+					PREFIX + ".foo.signing.credentials[0].certificate-location=classpath:org/springframework/boot/autoconfigure/security/saml2/rsa.crt")
+				.run((context) -> {
+					RelyingPartyRegistrationRepository repository = context
+						.getBean(RelyingPartyRegistrationRepository.class);
+					RelyingPartyRegistration registration = repository.findByRegistrationId("foo");
+					assertThat(registration.getAssertingPartyDetails().getWantAuthnRequestsSigned()).isTrue();
+				});
+		}
+	}
+
+	private void testMultipleProviders(String specifiedEntityId, String expected) throws Exception {
 		try (MockWebServer server = new MockWebServer()) {
 			server.start();
 			String metadataUrl = server.url("").toString();
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyPropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyPropertiesTests.java
index 0ac09bc3c48e..ff0e8106209f 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyPropertiesTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyPropertiesTests.java
@@ -67,7 +67,7 @@ void customizeSsoSignRequests() {
 			.get("simplesamlphp")
 			.getAssertingparty()
 			.getSinglesignon()
-			.isSignRequest()).isFalse();
+			.getSignRequest()).isFalse();
 	}
 
 	@Test
@@ -93,13 +93,13 @@ void customizeAssertingPartyMetadataUri() {
 	}
 
 	@Test
-	void customizeSsoSignRequestsIsTrueByDefault() {
+	void customizeSsoSignRequestsIsNullByDefault() {
 		this.properties.getRegistration().put("simplesamlphp", new Saml2RelyingPartyProperties.Registration());
 		assertThat(this.properties.getRegistration()
 			.get("simplesamlphp")
 			.getAssertingparty()
 			.getSinglesignon()
-			.isSignRequest()).isTrue();
+			.getSignRequest()).isNull();
 	}
 
 	private void bind(String name, String value) {
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/servlet/SecurityFilterAutoConfigurationEarlyInitializationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/servlet/SecurityFilterAutoConfigurationEarlyInitializationTests.java
index c186f9c009c2..40bcf8bddc53 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/servlet/SecurityFilterAutoConfigurationEarlyInitializationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/servlet/SecurityFilterAutoConfigurationEarlyInitializationTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -37,6 +37,8 @@
 import org.springframework.boot.test.system.OutputCaptureExtension;
 import org.springframework.boot.test.util.TestPropertyValues;
 import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
+import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories;
 import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
 import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext;
 import org.springframework.context.annotation.Bean;
@@ -63,6 +65,9 @@ class SecurityFilterAutoConfigurationEarlyInitializationTests {
 			Pattern.MULTILINE);
 
 	@Test
+	@DirtiesUrlFactories
+	@ClassPathExclusions({ "spring-security-oauth2-client-*.jar", "spring-security-oauth2-resource-server-*.jar",
+			"spring-security-saml2-service-provider-*.jar" })
 	void testSecurityFilterDoesNotCauseEarlyInitialization(CapturedOutput output) {
 		try (AnnotationConfigServletWebServerApplicationContext context = new AnnotationConfigServletWebServerApplicationContext()) {
 			TestPropertyValues.of("server.port:0").applyTo(context);
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/servlet/UserDetailsServiceAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/servlet/UserDetailsServiceAutoConfigurationTests.java
index b0375a3afc85..660a2ea97247 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/servlet/UserDetailsServiceAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/servlet/UserDetailsServiceAutoConfigurationTests.java
@@ -17,6 +17,7 @@
 package org.springframework.boot.autoconfigure.security.servlet;
 
 import java.util.Collections;
+import java.util.function.Function;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -24,6 +25,7 @@
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.security.SecurityProperties;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.test.context.FilteredClassLoader;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.boot.test.system.CapturedOutput;
 import org.springframework.boot.test.system.OutputCaptureExtension;
@@ -64,7 +66,7 @@ class UserDetailsServiceAutoConfigurationTests {
 
 	@Test
 	void testDefaultUsernamePassword(CapturedOutput output) {
-		this.contextRunner.run((context) -> {
+		this.contextRunner.with(noOtherFormsOfAuthenticationOnTheClasspath()).run((context) -> {
 			UserDetailsService manager = context.getBean(UserDetailsService.class);
 			assertThat(output).contains("Using generated security password:");
 			assertThat(manager.loadUserByUsername("user")).isNotNull();
@@ -126,11 +128,13 @@ void defaultUserNotCreatedIfResourceServerWithJWTIsUsed() {
 
 	@Test
 	void userDetailsServiceWhenPasswordEncoderAbsentAndDefaultPassword() {
-		this.contextRunner.withUserConfiguration(TestSecurityConfiguration.class).run(((context) -> {
-			InMemoryUserDetailsManager userDetailsService = context.getBean(InMemoryUserDetailsManager.class);
-			String password = userDetailsService.loadUserByUsername("user").getPassword();
-			assertThat(password).startsWith("{noop}");
-		}));
+		this.contextRunner.with(noOtherFormsOfAuthenticationOnTheClasspath())
+			.withUserConfiguration(TestSecurityConfiguration.class)
+			.run(((context) -> {
+				InMemoryUserDetailsManager userDetailsService = context.getBean(InMemoryUserDetailsManager.class);
+				String password = userDetailsService.loadUserByUsername("user").getPassword();
+				assertThat(password).startsWith("{noop}");
+			}));
 	}
 
 	@Test
@@ -150,20 +154,39 @@ void userDetailsServiceWhenPasswordEncoderBeanPresent() {
 	}
 
 	@Test
-	void userDetailsServiceWhenClientRegistrationRepositoryBeanPresent() {
-		this.contextRunner.withUserConfiguration(TestConfigWithClientRegistrationRepository.class)
+	void userDetailsServiceWhenClientRegistrationRepositoryPresent() {
+		this.contextRunner
+			.withClassLoader(
+					new FilteredClassLoader(OpaqueTokenIntrospector.class, RelyingPartyRegistrationRepository.class))
+			.run(((context) -> assertThat(context).doesNotHaveBean(InMemoryUserDetailsManager.class)));
+	}
+
+	@Test
+	void userDetailsServiceWhenOpaqueTokenIntrospectorPresent() {
+		this.contextRunner
+			.withClassLoader(new FilteredClassLoader(ClientRegistrationRepository.class,
+					RelyingPartyRegistrationRepository.class))
 			.run(((context) -> assertThat(context).doesNotHaveBean(InMemoryUserDetailsManager.class)));
 	}
 
 	@Test
-	void userDetailsServiceWhenRelyingPartyRegistrationRepositoryBeanPresent() {
+	void userDetailsServiceWhenRelyingPartyRegistrationRepositoryPresent() {
 		this.contextRunner
-			.withBean(RelyingPartyRegistrationRepository.class, () -> mock(RelyingPartyRegistrationRepository.class))
+			.withClassLoader(new FilteredClassLoader(ClientRegistrationRepository.class, OpaqueTokenIntrospector.class))
 			.run(((context) -> assertThat(context).doesNotHaveBean(InMemoryUserDetailsManager.class)));
 	}
 
+	private Function<ApplicationContextRunner, ApplicationContextRunner> noOtherFormsOfAuthenticationOnTheClasspath() {
+		return (contextRunner) -> contextRunner
+			.withClassLoader(new FilteredClassLoader(ClientRegistrationRepository.class, OpaqueTokenIntrospector.class,
+					RelyingPartyRegistrationRepository.class));
+	}
+
 	private void testPasswordEncoding(Class<?> configClass, String providedPassword, String expectedPassword) {
-		this.contextRunner.withUserConfiguration(configClass)
+		this.contextRunner.with(noOtherFormsOfAuthenticationOnTheClasspath())
+			.withClassLoader(new FilteredClassLoader(ClientRegistrationRepository.class, OpaqueTokenIntrospector.class,
+					RelyingPartyRegistrationRepository.class))
+			.withUserConfiguration(configClass)
 			.withPropertyValues("spring.security.user.password=" + providedPassword)
 			.run(((context) -> {
 				InMemoryUserDetailsManager userDetailsService = context.getBean(InMemoryUserDetailsManager.class);
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/session/SessionAutoConfigurationHazelcastTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/session/SessionAutoConfigurationHazelcastTests.java
index 556a38f8cc46..6ee21e160611 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/session/SessionAutoConfigurationHazelcastTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/session/SessionAutoConfigurationHazelcastTests.java
@@ -31,6 +31,7 @@
 import org.springframework.context.annotation.Configuration;
 import org.springframework.session.FlushMode;
 import org.springframework.session.SaveMode;
+import org.springframework.session.config.SessionRepositoryCustomizer;
 import org.springframework.session.data.mongo.MongoIndexedSessionRepository;
 import org.springframework.session.data.redis.RedisIndexedSessionRepository;
 import org.springframework.session.hazelcast.HazelcastIndexedSessionRepository;
@@ -112,6 +113,17 @@ void customSaveMode() {
 		});
 	}
 
+	@Test
+	void whenTheUserDefinesTheirOwnSessionRepositoryCustomizerThenDefaultConfigurationIsOverwritten() {
+		this.contextRunner.withUserConfiguration(CustomizerConfiguration.class)
+			.withPropertyValues("spring.session.hazelcast.save-mode=on-get-attribute")
+			.run((context) -> {
+				HazelcastIndexedSessionRepository repository = validateSessionRepository(context,
+						HazelcastIndexedSessionRepository.class);
+				assertThat(repository).hasFieldOrPropertyWithValue("saveMode", SaveMode.ALWAYS);
+			});
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class HazelcastConfiguration {
 
@@ -127,4 +139,14 @@ HazelcastInstance hazelcastInstance() {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class CustomizerConfiguration {
+
+		@Bean
+		SessionRepositoryCustomizer<HazelcastIndexedSessionRepository> sessionRepositoryCustomizer() {
+			return (repository) -> repository.setSaveMode(SaveMode.ALWAYS);
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/session/SessionAutoConfigurationMongoTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/session/SessionAutoConfigurationMongoTests.java
index 6af94eb57415..ec221f78d6d4 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/session/SessionAutoConfigurationMongoTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/session/SessionAutoConfigurationMongoTests.java
@@ -32,6 +32,9 @@
 import org.springframework.boot.test.context.runner.ContextConsumer;
 import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
 import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.session.config.SessionRepositoryCustomizer;
 import org.springframework.session.data.mongo.MongoIndexedSessionRepository;
 import org.springframework.session.data.redis.RedisIndexedSessionRepository;
 import org.springframework.session.hazelcast.HazelcastIndexedSessionRepository;
@@ -75,6 +78,13 @@ void mongoSessionStoreWithCustomizations() {
 			.run(validateSpringSessionUsesMongo("foo"));
 	}
 
+	@Test
+	void whenTheUserDefinesTheirOwnSessionRepositoryCustomizerThenDefaultConfigurationIsOverwritten() {
+		this.contextRunner.withUserConfiguration(CustomizerConfiguration.class)
+			.withPropertyValues("spring.session.mongodb.collection-name=foo")
+			.run(validateSpringSessionUsesMongo("customized"));
+	}
+
 	private ContextConsumer<AssertableWebApplicationContext> validateSpringSessionUsesMongo(String collectionName) {
 		return validateSpringSessionUsesMongo(collectionName,
 				new ServerProperties().getServlet().getSession().getTimeout());
@@ -90,4 +100,14 @@ private ContextConsumer<AssertableWebApplicationContext> validateSpringSessionUs
 		};
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class CustomizerConfiguration {
+
+		@Bean
+		SessionRepositoryCustomizer<MongoIndexedSessionRepository> sessionRepositoryCustomizer() {
+			return (repository) -> repository.setCollectionName("customized");
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/session/SessionAutoConfigurationRedisTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/session/SessionAutoConfigurationRedisTests.java
index 781b388fff9e..e98b88fc0a52 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/session/SessionAutoConfigurationRedisTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/session/SessionAutoConfigurationRedisTests.java
@@ -32,10 +32,13 @@
 import org.springframework.boot.test.context.runner.ContextConsumer;
 import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
 import org.springframework.boot.testsupport.testcontainers.RedisContainer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
 import org.springframework.data.redis.connection.RedisConnection;
 import org.springframework.data.redis.connection.RedisConnectionFactory;
 import org.springframework.session.FlushMode;
 import org.springframework.session.SaveMode;
+import org.springframework.session.config.SessionRepositoryCustomizer;
 import org.springframework.session.data.mongo.MongoIndexedSessionRepository;
 import org.springframework.session.data.redis.RedisIndexedSessionRepository;
 import org.springframework.session.data.redis.RedisSessionRepository;
@@ -165,6 +168,32 @@ void indexedRedisSessionWithCustomConfigureRedisActionBean() {
 
 	}
 
+	@Test
+	void whenTheUserDefinesTheirOwnSessionRepositoryCustomizerThenDefaultConfigurationIsOverwritten() {
+		this.contextRunner.withConfiguration(AutoConfigurations.of(RedisAutoConfiguration.class))
+			.withUserConfiguration(CustomizerConfiguration.class)
+			.withPropertyValues("spring.session.redis.flush-mode=immediate",
+					"spring.data.redis.host=" + redis.getHost(), "spring.data.redis.port=" + redis.getFirstMappedPort())
+			.run((context) -> {
+				RedisSessionRepository repository = validateSessionRepository(context, RedisSessionRepository.class);
+				assertThat(repository).hasFieldOrPropertyWithValue("flushMode", FlushMode.ON_SAVE);
+			});
+	}
+
+	@Test
+	void whenIndexedAndTheUserDefinesTheirOwnSessionRepositoryCustomizerThenDefaultConfigurationIsOverwritten() {
+		this.contextRunner.withConfiguration(AutoConfigurations.of(RedisAutoConfiguration.class))
+			.withUserConfiguration(IndexedCustomizerConfiguration.class)
+			.withPropertyValues("spring.session.redis.repository-type=indexed",
+					"spring.session.redis.flush-mode=immediate", "spring.data.redis.host=" + redis.getHost(),
+					"spring.data.redis.port=" + redis.getFirstMappedPort())
+			.run((context) -> {
+				RedisIndexedSessionRepository repository = validateSessionRepository(context,
+						RedisIndexedSessionRepository.class);
+				assertThat(repository).hasFieldOrPropertyWithValue("flushMode", FlushMode.ON_SAVE);
+			});
+	}
+
 	private ContextConsumer<AssertableWebApplicationContext> validateSpringSessionUsesDefaultRedis(String keyNamespace,
 			FlushMode flushMode, SaveMode saveMode) {
 		return (context) -> {
@@ -213,4 +242,24 @@ public void configure(RedisConnection connection) {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class CustomizerConfiguration {
+
+		@Bean
+		SessionRepositoryCustomizer<RedisSessionRepository> sessionRepositoryCustomizer() {
+			return (repository) -> repository.setFlushMode(FlushMode.ON_SAVE);
+		}
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	static class IndexedCustomizerConfiguration {
+
+		@Bean
+		SessionRepositoryCustomizer<RedisIndexedSessionRepository> sessionRepositoryCustomizer() {
+			return (repository) -> repository.setFlushMode(FlushMode.ON_SAVE);
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/BundleContentPropertyTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/BundleContentPropertyTests.java
new file mode 100644
index 000000000000..72d5f6ae3190
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/BundleContentPropertyTests.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.ssl;
+
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+
+/**
+ * Tests for {@link BundleContentProperty}.
+ *
+ * @author Moritz Halbritter
+ * @author Phillip Webb
+ */
+class BundleContentPropertyTests {
+
+	private static final String PEM_TEXT = """
+			-----BEGIN CERTIFICATE-----
+			-----END CERTIFICATE-----
+			""";
+
+	@TempDir
+	Path temp;
+
+	@Test
+	void isPemContentWhenValueIsPemTextReturnsTrue() {
+		BundleContentProperty property = new BundleContentProperty("name", PEM_TEXT);
+		assertThat(property.isPemContent()).isTrue();
+	}
+
+	@Test
+	void isPemContentWhenValueIsNotPemTextReturnsFalse() {
+		BundleContentProperty property = new BundleContentProperty("name", "file.pem");
+		assertThat(property.isPemContent()).isFalse();
+	}
+
+	@Test
+	void hasValueWhenHasValueReturnsTrue() {
+		BundleContentProperty property = new BundleContentProperty("name", "file.pem");
+		assertThat(property.hasValue()).isTrue();
+	}
+
+	@Test
+	void hasValueWhenHasNullValueReturnsFalse() {
+		BundleContentProperty property = new BundleContentProperty("name", null);
+		assertThat(property.hasValue()).isFalse();
+	}
+
+	@Test
+	void hasValueWhenHasEmptyValueReturnsFalse() {
+		BundleContentProperty property = new BundleContentProperty("name", "");
+		assertThat(property.hasValue()).isFalse();
+	}
+
+	@Test
+	void toWatchPathWhenNotPathThrowsException() {
+		BundleContentProperty property = new BundleContentProperty("name", PEM_TEXT);
+		assertThatIllegalStateException().isThrownBy(property::toWatchPath)
+			.withMessage("Unable to convert value of property 'name' to a path");
+	}
+
+	@Test
+	void toWatchPathWhenPathReturnsPath() {
+		Path file = this.temp.toAbsolutePath().resolve("file.txt");
+		BundleContentProperty property = new BundleContentProperty("name", file.toString());
+		assertThat(property.toWatchPath()).isEqualTo(file);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatcherTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatcherTests.java
new file mode 100644
index 000000000000..c1516bdf6358
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatcherTests.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.ssl;
+
+import java.security.cert.Certificate;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link CertificateMatcher}.
+ *
+ * @author Moritz Halbritter
+ * @author Phillip Webb
+ */
+class CertificateMatcherTests {
+
+	@CertificateMatchingTest
+	void matchesWhenMatchReturnsTrue(CertificateMatchingTestSource source) {
+		CertificateMatcher matcher = new CertificateMatcher(source.privateKey());
+		assertThat(matcher.matches(source.matchingCertificate())).isTrue();
+	}
+
+	@CertificateMatchingTest
+	void matchesWhenNoMatchReturnsFalse(CertificateMatchingTestSource source) {
+		CertificateMatcher matcher = new CertificateMatcher(source.privateKey());
+		for (Certificate nonMatchingCertificate : source.nonMatchingCertificates()) {
+			assertThat(matcher.matches(nonMatchingCertificate)).isFalse();
+		}
+	}
+
+	@CertificateMatchingTest
+	void matchesAnyWhenNoneMatchReturnsFalse(CertificateMatchingTestSource source) {
+		CertificateMatcher matcher = new CertificateMatcher(source.privateKey());
+		assertThat(matcher.matchesAny(source.nonMatchingCertificates())).isFalse();
+	}
+
+	@CertificateMatchingTest
+	void matchesAnyWhenOneMatchesReturnsTrue(CertificateMatchingTestSource source) {
+		CertificateMatcher matcher = new CertificateMatcher(source.privateKey());
+		List<Certificate> certificates = new ArrayList<>(source.nonMatchingCertificates());
+		certificates.add(source.matchingCertificate());
+		assertThat(matcher.matchesAny(certificates)).isTrue();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatchingTest.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatchingTest.java
new file mode 100644
index 000000000000..fcf5e39d6534
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatchingTest.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.ssl;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Annotation for a {@code ParameterizedTest @ParameterizedTest} with a
+ * {@link CertificateMatchingTestSource} parameter.
+ *
+ * @author Phillip Webb
+ */
+@Target({ ElementType.ANNOTATION_TYPE, ElementType.METHOD })
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@ParameterizedTest(name = "{0}")
+@MethodSource("org.springframework.boot.autoconfigure.ssl.CertificateMatchingTestSource#create")
+public @interface CertificateMatchingTest {
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatchingTestSource.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatchingTestSource.java
new file mode 100644
index 000000000000..e04f5651fa0a
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatchingTestSource.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.ssl;
+
+import java.security.InvalidAlgorithmParameterException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.spec.AlgorithmParameterSpec;
+import java.security.spec.ECGenParameterSpec;
+import java.security.spec.NamedParameterSpec;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Source used with {@link CertificateMatchingTest @CertificateMatchingTest} annotated
+ * tests that provides access to useful test material.
+ *
+ * @param algorithm the algorithm
+ * @param privateKey the private key to use for matching
+ * @param matchingCertificate a certificate that matches the private key
+ * @param nonMatchingCertificates a list of certificate that do not match the private key
+ * @param nonMatchingPrivateKeys a list of private keys that do not match the certificate
+ * @author Moritz Halbritter
+ * @author Phillip Webb
+ */
+record CertificateMatchingTestSource(CertificateMatchingTestSource.Algorithm algorithm, PrivateKey privateKey,
+		X509Certificate matchingCertificate, List<X509Certificate> nonMatchingCertificates,
+		List<PrivateKey> nonMatchingPrivateKeys) {
+
+	private static final List<Algorithm> ALGORITHMS;
+	static {
+		List<Algorithm> algorithms = new ArrayList<>();
+		Stream.of("RSA", "DSA", "ed25519", "ed448").map(Algorithm::of).forEach(algorithms::add);
+		Stream.of("secp256r1", "secp521r1").map(Algorithm::ec).forEach(algorithms::add);
+		ALGORITHMS = List.copyOf(algorithms);
+	}
+
+	CertificateMatchingTestSource(Algorithm algorithm, KeyPair matchingKeyPair, List<KeyPair> nonMatchingKeyPairs) {
+		this(algorithm, matchingKeyPair.getPrivate(), asCertificate(matchingKeyPair),
+				nonMatchingKeyPairs.stream().map(CertificateMatchingTestSource::asCertificate).toList(),
+				nonMatchingKeyPairs.stream().map(KeyPair::getPrivate).toList());
+	}
+
+	private static X509Certificate asCertificate(KeyPair keyPair) {
+		X509Certificate certificate = mock(X509Certificate.class);
+		given(certificate.getPublicKey()).willReturn(keyPair.getPublic());
+		return certificate;
+	}
+
+	@Override
+	public String toString() {
+		return this.algorithm.toString();
+	}
+
+	static List<CertificateMatchingTestSource> create()
+			throws NoSuchAlgorithmException, InvalidAlgorithmParameterException {
+		Map<Algorithm, KeyPair> keyPairs = new LinkedHashMap<>();
+		for (Algorithm algorithm : ALGORITHMS) {
+			keyPairs.put(algorithm, algorithm.generateKeyPair());
+		}
+		List<CertificateMatchingTestSource> parameters = new ArrayList<>();
+		keyPairs.forEach((algorith, matchingKeyPair) -> {
+			List<KeyPair> nonMatchingKeyPairs = new ArrayList<>(keyPairs.values());
+			nonMatchingKeyPairs.remove(matchingKeyPair);
+			parameters.add(new CertificateMatchingTestSource(algorith, matchingKeyPair, nonMatchingKeyPairs));
+		});
+		return List.copyOf(parameters);
+	}
+
+	/**
+	 * An individual algorithm.
+	 *
+	 * @param name the algorithm name
+	 * @param spec the algorithm spec or {@code null}
+	 */
+	record Algorithm(String name, AlgorithmParameterSpec spec) {
+
+		KeyPair generateKeyPair() throws NoSuchAlgorithmException, InvalidAlgorithmParameterException {
+			KeyPairGenerator generator = KeyPairGenerator.getInstance(this.name);
+			if (this.spec != null) {
+				generator.initialize(this.spec);
+			}
+			return generator.generateKeyPair();
+		}
+
+		@Override
+		public String toString() {
+			String spec = (this.spec instanceof NamedParameterSpec namedSpec) ? namedSpec.getName() : "";
+			return this.name + ((!spec.isEmpty()) ? ":" + spec : "");
+		}
+
+		static Algorithm of(String name) {
+			return new Algorithm(name, null);
+		}
+
+		static Algorithm ec(String curve) {
+			return new Algorithm("EC", new ECGenParameterSpec(curve));
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/FileWatcherTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/FileWatcherTests.java
new file mode 100644
index 000000000000..ef09b28481d1
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/FileWatcherTests.java
@@ -0,0 +1,200 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.ssl;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.fail;
+
+/**
+ * Tests for {@link FileWatcher}.
+ *
+ * @author Moritz Halbritter
+ */
+class FileWatcherTests {
+
+	private FileWatcher fileWatcher;
+
+	@BeforeEach
+	void setUp() {
+		this.fileWatcher = new FileWatcher(Duration.ofMillis(10));
+	}
+
+	@AfterEach
+	void tearDown() throws IOException {
+		this.fileWatcher.close();
+	}
+
+	@Test
+	void shouldTriggerOnFileCreation(@TempDir Path tempDir) throws Exception {
+		Path newFile = tempDir.resolve("new-file.txt");
+		WaitingCallback callback = new WaitingCallback();
+		this.fileWatcher.watch(Set.of(tempDir), callback);
+		Files.createFile(newFile);
+		callback.expectChanges();
+	}
+
+	@Test
+	void shouldTriggerOnFileDeletion(@TempDir Path tempDir) throws Exception {
+		Path deletedFile = tempDir.resolve("deleted-file.txt");
+		Files.createFile(deletedFile);
+		WaitingCallback callback = new WaitingCallback();
+		this.fileWatcher.watch(Set.of(tempDir), callback);
+		Files.delete(deletedFile);
+		callback.expectChanges();
+	}
+
+	@Test
+	void shouldTriggerOnFileModification(@TempDir Path tempDir) throws Exception {
+		Path deletedFile = tempDir.resolve("modified-file.txt");
+		Files.createFile(deletedFile);
+		WaitingCallback callback = new WaitingCallback();
+		this.fileWatcher.watch(Set.of(tempDir), callback);
+		Files.writeString(deletedFile, "Some content");
+		callback.expectChanges();
+	}
+
+	@Test
+	void shouldWatchFile(@TempDir Path tempDir) throws Exception {
+		Path watchedFile = tempDir.resolve("watched.txt");
+		Files.createFile(watchedFile);
+		WaitingCallback callback = new WaitingCallback();
+		this.fileWatcher.watch(Set.of(watchedFile), callback);
+		Files.writeString(watchedFile, "Some content");
+		callback.expectChanges();
+	}
+
+	@Test
+	void shouldIgnoreNotWatchedFiles(@TempDir Path tempDir) throws Exception {
+		Path watchedFile = tempDir.resolve("watched.txt");
+		Path notWatchedFile = tempDir.resolve("not-watched.txt");
+		Files.createFile(watchedFile);
+		Files.createFile(notWatchedFile);
+		WaitingCallback callback = new WaitingCallback();
+		this.fileWatcher.watch(Set.of(watchedFile), callback);
+		Files.writeString(notWatchedFile, "Some content");
+		callback.expectNoChanges();
+	}
+
+	@Test
+	void shouldFailIfDirectoryOrFileDoesNotExist(@TempDir Path tempDir) {
+		Path directory = tempDir.resolve("dir1");
+		assertThatExceptionOfType(UncheckedIOException.class)
+			.isThrownBy(() -> this.fileWatcher.watch(Set.of(directory), new WaitingCallback()))
+			.withMessage("Failed to register paths for watching: [%s]".formatted(directory));
+	}
+
+	@Test
+	void shouldNotFailIfDirectoryIsRegisteredMultipleTimes(@TempDir Path tempDir) {
+		WaitingCallback callback = new WaitingCallback();
+		assertThatCode(() -> {
+			this.fileWatcher.watch(Set.of(tempDir), callback);
+			this.fileWatcher.watch(Set.of(tempDir), callback);
+		}).doesNotThrowAnyException();
+	}
+
+	@Test
+	void shouldNotFailIfStoppedMultipleTimes(@TempDir Path tempDir) {
+		WaitingCallback callback = new WaitingCallback();
+		this.fileWatcher.watch(Set.of(tempDir), callback);
+		assertThatCode(() -> {
+			this.fileWatcher.close();
+			this.fileWatcher.close();
+		}).doesNotThrowAnyException();
+	}
+
+	@Test
+	void testRelativeFiles() throws Exception {
+		Path watchedFile = Path.of(UUID.randomUUID() + ".txt");
+		Files.createFile(watchedFile);
+		try {
+			WaitingCallback callback = new WaitingCallback();
+			this.fileWatcher.watch(Set.of(watchedFile), callback);
+			Files.delete(watchedFile);
+			callback.expectChanges();
+		}
+		finally {
+			Files.deleteIfExists(watchedFile);
+		}
+	}
+
+	@Test
+	void testRelativeDirectories() throws Exception {
+		Path watchedDirectory = Path.of(UUID.randomUUID() + "/");
+		Path file = watchedDirectory.resolve("file.txt");
+		Files.createDirectory(watchedDirectory);
+		try {
+			WaitingCallback callback = new WaitingCallback();
+			this.fileWatcher.watch(Set.of(watchedDirectory), callback);
+			Files.createFile(file);
+			callback.expectChanges();
+		}
+		finally {
+			Files.deleteIfExists(file);
+			Files.deleteIfExists(watchedDirectory);
+		}
+	}
+
+	private static class WaitingCallback implements Runnable {
+
+		private final CountDownLatch latch = new CountDownLatch(1);
+
+		volatile boolean changed = false;
+
+		@Override
+		public void run() {
+			this.changed = true;
+			this.latch.countDown();
+		}
+
+		void expectChanges() throws InterruptedException {
+			waitForChanges(true);
+			assertThat(this.changed).as("changed").isTrue();
+		}
+
+		void expectNoChanges() throws InterruptedException {
+			waitForChanges(false);
+			assertThat(this.changed).as("changed").isFalse();
+		}
+
+		void waitForChanges(boolean fail) throws InterruptedException {
+			if (!this.latch.await(5, TimeUnit.SECONDS)) {
+				if (fail) {
+					fail("Timeout while waiting for changes");
+				}
+			}
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/PropertiesSslBundleTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/PropertiesSslBundleTests.java
index 43c00ac56b2a..d6b770a3d927 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/PropertiesSslBundleTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/PropertiesSslBundleTests.java
@@ -16,48 +16,62 @@
 
 package org.springframework.boot.autoconfigure.ssl;
 
+import java.security.Key;
+import java.security.KeyStore;
+import java.security.cert.Certificate;
 import java.util.Set;
+import java.util.function.Consumer;
 
 import org.junit.jupiter.api.Test;
 
 import org.springframework.boot.ssl.SslBundle;
+import org.springframework.util.function.ThrowingConsumer;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
 
 /**
  * Tests for {@link PropertiesSslBundle}.
  *
  * @author Scott Frederick
+ * @author Moritz Halbritter
  */
 class PropertiesSslBundleTests {
 
+	private static final char[] EMPTY_KEY_PASSWORD = new char[] {};
+
 	@Test
-	void pemPropertiesAreMappedToSslBundle() {
+	void pemPropertiesAreMappedToSslBundle() throws Exception {
 		PemSslBundleProperties properties = new PemSslBundleProperties();
 		properties.getKey().setAlias("alias");
 		properties.getKey().setPassword("secret");
 		properties.getOptions().setCiphers(Set.of("cipher1", "cipher2", "cipher3"));
 		properties.getOptions().setEnabledProtocols(Set.of("protocol1", "protocol2"));
-		properties.getKeystore().setCertificate("cert1.pem");
-		properties.getKeystore().setPrivateKey("key1.pem");
-		properties.getKeystore().setPrivateKeyPassword("keysecret1");
+		properties.getKeystore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/rsa-cert.pem");
+		properties.getKeystore().setPrivateKey("classpath:org/springframework/boot/autoconfigure/ssl/rsa-key.pem");
+		properties.getKeystore().setPrivateKeyPassword(null);
 		properties.getKeystore().setType("PKCS12");
-		properties.getTruststore().setCertificate("cert2.pem");
-		properties.getTruststore().setPrivateKey("key2.pem");
-		properties.getTruststore().setPrivateKeyPassword("keysecret2");
-		properties.getTruststore().setType("JKS");
+		properties.getTruststore()
+			.setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/ed25519-cert.pem");
+		properties.getTruststore()
+			.setPrivateKey("classpath:org/springframework/boot/autoconfigure/ssl/ed25519-key.pem");
+		properties.getTruststore().setPrivateKeyPassword("secret");
+		properties.getTruststore().setType("PKCS12");
 		SslBundle sslBundle = PropertiesSslBundle.get(properties);
 		assertThat(sslBundle.getKey().getAlias()).isEqualTo("alias");
 		assertThat(sslBundle.getKey().getPassword()).isEqualTo("secret");
 		assertThat(sslBundle.getOptions().getCiphers()).containsExactlyInAnyOrder("cipher1", "cipher2", "cipher3");
 		assertThat(sslBundle.getOptions().getEnabledProtocols()).containsExactlyInAnyOrder("protocol1", "protocol2");
 		assertThat(sslBundle.getStores()).isNotNull();
-		assertThat(sslBundle.getStores()).extracting("keyStoreDetails")
-			.extracting("certificate", "privateKey", "privateKeyPassword", "type")
-			.containsExactly("cert1.pem", "key1.pem", "keysecret1", "PKCS12");
-		assertThat(sslBundle.getStores()).extracting("trustStoreDetails")
-			.extracting("certificate", "privateKey", "privateKeyPassword", "type")
-			.containsExactly("cert2.pem", "key2.pem", "keysecret2", "JKS");
+		Certificate certificate = sslBundle.getStores().getKeyStore().getCertificate("alias");
+		assertThat(certificate).isNotNull();
+		assertThat(certificate.getType()).isEqualTo("X.509");
+		Key key = sslBundle.getStores().getKeyStore().getKey("alias", "secret".toCharArray());
+		assertThat(key).isNotNull();
+		assertThat(key.getAlgorithm()).isEqualTo("RSA");
+		certificate = sslBundle.getStores().getTrustStore().getCertificate("ssl");
+		assertThat(certificate).isNotNull();
+		assertThat(certificate.getType()).isEqualTo("X.509");
 	}
 
 	@Test
@@ -67,14 +81,14 @@ void jksPropertiesAreMappedToSslBundle() {
 		properties.getKey().setPassword("secret");
 		properties.getOptions().setCiphers(Set.of("cipher1", "cipher2", "cipher3"));
 		properties.getOptions().setEnabledProtocols(Set.of("protocol1", "protocol2"));
-		properties.getKeystore().setLocation("cert1.p12");
-		properties.getKeystore().setPassword("secret1");
-		properties.getKeystore().setProvider("provider1");
+		properties.getKeystore().setPassword("secret");
+		properties.getKeystore().setProvider("SUN");
 		properties.getKeystore().setType("JKS");
-		properties.getTruststore().setLocation("cert2.jks");
-		properties.getTruststore().setPassword("secret2");
-		properties.getTruststore().setProvider("provider2");
+		properties.getKeystore().setLocation("classpath:org/springframework/boot/autoconfigure/ssl/keystore.jks");
+		properties.getTruststore().setPassword("secret");
+		properties.getTruststore().setProvider("SUN");
 		properties.getTruststore().setType("PKCS12");
+		properties.getTruststore().setLocation("classpath:org/springframework/boot/autoconfigure/ssl/keystore.pkcs12");
 		SslBundle sslBundle = PropertiesSslBundle.get(properties);
 		assertThat(sslBundle.getKey().getAlias()).isEqualTo("alias");
 		assertThat(sslBundle.getKey().getPassword()).isEqualTo("secret");
@@ -83,10 +97,54 @@ void jksPropertiesAreMappedToSslBundle() {
 		assertThat(sslBundle.getStores()).isNotNull();
 		assertThat(sslBundle.getStores()).extracting("keyStoreDetails")
 			.extracting("location", "password", "provider", "type")
-			.containsExactly("cert1.p12", "secret1", "provider1", "JKS");
-		assertThat(sslBundle.getStores()).extracting("trustStoreDetails")
-			.extracting("location", "password", "provider", "type")
-			.containsExactly("cert2.jks", "secret2", "provider2", "PKCS12");
+			.containsExactly("classpath:org/springframework/boot/autoconfigure/ssl/keystore.jks", "secret", "SUN",
+					"JKS");
+		KeyStore trustStore = sslBundle.getStores().getTrustStore();
+		assertThat(trustStore.getType()).isEqualTo("PKCS12");
+		assertThat(trustStore.getProvider().getName()).isEqualTo("SUN");
+	}
+
+	@Test
+	void getWithPemSslBundlePropertiesWhenVerifyKeyStoreAgainstSingleCertificateWithMatchCreatesBundle() {
+		PemSslBundleProperties properties = new PemSslBundleProperties();
+		properties.getKeystore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/key1.crt");
+		properties.getKeystore().setPrivateKey("classpath:org/springframework/boot/autoconfigure/ssl/key1.pem");
+		properties.getKeystore().setVerifyKeys(true);
+		properties.getKey().setAlias("test-alias");
+		SslBundle bundle = PropertiesSslBundle.get(properties);
+		assertThat(bundle.getStores().getKeyStore()).satisfies(storeContainingCertAndKey("test-alias"));
+	}
+
+	@Test
+	void getWithPemSslBundlePropertiesWhenVerifyKeyStoreAgainstCertificateChainWithMatchCreatesBundle() {
+		PemSslBundleProperties properties = new PemSslBundleProperties();
+		properties.getKeystore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/key2-chain.crt");
+		properties.getKeystore().setPrivateKey("classpath:org/springframework/boot/autoconfigure/ssl/key2.pem");
+		properties.getKeystore().setVerifyKeys(true);
+		properties.getKey().setAlias("test-alias");
+		SslBundle bundle = PropertiesSslBundle.get(properties);
+		assertThat(bundle.getStores().getKeyStore()).satisfies(storeContainingCertAndKey("test-alias"));
+	}
+
+	@Test
+	void getWithPemSslBundlePropertiesWhenVerifyKeyStoreWithNoMatchThrowsException() {
+		PemSslBundleProperties properties = new PemSslBundleProperties();
+		properties.getKeystore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/key2.crt");
+		properties.getKeystore().setPrivateKey("classpath:org/springframework/boot/autoconfigure/ssl/key1.pem");
+		properties.getKeystore().setVerifyKeys(true);
+		properties.getKey().setAlias("test-alias");
+		assertThatIllegalStateException().isThrownBy(() -> PropertiesSslBundle.get(properties))
+			.withMessageContaining("Private key in keystore matches none of the certificates");
+	}
+
+	private Consumer<KeyStore> storeContainingCertAndKey(String keyAlias) {
+		return ThrowingConsumer.of((keyStore) -> {
+			assertThat(keyStore).isNotNull();
+			assertThat(keyStore.getType()).isEqualTo(KeyStore.getDefaultType());
+			assertThat(keyStore.containsAlias(keyAlias)).isTrue();
+			assertThat(keyStore.getCertificate(keyAlias)).isNotNull();
+			assertThat(keyStore.getKey(keyAlias, EMPTY_KEY_PASSWORD)).isNotNull();
+		});
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/SslAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/SslAutoConfigurationTests.java
index 5717fedfbdfd..06cbc412829b 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/SslAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/SslAutoConfigurationTests.java
@@ -38,6 +38,7 @@
  *
  * @author Scott Frederick
  * @author Phillip Webb
+ * @author Moritz Halbritter
  */
 class SslAutoConfigurationTests {
 
@@ -54,18 +55,28 @@ void sslBundlesCreatedWithCertificates() {
 		List<String> propertyValues = new ArrayList<>();
 		propertyValues.add("spring.ssl.bundle.pem.first.key.alias=alias1");
 		propertyValues.add("spring.ssl.bundle.pem.first.key.password=secret1");
-		propertyValues.add("spring.ssl.bundle.pem.first.keystore.certificate=cert1.pem");
-		propertyValues.add("spring.ssl.bundle.pem.first.keystore.private-key=key1.pem");
-		propertyValues.add("spring.ssl.bundle.pem.first.keystore.type=JKS");
+		propertyValues.add(
+				"spring.ssl.bundle.pem.first.keystore.certificate=classpath:org/springframework/boot/autoconfigure/ssl/rsa-cert.pem");
+		propertyValues.add(
+				"spring.ssl.bundle.pem.first.keystore.private-key=classpath:org/springframework/boot/autoconfigure/ssl/rsa-key.pem");
+		propertyValues.add("spring.ssl.bundle.pem.first.keystore.type=PKCS12");
 		propertyValues.add("spring.ssl.bundle.pem.first.truststore.type=PKCS12");
+		propertyValues.add(
+				"spring.ssl.bundle.pem.first.truststore.certificate=classpath:org/springframework/boot/autoconfigure/ssl/rsa-cert.pem");
+		propertyValues.add(
+				"spring.ssl.bundle.pem.first.truststore.private-key=classpath:org/springframework/boot/autoconfigure/ssl/rsa-key.pem");
 		propertyValues.add("spring.ssl.bundle.pem.second.key.alias=alias2");
 		propertyValues.add("spring.ssl.bundle.pem.second.key.password=secret2");
-		propertyValues.add("spring.ssl.bundle.pem.second.keystore.certificate=cert2.pem");
-		propertyValues.add("spring.ssl.bundle.pem.second.keystore.private-key=key2.pem");
+		propertyValues.add(
+				"spring.ssl.bundle.pem.second.keystore.certificate=classpath:org/springframework/boot/autoconfigure/ssl/ed25519-cert.pem");
+		propertyValues.add(
+				"spring.ssl.bundle.pem.second.keystore.private-key=classpath:org/springframework/boot/autoconfigure/ssl/ed25519-key.pem");
 		propertyValues.add("spring.ssl.bundle.pem.second.keystore.type=PKCS12");
-		propertyValues.add("spring.ssl.bundle.pem.second.truststore.certificate=ca.pem");
-		propertyValues.add("spring.ssl.bundle.pem.second.truststore.private-key=ca-key.pem");
-		propertyValues.add("spring.ssl.bundle.pem.second.truststore.type=JKS");
+		propertyValues.add(
+				"spring.ssl.bundle.pem.second.truststore.certificate=classpath:org/springframework/boot/autoconfigure/ssl/ed25519-cert.pem");
+		propertyValues.add(
+				"spring.ssl.bundle.pem.second.truststore.private-key=classpath:org/springframework/boot/autoconfigure/ssl/ed25519-key.pem");
+		propertyValues.add("spring.ssl.bundle.pem.second.truststore.type=PKCS12");
 		this.contextRunner.withPropertyValues(propertyValues.toArray(String[]::new)).run((context) -> {
 			assertThat(context).hasSingleBean(SslBundles.class);
 			SslBundles bundles = context.getBean(SslBundles.class);
@@ -75,16 +86,16 @@ void sslBundlesCreatedWithCertificates() {
 			assertThat(first.getManagers()).isNotNull();
 			assertThat(first.getKey().getAlias()).isEqualTo("alias1");
 			assertThat(first.getKey().getPassword()).isEqualTo("secret1");
-			assertThat(first.getStores()).extracting("keyStoreDetails").extracting("type").isEqualTo("JKS");
-			assertThat(first.getStores()).extracting("trustStoreDetails").extracting("type").isEqualTo("PKCS12");
+			assertThat(first.getStores().getKeyStore().getType()).isEqualTo("PKCS12");
+			assertThat(first.getStores().getTrustStore().getType()).isEqualTo("PKCS12");
 			SslBundle second = bundles.getBundle("second");
 			assertThat(second).isNotNull();
 			assertThat(second.getStores()).isNotNull();
 			assertThat(second.getManagers()).isNotNull();
 			assertThat(second.getKey().getAlias()).isEqualTo("alias2");
 			assertThat(second.getKey().getPassword()).isEqualTo("secret2");
-			assertThat(second.getStores()).extracting("keyStoreDetails").extracting("type").isEqualTo("PKCS12");
-			assertThat(second.getStores()).extracting("trustStoreDetails").extracting("type").isEqualTo("JKS");
+			assertThat(second.getStores().getKeyStore().getType()).isEqualTo("PKCS12");
+			assertThat(second.getStores().getTrustStore().getType()).isEqualTo("PKCS12");
 		});
 	}
 
@@ -93,7 +104,13 @@ void sslBundlesCreatedWithCustomSslBundle() {
 		List<String> propertyValues = new ArrayList<>();
 		propertyValues.add("custom.ssl.key.alias=alias1");
 		propertyValues.add("custom.ssl.key.password=secret1");
-		propertyValues.add("custom.ssl.keystore.type=JKS");
+		propertyValues
+			.add("custom.ssl.keystore.certificate=classpath:org/springframework/boot/autoconfigure/ssl/rsa-cert.pem");
+		propertyValues.add(
+				"custom.ssl.keystore.keystore.private-key=classpath:org/springframework/boot/autoconfigure/ssl/rsa-key.pem");
+		propertyValues
+			.add("custom.ssl.truststore.certificate=classpath:org/springframework/boot/autoconfigure/ssl/rsa-cert.pem");
+		propertyValues.add("custom.ssl.keystore.type=PKCS12");
 		propertyValues.add("custom.ssl.truststore.type=PKCS12");
 		this.contextRunner.withUserConfiguration(CustomSslBundleConfiguration.class)
 			.withPropertyValues(propertyValues.toArray(String[]::new))
@@ -106,8 +123,8 @@ void sslBundlesCreatedWithCustomSslBundle() {
 				assertThat(first.getManagers()).isNotNull();
 				assertThat(first.getKey().getAlias()).isEqualTo("alias1");
 				assertThat(first.getKey().getPassword()).isEqualTo("secret1");
-				assertThat(first.getStores()).extracting("keyStoreDetails").extracting("type").isEqualTo("JKS");
-				assertThat(first.getStores()).extracting("trustStoreDetails").extracting("type").isEqualTo("PKCS12");
+				assertThat(first.getStores().getKeyStore().getType()).isEqualTo("PKCS12");
+				assertThat(first.getStores().getTrustStore().getType()).isEqualTo("PKCS12");
 			});
 	}
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrarTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrarTests.java
new file mode 100644
index 000000000000..759bb474609b
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrarTests.java
@@ -0,0 +1,180 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.ssl;
+
+import java.nio.file.Path;
+import java.util.Set;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import org.springframework.boot.ssl.SslBundleRegistry;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.assertArg;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.times;
+
+/**
+ * Tests for {@link SslPropertiesBundleRegistrar}.
+ *
+ * @author Moritz Halbritter
+ */
+class SslPropertiesBundleRegistrarTests {
+
+	private SslPropertiesBundleRegistrar registrar;
+
+	private FileWatcher fileWatcher;
+
+	private SslProperties properties;
+
+	private SslBundleRegistry registry;
+
+	@BeforeEach
+	void setUp() {
+		this.properties = new SslProperties();
+		this.fileWatcher = Mockito.mock(FileWatcher.class);
+		this.registrar = new SslPropertiesBundleRegistrar(this.properties, this.fileWatcher);
+		this.registry = Mockito.mock(SslBundleRegistry.class);
+	}
+
+	@Test
+	void shouldWatchJksBundles() {
+		JksSslBundleProperties jks = new JksSslBundleProperties();
+		jks.setReloadOnUpdate(true);
+		jks.getKeystore().setLocation("classpath:test.jks");
+		jks.getKeystore().setPassword("secret");
+		jks.getTruststore().setLocation("classpath:test.jks");
+		jks.getTruststore().setPassword("secret");
+		this.properties.getBundle().getJks().put("bundle1", jks);
+		this.registrar.registerBundles(this.registry);
+		then(this.registry).should(times(1)).registerBundle(eq("bundle1"), any());
+		then(this.fileWatcher).should().watch(assertArg((set) -> pathEndingWith(set, "test.jks")), any());
+	}
+
+	@Test
+	void shouldWatchPemBundles() {
+		PemSslBundleProperties pem = new PemSslBundleProperties();
+		pem.setReloadOnUpdate(true);
+		pem.getKeystore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/rsa-cert.pem");
+		pem.getKeystore().setPrivateKey("classpath:org/springframework/boot/autoconfigure/ssl/rsa-key.pem");
+		pem.getTruststore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/ed25519-cert.pem");
+		pem.getTruststore().setPrivateKey("classpath:org/springframework/boot/autoconfigure/ssl/ed25519-key.pem");
+		this.properties.getBundle().getPem().put("bundle1", pem);
+		this.registrar.registerBundles(this.registry);
+		then(this.registry).should(times(1)).registerBundle(eq("bundle1"), any());
+		then(this.fileWatcher).should()
+			.watch(assertArg((set) -> pathEndingWith(set, "rsa-cert.pem", "rsa-key.pem")), any());
+	}
+
+	@Test
+	void shouldFailIfPemKeystoreCertificateIsEmbedded() {
+		PemSslBundleProperties pem = new PemSslBundleProperties();
+		pem.setReloadOnUpdate(true);
+		pem.getKeystore().setCertificate("""
+				-----BEGIN CERTIFICATE-----
+				MIICCzCCAb2gAwIBAgIUZbDi7G5czH+Yi0k2EMWxdf00XagwBQYDK2VwMHsxCzAJ
+				BgNVBAYTAlhYMRIwEAYDVQQIDAlTdGF0ZU5hbWUxETAPBgNVBAcMCENpdHlOYW1l
+				MRQwEgYDVQQKDAtDb21wYW55TmFtZTEbMBkGA1UECwwSQ29tcGFueVNlY3Rpb25O
+				YW1lMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMjMwOTExMTIxNDMwWhcNMzMwOTA4
+				MTIxNDMwWjB7MQswCQYDVQQGEwJYWDESMBAGA1UECAwJU3RhdGVOYW1lMREwDwYD
+				VQQHDAhDaXR5TmFtZTEUMBIGA1UECgwLQ29tcGFueU5hbWUxGzAZBgNVBAsMEkNv
+				bXBhbnlTZWN0aW9uTmFtZTESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEA
+				Q/DDA4BSgZ+Hx0DUxtIRjVjN+OcxXVURwAWc3Gt9GUyjUzBRMB0GA1UdDgQWBBSv
+				EdpoaBMBoxgO96GFbf03k07DSTAfBgNVHSMEGDAWgBSvEdpoaBMBoxgO96GFbf03
+				k07DSTAPBgNVHRMBAf8EBTADAQH/MAUGAytlcANBAHMXDkGd57d4F4cRk/8UjhxD
+				7OtRBZfdfznSvlhJIMNfH5q0zbC2eO3hWCB3Hrn/vIeswGP8Ov4AJ6eXeX44BQM=
+				-----END CERTIFICATE-----
+				""".strip());
+		this.properties.getBundle().getPem().put("bundle1", pem);
+		assertThatIllegalStateException().isThrownBy(() -> this.registrar.registerBundles(this.registry))
+			.withMessageContaining("Unable to register SSL bundle 'bundle1'")
+			.havingCause()
+			.withMessage("Unable to watch for reload on update");
+	}
+
+	@Test
+	void shouldFailIfPemKeystorePrivateKeyIsEmbedded() {
+		PemSslBundleProperties pem = new PemSslBundleProperties();
+		pem.setReloadOnUpdate(true);
+		pem.getKeystore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/ed25519-cert.pem");
+		pem.getKeystore().setPrivateKey("""
+				-----BEGIN PRIVATE KEY-----
+				MC4CAQAwBQYDK2VwBCIEIC29RnMVTcyqXEAIO1b/6p7RdbM6TiqvnztVQ4IxYxUh
+				-----END PRIVATE KEY-----
+				""".strip());
+		this.properties.getBundle().getPem().put("bundle1", pem);
+		assertThatIllegalStateException().isThrownBy(() -> this.registrar.registerBundles(this.registry))
+			.withMessageContaining("Unable to register SSL bundle 'bundle1'")
+			.havingCause()
+			.withMessage("Unable to watch for reload on update");
+	}
+
+	@Test
+	void shouldFailIfPemTruststoreCertificateIsEmbedded() {
+		PemSslBundleProperties pem = new PemSslBundleProperties();
+		pem.setReloadOnUpdate(true);
+		pem.getTruststore().setCertificate("""
+				-----BEGIN CERTIFICATE-----
+				MIICCzCCAb2gAwIBAgIUZbDi7G5czH+Yi0k2EMWxdf00XagwBQYDK2VwMHsxCzAJ
+				BgNVBAYTAlhYMRIwEAYDVQQIDAlTdGF0ZU5hbWUxETAPBgNVBAcMCENpdHlOYW1l
+				MRQwEgYDVQQKDAtDb21wYW55TmFtZTEbMBkGA1UECwwSQ29tcGFueVNlY3Rpb25O
+				YW1lMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMjMwOTExMTIxNDMwWhcNMzMwOTA4
+				MTIxNDMwWjB7MQswCQYDVQQGEwJYWDESMBAGA1UECAwJU3RhdGVOYW1lMREwDwYD
+				VQQHDAhDaXR5TmFtZTEUMBIGA1UECgwLQ29tcGFueU5hbWUxGzAZBgNVBAsMEkNv
+				bXBhbnlTZWN0aW9uTmFtZTESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEA
+				Q/DDA4BSgZ+Hx0DUxtIRjVjN+OcxXVURwAWc3Gt9GUyjUzBRMB0GA1UdDgQWBBSv
+				EdpoaBMBoxgO96GFbf03k07DSTAfBgNVHSMEGDAWgBSvEdpoaBMBoxgO96GFbf03
+				k07DSTAPBgNVHRMBAf8EBTADAQH/MAUGAytlcANBAHMXDkGd57d4F4cRk/8UjhxD
+				7OtRBZfdfznSvlhJIMNfH5q0zbC2eO3hWCB3Hrn/vIeswGP8Ov4AJ6eXeX44BQM=
+				-----END CERTIFICATE-----
+				""".strip());
+		this.properties.getBundle().getPem().put("bundle1", pem);
+		assertThatIllegalStateException().isThrownBy(() -> this.registrar.registerBundles(this.registry))
+			.withMessageContaining("Unable to register SSL bundle 'bundle1'")
+			.havingCause()
+			.withMessage("Unable to watch for reload on update");
+	}
+
+	@Test
+	void shouldFailIfPemTruststorePrivateKeyIsEmbedded() {
+		PemSslBundleProperties pem = new PemSslBundleProperties();
+		pem.setReloadOnUpdate(true);
+		pem.getTruststore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/ed25519-cert.pem");
+		pem.getTruststore().setPrivateKey("""
+				-----BEGIN PRIVATE KEY-----
+				MC4CAQAwBQYDK2VwBCIEIC29RnMVTcyqXEAIO1b/6p7RdbM6TiqvnztVQ4IxYxUh
+				-----END PRIVATE KEY-----
+				""".strip());
+		this.properties.getBundle().getPem().put("bundle1", pem);
+		assertThatIllegalStateException().isThrownBy(() -> this.registrar.registerBundles(this.registry))
+			.withMessageContaining("Unable to register SSL bundle 'bundle1'")
+			.havingCause()
+			.withMessage("Unable to watch for reload on update");
+	}
+
+	private void pathEndingWith(Set<Path> paths, String... suffixes) {
+		for (String suffix : suffixes) {
+			assertThat(paths).anyMatch((path) -> path.getFileName().toString().endsWith(suffix));
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfigurationTests.java
index c1d0507903ac..0ae673011165 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfigurationTests.java
@@ -17,23 +17,31 @@
 package org.springframework.boot.autoconfigure.task;
 
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.Executor;
 import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Consumer;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.api.condition.JRE;
 import org.junit.jupiter.api.extension.ExtendWith;
 
 import org.springframework.beans.factory.config.BeanDefinition;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.task.SimpleAsyncTaskExecutorBuilder;
 import org.springframework.boot.task.TaskExecutorBuilder;
 import org.springframework.boot.task.TaskExecutorCustomizer;
+import org.springframework.boot.task.ThreadPoolTaskExecutorBuilder;
 import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.boot.test.context.runner.ContextConsumer;
 import org.springframework.boot.test.system.OutputCaptureExtension;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.core.task.SimpleAsyncTaskExecutor;
 import org.springframework.core.task.SyncTaskExecutor;
 import org.springframework.core.task.TaskDecorator;
 import org.springframework.core.task.TaskExecutor;
@@ -51,13 +59,34 @@
  *
  * @author Stephane Nicoll
  * @author Camille Vienot
+ * @author Moritz Halbritter
  */
 @ExtendWith(OutputCaptureExtension.class)
+@SuppressWarnings("removal")
 class TaskExecutionAutoConfigurationTests {
 
 	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
 		.withConfiguration(AutoConfigurations.of(TaskExecutionAutoConfiguration.class));
 
+	@Test
+	void shouldSupplyBeans() {
+		this.contextRunner.run((context) -> {
+			assertThat(context).hasSingleBean(TaskExecutorBuilder.class);
+			assertThat(context).hasSingleBean(ThreadPoolTaskExecutorBuilder.class);
+			assertThat(context).hasSingleBean(ThreadPoolTaskExecutor.class);
+			assertThat(context).hasSingleBean(SimpleAsyncTaskExecutorBuilder.class);
+		});
+	}
+
+	@Test
+	void shouldNotSupplyThreadPoolTaskExecutorBuilderIfCustomTaskExecutorBuilderIsPresent() {
+		this.contextRunner.withBean(TaskExecutorBuilder.class, TaskExecutorBuilder::new).run((context) -> {
+			assertThat(context).hasSingleBean(TaskExecutorBuilder.class);
+			assertThat(context).doesNotHaveBean(ThreadPoolTaskExecutorBuilder.class);
+			assertThat(context).hasSingleBean(ThreadPoolTaskExecutor.class);
+		});
+	}
+
 	@Test
 	void taskExecutorBuilderShouldApplyCustomSettings() {
 		this.contextRunner
@@ -79,6 +108,38 @@ void taskExecutorBuilderShouldApplyCustomSettings() {
 			}));
 	}
 
+	@Test
+	void simpleAsyncTaskExecutorBuilderShouldReadProperties() {
+		this.contextRunner
+			.withPropertyValues("spring.task.execution.thread-name-prefix=mytest-",
+					"spring.task.execution.simple.concurrency-limit=1")
+			.run(assertSimpleAsyncTaskExecutor((taskExecutor) -> {
+				assertThat(taskExecutor.getConcurrencyLimit()).isEqualTo(1);
+				assertThat(taskExecutor.getThreadNamePrefix()).isEqualTo("mytest-");
+			}));
+	}
+
+	@Test
+	void threadPoolTaskExecutorBuilderShouldApplyCustomSettings() {
+		this.contextRunner
+			.withPropertyValues("spring.task.execution.pool.queue-capacity=10",
+					"spring.task.execution.pool.core-size=2", "spring.task.execution.pool.max-size=4",
+					"spring.task.execution.pool.allow-core-thread-timeout=true",
+					"spring.task.execution.pool.keep-alive=5s", "spring.task.execution.shutdown.await-termination=true",
+					"spring.task.execution.shutdown.await-termination-period=30s",
+					"spring.task.execution.thread-name-prefix=mytest-")
+			.run(assertThreadPoolTaskExecutor((taskExecutor) -> {
+				assertThat(taskExecutor).hasFieldOrPropertyWithValue("queueCapacity", 10);
+				assertThat(taskExecutor.getCorePoolSize()).isEqualTo(2);
+				assertThat(taskExecutor.getMaxPoolSize()).isEqualTo(4);
+				assertThat(taskExecutor).hasFieldOrPropertyWithValue("allowCoreThreadTimeOut", true);
+				assertThat(taskExecutor.getKeepAliveSeconds()).isEqualTo(5);
+				assertThat(taskExecutor).hasFieldOrPropertyWithValue("waitForTasksToCompleteOnShutdown", true);
+				assertThat(taskExecutor).hasFieldOrPropertyWithValue("awaitTerminationMillis", 30000L);
+				assertThat(taskExecutor.getThreadNamePrefix()).isEqualTo("mytest-");
+			}));
+	}
+
 	@Test
 	void taskExecutorBuilderWhenHasCustomBuilderShouldUseCustomBuilder() {
 		this.contextRunner.withUserConfiguration(CustomTaskExecutorBuilderConfig.class).run((context) -> {
@@ -88,6 +149,15 @@ void taskExecutorBuilderWhenHasCustomBuilderShouldUseCustomBuilder() {
 		});
 	}
 
+	@Test
+	void threadPoolTaskExecutorBuilderWhenHasCustomBuilderShouldUseCustomBuilder() {
+		this.contextRunner.withUserConfiguration(CustomThreadPoolTaskExecutorBuilderConfig.class).run((context) -> {
+			assertThat(context).hasSingleBean(ThreadPoolTaskExecutorBuilder.class);
+			assertThat(context.getBean(ThreadPoolTaskExecutorBuilder.class))
+				.isSameAs(context.getBean(CustomThreadPoolTaskExecutorBuilderConfig.class).builder);
+		});
+	}
+
 	@Test
 	void taskExecutorBuilderShouldUseTaskDecorator() {
 		this.contextRunner.withUserConfiguration(TaskDecoratorConfig.class).run((context) -> {
@@ -98,7 +168,16 @@ void taskExecutorBuilderShouldUseTaskDecorator() {
 	}
 
 	@Test
-	void taskExecutorAutoConfiguredIsLazy() {
+	void threadPoolTaskExecutorBuilderShouldUseTaskDecorator() {
+		this.contextRunner.withUserConfiguration(TaskDecoratorConfig.class).run((context) -> {
+			assertThat(context).hasSingleBean(ThreadPoolTaskExecutorBuilder.class);
+			ThreadPoolTaskExecutor executor = context.getBean(ThreadPoolTaskExecutorBuilder.class).build();
+			assertThat(executor).extracting("taskDecorator").isSameAs(context.getBean(TaskDecorator.class));
+		});
+	}
+
+	@Test
+	void whenThreadPoolTaskExecutorIsAutoConfiguredThenItIsLazy() {
 		this.contextRunner.run((context) -> {
 			assertThat(context).hasSingleBean(Executor.class).hasBean("applicationTaskExecutor");
 			BeanDefinition beanDefinition = context.getSourceApplicationContext()
@@ -109,6 +188,68 @@ void taskExecutorAutoConfiguredIsLazy() {
 		});
 	}
 
+	@Test
+	@EnabledForJreRange(min = JRE.JAVA_21)
+	void whenVirtualThreadsAreEnabledThenSimpleAsyncTaskExecutorWithVirtualThreadsIsAutoConfigured() {
+		this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true").run((context) -> {
+			assertThat(context).hasSingleBean(Executor.class).hasBean("applicationTaskExecutor");
+			assertThat(context).getBean("applicationTaskExecutor").isInstanceOf(SimpleAsyncTaskExecutor.class);
+			SimpleAsyncTaskExecutor taskExecutor = context.getBean("applicationTaskExecutor",
+					SimpleAsyncTaskExecutor.class);
+			assertThat(virtualThreadName(taskExecutor)).startsWith("task-");
+		});
+	}
+
+	@Test
+	@EnabledForJreRange(min = JRE.JAVA_21)
+	void whenTaskNamePrefixIsConfiguredThenSimpleAsyncTaskExecutorWithVirtualThreadsUsesIt() {
+		this.contextRunner
+			.withPropertyValues("spring.threads.virtual.enabled=true",
+					"spring.task.execution.thread-name-prefix=custom-")
+			.run((context) -> {
+				SimpleAsyncTaskExecutor taskExecutor = context.getBean("applicationTaskExecutor",
+						SimpleAsyncTaskExecutor.class);
+				assertThat(virtualThreadName(taskExecutor)).startsWith("custom-");
+			});
+	}
+
+	@Test
+	@EnabledForJreRange(min = JRE.JAVA_21)
+	void whenVirtualThreadsAreAvailableButNotEnabledThenThreadPoolTaskExecutorIsAutoConfigured() {
+		this.contextRunner.run((context) -> {
+			assertThat(context).hasSingleBean(Executor.class).hasBean("applicationTaskExecutor");
+			assertThat(context).getBean("applicationTaskExecutor").isInstanceOf(ThreadPoolTaskExecutor.class);
+		});
+	}
+
+	@Test
+	@EnabledForJreRange(min = JRE.JAVA_21)
+	void whenTaskDecoratorIsDefinedThenSimpleAsyncTaskExecutorWithVirtualThreadsUsesIt() {
+		this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true")
+			.withUserConfiguration(TaskDecoratorConfig.class)
+			.run((context) -> {
+				SimpleAsyncTaskExecutor executor = context.getBean(SimpleAsyncTaskExecutor.class);
+				assertThat(executor).extracting("taskDecorator").isSameAs(context.getBean(TaskDecorator.class));
+			});
+	}
+
+	@Test
+	void simpleAsyncTaskExecutorBuilderUsesPlatformThreadsByDefault() {
+		this.contextRunner.run((context) -> {
+			SimpleAsyncTaskExecutorBuilder builder = context.getBean(SimpleAsyncTaskExecutorBuilder.class);
+			assertThat(builder).hasFieldOrPropertyWithValue("virtualThreads", null);
+		});
+	}
+
+	@Test
+	@EnabledForJreRange(min = JRE.JAVA_21)
+	void simpleAsyncTaskExecutorBuilderUsesVirtualThreadsWhenEnabled() {
+		this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true").run((context) -> {
+			SimpleAsyncTaskExecutorBuilder builder = context.getBean(SimpleAsyncTaskExecutorBuilder.class);
+			assertThat(builder).hasFieldOrPropertyWithValue("virtualThreads", true);
+		});
+	}
+
 	@Test
 	void taskExecutorWhenHasCustomTaskExecutorShouldBackOff() {
 		this.contextRunner.withUserConfiguration(CustomTaskExecutorConfig.class).run((context) -> {
@@ -117,6 +258,17 @@ void taskExecutorWhenHasCustomTaskExecutorShouldBackOff() {
 		});
 	}
 
+	@Test
+	@EnabledForJreRange(min = JRE.JAVA_21)
+	void whenVirtualThreadsAreEnabledAndCustomTaskExecutorIsDefinedThenSimpleAsyncTaskExecutorThatUsesVirtualThreadsBacksOff() {
+		this.contextRunner.withUserConfiguration(CustomTaskExecutorConfig.class)
+			.withPropertyValues("spring.threads.virtual.enabled=true")
+			.run((context) -> {
+				assertThat(context).hasSingleBean(Executor.class);
+				assertThat(context.getBean(Executor.class)).isSameAs(context.getBean("customTaskExecutor"));
+			});
+	}
+
 	@Test
 	void taskExecutorBuilderShouldApplyCustomizer() {
 		this.contextRunner.withUserConfiguration(TaskExecutorCustomizerConfig.class).run((context) -> {
@@ -126,6 +278,15 @@ void taskExecutorBuilderShouldApplyCustomizer() {
 		});
 	}
 
+	@Test
+	void threadPoolTaskExecutorBuilderShouldApplyCustomizer() {
+		this.contextRunner.withUserConfiguration(TaskExecutorCustomizerConfig.class).run((context) -> {
+			TaskExecutorCustomizer customizer = context.getBean(TaskExecutorCustomizer.class);
+			ThreadPoolTaskExecutor executor = context.getBean(ThreadPoolTaskExecutorBuilder.class).build();
+			then(customizer).should().customize(executor);
+		});
+	}
+
 	@Test
 	void enableAsyncUsesAutoConfiguredOneByDefault() {
 		this.contextRunner.withPropertyValues("spring.task.execution.thread-name-prefix=task-test-")
@@ -150,6 +311,25 @@ void enableAsyncUsesAutoConfiguredOneByDefaultEvenThoughSchedulingIsConfigured()
 			});
 	}
 
+	@Test
+	void customTaskExecutorBuilderOverridesThreadPoolTaskExecutorBuilder() {
+		this.contextRunner.withUserConfiguration(CustomTaskExecutorBuilderConfig.class).run((context) -> {
+			ThreadPoolTaskExecutor bean = context.getBean(ThreadPoolTaskExecutor.class);
+			assertThat(bean.getThreadNamePrefix()).isEqualTo("CustomTaskExecutorBuilderConfig-");
+		});
+	}
+
+	@Test
+	void threadPoolTaskExecutorBuilderAppliesTaskExecutorCustomizer() {
+		this.contextRunner
+			.withBean(TaskExecutorCustomizer.class,
+					() -> (taskExecutor) -> taskExecutor.setThreadNamePrefix("custom-prefix-"))
+			.run((context) -> {
+				ThreadPoolTaskExecutor bean = context.getBean(ThreadPoolTaskExecutor.class);
+				assertThat(bean.getThreadNamePrefix()).isEqualTo("custom-prefix-");
+			});
+	}
+
 	private ContextConsumer<AssertableApplicationContext> assertTaskExecutor(
 			Consumer<ThreadPoolTaskExecutor> taskExecutor) {
 		return (context) -> {
@@ -159,10 +339,43 @@ private ContextConsumer<AssertableApplicationContext> assertTaskExecutor(
 		};
 	}
 
+	private ContextConsumer<AssertableApplicationContext> assertThreadPoolTaskExecutor(
+			Consumer<ThreadPoolTaskExecutor> taskExecutor) {
+		return (context) -> {
+			assertThat(context).hasSingleBean(ThreadPoolTaskExecutorBuilder.class);
+			ThreadPoolTaskExecutorBuilder builder = context.getBean(ThreadPoolTaskExecutorBuilder.class);
+			taskExecutor.accept(builder.build());
+		};
+	}
+
+	private ContextConsumer<AssertableApplicationContext> assertSimpleAsyncTaskExecutor(
+			Consumer<SimpleAsyncTaskExecutor> taskExecutor) {
+		return (context) -> {
+			assertThat(context).hasSingleBean(SimpleAsyncTaskExecutorBuilder.class);
+			SimpleAsyncTaskExecutorBuilder builder = context.getBean(SimpleAsyncTaskExecutorBuilder.class);
+			taskExecutor.accept(builder.build());
+		};
+	}
+
+	private String virtualThreadName(SimpleAsyncTaskExecutor taskExecutor) throws InterruptedException {
+		AtomicReference<Thread> threadReference = new AtomicReference<>();
+		CountDownLatch latch = new CountDownLatch(1);
+		taskExecutor.execute(() -> {
+			Thread currentThread = Thread.currentThread();
+			threadReference.set(currentThread);
+			latch.countDown();
+		});
+		assertThat(latch.await(30, TimeUnit.SECONDS)).isTrue();
+		Thread thread = threadReference.get();
+		assertThat(thread).extracting("virtual").as("%s is virtual", thread).isEqualTo(true);
+		return thread.getName();
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class CustomTaskExecutorBuilderConfig {
 
-		private final TaskExecutorBuilder taskExecutorBuilder = new TaskExecutorBuilder();
+		private final TaskExecutorBuilder taskExecutorBuilder = new TaskExecutorBuilder()
+			.threadNamePrefix("CustomTaskExecutorBuilderConfig-");
 
 		@Bean
 		TaskExecutorBuilder customTaskExecutorBuilder() {
@@ -171,6 +384,18 @@ TaskExecutorBuilder customTaskExecutorBuilder() {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class CustomThreadPoolTaskExecutorBuilderConfig {
+
+		private final ThreadPoolTaskExecutorBuilder builder = new ThreadPoolTaskExecutorBuilder();
+
+		@Bean
+		ThreadPoolTaskExecutorBuilder customThreadPoolTaskExecutorBuilder() {
+			return this.builder;
+		}
+
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class TaskExecutorCustomizerConfig {
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskSchedulingAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskSchedulingAutoConfigurationTests.java
index 990e8cb6dbe8..ca211b30b965 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskSchedulingAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskSchedulingAutoConfigurationTests.java
@@ -26,12 +26,20 @@
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 
+import org.assertj.core.api.InstanceOfAssertFactories;
 import org.awaitility.Awaitility;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.api.condition.JRE;
 
 import org.springframework.boot.LazyInitializationBeanFactoryPostProcessor;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.task.SimpleAsyncTaskSchedulerBuilder;
+import org.springframework.boot.task.SimpleAsyncTaskSchedulerCustomizer;
+import org.springframework.boot.task.TaskSchedulerBuilder;
 import org.springframework.boot.task.TaskSchedulerCustomizer;
+import org.springframework.boot.task.ThreadPoolTaskSchedulerBuilder;
+import org.springframework.boot.task.ThreadPoolTaskSchedulerCustomizer;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
@@ -49,7 +57,9 @@
  * Tests for {@link TaskSchedulingAutoConfiguration}.
  *
  * @author Stephane Nicoll
+ * @author Moritz Halbritter
  */
+@SuppressWarnings("removal")
 class TaskSchedulingAutoConfigurationTests {
 
 	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
@@ -67,6 +77,26 @@ void noSchedulingDoesNotExposeScheduledBeanLazyInitializationExcludeFilter() {
 			.run((context) -> assertThat(context).doesNotHaveBean(ScheduledBeanLazyInitializationExcludeFilter.class));
 	}
 
+	@Test
+	void shouldSupplyBeans() {
+		this.contextRunner.withUserConfiguration(SchedulingConfiguration.class).run((context) -> {
+			assertThat(context).hasSingleBean(TaskSchedulerBuilder.class);
+			assertThat(context).hasSingleBean(ThreadPoolTaskSchedulerBuilder.class);
+			assertThat(context).hasSingleBean(ThreadPoolTaskScheduler.class);
+		});
+	}
+
+	@Test
+	void shouldNotSupplyThreadPoolTaskSchedulerBuilderIfCustomTaskSchedulerBuilderIsPresent() {
+		this.contextRunner.withUserConfiguration(SchedulingConfiguration.class)
+			.withBean(TaskSchedulerBuilder.class, TaskSchedulerBuilder::new)
+			.run((context) -> {
+				assertThat(context).hasSingleBean(TaskSchedulerBuilder.class);
+				assertThat(context).doesNotHaveBean(ThreadPoolTaskSchedulerBuilder.class);
+				assertThat(context).hasSingleBean(ThreadPoolTaskScheduler.class);
+			});
+	}
+
 	@Test
 	void enableSchedulingWithNoTaskExecutorAutoConfiguresOne() {
 		this.contextRunner
@@ -86,7 +116,59 @@ void enableSchedulingWithNoTaskExecutorAutoConfiguresOne() {
 	}
 
 	@Test
-	void enableSchedulingWithNoTaskExecutorAppliesCustomizers() {
+	void simpleAsyncTaskSchedulerBuilderShouldReadProperties() {
+		this.contextRunner
+			.withPropertyValues("spring.task.scheduling.simple.concurrency-limit=1",
+					"spring.task.scheduling.thread-name-prefix=scheduling-test-")
+			.withUserConfiguration(SchedulingConfiguration.class)
+			.run((context) -> {
+				assertThat(context).hasSingleBean(SimpleAsyncTaskSchedulerBuilder.class);
+				SimpleAsyncTaskSchedulerBuilder builder = context.getBean(SimpleAsyncTaskSchedulerBuilder.class);
+				assertThat(builder).hasFieldOrPropertyWithValue("threadNamePrefix", "scheduling-test-");
+				assertThat(builder).hasFieldOrPropertyWithValue("concurrencyLimit", 1);
+			});
+	}
+
+	@Test
+	@EnabledForJreRange(min = JRE.JAVA_21)
+	void simpleAsyncTaskSchedulerBuilderShouldUseVirtualThreadsIfEnabled() {
+		this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true")
+			.withUserConfiguration(SchedulingConfiguration.class)
+			.run((context) -> {
+				assertThat(context).hasSingleBean(SimpleAsyncTaskSchedulerBuilder.class);
+				SimpleAsyncTaskSchedulerBuilder builder = context.getBean(SimpleAsyncTaskSchedulerBuilder.class);
+				assertThat(builder).hasFieldOrPropertyWithValue("virtualThreads", true);
+			});
+	}
+
+	@Test
+	@EnabledForJreRange(min = JRE.JAVA_21)
+	void simpleAsyncTaskSchedulerBuilderShouldUsePlatformThreadsByDefault() {
+		this.contextRunner.withUserConfiguration(SchedulingConfiguration.class).run((context) -> {
+			assertThat(context).hasSingleBean(SimpleAsyncTaskSchedulerBuilder.class);
+			SimpleAsyncTaskSchedulerBuilder builder = context.getBean(SimpleAsyncTaskSchedulerBuilder.class);
+			assertThat(builder).hasFieldOrPropertyWithValue("virtualThreads", null);
+		});
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	void simpleAsyncTaskSchedulerBuilderShouldApplyCustomizers() {
+		SimpleAsyncTaskSchedulerCustomizer customizer = (scheduler) -> {
+		};
+		this.contextRunner.withBean(SimpleAsyncTaskSchedulerCustomizer.class, () -> customizer)
+			.withUserConfiguration(SchedulingConfiguration.class)
+			.run((context) -> {
+				assertThat(context).hasSingleBean(SimpleAsyncTaskSchedulerBuilder.class);
+				SimpleAsyncTaskSchedulerBuilder builder = context.getBean(SimpleAsyncTaskSchedulerBuilder.class);
+				assertThat(builder).extracting("customizers")
+					.asInstanceOf(InstanceOfAssertFactories.collection(SimpleAsyncTaskSchedulerCustomizer.class))
+					.containsExactly(customizer);
+			});
+	}
+
+	@Test
+	void enableSchedulingWithNoTaskExecutorAppliesTaskSchedulerCustomizers() {
 		this.contextRunner.withPropertyValues("spring.task.scheduling.thread-name-prefix=scheduling-test-")
 			.withUserConfiguration(SchedulingConfiguration.class, TaskSchedulerCustomizerConfiguration.class)
 			.run((context) -> {
@@ -97,6 +179,18 @@ void enableSchedulingWithNoTaskExecutorAppliesCustomizers() {
 			});
 	}
 
+	@Test
+	void enableSchedulingWithNoTaskExecutorAppliesCustomizers() {
+		this.contextRunner.withPropertyValues("spring.task.scheduling.thread-name-prefix=scheduling-test-")
+			.withUserConfiguration(SchedulingConfiguration.class, ThreadPoolTaskSchedulerCustomizerConfiguration.class)
+			.run((context) -> {
+				assertThat(context).hasSingleBean(TaskExecutor.class);
+				TestBean bean = context.getBean(TestBean.class);
+				assertThat(bean.latch.await(30, TimeUnit.SECONDS)).isTrue();
+				assertThat(bean.threadNames).allMatch((name) -> name.contains("customized-scheduler-"));
+			});
+	}
+
 	@Test
 	void enableSchedulingWithExistingTaskSchedulerBacksOff() {
 		this.contextRunner.withUserConfiguration(SchedulingConfiguration.class, TaskSchedulerConfiguration.class)
@@ -122,17 +216,6 @@ void enableSchedulingWithExistingScheduledExecutorServiceBacksOff() {
 			});
 	}
 
-	@Test
-	void enableSchedulingWithConfigurerBacksOff() {
-		this.contextRunner.withUserConfiguration(SchedulingConfiguration.class, SchedulingConfigurerConfiguration.class)
-			.run((context) -> {
-				assertThat(context).doesNotHaveBean(TaskScheduler.class);
-				TestBean bean = context.getBean(TestBean.class);
-				assertThat(bean.latch.await(30, TimeUnit.SECONDS)).isTrue();
-				assertThat(bean.threadNames).containsExactly("test-1");
-			});
-	}
-
 	@Test
 	void enableSchedulingWithLazyInitializationInvokeScheduledMethods() {
 		List<String> threadNames = new ArrayList<>();
@@ -186,6 +269,16 @@ TaskSchedulerCustomizer testTaskSchedulerCustomizer() {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class ThreadPoolTaskSchedulerCustomizerConfiguration {
+
+		@Bean
+		ThreadPoolTaskSchedulerCustomizer testTaskSchedulerCustomizer() {
+			return ((taskScheduler) -> taskScheduler.setThreadNamePrefix("customized-scheduler-"));
+		}
+
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class SchedulingConfigurerConfiguration implements SchedulingConfigurer {
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/ExecutionListenersTransactionManagerCustomizerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/ExecutionListenersTransactionManagerCustomizerTests.java
new file mode 100644
index 000000000000..3722680f6d7c
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/ExecutionListenersTransactionManagerCustomizerTests.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.transaction;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.transaction.ConfigurableTransactionManager;
+import org.springframework.transaction.TransactionExecutionListener;
+
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link ExecutionListenersTransactionManagerCustomizer}.
+ *
+ * @author Andy Wilkinson
+ */
+class ExecutionListenersTransactionManagerCustomizerTests {
+
+	@Test
+	void whenTransactionManagerIsCustomizedThenExecutionListenersAreAddedToIt() {
+		TransactionExecutionListener listener1 = mock(TransactionExecutionListener.class);
+		TransactionExecutionListener listener2 = mock(TransactionExecutionListener.class);
+		ConfigurableTransactionManager transactionManager = mock(ConfigurableTransactionManager.class);
+		new ExecutionListenersTransactionManagerCustomizer(List.of(listener1, listener2)).customize(transactionManager);
+		then(transactionManager).should().addListener(listener1);
+		then(transactionManager).should().addListener(listener2);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/TransactionAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/TransactionAutoConfigurationTests.java
index 502124eb177a..e3aa69357502 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/TransactionAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/TransactionAutoConfigurationTests.java
@@ -22,11 +22,13 @@
 
 import org.junit.jupiter.api.Test;
 
+import org.springframework.boot.LazyInitializationExcludeFilter;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
 import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration;
 import org.springframework.boot.jdbc.DataSourceBuilder;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.AdviceMode;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Import;
@@ -35,6 +37,7 @@
 import org.springframework.transaction.ReactiveTransactionManager;
 import org.springframework.transaction.annotation.EnableTransactionManagement;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.aspectj.AbstractTransactionAspect;
 import org.springframework.transaction.reactive.TransactionalOperator;
 import org.springframework.transaction.support.TransactionSynchronizationManager;
 import org.springframework.transaction.support.TransactionTemplate;
@@ -127,18 +130,6 @@ void whenAUserProvidesATransactionalOperatorTheAutoConfiguredOperatorBacksOff()
 			});
 	}
 
-	@Test
-	void platformTransactionManagerCustomizers() {
-		this.contextRunner.withUserConfiguration(SeveralPlatformTransactionManagersConfiguration.class)
-			.run((context) -> {
-				TransactionManagerCustomizers customizers = context.getBean(TransactionManagerCustomizers.class);
-				assertThat(customizers).extracting("customizers")
-					.asList()
-					.singleElement()
-					.isInstanceOf(TransactionProperties.class);
-			});
-	}
-
 	@Test
 	void transactionNotManagedWithNoTransactionManager() {
 		this.contextRunner.withUserConfiguration(BaseConfiguration.class)
@@ -177,6 +168,14 @@ void customEnableTransactionManagementTakesPrecedence() {
 			});
 	}
 
+	@Test
+	void excludesAbstractTransactionAspectFromLazyInit() {
+		this.contextRunner.withUserConfiguration(AspectJTransactionManagementConfiguration.class).run((context) -> {
+			LazyInitializationExcludeFilter filter = context.getBean(LazyInitializationExcludeFilter.class);
+			assertThat(filter.isExcluded(null, null, AbstractTransactionAspect.class)).isTrue();
+		});
+	}
+
 	@Configuration
 	static class SinglePlatformTransactionManagerConfiguration {
 
@@ -293,6 +292,12 @@ static class CustomTransactionManagementConfiguration {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
+	static class AspectJTransactionManagementConfiguration {
+
+	}
+
 	interface TransactionalService {
 
 		@Transactional
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizationAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizationAutoConfigurationTests.java
new file mode 100644
index 000000000000..24bf90e0b27b
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizationAutoConfigurationTests.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.transaction;
+
+import java.util.Collections;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link TransactionManagerCustomizationAutoConfiguration}.
+ *
+ * @author Andy Wilkinson
+ */
+class TransactionManagerCustomizationAutoConfigurationTests {
+
+	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+		.withConfiguration(AutoConfigurations.of(TransactionManagerCustomizationAutoConfiguration.class));
+
+	@Test
+	void autoConfiguresTransactionManagerCustomizers() {
+		this.contextRunner.run((context) -> {
+			TransactionManagerCustomizers customizers = context.getBean(TransactionManagerCustomizers.class);
+			assertThat(customizers).extracting("customizers")
+				.asList()
+				.hasSize(2)
+				.hasAtLeastOneElementOfType(TransactionProperties.class)
+				.hasAtLeastOneElementOfType(ExecutionListenersTransactionManagerCustomizer.class);
+		});
+	}
+
+	@Test
+	void autoConfiguredTransactionManagerCustomizersBacksOff() {
+		this.contextRunner.withUserConfiguration(CustomTransactionManagerCustomizersConfiguration.class)
+			.run((context) -> {
+				TransactionManagerCustomizers customizers = context.getBean(TransactionManagerCustomizers.class);
+				assertThat(customizers).extracting("customizers").asList().isEmpty();
+			});
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	static class CustomTransactionManagerCustomizersConfiguration {
+
+		@Bean
+		TransactionManagerCustomizers customTransactionManagerCustomizers() {
+			return TransactionManagerCustomizers.of(Collections.<TransactionManagerCustomizer<?>>emptyList());
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizersTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizersTests.java
index 9f5827813760..396b00987650 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizersTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizersTests.java
@@ -22,6 +22,7 @@
 import org.junit.jupiter.api.Test;
 
 import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.TransactionManager;
 import org.springframework.transaction.jta.JtaTransactionManager;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -36,7 +37,7 @@ class TransactionManagerCustomizersTests {
 
 	@Test
 	void customizeWithNullCustomizersShouldDoNothing() {
-		new TransactionManagerCustomizers(null).customize(mock(PlatformTransactionManager.class));
+		TransactionManagerCustomizers.of(null).customize(mock(TransactionManager.class));
 	}
 
 	@Test
@@ -44,15 +45,14 @@ void customizeShouldCheckGeneric() {
 		List<TestCustomizer<?>> list = new ArrayList<>();
 		list.add(new TestCustomizer<>());
 		list.add(new TestJtaCustomizer());
-		TransactionManagerCustomizers customizers = new TransactionManagerCustomizers(list);
-		customizers.customize(mock(PlatformTransactionManager.class));
-		customizers.customize(mock(JtaTransactionManager.class));
+		TransactionManagerCustomizers customizers = TransactionManagerCustomizers.of(list);
+		customizers.customize((TransactionManager) mock(PlatformTransactionManager.class));
+		customizers.customize((TransactionManager) mock(JtaTransactionManager.class));
 		assertThat(list.get(0).getCount()).isEqualTo(2);
 		assertThat(list.get(1).getCount()).isOne();
 	}
 
-	static class TestCustomizer<T extends PlatformTransactionManager>
-			implements PlatformTransactionManagerCustomizer<T> {
+	static class TestCustomizer<T extends PlatformTransactionManager> implements TransactionManagerCustomizer<T> {
 
 		private int count;
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/jta/JtaAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/jta/JtaAutoConfigurationTests.java
index e70fb24e4775..a2e0647780cc 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/jta/JtaAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/jta/JtaAutoConfigurationTests.java
@@ -43,6 +43,7 @@
 
 import org.springframework.beans.factory.NoSuchBeanDefinitionException;
 import org.springframework.boot.test.util.TestPropertyValues;
+import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
 import org.springframework.context.annotation.AnnotationConfigApplicationContext;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
@@ -62,6 +63,7 @@
  * @author Kazuki Shimizu
  * @author Nishant Raut
  */
+@ClassPathExclusions("jetty-jndi-*.jar")
 class JtaAutoConfigurationTests {
 
 	private AnnotationConfigApplicationContext context;
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/validation/ValidationAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/validation/ValidationAutoConfigurationTests.java
index 4a46ae5ddc82..66dc324099c3 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/validation/ValidationAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/validation/ValidationAutoConfigurationTests.java
@@ -217,7 +217,7 @@ void userDefinedMethodValidationPostProcessorTakesPrecedence() {
 				.isSameAs(userMethodValidationPostProcessor);
 			assertThat(context.getBeansOfType(MethodValidationPostProcessor.class)).hasSize(1);
 			Object validator = ReflectionTestUtils.getField(userMethodValidationPostProcessor, "validator");
-			assertThat(validator).isNotNull().isInstanceOf(Supplier.class);
+			assertThat(validator).isInstanceOf(Supplier.class);
 			assertThat(context.getBean(Validator.class)).isNotSameAs(((Supplier<Validator>) validator).get());
 		});
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/validation/ValidatorAdapterTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/validation/ValidatorAdapterTests.java
index bf57084b98ef..641c45dc220f 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/validation/ValidatorAdapterTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/validation/ValidatorAdapterTests.java
@@ -18,7 +18,9 @@
 
 import java.util.HashMap;
 
+import jakarta.validation.Validator;
 import jakarta.validation.constraints.Min;
+import org.hibernate.validator.HibernateValidator;
 import org.junit.jupiter.api.Test;
 
 import org.springframework.boot.test.context.FilteredClassLoader;
@@ -27,10 +29,13 @@
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.core.io.ClassPathResource;
+import org.springframework.validation.Errors;
 import org.springframework.validation.MapBindingResult;
+import org.springframework.validation.SmartValidator;
 import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatRuntimeException;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.BDDMockito.then;
 import static org.mockito.Mockito.mock;
@@ -91,6 +96,30 @@ void wrapperWhenValidationProviderNotPresentShouldNotThrowException() {
 			.run((context) -> ValidatorAdapter.get(context, null));
 	}
 
+	@Test
+	void unwrapToJakartaValidatorShouldReturnJakartaValidator() {
+		this.contextRunner.withUserConfiguration(LocalValidatorFactoryBeanConfig.class).run((context) -> {
+			ValidatorAdapter wrapper = context.getBean(ValidatorAdapter.class);
+			assertThat(wrapper.unwrap(Validator.class)).isInstanceOf(Validator.class);
+		});
+	}
+
+	@Test
+	void whenJakartaValidatorIsWrappedMultipleTimesUnwrapToJakartaValidatorShouldReturnJakartaValidator() {
+		this.contextRunner.withUserConfiguration(DoubleWrappedConfig.class).run((context) -> {
+			ValidatorAdapter wrapper = context.getBean(ValidatorAdapter.class);
+			assertThat(wrapper.unwrap(Validator.class)).isInstanceOf(Validator.class);
+		});
+	}
+
+	@Test
+	void unwrapToUnsupportedTypeShouldThrow() {
+		this.contextRunner.withUserConfiguration(LocalValidatorFactoryBeanConfig.class).run((context) -> {
+			ValidatorAdapter wrapper = context.getBean(ValidatorAdapter.class);
+			assertThatRuntimeException().isThrownBy(() -> wrapper.unwrap(HibernateValidator.class));
+		});
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class LocalValidatorFactoryBeanConfig {
 
@@ -106,6 +135,55 @@ ValidatorAdapter wrapper(LocalValidatorFactoryBean validator) {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class DoubleWrappedConfig {
+
+		@Bean
+		LocalValidatorFactoryBean validator() {
+			return new LocalValidatorFactoryBean();
+		}
+
+		@Bean
+		ValidatorAdapter wrapper(LocalValidatorFactoryBean validator) {
+			return new ValidatorAdapter(new Wrapper(validator), true);
+		}
+
+		static class Wrapper implements SmartValidator {
+
+			private final SmartValidator delegate;
+
+			Wrapper(SmartValidator delegate) {
+				this.delegate = delegate;
+			}
+
+			@Override
+			public boolean supports(Class<?> clazz) {
+				return this.delegate.supports(clazz);
+			}
+
+			@Override
+			public void validate(Object target, Errors errors) {
+				this.delegate.validate(target, errors);
+			}
+
+			@Override
+			public void validate(Object target, Errors errors, Object... validationHints) {
+				this.delegate.validate(target, errors, validationHints);
+			}
+
+			@Override
+			@SuppressWarnings("unchecked")
+			public <T> T unwrap(Class<T> type) {
+				if (type.isInstance(this.delegate)) {
+					return (T) this.delegate;
+				}
+				return this.delegate.unwrap(type);
+			}
+
+		}
+
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class NonManagedBeanConfig {
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/ServerPropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/ServerPropertiesTests.java
index eaf0b45c2b0a..ff8b377d2d95 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/ServerPropertiesTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/ServerPropertiesTests.java
@@ -16,21 +16,14 @@
 
 package org.springframework.boot.autoconfigure.web;
 
-import java.io.IOException;
 import java.net.InetAddress;
-import java.net.URI;
 import java.nio.charset.StandardCharsets;
 import java.time.Duration;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
-import java.util.concurrent.atomic.AtomicReference;
 
 import io.undertow.UndertowOptions;
-import jakarta.servlet.ServletException;
-import jakarta.servlet.http.HttpServlet;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
 import org.apache.catalina.connector.Connector;
 import org.apache.catalina.core.StandardContext;
 import org.apache.catalina.core.StandardEngine;
@@ -38,8 +31,7 @@
 import org.apache.catalina.valves.RemoteIpValve;
 import org.apache.coyote.AbstractProtocol;
 import org.apache.tomcat.util.net.AbstractEndpoint;
-import org.eclipse.jetty.server.HttpChannel;
-import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.ee10.servlet.ServletContextHandler;
 import org.eclipse.jetty.server.Server;
 import org.eclipse.jetty.util.thread.QueuedThreadPool;
 import org.junit.jupiter.api.Test;
@@ -51,21 +43,11 @@
 import org.springframework.boot.context.properties.source.ConfigurationPropertySource;
 import org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
 import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories;
-import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides;
 import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory;
 import org.springframework.boot.web.embedded.jetty.JettyWebServer;
 import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
-import org.springframework.boot.web.servlet.ServletContextInitializer;
-import org.springframework.http.HttpEntity;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.MediaType;
-import org.springframework.http.client.ClientHttpResponse;
 import org.springframework.test.util.ReflectionTestUtils;
-import org.springframework.util.LinkedMultiValueMap;
-import org.springframework.util.MultiValueMap;
 import org.springframework.util.unit.DataSize;
-import org.springframework.web.client.ResponseErrorHandler;
-import org.springframework.web.client.RestTemplate;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -114,6 +96,7 @@ void testServerHeader() {
 	}
 
 	@Test
+	@SuppressWarnings("removal")
 	void testTomcatBinding() {
 		Map<String, String> map = new HashMap<>();
 		map.put("server.tomcat.accesslog.conditionIf", "foo");
@@ -205,24 +188,6 @@ void testCustomizeUriEncoding() {
 		assertThat(this.properties.getTomcat().getUriEncoding()).isEqualTo(StandardCharsets.US_ASCII);
 	}
 
-	@Test
-	@SuppressWarnings("removal")
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	void testCustomizeHeaderSize() {
-		bind("server.max-http-header-size", "1MB");
-		assertThat(this.properties.getMaxHttpHeaderSize()).isEqualTo(DataSize.ofMegabytes(1));
-		assertThat(this.properties.getMaxHttpRequestHeaderSize()).isEqualTo(DataSize.ofMegabytes(1));
-	}
-
-	@Test
-	@SuppressWarnings("removal")
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	void testCustomizeHeaderSizeUseBytesByDefault() {
-		bind("server.max-http-header-size", "1024");
-		assertThat(this.properties.getMaxHttpHeaderSize()).isEqualTo(DataSize.ofKilobytes(1));
-		assertThat(this.properties.getMaxHttpRequestHeaderSize()).isEqualTo(DataSize.ofKilobytes(1));
-	}
-
 	@Test
 	void testCustomizeMaxHttpRequestHeaderSize() {
 		bind("server.max-http-request-header-size", "1MB");
@@ -441,6 +406,7 @@ void tomcatInternalProxiesMatchesDefault() {
 	}
 
 	@Test
+	@SuppressWarnings("removal")
 	void tomcatRejectIllegalHeaderMatchesProtocolDefault() throws Exception {
 		assertThat(getDefaultProtocol()).hasFieldOrPropertyWithValue("rejectIllegalHeader",
 				this.properties.getTomcat().isRejectIllegalHeader());
@@ -460,7 +426,6 @@ void tomcatMaxKeepAliveRequestsDefault() throws Exception {
 	}
 
 	@Test
-	@Servlet5ClassPathOverrides
 	void jettyThreadPoolPropertyDefaultsShouldMatchServerDefault() {
 		JettyServletWebServerFactory jettyFactory = new JettyServletWebServerFactory(0);
 		JettyWebServer jetty = (JettyWebServer) jettyFactory.getWebServer();
@@ -475,61 +440,12 @@ void jettyThreadPoolPropertyDefaultsShouldMatchServerDefault() {
 	}
 
 	@Test
-	@Servlet5ClassPathOverrides
 	void jettyMaxHttpFormPostSizeMatchesDefault() {
 		JettyServletWebServerFactory jettyFactory = new JettyServletWebServerFactory(0);
-		JettyWebServer jetty = (JettyWebServer) jettyFactory
-			.getWebServer((ServletContextInitializer) (servletContext) -> servletContext
-				.addServlet("formPost", new HttpServlet() {
-
-					@Override
-					protected void doPost(HttpServletRequest req, HttpServletResponse resp)
-							throws ServletException, IOException {
-						req.getParameterMap();
-					}
-
-				})
-				.addMapping("/form"));
-		jetty.start();
-		org.eclipse.jetty.server.Connector connector = jetty.getServer().getConnectors()[0];
-		final AtomicReference<Throwable> failure = new AtomicReference<>();
-		connector.addBean(new HttpChannel.Listener() {
-
-			@Override
-			public void onDispatchFailure(Request request, Throwable ex) {
-				failure.set(ex);
-			}
-
-		});
-		try {
-			RestTemplate template = new RestTemplate();
-			template.setErrorHandler(new ResponseErrorHandler() {
-
-				@Override
-				public boolean hasError(ClientHttpResponse response) throws IOException {
-					return false;
-				}
-
-				@Override
-				public void handleError(ClientHttpResponse response) throws IOException {
-
-				}
-
-			});
-			HttpHeaders headers = new HttpHeaders();
-			headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
-			MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
-			body.add("data", "a".repeat(250000));
-			HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
-			template.postForEntity(URI.create("http://localhost:" + jetty.getPort() + "/form"), entity, Void.class);
-			assertThat(failure.get()).isNotNull();
-			String message = failure.get().getCause().getMessage();
-			int defaultMaxPostSize = Integer.parseInt(message.substring(message.lastIndexOf(' ')).trim());
-			assertThat(this.properties.getJetty().getMaxHttpFormPostSize().toBytes()).isEqualTo(defaultMaxPostSize);
-		}
-		finally {
-			jetty.stop();
-		}
+		JettyWebServer jetty = (JettyWebServer) jettyFactory.getWebServer();
+		Server server = jetty.getServer();
+		assertThat(this.properties.getJetty().getMaxHttpFormPostSize().toBytes())
+			.isEqualTo(((ServletContextHandler) server.getHandler()).getMaxFormContentSize());
 	}
 
 	@Test
@@ -538,14 +454,6 @@ void undertowMaxHttpPostSizeMatchesDefault() {
 			.isEqualTo(UndertowOptions.DEFAULT_MAX_ENTITY_SIZE);
 	}
 
-	@Test
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	@SuppressWarnings("removal")
-	void nettyMaxChunkSizeMatchesHttpDecoderSpecDefault() {
-		assertThat(this.properties.getNetty().getMaxChunkSize().toBytes())
-			.isEqualTo(HttpDecoderSpec.DEFAULT_MAX_CHUNK_SIZE);
-	}
-
 	@Test
 	void nettyMaxInitialLineLengthMatchesHttpDecoderSpecDefault() {
 		assertThat(this.properties.getNetty().getMaxInitialLineLength().toBytes())
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/HttpMessageConvertersRestClientCustomizerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/HttpMessageConvertersRestClientCustomizerTests.java
new file mode 100644
index 000000000000..f4138baa1819
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/HttpMessageConvertersRestClientCustomizerTests.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.web.client;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Consumer;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.web.client.RestClient;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link HttpMessageConvertersRestClientCustomizer}
+ *
+ * @author Phillip Webb
+ */
+class HttpMessageConvertersRestClientCustomizerTests {
+
+	@Test
+	void createWhenNullMessageConvertersArrayThrowsException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> new HttpMessageConvertersRestClientCustomizer((HttpMessageConverter<?>[]) null))
+			.withMessage("MessageConverters must not be null");
+	}
+
+	@Test
+	void createWhenNullMessageConvertersDoesNotCustomize() {
+		HttpMessageConverter<?> c0 = mock();
+		assertThat(apply(new HttpMessageConvertersRestClientCustomizer((HttpMessageConverters) null), c0))
+			.containsExactly(c0);
+	}
+
+	@Test
+	void customizeConfiguresMessageConverters() {
+		HttpMessageConverter<?> c0 = mock();
+		HttpMessageConverter<?> c1 = mock();
+		HttpMessageConverter<?> c2 = mock();
+		assertThat(apply(new HttpMessageConvertersRestClientCustomizer(c1, c2), c0)).containsExactly(c1, c2);
+	}
+
+	@SuppressWarnings("unchecked")
+	private List<HttpMessageConverter<?>> apply(HttpMessageConvertersRestClientCustomizer customizer,
+			HttpMessageConverter<?>... converters) {
+		List<HttpMessageConverter<?>> messageConverters = new ArrayList<>(Arrays.asList(converters));
+		RestClient.Builder restClientBuilder = mock();
+		ArgumentCaptor<Consumer<List<HttpMessageConverter<?>>>> captor = ArgumentCaptor.forClass(Consumer.class);
+		given(restClientBuilder.messageConverters(captor.capture())).willReturn(restClientBuilder);
+		customizer.customize(restClientBuilder);
+		captor.getValue().accept(messageConverters);
+		return messageConverters;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/RestClientAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/RestClientAutoConfigurationTests.java
new file mode 100644
index 000000000000..576d6e45808b
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/RestClientAutoConfigurationTests.java
@@ -0,0 +1,207 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.web.client;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
+import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
+import org.springframework.boot.ssl.SslBundles;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.boot.web.client.RestClientCustomizer;
+import org.springframework.boot.web.codec.CodecCustomizer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.http.converter.StringHttpMessageConverter;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.client.RestClient.Builder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link RestClientAutoConfiguration}
+ *
+ * @author Arjen Poutsma
+ * @author Moritz Halbritter
+ */
+class RestClientAutoConfigurationTests {
+
+	private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+		.withConfiguration(AutoConfigurations.of(RestClientAutoConfiguration.class));
+
+	@Test
+	void shouldSupplyBeans() {
+		this.contextRunner.run((context) -> {
+			assertThat(context).hasSingleBean(HttpMessageConvertersRestClientCustomizer.class);
+			assertThat(context).hasSingleBean(RestClientBuilderConfigurer.class);
+			assertThat(context).hasSingleBean(RestClient.Builder.class);
+		});
+	}
+
+	@Test
+	void shouldSupplyRestClientSslIfSslBundlesIsThere() {
+		this.contextRunner.withBean(SslBundles.class, () -> mock(SslBundles.class))
+			.run((context) -> assertThat(context).hasSingleBean(RestClientSsl.class));
+	}
+
+	@Test
+	void shouldCreateBuilder() {
+		this.contextRunner.run((context) -> {
+			RestClient.Builder builder = context.getBean(RestClient.Builder.class);
+			RestClient restClient = builder.build();
+			assertThat(restClient).isNotNull();
+		});
+	}
+
+	@Test
+	void configurerShouldCallCustomizers() {
+		this.contextRunner.withUserConfiguration(RestClientCustomizerConfig.class).run((context) -> {
+			RestClientBuilderConfigurer configurer = context.getBean(RestClientBuilderConfigurer.class);
+			RestClientCustomizer customizer = context.getBean("restClientCustomizer", RestClientCustomizer.class);
+			Builder builder = RestClient.builder();
+			configurer.configure(builder);
+			then(customizer).should().customize(builder);
+		});
+	}
+
+	@Test
+	void restClientShouldApplyCustomizers() {
+		this.contextRunner.withUserConfiguration(RestClientCustomizerConfig.class).run((context) -> {
+			RestClient.Builder builder = context.getBean(RestClient.Builder.class);
+			RestClientCustomizer customizer = context.getBean("restClientCustomizer", RestClientCustomizer.class);
+			builder.build();
+			then(customizer).should().customize(any(RestClient.Builder.class));
+		});
+	}
+
+	@Test
+	void shouldGetPrototypeScopedBean() {
+		this.contextRunner.withUserConfiguration(RestClientCustomizerConfig.class).run((context) -> {
+			RestClient.Builder firstBuilder = context.getBean(RestClient.Builder.class);
+			RestClient.Builder secondBuilder = context.getBean(RestClient.Builder.class);
+			assertThat(firstBuilder).isNotEqualTo(secondBuilder);
+		});
+	}
+
+	@Test
+	void shouldNotCreateClientBuilderIfAlreadyPresent() {
+		this.contextRunner.withUserConfiguration(CustomRestClientBuilderConfig.class).run((context) -> {
+			RestClient.Builder builder = context.getBean(RestClient.Builder.class);
+			assertThat(builder).isInstanceOf(MyRestClientBuilder.class);
+		});
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	void restClientWhenMessageConvertersDefinedShouldHaveMessageConverters() {
+		this.contextRunner.withConfiguration(AutoConfigurations.of(HttpMessageConvertersAutoConfiguration.class))
+			.withUserConfiguration(RestClientConfig.class)
+			.run((context) -> {
+				RestClient restClient = context.getBean(RestClient.class);
+				List<HttpMessageConverter<?>> expectedConverters = context.getBean(HttpMessageConverters.class)
+					.getConverters();
+				List<HttpMessageConverter<?>> actualConverters = (List<HttpMessageConverter<?>>) ReflectionTestUtils
+					.getField(restClient, "messageConverters");
+				assertThat(actualConverters).containsExactlyElementsOf(expectedConverters);
+			});
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	void restClientWhenNoMessageConvertersDefinedShouldHaveDefaultMessageConverters() {
+		this.contextRunner.withUserConfiguration(RestClientConfig.class).run((context) -> {
+			RestClient restClient = context.getBean(RestClient.class);
+			RestClient defaultRestClient = RestClient.builder().build();
+			List<HttpMessageConverter<?>> actualConverters = (List<HttpMessageConverter<?>>) ReflectionTestUtils
+				.getField(restClient, "messageConverters");
+			List<HttpMessageConverter<?>> expectedConverters = (List<HttpMessageConverter<?>>) ReflectionTestUtils
+				.getField(defaultRestClient, "messageConverters");
+			assertThat(actualConverters).hasSameSizeAs(expectedConverters);
+		});
+	}
+
+	@Test
+	@SuppressWarnings({ "unchecked", "rawtypes" })
+	void restClientWhenHasCustomMessageConvertersShouldHaveMessageConverters() {
+		this.contextRunner.withConfiguration(AutoConfigurations.of(HttpMessageConvertersAutoConfiguration.class))
+			.withUserConfiguration(CustomHttpMessageConverter.class, RestClientConfig.class)
+			.run((context) -> {
+				RestClient restClient = context.getBean(RestClient.class);
+				List<HttpMessageConverter<?>> actualConverters = (List<HttpMessageConverter<?>>) ReflectionTestUtils
+					.getField(restClient, "messageConverters");
+				assertThat(actualConverters).extracting(HttpMessageConverter::getClass)
+					.contains((Class) CustomHttpMessageConverter.class);
+			});
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	static class CodecConfiguration {
+
+		@Bean
+		CodecCustomizer myCodecCustomizer() {
+			return mock(CodecCustomizer.class);
+		}
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	static class RestClientCustomizerConfig {
+
+		@Bean
+		RestClientCustomizer restClientCustomizer() {
+			return mock(RestClientCustomizer.class);
+		}
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	static class CustomRestClientBuilderConfig {
+
+		@Bean
+		MyRestClientBuilder myRestClientBuilder() {
+			return mock(MyRestClientBuilder.class);
+		}
+
+	}
+
+	interface MyRestClientBuilder extends RestClient.Builder {
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	static class RestClientConfig {
+
+		@Bean
+		RestClient restClient(RestClient.Builder restClientBuilder) {
+			return restClientBuilder.build();
+		}
+
+	}
+
+	static class CustomHttpMessageConverter extends StringHttpMessageConverter {
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/RestClientBuilderConfigurerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/RestClientBuilderConfigurerTests.java
new file mode 100644
index 000000000000..c4c8395c2177
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/RestClientBuilderConfigurerTests.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.web.client;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.web.client.RestClientCustomizer;
+import org.springframework.web.client.RestClient;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link RestClientBuilderConfigurer}.
+ *
+ * @author Moritz Halbritter
+ */
+class RestClientBuilderConfigurerTests {
+
+	@Test
+	void shouldApplyCustomizers() {
+		RestClientBuilderConfigurer configurer = new RestClientBuilderConfigurer();
+		RestClientCustomizer customizer = mock(RestClientCustomizer.class);
+		configurer.setRestClientCustomizers(List.of(customizer));
+		RestClient.Builder builder = RestClient.builder();
+		configurer.configure(builder);
+		then(customizer).should().customize(builder);
+	}
+
+	@Test
+	void shouldSupportNullAsCustomizers() {
+		RestClientBuilderConfigurer configurer = new RestClientBuilderConfigurer();
+		configurer.setRestClientCustomizers(null);
+		assertThatCode(() -> configurer.configure(RestClient.builder())).doesNotThrowAnyException();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/RestTemplateAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/RestTemplateAutoConfigurationTests.java
index a80f93a73720..582752925976 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/RestTemplateAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/RestTemplateAutoConfigurationTests.java
@@ -21,6 +21,7 @@
 
 import org.junit.jupiter.api.Test;
 
+import org.springframework.beans.factory.support.BeanDefinitionOverrideException;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
 import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
@@ -65,6 +66,14 @@ void restTemplateBuilderConfigurerShouldBeLazilyDefined() {
 			.isTrue());
 	}
 
+	@Test
+	void shouldFailOnCustomRestTemplateBuilderConfigurer() {
+		this.contextRunner.withUserConfiguration(RestTemplateBuilderConfigurerConfig.class)
+			.run((context) -> assertThat(context).getFailure()
+				.isInstanceOf(BeanDefinitionOverrideException.class)
+				.hasMessageContaining("with name 'restTemplateBuilderConfigurer'"));
+	}
+
 	@Test
 	void restTemplateBuilderShouldBeLazilyDefined() {
 		this.contextRunner
@@ -263,6 +272,16 @@ RestTemplateRequestCustomizer<?> restTemplateRequestCustomizer() {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class RestTemplateBuilderConfigurerConfig {
+
+		@Bean
+		RestTemplateBuilderConfigurer restTemplateBuilderConfigurer() {
+			return new RestTemplateBuilderConfigurer();
+		}
+
+	}
+
 	static class CustomHttpMessageConverter extends StringHttpMessageConverter {
 
 	}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/JettyVirtualThreadsWebServerFactoryCustomizerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/JettyVirtualThreadsWebServerFactoryCustomizerTests.java
new file mode 100644
index 000000000000..6f7fb09dd7f3
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/JettyVirtualThreadsWebServerFactoryCustomizerTests.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.web.embedded;
+
+import org.eclipse.jetty.util.thread.QueuedThreadPool;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.api.condition.JRE;
+
+import org.springframework.boot.autoconfigure.web.ServerProperties;
+import org.springframework.boot.web.embedded.jetty.ConfigurableJettyWebServerFactory;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.assertArg;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link JettyVirtualThreadsWebServerFactoryCustomizer}.
+ *
+ * @author Moritz Halbritter
+ */
+class JettyVirtualThreadsWebServerFactoryCustomizerTests {
+
+	@Test
+	@EnabledForJreRange(min = JRE.JAVA_21)
+	void shouldConfigureVirtualThreads() {
+		ServerProperties properties = new ServerProperties();
+		JettyVirtualThreadsWebServerFactoryCustomizer customizer = new JettyVirtualThreadsWebServerFactoryCustomizer(
+				properties);
+		ConfigurableJettyWebServerFactory factory = mock(ConfigurableJettyWebServerFactory.class);
+		customizer.customize(factory);
+		then(factory).should().setThreadPool(assertArg((threadPool) -> {
+			assertThat(threadPool).isInstanceOf(QueuedThreadPool.class);
+			QueuedThreadPool queuedThreadPool = (QueuedThreadPool) threadPool;
+			assertThat(queuedThreadPool.getVirtualThreadsExecutor()).isNotNull();
+		}));
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/JettyWebServerFactoryCustomizerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/JettyWebServerFactoryCustomizerTests.java
index c024fc15c6cf..eef94bb88a20 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/JettyWebServerFactoryCustomizerTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/JettyWebServerFactoryCustomizerTests.java
@@ -47,7 +47,6 @@
 import org.springframework.boot.context.properties.bind.Binder;
 import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
 import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories;
-import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides;
 import org.springframework.boot.web.embedded.jetty.ConfigurableJettyWebServerFactory;
 import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory;
 import org.springframework.boot.web.embedded.jetty.JettyWebServer;
@@ -67,7 +66,6 @@
  * @author HaiTao Zhang
  */
 @DirtiesUrlFactories
-@Servlet5ClassPathOverrides
 class JettyWebServerFactoryCustomizerTests {
 
 	private MockEnvironment environment;
@@ -263,30 +261,6 @@ void setUseForwardHeaders() {
 		then(factory).should().setUseForwardHeaders(true);
 	}
 
-	@Test
-	void customizeMaxHttpHeaderSize() {
-		bind("server.max-http-header-size=2048");
-		JettyWebServer server = customizeAndGetServer();
-		List<Integer> requestHeaderSizes = getRequestHeaderSizes(server);
-		assertThat(requestHeaderSizes).containsOnly(2048);
-	}
-
-	@Test
-	void customMaxHttpHeaderSizeIgnoredIfNegative() {
-		bind("server.max-http-header-size=-1");
-		JettyWebServer server = customizeAndGetServer();
-		List<Integer> requestHeaderSizes = getRequestHeaderSizes(server);
-		assertThat(requestHeaderSizes).containsOnly(8192);
-	}
-
-	@Test
-	void customMaxHttpHeaderSizeIgnoredIfZero() {
-		bind("server.max-http-header-size=0");
-		JettyWebServer server = customizeAndGetServer();
-		List<Integer> requestHeaderSizes = getRequestHeaderSizes(server);
-		assertThat(requestHeaderSizes).containsOnly(8192);
-	}
-
 	@Test
 	void customizeMaxRequestHttpHeaderSize() {
 		bind("server.max-http-request-header-size=2048");
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/NettyWebServerFactoryCustomizerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/NettyWebServerFactoryCustomizerTests.java
index e2267a005a74..70046794354d 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/NettyWebServerFactoryCustomizerTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/NettyWebServerFactoryCustomizerTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -26,6 +26,7 @@
 import org.mockito.ArgumentCaptor;
 import org.mockito.Captor;
 import org.mockito.junit.jupiter.MockitoExtension;
+import reactor.netty.http.Http2SettingsSpec;
 import reactor.netty.http.server.HttpRequestDecoderSpec;
 import reactor.netty.http.server.HttpServer;
 
@@ -126,37 +127,37 @@ void setMaxKeepAliveRequests() {
 		verifyMaxKeepAliveRequests(factory, 100);
 	}
 
+	@Test
+	void setHttp2MaxRequestHeaderSize() {
+		DataSize headerSize = DataSize.ofKilobytes(24);
+		this.serverProperties.getHttp2().setEnabled(true);
+		this.serverProperties.setMaxHttpRequestHeaderSize(headerSize);
+		NettyReactiveWebServerFactory factory = mock(NettyReactiveWebServerFactory.class);
+		this.customizer.customize(factory);
+		verifyHttp2MaxHeaderSize(factory, headerSize.toBytes());
+	}
+
 	@Test
 	void configureHttpRequestDecoder() {
 		ServerProperties.Netty nettyProperties = this.serverProperties.getNetty();
+		this.serverProperties.setMaxHttpRequestHeaderSize(DataSize.ofKilobytes(24));
 		nettyProperties.setValidateHeaders(false);
 		nettyProperties.setInitialBufferSize(DataSize.ofBytes(512));
 		nettyProperties.setH2cMaxContentLength(DataSize.ofKilobytes(1));
-		setMaxChunkSize(nettyProperties);
 		nettyProperties.setMaxInitialLineLength(DataSize.ofKilobytes(32));
 		NettyReactiveWebServerFactory factory = mock(NettyReactiveWebServerFactory.class);
 		this.customizer.customize(factory);
 		then(factory).should().addServerCustomizers(this.customizerCaptor.capture());
-		NettyServerCustomizer serverCustomizer = this.customizerCaptor.getValue();
+		NettyServerCustomizer serverCustomizer = this.customizerCaptor.getAllValues().get(0);
 		HttpServer httpServer = serverCustomizer.apply(HttpServer.create());
 		HttpRequestDecoderSpec decoder = httpServer.configuration().decoder();
 		assertThat(decoder.validateHeaders()).isFalse();
+		assertThat(decoder.maxHeaderSize()).isEqualTo(this.serverProperties.getMaxHttpRequestHeaderSize().toBytes());
 		assertThat(decoder.initialBufferSize()).isEqualTo(nettyProperties.getInitialBufferSize().toBytes());
 		assertThat(decoder.h2cMaxContentLength()).isEqualTo(nettyProperties.getH2cMaxContentLength().toBytes());
-		assertMaxChunkSize(nettyProperties, decoder);
 		assertThat(decoder.maxInitialLineLength()).isEqualTo(nettyProperties.getMaxInitialLineLength().toBytes());
 	}
 
-	@SuppressWarnings("removal")
-	private void setMaxChunkSize(ServerProperties.Netty nettyProperties) {
-		nettyProperties.setMaxChunkSize(DataSize.ofKilobytes(16));
-	}
-
-	@SuppressWarnings({ "deprecation", "removal" })
-	private void assertMaxChunkSize(ServerProperties.Netty nettyProperties, HttpRequestDecoderSpec decoder) {
-		assertThat(decoder.maxChunkSize()).isEqualTo(nettyProperties.getMaxChunkSize().toBytes());
-	}
-
 	private void verifyConnectionTimeout(NettyReactiveWebServerFactory factory, Integer expected) {
 		if (expected == null) {
 			then(factory).should(never()).addServerCustomizers(any(NettyServerCustomizer.class));
@@ -189,4 +190,12 @@ private void verifyMaxKeepAliveRequests(NettyReactiveWebServerFactory factory, i
 		assertThat(maxKeepAliveRequests).isEqualTo(expected);
 	}
 
+	private void verifyHttp2MaxHeaderSize(NettyReactiveWebServerFactory factory, long expected) {
+		then(factory).should(times(2)).addServerCustomizers(this.customizerCaptor.capture());
+		NettyServerCustomizer serverCustomizer = this.customizerCaptor.getAllValues().get(0);
+		HttpServer httpServer = serverCustomizer.apply(HttpServer.create());
+		Http2SettingsSpec decoder = httpServer.configuration().http2SettingsSpec();
+		assertThat(decoder.maxHeaderListSize()).isEqualTo(expected);
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/TomcatVirtualThreadsWebServerFactoryCustomizerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/TomcatVirtualThreadsWebServerFactoryCustomizerTests.java
new file mode 100644
index 000000000000..5fcf72d1f937
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/TomcatVirtualThreadsWebServerFactoryCustomizerTests.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.autoconfigure.web.embedded;
+
+import java.util.function.Consumer;
+
+import org.apache.tomcat.util.threads.VirtualThreadExecutor;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.api.condition.JRE;
+
+import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
+import org.springframework.boot.web.embedded.tomcat.TomcatWebServer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link TomcatVirtualThreadsWebServerFactoryCustomizer}.
+ *
+ * @author Moritz Halbritter
+ */
+class TomcatVirtualThreadsWebServerFactoryCustomizerTests {
+
+	private final TomcatVirtualThreadsWebServerFactoryCustomizer customizer = new TomcatVirtualThreadsWebServerFactoryCustomizer();
+
+	@Test
+	@EnabledForJreRange(min = JRE.JAVA_21)
+	void shouldSetVirtualThreadExecutor() {
+		withWebServer((webServer) -> assertThat(webServer.getTomcat().getConnector().getProtocolHandler().getExecutor())
+			.isInstanceOf(VirtualThreadExecutor.class));
+	}
+
+	private TomcatWebServer getWebServer() {
+		TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(0);
+		this.customizer.customize(factory);
+		return (TomcatWebServer) factory.getWebServer();
+	}
+
+	private void withWebServer(Consumer<TomcatWebServer> callback) {
+		TomcatWebServer webServer = getWebServer();
+		webServer.start();
+		try {
+			callback.accept(webServer);
+		}
+		finally {
+			webServer.stop();
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizerTests.java
index c24b4f179cde..f885699393ad 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizerTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizerTests.java
@@ -176,47 +176,6 @@ void customMaxHttpFormPostSize() {
 				(server) -> assertThat(server.getTomcat().getConnector().getMaxPostSize()).isEqualTo(10000));
 	}
 
-	@Test
-	void customMaxHttpHeaderSize() {
-		bind("server.max-http-header-size=1KB");
-		customizeAndRunServer((server) -> assertThat(
-				((AbstractHttp11Protocol<?>) server.getTomcat().getConnector().getProtocolHandler())
-					.getMaxHttpRequestHeaderSize())
-			.isEqualTo(DataSize.ofKilobytes(1).toBytes()));
-	}
-
-	@Test
-	void customMaxHttpHeaderSizeWithHttp2() {
-		bind("server.max-http-header-size=1KB", "server.http2.enabled=true");
-		customizeAndRunServer((server) -> {
-			AbstractHttp11Protocol<?> protocolHandler = (AbstractHttp11Protocol<?>) server.getTomcat()
-				.getConnector()
-				.getProtocolHandler();
-			long expectedSize = DataSize.ofKilobytes(1).toBytes();
-			assertThat(protocolHandler.getMaxHttpRequestHeaderSize()).isEqualTo(expectedSize);
-			assertThat(((Http2Protocol) protocolHandler.getUpgradeProtocol("h2c")).getMaxHeaderSize())
-				.isEqualTo(expectedSize);
-		});
-	}
-
-	@Test
-	void customMaxHttpHeaderSizeIgnoredIfNegative() {
-		bind("server.max-http-header-size=-1");
-		customizeAndRunServer((server) -> assertThat(
-				((AbstractHttp11Protocol<?>) server.getTomcat().getConnector().getProtocolHandler())
-					.getMaxHttpRequestHeaderSize())
-			.isEqualTo(DataSize.ofKilobytes(8).toBytes()));
-	}
-
-	@Test
-	void customMaxHttpHeaderSizeIgnoredIfZero() {
-		bind("server.max-http-header-size=0");
-		customizeAndRunServer((server) -> assertThat(
-				((AbstractHttp11Protocol<?>) server.getTomcat().getConnector().getProtocolHandler())
-					.getMaxHttpRequestHeaderSize())
-			.isEqualTo(DataSize.ofKilobytes(8).toBytes()));
-	}
-
 	@Test
 	void defaultMaxHttpRequestHeaderSize() {
 		customizeAndRunServer((server) -> assertThat(
@@ -436,16 +395,6 @@ void disableRemoteIpValve() {
 		assertThat(factory.getEngineValves()).isEmpty();
 	}
 
-	@Test
-	@Deprecated(since = "2.7.12", forRemoval = true)
-	void testCustomizeRejectIllegalHeader() {
-		bind("server.tomcat.reject-illegal-header=false");
-		customizeAndRunServer((server) -> assertThat(
-				((AbstractHttp11Protocol<?>) server.getTomcat().getConnector().getProtocolHandler())
-					.getRejectIllegalHeader())
-			.isFalse());
-	}
-
 	@Test
 	void errorReportValveIsConfiguredToNotReportStackTraces() {
 		TomcatWebServer server = customizeAndGetServer();
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/UndertowWebServerFactoryCustomizerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/UndertowWebServerFactoryCustomizerTests.java
index 934f0689223a..e5a2c95e8271 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/UndertowWebServerFactoryCustomizerTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/UndertowWebServerFactoryCustomizerTests.java
@@ -85,24 +85,6 @@ void customizeUndertowAccessLog() {
 		then(factory).should().setAccessLogRotate(false);
 	}
 
-	@Test
-	void customMaxHttpHeaderSize() {
-		bind("server.max-http-header-size=2048");
-		assertThat(boundServerOption(UndertowOptions.MAX_HEADER_SIZE)).isEqualTo(2048);
-	}
-
-	@Test
-	void customMaxHttpHeaderSizeIgnoredIfNegative() {
-		bind("server.max-http-header-size=-1");
-		assertThat(boundServerOption(UndertowOptions.MAX_HEADER_SIZE)).isNull();
-	}
-
-	@Test
-	void customMaxHttpHeaderSizeIgnoredIfZero() {
-		bind("server.max-http-header-size=0");
-		assertThat(boundServerOption(UndertowOptions.MAX_HEADER_SIZE)).isNull();
-	}
-
 	@Test
 	void customMaxHttpRequestHeaderSize() {
 		bind("server.max-http-request-header-size=2048");
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfigurationTests.java
index ccbc01ae7bbd..ae3d0099ce22 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfigurationTests.java
@@ -62,7 +62,7 @@ void shouldConfigureMultipartPropertiesForDefaultReader() {
 		this.contextRunner
 			.withPropertyValues("spring.webflux.multipart.max-in-memory-size=1GB",
 					"spring.webflux.multipart.max-headers-size=16KB",
-					"spring.webflux.multipart.max-disk-usage-per-part=100MB", "spring.webflux.multipart.max-parts=7",
+					"spring.webflux.multipart.max-disk-usage-per-part=3GB", "spring.webflux.multipart.max-parts=7",
 					"spring.webflux.multipart.headers-charset:UTF_16")
 			.run((context) -> {
 				CodecCustomizer customizer = context.getBean(CodecCustomizer.class);
@@ -76,7 +76,7 @@ void shouldConfigureMultipartPropertiesForDefaultReader() {
 				assertThat(partReader).hasFieldOrPropertyWithValue("maxInMemorySize",
 						Math.toIntExact(DataSize.ofGigabytes(1).toBytes()));
 				assertThat(partReader).hasFieldOrPropertyWithValue("maxDiskUsagePerPart",
-						DataSize.ofMegabytes(100).toBytes());
+						DataSize.ofGigabytes(3).toBytes());
 			});
 	}
 
@@ -84,17 +84,21 @@ void shouldConfigureMultipartPropertiesForDefaultReader() {
 	void shouldConfigureMultipartPropertiesForPartEventReader() {
 		this.contextRunner
 			.withPropertyValues("spring.webflux.multipart.max-in-memory-size=1GB",
-					"spring.webflux.multipart.max-headers-size=16KB", "spring.webflux.multipart.headers-charset:UTF_16")
+					"spring.webflux.multipart.max-headers-size=16KB",
+					"spring.webflux.multipart.max-disk-usage-per-part=3GB", "spring.webflux.multipart.max-parts=7",
+					"spring.webflux.multipart.headers-charset:UTF_16")
 			.run((context) -> {
 				CodecCustomizer customizer = context.getBean(CodecCustomizer.class);
 				DefaultServerCodecConfigurer configurer = new DefaultServerCodecConfigurer();
 				customizer.customize(configurer);
 				PartEventHttpMessageReader partReader = getPartEventReader(configurer);
+				assertThat(partReader).hasFieldOrPropertyWithValue("maxParts", 7);
 				assertThat(partReader).hasFieldOrPropertyWithValue("maxHeadersSize",
 						Math.toIntExact(DataSize.ofKilobytes(16).toBytes()));
 				assertThat(partReader).hasFieldOrPropertyWithValue("headersCharset", StandardCharsets.UTF_16);
 				assertThat(partReader).hasFieldOrPropertyWithValue("maxInMemorySize",
 						Math.toIntExact(DataSize.ofGigabytes(1).toBytes()));
+				assertThat(partReader).hasFieldOrPropertyWithValue("maxPartSize", DataSize.ofGigabytes(3).toBytes());
 			});
 	}
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryAutoConfigurationTests.java
index 7b1b38cf2678..044c1bfff9f1 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryAutoConfigurationTests.java
@@ -31,7 +31,6 @@
 import org.springframework.boot.test.context.FilteredClassLoader;
 import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
 import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories;
-import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides;
 import org.springframework.boot.web.embedded.jetty.JettyReactiveWebServerFactory;
 import org.springframework.boot.web.embedded.jetty.JettyServerCustomizer;
 import org.springframework.boot.web.embedded.netty.NettyReactiveWebServerFactory;
@@ -231,7 +230,6 @@ void tomcatProtocolHandlerCustomizerRegisteredAsBeanAndViaFactoryIsOnlyCalledOnc
 	}
 
 	@Test
-	@Servlet5ClassPathOverrides
 	void jettyServerCustomizerBeanIsAddedToFactory() {
 		new ReactiveWebApplicationContextRunner(AnnotationConfigReactiveWebApplicationContext::new)
 			.withConfiguration(AutoConfigurations.of(ReactiveWebServerFactoryAutoConfiguration.class))
@@ -244,7 +242,6 @@ void jettyServerCustomizerBeanIsAddedToFactory() {
 	}
 
 	@Test
-	@Servlet5ClassPathOverrides
 	void jettyServerCustomizerRegisteredAsBeanAndViaFactoryIsOnlyCalledOnce() {
 		new ReactiveWebApplicationContextRunner(AnnotationConfigReactiveWebServerApplicationContext::new)
 			.withConfiguration(AutoConfigurations.of(ReactiveWebServerFactoryAutoConfiguration.class))
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfigurationTests.java
index 15759a908e2d..cf40c58a52c9 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfigurationTests.java
@@ -28,6 +28,7 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.concurrent.Executor;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Consumer;
 
@@ -36,6 +37,7 @@
 import org.aspectj.lang.annotation.AfterReturning;
 import org.aspectj.lang.annotation.Aspect;
 import org.assertj.core.api.Assertions;
+import org.assertj.core.api.InstanceOfAssertFactories;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
@@ -43,10 +45,13 @@
 import org.springframework.aop.support.AopUtils;
 import org.springframework.boot.autoconfigure.AutoConfigurations;
 import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
+import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration;
 import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration;
 import org.springframework.boot.autoconfigure.validation.ValidatorAdapter;
 import org.springframework.boot.autoconfigure.web.ServerProperties;
 import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration.WebFluxConfig;
+import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfigurationTests.OrderedControllerAdviceBeansConfiguration.HighestOrderedControllerAdvice;
+import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfigurationTests.OrderedControllerAdviceBeansConfiguration.LowestOrderedControllerAdvice;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.boot.test.context.runner.ContextConsumer;
 import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
@@ -62,6 +67,7 @@
 import org.springframework.core.annotation.Order;
 import org.springframework.core.convert.ConversionService;
 import org.springframework.core.io.ClassPathResource;
+import org.springframework.core.task.AsyncTaskExecutor;
 import org.springframework.format.Parser;
 import org.springframework.format.Printer;
 import org.springframework.format.support.FormattingConversionService;
@@ -77,8 +83,10 @@
 import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
 import org.springframework.web.bind.annotation.ControllerAdvice;
 import org.springframework.web.filter.reactive.HiddenHttpMethodFilter;
+import org.springframework.web.method.ControllerAdviceBean;
 import org.springframework.web.reactive.HandlerMapping;
 import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
+import org.springframework.web.reactive.config.BlockingExecutionConfigurer;
 import org.springframework.web.reactive.config.DelegatingWebFluxConfiguration;
 import org.springframework.web.reactive.config.WebFluxConfigurationSupport;
 import org.springframework.web.reactive.config.WebFluxConfigurer;
@@ -667,6 +675,58 @@ void problemDetailsBacksOffWhenExceptionHandler() {
 				.hasSingleBean(CustomExceptionHandler.class));
 	}
 
+	@Test
+	void problemDetailsExceptionHandlerIsOrderedAt0() {
+		this.contextRunner.withPropertyValues("spring.webflux.problemdetails.enabled:true")
+			.withUserConfiguration(OrderedControllerAdviceBeansConfiguration.class)
+			.run((context) -> assertThat(
+					ControllerAdviceBean.findAnnotatedBeans(context).stream().map(ControllerAdviceBean::getBeanType))
+				.asInstanceOf(InstanceOfAssertFactories.list(Class.class))
+				.containsExactly(HighestOrderedControllerAdvice.class, ProblemDetailsExceptionHandler.class,
+						LowestOrderedControllerAdvice.class));
+	}
+
+	@Test
+	void asyncTaskExecutorWithApplicationTaskExecutor() {
+		this.contextRunner.withConfiguration(AutoConfigurations.of(TaskExecutionAutoConfiguration.class))
+			.run((context) -> {
+				assertThat(context).hasSingleBean(AsyncTaskExecutor.class);
+				assertThat(context.getBean(RequestMappingHandlerAdapter.class)).extracting("scheduler.executor")
+					.isSameAs(context.getBean("applicationTaskExecutor"));
+			});
+	}
+
+	@Test
+	void asyncTaskExecutorWithNonMatchApplicationTaskExecutorBean() {
+		this.contextRunner.withUserConfiguration(CustomApplicationTaskExecutorConfig.class)
+			.withConfiguration(AutoConfigurations.of(TaskExecutionAutoConfiguration.class))
+			.run((context) -> {
+				assertThat(context).doesNotHaveBean(AsyncTaskExecutor.class);
+				assertThat(context.getBean(RequestMappingHandlerAdapter.class)).extracting("scheduler.executor")
+					.isNotSameAs(context.getBean("applicationTaskExecutor"));
+			});
+	}
+
+	@Test
+	void asyncTaskExecutorWithWebFluxConfigurerCanOverrideExecutor() {
+		this.contextRunner.withUserConfiguration(CustomAsyncTaskExecutorConfigurer.class)
+			.withConfiguration(AutoConfigurations.of(TaskExecutionAutoConfiguration.class))
+			.run((context) -> assertThat(context.getBean(RequestMappingHandlerAdapter.class))
+				.extracting("scheduler.executor")
+				.isSameAs(context.getBean(CustomAsyncTaskExecutorConfigurer.class).taskExecutor));
+	}
+
+	@Test
+	void asyncTaskExecutorWithCustomNonApplicationTaskExecutor() {
+		this.contextRunner.withUserConfiguration(CustomAsyncTaskExecutorConfig.class)
+			.withConfiguration(AutoConfigurations.of(TaskExecutionAutoConfiguration.class))
+			.run((context) -> {
+				assertThat(context).hasSingleBean(AsyncTaskExecutor.class);
+				assertThat(context.getBean(RequestMappingHandlerAdapter.class)).extracting("scheduler.executor")
+					.isNotSameAs(context.getBean("customTaskExecutor"));
+			});
+	}
+
 	private ContextConsumer<ReactiveWebApplicationContext> assertExchangeWithSession(
 			Consumer<MockServerWebExchange> exchange) {
 		return (context) -> {
@@ -971,6 +1031,24 @@ static class CustomExceptionHandler extends ResponseEntityExceptionHandler {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	@Import({ LowestOrderedControllerAdvice.class, HighestOrderedControllerAdvice.class })
+	static class OrderedControllerAdviceBeansConfiguration {
+
+		@ControllerAdvice
+		@Order
+		static class LowestOrderedControllerAdvice {
+
+		}
+
+		@ControllerAdvice
+		@Order(Ordered.HIGHEST_PRECEDENCE)
+		static class HighestOrderedControllerAdvice {
+
+		}
+
+	}
+
 	@Aspect
 	static class ExceptionHandlerInterceptor {
 
@@ -981,4 +1059,36 @@ void exceptionHandlerIntercept(JoinPoint joinPoint, Object returnValue) {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class CustomApplicationTaskExecutorConfig {
+
+		@Bean
+		Executor applicationTaskExecutor() {
+			return mock(Executor.class);
+		}
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	static class CustomAsyncTaskExecutorConfig {
+
+		@Bean
+		AsyncTaskExecutor customTaskExecutor() {
+			return mock(AsyncTaskExecutor.class);
+		}
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	static class CustomAsyncTaskExecutorConfigurer implements WebFluxConfigurer {
+
+		private final AsyncTaskExecutor taskExecutor = mock(AsyncTaskExecutor.class);
+
+		@Override
+		public void configureBlockingExecution(BlockingExecutionConfigurer configurer) {
+			configurer.setExecutor(this.taskExecutor);
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorWebExceptionHandlerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorWebExceptionHandlerTests.java
index c61f09fba999..a704411e835f 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorWebExceptionHandlerTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorWebExceptionHandlerTests.java
@@ -38,7 +38,7 @@
 import org.springframework.web.reactive.result.view.View;
 import org.springframework.web.reactive.result.view.ViewResolver;
 import org.springframework.web.server.ServerWebExchange;
-import org.springframework.web.server.adapter.HttpWebHandlerAdapter;
+import org.springframework.web.util.DisconnectedClientHelper;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.ArgumentMatchers.any;
@@ -58,8 +58,7 @@ class DefaultErrorWebExceptionHandlerTests {
 	void disconnectedClientExceptionsMatchesFramework() {
 		Object errorHandlers = ReflectionTestUtils.getField(AbstractErrorWebExceptionHandler.class,
 				"DISCONNECTED_CLIENT_EXCEPTIONS");
-		Object webHandlers = ReflectionTestUtils.getField(HttpWebHandlerAdapter.class,
-				"DISCONNECTED_CLIENT_EXCEPTIONS");
+		Object webHandlers = ReflectionTestUtils.getField(DisconnectedClientHelper.class, "EXCEPTION_TYPE_NAMES");
 		assertThat(errorHandlers).isNotNull().isEqualTo(webHandlers);
 	}
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfigurationTests.java
index 2a05496baf18..9c0d2ee1f2b3 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfigurationTests.java
@@ -17,7 +17,6 @@
 package org.springframework.boot.autoconfigure.web.reactive.function.client;
 
 import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
-import org.eclipse.jetty.reactive.client.ReactiveRequest;
 import org.junit.jupiter.api.Test;
 import reactor.netty.http.client.HttpClient;
 
@@ -28,9 +27,9 @@
 import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.http.client.ReactorResourceFactory;
 import org.springframework.http.client.reactive.ClientHttpConnector;
 import org.springframework.http.client.reactive.ReactorClientHttpConnector;
-import org.springframework.http.client.reactive.ReactorResourceFactory;
 import org.springframework.web.reactive.function.client.WebClient;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -62,36 +61,20 @@ void whenReactorIsAvailableThenReactorBeansAreDefined() {
 	}
 
 	@Test
-	void whenReactorIsUnavailableThenJettyBeansAreDefined() {
+	void whenReactorIsUnavailableThenHttpClientBeansAreDefined() {
 		this.contextRunner.withClassLoader(new FilteredClassLoader(HttpClient.class)).run((context) -> {
 			BeanDefinition customizerDefinition = context.getBeanFactory()
 				.getBeanDefinition("webClientHttpConnectorCustomizer");
 			assertThat(customizerDefinition.isLazyInit()).isTrue();
 			BeanDefinition connectorDefinition = context.getBeanFactory().getBeanDefinition("webClientHttpConnector");
 			assertThat(connectorDefinition.isLazyInit()).isTrue();
-			assertThat(context).hasBean("jettyClientResourceFactory");
-			assertThat(context).hasBean("jettyClientHttpConnectorFactory");
+			assertThat(context).hasBean("httpComponentsClientHttpConnectorFactory");
 		});
 	}
 
 	@Test
-	void whenReactorAndJettyAreUnavailableThenHttpClientBeansAreDefined() {
-		this.contextRunner.withClassLoader(new FilteredClassLoader(HttpClient.class, ReactiveRequest.class))
-			.run((context) -> {
-				BeanDefinition customizerDefinition = context.getBeanFactory()
-					.getBeanDefinition("webClientHttpConnectorCustomizer");
-				assertThat(customizerDefinition.isLazyInit()).isTrue();
-				BeanDefinition connectorDefinition = context.getBeanFactory()
-					.getBeanDefinition("webClientHttpConnector");
-				assertThat(connectorDefinition.isLazyInit()).isTrue();
-				assertThat(context).hasBean("httpComponentsClientHttpConnectorFactory");
-			});
-	}
-
-	@Test
-	void whenReactorJettyAndHttpClientBeansAreUnavailableThenJdkClientBeansAreDefined() {
-		this.contextRunner
-			.withClassLoader(new FilteredClassLoader(HttpClient.class, ReactiveRequest.class, HttpAsyncClients.class))
+	void whenReactorAndHttpClientBeansAreUnavailableThenJdkClientBeansAreDefined() {
+		this.contextRunner.withClassLoader(new FilteredClassLoader(HttpClient.class, HttpAsyncClients.class))
 			.run((context) -> {
 				BeanDefinition customizerDefinition = context.getBeanFactory()
 					.getBeanDefinition("webClientHttpConnectorCustomizer");
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfigurationTests.java
index 79991a079b64..5d7fd0fab66e 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfigurationTests.java
@@ -16,11 +16,6 @@
 
 package org.springframework.boot.autoconfigure.web.reactive.function.client;
 
-import java.util.concurrent.Executor;
-
-import org.eclipse.jetty.client.HttpClient;
-import org.eclipse.jetty.io.ByteBufferPool;
-import org.eclipse.jetty.util.thread.Scheduler;
 import org.junit.jupiter.api.Test;
 
 import org.springframework.boot.autoconfigure.AutoConfigurations;
@@ -32,13 +27,9 @@
 import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
 import org.springframework.context.annotation.Bean;
 import org.springframework.http.client.reactive.HttpComponentsClientHttpConnector;
-import org.springframework.http.client.reactive.JettyClientHttpConnector;
-import org.springframework.http.client.reactive.JettyResourceFactory;
-import org.springframework.test.util.ReflectionTestUtils;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.BDDMockito.then;
-import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.spy;
 
 /**
@@ -50,40 +41,6 @@
  */
 class ClientHttpConnectorFactoryConfigurationTests {
 
-	@Test
-	void jettyClientHttpConnectorAppliesJettyResourceFactory() {
-		Executor executor = mock(Executor.class);
-		ByteBufferPool byteBufferPool = mock(ByteBufferPool.class);
-		Scheduler scheduler = mock(Scheduler.class);
-		JettyResourceFactory jettyResourceFactory = new JettyResourceFactory();
-		jettyResourceFactory.setExecutor(executor);
-		jettyResourceFactory.setByteBufferPool(byteBufferPool);
-		jettyResourceFactory.setScheduler(scheduler);
-		JettyClientHttpConnectorFactory connectorFactory = getJettyClientHttpConnectorFactory(jettyResourceFactory);
-		JettyClientHttpConnector connector = connectorFactory.createClientHttpConnector();
-		HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(connector, "httpClient");
-		assertThat(httpClient.getExecutor()).isSameAs(executor);
-		assertThat(httpClient.getByteBufferPool()).isSameAs(byteBufferPool);
-		assertThat(httpClient.getScheduler()).isSameAs(scheduler);
-	}
-
-	@Test
-	void JettyResourceFactoryHasSslContextFactory() {
-		// gh-16810
-		JettyResourceFactory jettyResourceFactory = new JettyResourceFactory();
-		JettyClientHttpConnectorFactory connectorFactory = getJettyClientHttpConnectorFactory(jettyResourceFactory);
-		JettyClientHttpConnector connector = connectorFactory.createClientHttpConnector();
-		HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(connector, "httpClient");
-		assertThat(httpClient.getSslContextFactory()).isNotNull();
-	}
-
-	private JettyClientHttpConnectorFactory getJettyClientHttpConnectorFactory(
-			JettyResourceFactory jettyResourceFactory) {
-		ClientHttpConnectorFactoryConfiguration.JettyClient jettyClient = new ClientHttpConnectorFactoryConfiguration.JettyClient();
-		// We shouldn't usually call this method directly since it's on a non-proxy config
-		return ReflectionTestUtils.invokeMethod(jettyClient, "jettyClientHttpConnectorFactory", jettyResourceFactory);
-	}
-
 	@Test
 	void shouldApplyHttpClientMapper() {
 		JksSslStoreDetails storeDetails = JksSslStoreDetails.forLocation("classpath:test.jks");
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JettyClientHttpConnectorFactoryTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JettyClientHttpConnectorFactoryTests.java
deleted file mode 100644
index ad99f85a778a..000000000000
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JettyClientHttpConnectorFactoryTests.java
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.autoconfigure.web.reactive.function.client;
-
-import org.springframework.http.client.reactive.JettyResourceFactory;
-
-/**
- * Tests for {@link JettyClientHttpConnectorFactory}.
- *
- * @author Phillip Webb
- */
-class JettyClientHttpConnectorFactoryTests extends AbstractClientHttpConnectorFactoryTests {
-
-	@Override
-	protected ClientHttpConnectorFactory<?> getFactory() {
-		JettyResourceFactory resourceFactory = new JettyResourceFactory();
-		return new JettyClientHttpConnectorFactory(resourceFactory);
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactoryTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactoryTests.java
index 632d8f707636..951941d02446 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactoryTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactoryTests.java
@@ -19,7 +19,7 @@
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 
-import org.springframework.http.client.reactive.ReactorResourceFactory;
+import org.springframework.http.client.ReactorResourceFactory;
 
 /**
  * Tests for {@link ReactorClientHttpConnectorFactory}.
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/DispatcherServletAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/DispatcherServletAutoConfigurationTests.java
index 0dbf0eaf0ad6..100d36cdd917 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/DispatcherServletAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/DispatcherServletAutoConfigurationTests.java
@@ -141,7 +141,6 @@ void renamesMultipartResolver() {
 	void dispatcherServletDefaultConfig() {
 		this.contextRunner.run((context) -> {
 			DispatcherServlet dispatcherServlet = context.getBean(DispatcherServlet.class);
-			assertThat(dispatcherServlet).extracting("throwExceptionIfNoHandlerFound").isEqualTo(false);
 			assertThat(dispatcherServlet).extracting("dispatchOptionsRequest").isEqualTo(true);
 			assertThat(dispatcherServlet).extracting("dispatchTraceRequest").isEqualTo(false);
 			assertThat(dispatcherServlet).extracting("enableLoggingRequestDetails").isEqualTo(false);
@@ -151,15 +150,24 @@ void dispatcherServletDefaultConfig() {
 		});
 	}
 
+	@Test
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	void dispatcherServletThrowExceptionIfNoHandlerFoundDefaultConfig() {
+		this.contextRunner.run((context) -> {
+			DispatcherServlet dispatcherServlet = context.getBean(DispatcherServlet.class);
+			assertThat(dispatcherServlet).extracting("throwExceptionIfNoHandlerFound").isEqualTo(true);
+		});
+	}
+
 	@Test
 	void dispatcherServletCustomConfig() {
 		this.contextRunner
-			.withPropertyValues("spring.mvc.throw-exception-if-no-handler-found:true",
+			.withPropertyValues("spring.mvc.throw-exception-if-no-handler-found:false",
 					"spring.mvc.dispatch-options-request:false", "spring.mvc.dispatch-trace-request:true",
 					"spring.mvc.publish-request-handled-events:false", "spring.mvc.servlet.load-on-startup=5")
 			.run((context) -> {
 				DispatcherServlet dispatcherServlet = context.getBean(DispatcherServlet.class);
-				assertThat(dispatcherServlet).extracting("throwExceptionIfNoHandlerFound").isEqualTo(true);
+				assertThat(dispatcherServlet).extracting("throwExceptionIfNoHandlerFound").isEqualTo(false);
 				assertThat(dispatcherServlet).extracting("dispatchOptionsRequest").isEqualTo(false);
 				assertThat(dispatcherServlet).extracting("dispatchTraceRequest").isEqualTo(true);
 				assertThat(dispatcherServlet).extracting("publishEvents").isEqualTo(false);
@@ -168,6 +176,15 @@ void dispatcherServletCustomConfig() {
 			});
 	}
 
+	@Test
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	void dispatcherServletThrowExceptionIfNoHandlerFoundCustomConfig() {
+		this.contextRunner.withPropertyValues("spring.mvc.throw-exception-if-no-handler-found:false").run((context) -> {
+			DispatcherServlet dispatcherServlet = context.getBean(DispatcherServlet.class);
+			assertThat(dispatcherServlet).extracting("throwExceptionIfNoHandlerFound").isEqualTo(false);
+		});
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class MultipartConfiguration {
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/MultipartAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/MultipartAutoConfigurationTests.java
index b1e1dd0926a0..edbc441d871f 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/MultipartAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/MultipartAutoConfigurationTests.java
@@ -31,7 +31,6 @@
 import org.springframework.boot.test.util.TestPropertyValues;
 import org.springframework.boot.testsupport.classpath.ForkedClassPath;
 import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories;
-import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides;
 import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory;
 import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
 import org.springframework.boot.web.embedded.undertow.UndertowServletWebServerFactory;
@@ -65,6 +64,7 @@
  * @author Josh Long
  * @author Ivan Sopov
  * @author Toshiaki Maki
+ * @author Yanming Zhou
  */
 @DirtiesUrlFactories
 class MultipartAutoConfigurationTests {
@@ -175,6 +175,17 @@ void configureResolveLazily() {
 		assertThat(multipartResolver).hasFieldOrPropertyWithValue("resolveLazily", true);
 	}
 
+	@Test
+	void configureStrictServletCompliance() {
+		this.context = new AnnotationConfigServletWebServerApplicationContext();
+		TestPropertyValues.of("spring.servlet.multipart.strict-servlet-compliance=true").applyTo(this.context);
+		this.context.register(WebServerWithNothing.class, BaseConfiguration.class);
+		this.context.refresh();
+		StandardServletMultipartResolver multipartResolver = this.context
+			.getBean(StandardServletMultipartResolver.class);
+		assertThat(multipartResolver).hasFieldOrPropertyWithValue("strictServletCompliance", true);
+	}
+
 	@Test
 	void configureMultipartProperties() {
 		this.context = new AnnotationConfigServletWebServerApplicationContext();
@@ -221,7 +232,6 @@ static class WebServerWithNothing {
 
 	}
 
-	@Servlet5ClassPathOverrides
 	@Configuration(proxyBeanMethods = false)
 	static class WebServerWithNoMultipartJetty {
 
@@ -282,7 +292,6 @@ WebController controller() {
 
 	}
 
-	@Servlet5ClassPathOverrides
 	@Configuration(proxyBeanMethods = false)
 	static class WebServerWithEverythingJetty {
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryAutoConfigurationTests.java
index 6555da75f8ff..a3211bd3417b 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryAutoConfigurationTests.java
@@ -38,7 +38,6 @@
 import org.springframework.boot.test.context.runner.ContextConsumer;
 import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
 import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories;
-import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides;
 import org.springframework.boot.web.embedded.jetty.JettyServerCustomizer;
 import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory;
 import org.springframework.boot.web.embedded.tomcat.TomcatConnectorCustomizer;
@@ -156,7 +155,6 @@ void initParametersAreConfiguredOnTheServletContext() {
 	}
 
 	@Test
-	@Servlet5ClassPathOverrides
 	void jettyServerCustomizerBeanIsAddedToFactory() {
 		WebApplicationContextRunner runner = new WebApplicationContextRunner(
 				AnnotationConfigServletWebServerApplicationContext::new)
@@ -171,7 +169,6 @@ void jettyServerCustomizerBeanIsAddedToFactory() {
 	}
 
 	@Test
-	@Servlet5ClassPathOverrides
 	void jettyServerCustomizerRegisteredAsBeanAndViaFactoryIsOnlyCalledOnce() {
 		WebApplicationContextRunner runner = new WebApplicationContextRunner(
 				AnnotationConfigServletWebServerApplicationContext::new)
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryCustomizerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryCustomizerTests.java
index 7cff13798441..c428ff17819f 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryCustomizerTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryCustomizerTests.java
@@ -28,11 +28,11 @@
 import org.springframework.boot.context.properties.bind.Binder;
 import org.springframework.boot.context.properties.source.ConfigurationPropertySource;
 import org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
+import org.springframework.boot.web.server.Cookie;
 import org.springframework.boot.web.server.Shutdown;
 import org.springframework.boot.web.server.Ssl;
 import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory;
 import org.springframework.boot.web.servlet.server.Jsp;
-import org.springframework.boot.web.servlet.server.Session.Cookie;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.ArgumentMatchers.any;
@@ -97,7 +97,6 @@ void testCustomizeJsp() {
 	}
 
 	@Test
-	@SuppressWarnings("removal")
 	void customizeSessionProperties() {
 		Map<String, String> map = new HashMap<>();
 		map.put("server.servlet.session.timeout", "123");
@@ -105,7 +104,6 @@ void customizeSessionProperties() {
 		map.put("server.servlet.session.cookie.name", "testname");
 		map.put("server.servlet.session.cookie.domain", "testdomain");
 		map.put("server.servlet.session.cookie.path", "/testpath");
-		map.put("server.servlet.session.cookie.comment", "testcomment");
 		map.put("server.servlet.session.cookie.http-only", "true");
 		map.put("server.servlet.session.cookie.secure", "true");
 		map.put("server.servlet.session.cookie.max-age", "60");
@@ -118,7 +116,6 @@ void customizeSessionProperties() {
 			assertThat(cookie.getName()).isEqualTo("testname");
 			assertThat(cookie.getDomain()).isEqualTo("testdomain");
 			assertThat(cookie.getPath()).isEqualTo("/testpath");
-			assertThat(cookie.getComment()).isEqualTo("testcomment");
 			assertThat(cookie.getHttpOnly()).isTrue();
 			assertThat(cookie.getMaxAge()).hasSeconds(60);
 		}));
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerServletContextListenerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerServletContextListenerTests.java
index d2ef77630aa8..ec52ceab9cbb 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerServletContextListenerTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerServletContextListenerTests.java
@@ -26,7 +26,6 @@
 
 import org.springframework.boot.testsupport.classpath.ForkedClassPath;
 import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories;
-import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides;
 import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory;
 import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
 import org.springframework.boot.web.embedded.undertow.UndertowServletWebServerFactory;
@@ -90,7 +89,6 @@ ServletWebServerFactory webServerFactory() {
 
 	}
 
-	@Servlet5ClassPathOverrides
 	@Configuration(proxyBeanMethods = false)
 	static class JettyConfiguration {
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfigurationTests.java
index 4b8737d0424c..7ccc5e12bff4 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfigurationTests.java
@@ -39,6 +39,7 @@
 import org.aspectj.lang.JoinPoint;
 import org.aspectj.lang.annotation.AfterReturning;
 import org.aspectj.lang.annotation.Aspect;
+import org.assertj.core.api.InstanceOfAssertFactories;
 import org.junit.jupiter.api.Test;
 
 import org.springframework.aop.support.AopUtils;
@@ -51,6 +52,8 @@
 import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration;
 import org.springframework.boot.autoconfigure.validation.ValidatorAdapter;
 import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration.WebMvcAutoConfigurationAdapter;
+import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfigurationTests.OrderedControllerAdviceBeansConfiguration.HighestOrderedControllerAdvice;
+import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfigurationTests.OrderedControllerAdviceBeansConfiguration.LowestOrderedControllerAdvice;
 import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext;
 import org.springframework.boot.test.context.runner.ContextConsumer;
 import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
@@ -65,6 +68,8 @@
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Import;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
 import org.springframework.core.convert.ConversionService;
 import org.springframework.core.io.ClassPathResource;
 import org.springframework.core.io.Resource;
@@ -89,6 +94,7 @@
 import org.springframework.web.filter.FormContentFilter;
 import org.springframework.web.filter.HiddenHttpMethodFilter;
 import org.springframework.web.filter.RequestContextFilter;
+import org.springframework.web.method.ControllerAdviceBean;
 import org.springframework.web.servlet.DispatcherServlet;
 import org.springframework.web.servlet.FlashMap;
 import org.springframework.web.servlet.FlashMapManager;
@@ -229,7 +235,7 @@ void resourceHandlerMappingOverrideAll() {
 	@Test
 	void resourceHandlerMappingDisabled() {
 		this.contextRunner.withPropertyValues("spring.web.resources.add-mappings:false")
-			.run((context) -> assertThat(getResourceMappingLocations(context)).hasSize(0));
+			.run((context) -> assertThat(getResourceMappingLocations(context)).isEmpty());
 	}
 
 	@Test
@@ -380,29 +386,6 @@ void customLocaleResolverWithDifferentNameDoesNotReplaceAutoConfiguredLocaleReso
 			});
 	}
 
-	@Test
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	@SuppressWarnings("deprecation")
-	void customThemeResolverWithMatchingNameReplacesDefaultThemeResolver() {
-		this.contextRunner.withBean("themeResolver", CustomThemeResolver.class, CustomThemeResolver::new)
-			.run((context) -> {
-				assertThat(context).hasSingleBean(org.springframework.web.servlet.ThemeResolver.class);
-				assertThat(context.getBean("themeResolver")).isInstanceOf(CustomThemeResolver.class);
-			});
-	}
-
-	@Test
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	@SuppressWarnings("deprecation")
-	void customThemeResolverWithDifferentNameDoesNotReplaceDefaultThemeResolver() {
-		this.contextRunner.withBean("customThemeResolver", CustomThemeResolver.class, CustomThemeResolver::new)
-			.run((context) -> {
-				assertThat(context.getBean("customThemeResolver")).isInstanceOf(CustomThemeResolver.class);
-				assertThat(context.getBean("themeResolver"))
-					.isInstanceOf(org.springframework.web.servlet.theme.FixedThemeResolver.class);
-			});
-	}
-
 	@Test
 	void customFlashMapManagerWithMatchingNameReplacesDefaultFlashMapManager() {
 		this.contextRunner.withBean("flashMapManager", CustomFlashMapManager.class, CustomFlashMapManager::new)
@@ -493,21 +476,6 @@ void overrideMessageCodesFormat() {
 				.isNotNull());
 	}
 
-	@Test
-	void ignoreDefaultModelOnRedirectIsTrue() {
-		this.contextRunner.run((context) -> assertThat(context.getBean(RequestMappingHandlerAdapter.class))
-			.extracting("ignoreDefaultModelOnRedirect")
-			.isEqualTo(true));
-	}
-
-	@Test
-	void overrideIgnoreDefaultModelOnRedirect() {
-		this.contextRunner.withPropertyValues("spring.mvc.ignore-default-model-on-redirect:false")
-			.run((context) -> assertThat(context.getBean(RequestMappingHandlerAdapter.class))
-				.extracting("ignoreDefaultModelOnRedirect")
-				.isEqualTo(false));
-	}
-
 	@Test
 	void customViewResolver() {
 		this.contextRunner.withUserConfiguration(CustomViewResolver.class)
@@ -1010,6 +978,17 @@ void problemDetailsBacksOffWhenExceptionHandler() {
 				.hasSingleBean(CustomExceptionHandler.class));
 	}
 
+	@Test
+	void problemDetailsExceptionHandlerIsOrderedAt0() {
+		this.contextRunner.withPropertyValues("spring.mvc.problemdetails.enabled:true")
+			.withUserConfiguration(OrderedControllerAdviceBeansConfiguration.class)
+			.run((context) -> assertThat(
+					ControllerAdviceBean.findAnnotatedBeans(context).stream().map(ControllerAdviceBean::getBeanType))
+				.asInstanceOf(InstanceOfAssertFactories.list(Class.class))
+				.containsExactly(HighestOrderedControllerAdvice.class, ProblemDetailsExceptionHandler.class,
+						OrderedControllerAdviceBeansConfiguration.LowestOrderedControllerAdvice.class));
+	}
+
 	private void assertResourceHttpRequestHandler(AssertableWebApplicationContext context,
 			Consumer<ResourceHttpRequestHandler> handlerConsumer) {
 		Map<String, Object> handlerMap = getHandlerMap(context.getBean("resourceHandlerMapping", HandlerMapping.class));
@@ -1464,20 +1443,6 @@ public void setLocale(HttpServletRequest request, HttpServletResponse response,
 
 	}
 
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	static class CustomThemeResolver implements org.springframework.web.servlet.ThemeResolver {
-
-		@Override
-		public String resolveThemeName(HttpServletRequest request) {
-			return "custom";
-		}
-
-		@Override
-		public void setThemeName(HttpServletRequest request, HttpServletResponse response, String themeName) {
-		}
-
-	}
-
 	static class CustomFlashMapManager extends AbstractFlashMapManager {
 
 		@Override
@@ -1548,6 +1513,24 @@ CustomExceptionHandler customExceptionHandler() {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	@Import({ LowestOrderedControllerAdvice.class, HighestOrderedControllerAdvice.class })
+	static class OrderedControllerAdviceBeansConfiguration {
+
+		@ControllerAdvice
+		@Order
+		static class LowestOrderedControllerAdvice {
+
+		}
+
+		@ControllerAdvice
+		@Order(Ordered.HIGHEST_PRECEDENCE)
+		static class HighestOrderedControllerAdvice {
+
+		}
+
+	}
+
 	@ControllerAdvice
 	static class CustomExceptionHandler extends ResponseEntityExceptionHandler {
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WelcomePageHandlerMappingTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WelcomePageHandlerMappingTests.java
index b706b5e68b2b..96462cf037b8 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WelcomePageHandlerMappingTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WelcomePageHandlerMappingTests.java
@@ -22,6 +22,7 @@
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
 
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.beans.factory.annotation.Value;
@@ -30,6 +31,8 @@
 import org.springframework.boot.autoconfigure.template.TemplateAvailabilityProvider;
 import org.springframework.boot.autoconfigure.template.TemplateAvailabilityProviders;
 import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
 import org.springframework.context.ApplicationContext;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
@@ -53,7 +56,9 @@
  * Tests for {@link WelcomePageHandlerMapping}.
  *
  * @author Andy Wilkinson
+ * @author Moritz Halbritter
  */
+@ExtendWith(OutputCaptureExtension.class)
 class WelcomePageHandlerMappingTests {
 
 	private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner()
@@ -163,6 +168,17 @@ void prefersAStaticResourceToATemplate() {
 			});
 	}
 
+	@Test
+	void logsInvalidAcceptHeader(CapturedOutput output) {
+		this.contextRunner.withUserConfiguration(TemplateConfiguration.class).run((context) -> {
+			MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
+			mockMvc.perform(get("/").accept("*/*q=0.8"))
+				.andExpect(status().isOk())
+				.andExpect(content().string("index template"));
+		});
+		assertThat(output).contains("Received invalid Accept header. Assuming all media types are accepted");
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class HandlerMappingConfiguration {
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/reactive/WebSocketReactiveAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/reactive/WebSocketReactiveAutoConfigurationTests.java
index e9505d09632c..e092a9262f2e 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/reactive/WebSocketReactiveAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/reactive/WebSocketReactiveAutoConfigurationTests.java
@@ -24,14 +24,13 @@
 import org.apache.catalina.Container;
 import org.apache.catalina.Context;
 import org.apache.catalina.startup.Tomcat;
-import org.eclipse.jetty.servlet.ServletContextHandler;
+import org.eclipse.jetty.ee10.servlet.ServletContextHandler;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
 
 import org.springframework.boot.testsupport.classpath.ForkedClassPath;
 import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories;
-import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides;
 import org.springframework.boot.web.embedded.jetty.JettyReactiveWebServerFactory;
 import org.springframework.boot.web.embedded.jetty.JettyWebServer;
 import org.springframework.boot.web.embedded.tomcat.TomcatReactiveWebServerFactory;
@@ -123,7 +122,6 @@ ReactiveWebServerFactory webServerFactory() {
 
 	}
 
-	@Servlet5ClassPathOverrides
 	@Configuration(proxyBeanMethods = false)
 	static class JettyConfiguration extends CommonConfiguration {
 
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketMessagingAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketMessagingAutoConfigurationTests.java
index dcd02d40b8e0..f6700f991545 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketMessagingAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketMessagingAutoConfigurationTests.java
@@ -69,7 +69,7 @@
 import org.springframework.web.socket.sockjs.client.WebSocketTransport;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.fail;
+import static org.assertj.core.api.Assertions.fail;
 
 /**
  * Tests for {@link WebSocketMessagingAutoConfiguration}.
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketServletAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketServletAutoConfigurationTests.java
index d4eb88131c33..bd4d9213ba1d 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketServletAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketServletAutoConfigurationTests.java
@@ -16,23 +16,48 @@
 
 package org.springframework.boot.autoconfigure.websocket.servlet;
 
+import java.io.IOException;
+import java.util.Map;
 import java.util.stream.Stream;
 
+import jakarta.servlet.DispatcherType;
+import jakarta.servlet.Filter;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.websocket.DeploymentException;
 import jakarta.websocket.server.ServerContainer;
+import jakarta.websocket.server.ServerEndpoint;
+import org.eclipse.jetty.ee10.websocket.servlet.WebSocketUpgradeFilter;
+import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
 
+import org.springframework.beans.factory.config.BeanDefinition;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration;
+import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
+import org.springframework.boot.test.web.client.TestRestTemplate;
 import org.springframework.boot.testsupport.classpath.ForkedClassPath;
 import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories;
-import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides;
 import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory;
 import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
+import org.springframework.boot.web.server.WebServer;
 import org.springframework.boot.web.server.WebServerFactoryCustomizerBeanPostProcessor;
+import org.springframework.boot.web.servlet.AbstractFilterRegistrationBean;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.boot.web.servlet.ServletContextInitializer;
 import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext;
 import org.springframework.boot.web.servlet.server.ServletWebServerFactory;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.core.Ordered;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.RequestEntity;
+import org.springframework.http.ResponseEntity;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -56,18 +81,107 @@ void serverContainerIsAvailableFromTheServletContext(String server, Class<?>...
 		}
 	}
 
+	@ParameterizedTest(name = "{0}")
+	@MethodSource("testConfiguration")
+	@ForkedClassPath
+	void webSocketUpgradeDoesNotPreventAFilterFromRejectingTheRequest(String server, Class<?>... configuration)
+			throws DeploymentException {
+		try (AnnotationConfigServletWebServerApplicationContext context = new AnnotationConfigServletWebServerApplicationContext(
+				configuration)) {
+			ServerContainer serverContainer = (ServerContainer) context.getServletContext()
+				.getAttribute("jakarta.websocket.server.ServerContainer");
+			serverContainer.addEndpoint(TestEndpoint.class);
+			WebServer webServer = context.getWebServer();
+			int port = webServer.getPort();
+			TestRestTemplate rest = new TestRestTemplate();
+			RequestEntity<Void> request = RequestEntity.get("http://localhost:" + port)
+				.header("Upgrade", "websocket")
+				.header("Connection", "upgrade")
+				.header("Sec-WebSocket-Version", "13")
+				.header("Sec-WebSocket-Key", "key")
+				.build();
+			ResponseEntity<Void> response = rest.exchange(request, Void.class);
+			assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
+		}
+	}
+
+	@Test
+	void jettyWebSocketUpgradeFilterIsAddedToServletContextOfJettyServer() {
+		try (AnnotationConfigServletWebServerApplicationContext context = new AnnotationConfigServletWebServerApplicationContext(
+				JettyConfiguration.class, WebSocketServletAutoConfiguration.JettyWebSocketConfiguration.class)) {
+			assertThat(context.getServletContext().getFilterRegistration(WebSocketUpgradeFilter.class.getName()))
+				.isNotNull();
+		}
+	}
+
+	@Test
+	void jettyWebSocketUpgradeFilterIsNotAddedToServletContextOfTomcatServer() {
+		try (AnnotationConfigServletWebServerApplicationContext context = new AnnotationConfigServletWebServerApplicationContext(
+				TomcatConfiguration.class, WebSocketServletAutoConfiguration.JettyWebSocketConfiguration.class)) {
+			assertThat(context.getServletContext().getFilterRegistration(WebSocketUpgradeFilter.class.getName()))
+				.isNull();
+		}
+	}
+
+	@Test
+	@SuppressWarnings("rawtypes")
+	void jettyWebSocketUpgradeFilterIsNotExposedAsABean() {
+		new WebApplicationContextRunner()
+			.withConfiguration(AutoConfigurations.of(JettyConfiguration.class,
+					WebSocketServletAutoConfiguration.JettyWebSocketConfiguration.class))
+			.run((context) -> {
+				Map<String, Filter> filters = context.getBeansOfType(Filter.class);
+				assertThat(filters.values()).noneMatch(WebSocketUpgradeFilter.class::isInstance);
+				Map<String, AbstractFilterRegistrationBean> filterRegistrations = context
+					.getBeansOfType(AbstractFilterRegistrationBean.class);
+				assertThat(filterRegistrations.values()).extracting(AbstractFilterRegistrationBean::getFilter)
+					.noneMatch(WebSocketUpgradeFilter.class::isInstance);
+			});
+	}
+
+	@Test
+	void jettyWebSocketUpgradeFilterServletContextInitializerBacksOffWhenBeanWithSameNameIsDefined() {
+		try (AnnotationConfigServletWebServerApplicationContext context = new AnnotationConfigServletWebServerApplicationContext(
+				JettyConfiguration.class, CustomWebSocketUpgradeFilterServletContextInitializerConfiguration.class,
+				WebSocketServletAutoConfiguration.JettyWebSocketConfiguration.class)) {
+			BeanDefinition definition = context.getBeanFactory()
+				.getBeanDefinition("websocketUpgradeFilterServletContextInitializer");
+			assertThat(definition.getFactoryBeanName())
+				.contains("CustomWebSocketUpgradeFilterServletContextInitializerConfiguration");
+		}
+	}
+
 	static Stream<Arguments> testConfiguration() {
+		String response = "Tomcat";
 		return Stream.of(
 				Arguments.of("Jetty",
-						new Class<?>[] { JettyConfiguration.class,
+						new Class<?>[] { JettyConfiguration.class, DispatcherServletAutoConfiguration.class,
 								WebSocketServletAutoConfiguration.JettyWebSocketConfiguration.class }),
-				Arguments.of("Tomcat", new Class<?>[] { TomcatConfiguration.class,
-						WebSocketServletAutoConfiguration.TomcatWebSocketConfiguration.class }));
+				Arguments.of(response,
+						new Class<?>[] { TomcatConfiguration.class, DispatcherServletAutoConfiguration.class,
+								WebSocketServletAutoConfiguration.TomcatWebSocketConfiguration.class }));
 	}
 
 	@Configuration(proxyBeanMethods = false)
 	static class CommonConfiguration {
 
+		@Bean
+		FilterRegistrationBean<Filter> unauthorizedFilter() {
+			FilterRegistrationBean<Filter> registration = new FilterRegistrationBean<>(new Filter() {
+
+				@Override
+				public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+						throws IOException, ServletException {
+					((HttpServletResponse) response).sendError(HttpStatus.UNAUTHORIZED.value());
+				}
+
+			});
+			registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
+			registration.addUrlPatterns("/*");
+			registration.setDispatcherTypes(DispatcherType.REQUEST);
+			return registration;
+		}
+
 		@Bean
 		WebServerFactoryCustomizerBeanPostProcessor ServletWebServerCustomizerBeanPostProcessor() {
 			return new WebServerFactoryCustomizerBeanPostProcessor();
@@ -87,7 +201,6 @@ ServletWebServerFactory webServerFactory() {
 
 	}
 
-	@Servlet5ClassPathOverrides
 	@Configuration(proxyBeanMethods = false)
 	static class JettyConfiguration extends CommonConfiguration {
 
@@ -100,4 +213,21 @@ ServletWebServerFactory webServerFactory() {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class CustomWebSocketUpgradeFilterServletContextInitializerConfiguration {
+
+		@Bean
+		ServletContextInitializer websocketUpgradeFilterServletContextInitializer() {
+			return (servletContext) -> {
+
+			};
+		}
+
+	}
+
+	@ServerEndpoint("/")
+	public static class TestEndpoint {
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/META-INF/spring/org.springframework.boot.autoconfigure.ImportAutoConfigurationImportSelectorTests$FromImportsFile.imports b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/META-INF/spring/org.springframework.boot.autoconfigure.ImportAutoConfigurationImportSelectorTests$FromImportsFile.imports
new file mode 100644
index 000000000000..42102d02a8fc
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/META-INF/spring/org.springframework.boot.autoconfigure.ImportAutoConfigurationImportSelectorTests$FromImportsFile.imports
@@ -0,0 +1,2 @@
+org.springframework.boot.autoconfigure.freemarker.FreeMarkerAutoConfiguration
+org.springframework.boot.autoconfigure.missing.MissingAutoConfiguration
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/META-INF/spring/org.springframework.boot.autoconfigure.ImportAutoConfigurationImportSelectorTests$FromImportsFileIgnoresMissingOptionalClasses.imports b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/META-INF/spring/org.springframework.boot.autoconfigure.ImportAutoConfigurationImportSelectorTests$FromImportsFileIgnoresMissingOptionalClasses.imports
new file mode 100644
index 000000000000..b16393474237
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/META-INF/spring/org.springframework.boot.autoconfigure.ImportAutoConfigurationImportSelectorTests$FromImportsFileIgnoresMissingOptionalClasses.imports
@@ -0,0 +1,3 @@
+optional:org.springframework.boot.autoconfigure.freemarker.FreeMarkerAutoConfiguration
+optional:org.springframework.boot.autoconfigure.missing.MissingAutoConfiguration
+org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/security/saml2/rsa.crt b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/security/saml2/rsa.crt
new file mode 100644
index 000000000000..aa147065ded0
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/security/saml2/rsa.crt
@@ -0,0 +1,23 @@
+-----BEGIN CERTIFICATE-----
+MIID1zCCAr+gAwIBAgIUCzQeKBMTO0iHVW3iKmZC41haqCowDQYJKoZIhvcNAQEL
+BQAwezELMAkGA1UEBhMCWFgxEjAQBgNVBAgMCVN0YXRlTmFtZTERMA8GA1UEBwwI
+Q2l0eU5hbWUxFDASBgNVBAoMC0NvbXBhbnlOYW1lMRswGQYDVQQLDBJDb21wYW55
+U2VjdGlvbk5hbWUxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yMzA5MjAwODI5MDNa
+Fw0zMzA5MTcwODI5MDNaMHsxCzAJBgNVBAYTAlhYMRIwEAYDVQQIDAlTdGF0ZU5h
+bWUxETAPBgNVBAcMCENpdHlOYW1lMRQwEgYDVQQKDAtDb21wYW55TmFtZTEbMBkG
+A1UECwwSQ29tcGFueVNlY3Rpb25OYW1lMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEi
+MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDUfi4aaCotJZX6OSDjv6fxCCfc
+ihSs91Z/mmN+yc1fsxVSs53SIbqUuo+Wzhv34kp8I/r03P9LWVTkFPbeDxAl75Oa
+PGggxK55US0Zfy9Hj1BwWIKV3330N61emID1GDEtFKL4yJbJdreQXnIXTBL2o76V
+nuV/tYozyZnb07IQ1WhUm5WDxgzM0yFudMynTczCBeZHfvharDtB8PFFhCZXW2/9
+TZVVfW4oOML8EAX3hvnvYBlFl/foxXekZSwq/odOkmWCZavT2+0sburHUlOnPGUh
+Qj4tHwpMRczp7VX4ptV1D2UrxsK/2B+s9FK2QSLKQ9JzAYJ6WxQjHcvET9jvAgMB
+AAGjUzBRMB0GA1UdDgQWBBQjDr/1E/01pfLPD8uWF7gbaYL0TTAfBgNVHSMEGDAW
+gBQjDr/1E/01pfLPD8uWF7gbaYL0TTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
+DQEBCwUAA4IBAQAGjUuec0+0XNMCRDKZslbImdCAVsKsEWk6NpnUViDFAxL+KQuC
+NW131UeHb9SCzMqRwrY4QI3nAwJQCmilL/hFM3ss4acn3WHu1yci/iKPUKeL1ec5
+kCFUmqX1NpTiVaytZ/9TKEr69SMVqNfQiuW5U1bIIYTqK8xo46WpM6YNNHO3eJK6
+NH0MW79Wx5ryi4i4C6afqYbVbx7tqcmy8CFeNxgZ0bFQ87SiwYXIj77b6sVYbu32
+doykBQgSHLcagWASPQ73m73CWUgo+7+EqSKIQqORbgmTLPmOUh99gFIx7jmjTyHm
+NBszx1ZVWuIv3mWmp626Kncyc+LLM9tvgymx
+-----END CERTIFICATE-----
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/security/saml2/rsa.key b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/security/saml2/rsa.key
new file mode 100644
index 000000000000..e458f0d5eb44
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/security/saml2/rsa.key
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDUfi4aaCotJZX6
+OSDjv6fxCCfcihSs91Z/mmN+yc1fsxVSs53SIbqUuo+Wzhv34kp8I/r03P9LWVTk
+FPbeDxAl75OaPGggxK55US0Zfy9Hj1BwWIKV3330N61emID1GDEtFKL4yJbJdreQ
+XnIXTBL2o76VnuV/tYozyZnb07IQ1WhUm5WDxgzM0yFudMynTczCBeZHfvharDtB
+8PFFhCZXW2/9TZVVfW4oOML8EAX3hvnvYBlFl/foxXekZSwq/odOkmWCZavT2+0s
+burHUlOnPGUhQj4tHwpMRczp7VX4ptV1D2UrxsK/2B+s9FK2QSLKQ9JzAYJ6WxQj
+HcvET9jvAgMBAAECggEADdeRuZml1F65mDJm1enduaH+NWvEm1yEr3ecr0fbujYI
+bQ89+CVx/znvRvPH4aFwQwmgUZl12JrfS05MTectoPMBf/obDwtmPDPmsV2rdEi9
+2jEB11vW23T8X7L6hOdzCKHqrd8kkhzK1LuPnhHlaFipU8YlOBOuMYpv8eB78y79
+Qkd5/ZEygFhqVGz96R7nT/xS21aPC7OPhicAauLLuguF4caCNhwkjLi3bizLemUn
+4i41q69drg7G8WX6BTxzem5FupKfI8rn2EkOjO/biVRknzGxAdqkM8SDHWkqeOuY
+8QVhc1kZsMkB0BGPlDPStUwEHSfUiND4GJTcngc++QKBgQD2lyeW3PoPjQ1qzjN4
+V/0XE77zpcPE5dW7chLtiWRY1dqk2uOJ32iOtxuqk9Q/YMSZyPJlTkfI5JePuC/B
+MB+QXzXuWN03Vn0ZrOpQlxcdA4A1o10NT1nEw8kZlf4+LyUk8GpMGUhjnxFZpZbf
+5S3fy0/2V8wGvOmXR65c8m6ASQKBgQDcmfCV5npu1HrtO8jmU9gBIhniNjB4IWue
+TSRt3ANDQaVBqsVaIMe/mUEQrZ6MdikMeA4bobOA6bUYwOiq8JGWSenAzGL22TbA
+W51q6A8hgDCuH1JnoagqUIbr61kwEVcfbRHEFpuxLURsjoDg/xBtwO96SxWPh5Wr
++f1q8t5/dwKBgGWc+AVk3e6Wk1bVzcPjjjl6O4+vWTLD+wUZBs+3dBBfX4/bWzQv
+Sai1r8Lk0+uh9qHgenJghZg1CneA0LztFbSqZ1DmcZIiI7720D+RY0bjcGup++hG
+MJmyjCXs9y2sw8OrBkKBkKDspXupjriIehTkdPjwSPTl1+Qs9575j6txAoGAT8n+
+ErnCHsQLkjLFf0lkH0TOR9uBvHGaEy+jtXiWVYUw2IeDyg2BMfOkbPvfFL7IKhJi
+R+w8mKvvLHzZqrpIbitduLY0NURrYTfBwCEfF+bdtJzvmTwHLwbhRgNhxtj+wgcZ
+HetvdK4CyaDhTH/02T2nYHw32CoaIJHS7xPZFhECgYEAv7xRawjlrC4V0BLjP3Ej
+pk8BbsRABxN1CrS6nJK+So4u2gKQDsL3WA0oJTS8v8AD5LvQUNr1d57FVlq9lwCd
+u623eOIuluCUZBVy1iYdkRXWz9pg5bCidCgEYUpF3SqpsuFou0XFzDD773UVQFVw
+VYriYasPwmzS2y2P7PKFzJs=
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/ed25519-cert.pem b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/ed25519-cert.pem
new file mode 100644
index 000000000000..9f566ceceed6
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/ed25519-cert.pem
@@ -0,0 +1,13 @@
+-----BEGIN CERTIFICATE-----
+MIICCzCCAb2gAwIBAgIUZbDi7G5czH+Yi0k2EMWxdf00XagwBQYDK2VwMHsxCzAJ
+BgNVBAYTAlhYMRIwEAYDVQQIDAlTdGF0ZU5hbWUxETAPBgNVBAcMCENpdHlOYW1l
+MRQwEgYDVQQKDAtDb21wYW55TmFtZTEbMBkGA1UECwwSQ29tcGFueVNlY3Rpb25O
+YW1lMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMjMwOTExMTIxNDMwWhcNMzMwOTA4
+MTIxNDMwWjB7MQswCQYDVQQGEwJYWDESMBAGA1UECAwJU3RhdGVOYW1lMREwDwYD
+VQQHDAhDaXR5TmFtZTEUMBIGA1UECgwLQ29tcGFueU5hbWUxGzAZBgNVBAsMEkNv
+bXBhbnlTZWN0aW9uTmFtZTESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEA
+Q/DDA4BSgZ+Hx0DUxtIRjVjN+OcxXVURwAWc3Gt9GUyjUzBRMB0GA1UdDgQWBBSv
+EdpoaBMBoxgO96GFbf03k07DSTAfBgNVHSMEGDAWgBSvEdpoaBMBoxgO96GFbf03
+k07DSTAPBgNVHRMBAf8EBTADAQH/MAUGAytlcANBAHMXDkGd57d4F4cRk/8UjhxD
+7OtRBZfdfznSvlhJIMNfH5q0zbC2eO3hWCB3Hrn/vIeswGP8Ov4AJ6eXeX44BQM=
+-----END CERTIFICATE-----
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/ed25519-key.pem b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/ed25519-key.pem
new file mode 100644
index 000000000000..b32bf9e97330
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/ed25519-key.pem
@@ -0,0 +1,3 @@
+-----BEGIN PRIVATE KEY-----
+MC4CAQAwBQYDK2VwBCIEIC29RnMVTcyqXEAIO1b/6p7RdbM6TiqvnztVQ4IxYxUh
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key1.crt b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key1.crt
new file mode 100644
index 000000000000..e381ab69b3d8
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key1.crt
@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIDJjCCAhECFHuJXZO0JDPtCSc1/r0llpyc/j9TMA0GCSqGSIb3DQEBCwUAME8x
+CzAJBgNVBAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxHDAaBgNVBAoME0Rl
+ZmF1bHQgQ29tcGFueSBMdGQxCzAJBgNVBAMMAkNBMCAXDTIzMTAwNTA3Mjg0NVoY
+DzIxMjMwOTExMDcyODQ1WjBRMQswCQYDVQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVs
+dCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMQ0wCwYDVQQDDARr
+ZXkxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoYU2afupPq/b6PIy
+6MWDOMRdJk5uW51lrw6oudXpWlUQMXKdsaZT4sqbgjGLggfo7WWsPeCzQN3kIX3T
+OqBog5EMkXnlQhAfP2Htj0uXPFj97leZ+FqJrzgPnZY8wSqDXfy9/ycR3PgWjRsS
+GZJb05hTNVGTU2vpNQDDo+XBKgybB0afGU8Nk/InWfs1xd/Jv0YcVADQiQEmg41w
+g18B3LMIBZPWIJUQ1b7wMlhxWaCNXHfB1bUTIYCUAUOZyEaxPaOOiJo32xKmqOlU
+TCLM8zgWCBCEgHtQwSD0GMLhUarLPNE5GP3yo5qHBYqOque7BBjP4e58r6wAyBoe
+7kMYRQIDAQABMA0GCSqGSIb3DQEBCwUAA4H/AAMIYpTDxgQwpfk+U1IhkqJjb+Uh
+hj6KlT5TEdpn/saGYLZQECZAO21MWrUDTsV2Pax2Ee8ezarCg8Cthu4YOtPauPaL
+XpyrIagUOgrDcmXr6QxMKUqifiMurLRFaAS7mWXp0TAFNgzDg3WvF9zMJgkjUp/O
+gNSG9U7kXuFfxpVtoalyC2C3g3UeieVXSek3a28h5c/0/DomHqLbyqZh5rYwAJ7C
+q1bqA5TnZNVvV731SVueycj9+5PKHKG6eeRRh7roZ34l54O9adNEeDAF0Lqn4sbn
+a/h4GPK/u6J6Y3nwrdajipZ2DmfiQwoimxprMGNQKuKA0lc025SGHNno
+-----END CERTIFICATE-----
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key1.pem b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key1.pem
new file mode 100644
index 000000000000..197eabb17264
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key1.pem
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQChhTZp+6k+r9vo
+8jLoxYM4xF0mTm5bnWWvDqi51elaVRAxcp2xplPiypuCMYuCB+jtZaw94LNA3eQh
+fdM6oGiDkQyReeVCEB8/Ye2PS5c8WP3uV5n4WomvOA+dljzBKoNd/L3/JxHc+BaN
+GxIZklvTmFM1UZNTa+k1AMOj5cEqDJsHRp8ZTw2T8idZ+zXF38m/RhxUANCJASaD
+jXCDXwHcswgFk9YglRDVvvAyWHFZoI1cd8HVtRMhgJQBQ5nIRrE9o46ImjfbEqao
+6VRMIszzOBYIEISAe1DBIPQYwuFRqss80TkY/fKjmocFio6q57sEGM/h7nyvrADI
+Gh7uQxhFAgMBAAECggEABUfEGCHkjgqUv2BPnsF6QTxWKT7uJ6uVG+x4Qp8GInBe
+d6deFWUxH9xsygxRmb4ldMFaqKk0Yv3+C8Q/yA5fbFGtHgJkpsy9IMbUS9d2ScBF
+COovO+nFz4cfJ5E2SkBYDBYLphBCar1ni1RjupdIzjmQGtGgZd1EwflU7AJCVtwG
+S7ltIs2nSOqUFGTfjb9j0NiATZvWTDRtavNMhyrZplKK6M6VoH1ZcnmcvEfF7j5L
+oSmXrNKYs4iKn1qKypykfCQoEFK0/EEjj5EdnPaSeI9EERrZK1QnHafB2qK38LSr
+8cGaWH24mPW6c/26bDQnHkN3SqKLCODXZMBGhPlLDwKBgQDdMqOzRR3SpTx7KPqp
+h+P0diBZb1e6c+Ob0lXD/rfJEtkAqyFLqpi8hN9xodxw++JYbhC69kJE7VWtQLIt
+Lc+DG72KTS/cbpnvERL1+AoM0TRbO9Ds9aFP4+Zmm/VDxi9rR5yTgl9iAHJ46VrE
+BhnG8JQPBm4n5JU5/wJ9qCQCywKBgQC67uWchaewzDHCiefhTVgwTm1BmHiV/OR4
+50Je2x3GPW6VJGFnBjVzlScKrNyFeOYwscvVS8pTmFP8c5laTbQMC3pVqiWs28Ip
+6sy6cXfepVyc0njLFGbiek8ab0rjVYU27D0O9tucrxDx4pKOurilds1Gbm4HjfyE
+R7pWn/AfLwKBgQC+5wJzKLaJYsQlAwP6pmYtSHm41ihfqb8Jb2lHwyD4r4SLWCZf
+OHejVAXH+0rWU/1QFoXn5brh4/cqlIhyB3RtkdZucxlYZDgEJLc5g32g/Dj0eFZi
++8bhvS3O5tCxUm0AaIiQolcRrJMfGT6VqTI8CMuvf/w3/8ZujFCpBCE4KwKBgBiw
+lQMnZA6l6ayYKlhHru4ybZvMV6D31fViFhIRPs2AL6rjMzo4R7cMbCusyTOX1E96
+LEHv0LlZ1T3yxr52pOEyYuYNowxBulNu/7tgYUS28pSD+BBakXw4S1pieLGuCfpH
+GYlwcXEwbjyEgHb5konINzSmQUIeLswJ7UKjvUNhAoGAXmXvyHqdL04SD99G3B/5
++azzzAVR1fvGYOvq+/hWZMG5PS0kx2V3txCVyY8E1/lCysp9BuUHtW+vOS8YGhAT
+wkZ/X9igZteQvvdVw+E5CXS05b4EBI+7ZViL9ulXFZ4YC70lKcUE52bmaPM+onQJ
+Y1s9JWTe2EAkxsuxm+hkjo0=
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key2-chain.crt b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key2-chain.crt
new file mode 100644
index 000000000000..3b55b95a96ae
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key2-chain.crt
@@ -0,0 +1,38 @@
+-----BEGIN CERTIFICATE-----
+MIIDJjCCAhECFFjLlXVdTxDdLlCifzrA0dTHHJ2mMA0GCSqGSIb3DQEBCwUAME8x
+CzAJBgNVBAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxHDAaBgNVBAoME0Rl
+ZmF1bHQgQ29tcGFueSBMdGQxCzAJBgNVBAMMAkNBMCAXDTIzMTAwNTA3Mjg1MFoY
+DzIxMjMwOTExMDcyODUwWjBRMQswCQYDVQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVs
+dCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMQ0wCwYDVQQDDARr
+ZXkyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAspCMUdFGyKkgpMbW
++UwSg4fdKM4qLSH7voTdsdVM9aAvLvYjBQ4gpORxDZNfUz67R0Ua0/oJt9jD49Wp
+qcq+tDOnp0dPtn2hFluV5PxM6d+MCSx/frPsfvyt9234okLL1zdLDNFYEbLhSPjA
+ku3vHw/OwlJOxCRwTkPqcElIV4+IvIbzAgSffyokzm/wKVKEhoT6NcfeU+6wCkTu
+al1X8loJ+27N6jN13oGZfH7EveBqgR8rPs55+54S/OcVG/uqL9ggOGRJiIZ3jUBk
+m5cN27wKkaNg/CQwa1UjcU4qshVpknHw1dpgJ2Gbs/yUphwpEZl/FTsZFcK1KCHD
+rOp3PQIDAQABMA0GCSqGSIb3DQEBCwUAA4H/AAFmEq86broBFxs0cpImaM884PBT
+bvJBSsFhsOg6mi4Gt01G/lPSj/ExNtH3G5bytCYAPaRxNx/dCs7uON3p86ta4zL8
+2PxgyhX1oY/GG63ETwn5s3GKpRaGTNVDWvPIM9RX6+bvX/wOg8eYXVaQlG5XYadC
+Ms9lWqHaM1C/iLGNmUTGcdbvhnmQDky2CwPNm+lXogSWbrsGpAmCkXJD1H+0Mx8I
+wjDVtGLBwr/8oXI8WbhvISMnS9+dd7+GLm6mU+14Kswi5I7EmBmREvkswi2IVJ6M
+GL7EY3qA6iqJWqsseYyLxiMr3nBT0SETphzoDanUQI1/jXQPrWIyjqvs
+-----END CERTIFICATE-----
+-----BEGIN TRUSTED CERTIFICATE-----
+MIIDIDCCAgsCFH3lh1RXOEy2ESqUPyzb+9zxMYUnMA0GCSqGSIb3DQEBCwUAME8x
+CzAJBgNVBAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxHDAaBgNVBAoME0Rl
+ZmF1bHQgQ29tcGFueSBMdGQxCzAJBgNVBAMMAkNBMCAXDTIzMTAwNTA3MjU1M1oY
+DzIxMjMwOTExMDcyNTUzWjBPMQswCQYDVQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVs
+dCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMQswCQYDVQQDDAJD
+QTCCAR4wDQYJKoZIhvcNAQEBBQADggELADCCAQYCgf4NNpc+6B3qvwKcRYgoXmJ4
+3wyWktBK7BdShz/YnW1OlFZ+R845ZiDw0KdzElZWkYqn+BYJus6lPIS5dfLcrGSf
+a1e8IK02RpBiY/WJvupetnSk8gKA7emF94NlV4gXr4ICJAhXvXUFyBLpdEUE/lcg
+lgCbVJzs5jWUnffEF9mrClzzo0+iXw34zwmyYyBTFmlOEr+QUEdAb6Lr/klpTVit
+as2Ddg1QT4EaSIdTEpkVRZp2dyYVdqSxpaBq21xg0viDHsYQrP96IfacmUB7kFFn
+HsnptDHFvJj2WSQDX+PRS7tLl4mmfizZg80eGfLD22ShNspRSGnbJc0OzegPiwID
+AQABMA0GCSqGSIb3DQEBCwUAA4H/AAnC+FQqdeJaG5I7R+pNjgKplL2UsxW983kA
+CVVkv/Dt0+4rbPC67o9/8Tr+g4eo/wUntMNo2ghF3oBItGr7pJE16zPiLwIvha9c
+8BDhCEZWyhz3vkamZUi19lOnkm3zTmmDE/nX4WYH6CL4UWjxvniZYwW8AdVSnFXY
+ncriuvfliLa3dw1SJ7FtxdcBn4yfzrZWcY+psYNHpftLGYRmQF/VCDSB9EAIEggr
+yBcP749u2y8s44WvKAnnwfLcALIrylY25zN0pao/l2X8HI6qHUeA/QbbEBpDoQvR
+du/rgaHCVvFFxATefhBJ0CUA1Nn5nrGwyRTKnZWtR080qwUp
+-----END TRUSTED CERTIFICATE-----
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key2.crt b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key2.crt
new file mode 100644
index 000000000000..127882627896
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key2.crt
@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIDJjCCAhECFFjLlXVdTxDdLlCifzrA0dTHHJ2mMA0GCSqGSIb3DQEBCwUAME8x
+CzAJBgNVBAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxHDAaBgNVBAoME0Rl
+ZmF1bHQgQ29tcGFueSBMdGQxCzAJBgNVBAMMAkNBMCAXDTIzMTAwNTA3Mjg1MFoY
+DzIxMjMwOTExMDcyODUwWjBRMQswCQYDVQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVs
+dCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMQ0wCwYDVQQDDARr
+ZXkyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAspCMUdFGyKkgpMbW
++UwSg4fdKM4qLSH7voTdsdVM9aAvLvYjBQ4gpORxDZNfUz67R0Ua0/oJt9jD49Wp
+qcq+tDOnp0dPtn2hFluV5PxM6d+MCSx/frPsfvyt9234okLL1zdLDNFYEbLhSPjA
+ku3vHw/OwlJOxCRwTkPqcElIV4+IvIbzAgSffyokzm/wKVKEhoT6NcfeU+6wCkTu
+al1X8loJ+27N6jN13oGZfH7EveBqgR8rPs55+54S/OcVG/uqL9ggOGRJiIZ3jUBk
+m5cN27wKkaNg/CQwa1UjcU4qshVpknHw1dpgJ2Gbs/yUphwpEZl/FTsZFcK1KCHD
+rOp3PQIDAQABMA0GCSqGSIb3DQEBCwUAA4H/AAFmEq86broBFxs0cpImaM884PBT
+bvJBSsFhsOg6mi4Gt01G/lPSj/ExNtH3G5bytCYAPaRxNx/dCs7uON3p86ta4zL8
+2PxgyhX1oY/GG63ETwn5s3GKpRaGTNVDWvPIM9RX6+bvX/wOg8eYXVaQlG5XYadC
+Ms9lWqHaM1C/iLGNmUTGcdbvhnmQDky2CwPNm+lXogSWbrsGpAmCkXJD1H+0Mx8I
+wjDVtGLBwr/8oXI8WbhvISMnS9+dd7+GLm6mU+14Kswi5I7EmBmREvkswi2IVJ6M
+GL7EY3qA6iqJWqsseYyLxiMr3nBT0SETphzoDanUQI1/jXQPrWIyjqvs
+-----END CERTIFICATE-----
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key2.pem b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key2.pem
new file mode 100644
index 000000000000..9e21a1c3f421
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key2.pem
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCykIxR0UbIqSCk
+xtb5TBKDh90oziotIfu+hN2x1Uz1oC8u9iMFDiCk5HENk19TPrtHRRrT+gm32MPj
+1ampyr60M6enR0+2faEWW5Xk/Ezp34wJLH9+s+x+/K33bfiiQsvXN0sM0VgRsuFI
++MCS7e8fD87CUk7EJHBOQ+pwSUhXj4i8hvMCBJ9/KiTOb/ApUoSGhPo1x95T7rAK
+RO5qXVfyWgn7bs3qM3XegZl8fsS94GqBHys+znn7nhL85xUb+6ov2CA4ZEmIhneN
+QGSblw3bvAqRo2D8JDBrVSNxTiqyFWmScfDV2mAnYZuz/JSmHCkRmX8VOxkVwrUo
+IcOs6nc9AgMBAAECggEAPN9dDolG1aIeYD3uzCa8Sv2WjdIWe7NRlEXMI9MgvL1i
+SGKdVpxV0ZCU37llLkY85tNujWP4SyXIxdMxVxIoR9syJKsBSCd0sl//bgP6nmHY
+Zco3HnTswu+VyLtDHuGhhtkxKwn0uXffKBaw44XcVhz38bPIaUI4zN2HPscks8BG
+j2MEl0N8P/TVrTkhgdjfoRi73VAisrEe+1wCg74BT7cmR8fEr7iNFrv955sdPGdw
+UTmx8U26++wbeYQs1ZE1713SYnRQuCUFs5GGjzOhNFi27zuhI6TafoVm9PO4j+ZC
+JUKTyUTBUsRMvm9z1IoHdjM8yInAv2g0J1bAeCTY+wKBgQDuMNMbNVoiXRKsSUry
+22T3W6HVLfLNKiYMNxsAkJjOiyyJcC+yg9BErn/haIHSafD2WmuWbW5ASViyl6fn
+D8qMluTwEaSrTgHXWI4ahWyapDShDQYp1s4dB75Aa/LVcFCay54YEtyCPzCPlj1K
+jz5OBV14NEVVA2cf59fIc/LXCwKBgQC/6m3TefUp5jnN/QUOx2OtZo8Y1pVrsuMB
+AuTtb21Khxn/86ZpVzySzg79/DkSNf9/sZhzj0IkviWNP5S8iAAaFC1q08CYhdCX
+d7tVnHlzpZmmoHUhG6dlJZayr1duZrURp2rP18+wIsKiFRImAyjc6yswVRpZgAiG
+gOkHCB231wKBgGlwXZMWy/6YOtLfYvkcm5ZQDtSCkY+2j78qiZ53Y91SiHWSntqk
+NQaiRGOw0n8lfJBhOG0PphV5InV0YtQLDnurtE59UOqwDmqYfddJpujRtaZxUIAm
+4XjCW7rCzm0jWdscNbCscMaLWGDHffxKaqc5AsZaRTK73eOmysOmaCI/AoGAf/yd
+RZ1dzJWHE0Kb7uE2LlvpLo1clLh1/ySo+1eGMV+sDS+2WSYedWEKSoO8o9JzE/ui
+Sd7OI6bTcEFotdqVBs9SAp45IP6Mv5bPziZOMLvNnnv/4RaKKkBJId0hl7TTKHTY
+HMg176ce2eznb4ZH6BzFbrQyoGFsThcGUPQurX0CgYBYtkDTp21TI1nuak7xpMIY
+BJQpqF5ahBf/+QYWtL0f3ca9MO2++zv5/XXitvt48cY1bCHNrVvSHgRzwSrOorZA
+5u7a5zyvfXjY3LY3k0VHddaVjU0mHsjx/1ux0wO2v8wQjOVZpT7XweB3WlUEGV7C
+5T/p+rmGg5Y5dTKUVCyvbQ==
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/keystore.jks b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/keystore.jks
new file mode 100644
index 000000000000..4e5e1399aee4
Binary files /dev/null and b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/keystore.jks differ
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/keystore.pkcs12 b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/keystore.pkcs12
new file mode 100644
index 000000000000..8c9a6ffa62f4
Binary files /dev/null and b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/keystore.pkcs12 differ
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/rsa-cert.pem b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/rsa-cert.pem
new file mode 100644
index 000000000000..a92d2cca7fd5
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/rsa-cert.pem
@@ -0,0 +1,23 @@
+-----BEGIN CERTIFICATE-----
+MIID1zCCAr+gAwIBAgIUNM5QQv8IzVQsgSmmdPQNaqyzWs4wDQYJKoZIhvcNAQEL
+BQAwezELMAkGA1UEBhMCWFgxEjAQBgNVBAgMCVN0YXRlTmFtZTERMA8GA1UEBwwI
+Q2l0eU5hbWUxFDASBgNVBAoMC0NvbXBhbnlOYW1lMRswGQYDVQQLDBJDb21wYW55
+U2VjdGlvbk5hbWUxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yMzA5MTExMjExNTha
+Fw0zMzA5MDgxMjExNThaMHsxCzAJBgNVBAYTAlhYMRIwEAYDVQQIDAlTdGF0ZU5h
+bWUxETAPBgNVBAcMCENpdHlOYW1lMRQwEgYDVQQKDAtDb21wYW55TmFtZTEbMBkG
+A1UECwwSQ29tcGFueVNlY3Rpb25OYW1lMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEi
+MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCfdkeEiCk+5mpXUhJ1FLmOCx6/
+jAHHaDxZ8hIpyp/c4ZAqFX5uamP08jL056kRKL4RRoUamNWdt0dgpHqds/84pb+3
+OlCVjnFvzGVrvRwdrrQA2mda0BDm2Qnb0r9IhZr7tBpursbDsIC1U6zk1iwrbiO3
+hu0/9uXlMWt49nccTDOpTtuhYUPEA3+NQFqUCwHrd8H9j+BQD5lf4RhoE6krDdV1
+JD8qOns+uD6IKn0xfyPHmy8LD0mM5Rch6J13TZnH1yeFT8Y0ZnAPuwXHO5BNw504
+3Kt/das3NvV+4Qq0qQ08NFK+vmoooP11uIcZb8gUaMgmRINL4P3TOhyA1ueXAgMB
+AAGjUzBRMB0GA1UdDgQWBBRHYz8OjqU/4JZMegJaN/jQbdj4MjAfBgNVHSMEGDAW
+gBRHYz8OjqU/4JZMegJaN/jQbdj4MjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
+DQEBCwUAA4IBAQBr9zqlNx7Mr1ordGfhk+xFrDtyBnk1vXbwVdnog66REqpPLH+K
+MfCKdj6wFoPa8ZjPb4VYFp2DvMxVXtFMzqGfLjYJPqefEzQCleOcA5aiE/ENIaaD
+ybYh99V5CsFAqyKuHLBFEzeYJ028SR3QsCISom0k/Fh6y2IwHJJEHykjqJKvL4bb
+V0IJjcmYjEZbTvpjFKznvaFiOUv+8L7jHQ1/Yf+9c3C8gSjdUfv88m17pqYXd+Ds
+HEmfmNNjht130UyjNCITmLVXyy5p35vWmdf95U3uEbJSnNVtXH8qRmN9oK9mUpDb
+ngX6JBJI7fw7tXoqWSLHNiBODM88fUlQSho8
+-----END CERTIFICATE-----
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/rsa-key.pem b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/rsa-key.pem
new file mode 100644
index 000000000000..895b7763f499
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/rsa-key.pem
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCfdkeEiCk+5mpX
+UhJ1FLmOCx6/jAHHaDxZ8hIpyp/c4ZAqFX5uamP08jL056kRKL4RRoUamNWdt0dg
+pHqds/84pb+3OlCVjnFvzGVrvRwdrrQA2mda0BDm2Qnb0r9IhZr7tBpursbDsIC1
+U6zk1iwrbiO3hu0/9uXlMWt49nccTDOpTtuhYUPEA3+NQFqUCwHrd8H9j+BQD5lf
+4RhoE6krDdV1JD8qOns+uD6IKn0xfyPHmy8LD0mM5Rch6J13TZnH1yeFT8Y0ZnAP
+uwXHO5BNw5043Kt/das3NvV+4Qq0qQ08NFK+vmoooP11uIcZb8gUaMgmRINL4P3T
+OhyA1ueXAgMBAAECggEAPK1LqmULWMlhdoeeyVlQ//lAQn+6X4/MwycG/UsCSJC2
+BCV4nfgyv853UFRkM0jPBhDQ7h1wz1ohuWbs11xaBcqgKE7ywe3ZQULD5tqnO64y
+BU8V2+rnO4gjpbdMHQLlxdgy5KHxtR3Q4+6Kj+rlFMOMqLWZSmke8na7H+SczzGf
++dZO4LRTbjGmFdUidehovm2icSM8OdU2w3FHlFRu2NBsTHGeAhRw86Yw24KfJp4R
+GSDQIBdwp1wCs5w7w4zPjxS7Zi+Uwspyq31KDJwyfK2O1WLI05bQ6FLqVRD/xy+Y
+b4WCse1O08SYWze2No915LB07sokgmomr3//bOwuEQKBgQDPBrPQXokn0BoTlgsa
+JohgWzQ5P9u/2WY+u2SG/xgNEx0s+lk/AmAH80wsBJ68FV6z5Non7TzD7xCsf2HJ
+3cP/EHl2ngTctz/eqpCcS5UPZBHmay60q6WKIkH/3ml7c0UhlqSqS3EDVyEe05hk
+msWAN+fV4ajVlhWgiUZRVdxMpwKBgQDFLyPBOEn6zLOHfkQWcibVf8s2LTe76R/S
+8Gk3jbk5mimR3vNm0L/rHqGwl75rOuFiFOHVkfuY9Dawaht0QnagjayT5hDqr6aD
+s5Chyoy9qpXnfnqOgk6rQZqj+/ODkjqEkBdRCKWvCVnDIi3Au2kS3QIc4iTsGrBW
+ygZdbxM7kQKBgEuzS7T5nHVuZtqaltytElkJgIMekqAIQpbVtuCWDplZT+XOdSvR
+FoRRtpyx48kql0J4gDzxRrLui85Hld5WtQBjacax6V07tKMbA13jVVIXaWQz9RQj
+X5ivBisljLSTZcfuaa/LfjuWdIntHWBMJ8PGrYNLzIytIKNfDtNW7gMpAoGAIRZQ
+5JpCZ7Azq9e3KyEKfSbNfZDG2mQ679Vhgm3ol87TjOOhai47FgP008IStMGTkja4
+0nKFilvoVV/orXB9oWFEhSjEy+yff1gBO/TV+vmF3+tsOz+IXdpLTZr4eKpv4VCg
+aPuPebiS9Fhm3wFTl1O4iAo2cdvknRuXR9RcoNECgYADksGk1lJGW5kMIMJ+6os+
+CJdGnJiX7XsnM0VzkagswnqDe03SqkJuFOmIp96eitxLT4EfB+585pYQRSy2fyJX
+WR2AAnC7oqUcQFkgDt9WBZAazI6aLXYO+trRoGKuWynGM8mjetr5C75g0auj4lsN
+rGiie2UnjshJ67FrG4kZoA==
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot-dependencies/build.gradle b/spring-boot-project/spring-boot-dependencies/build.gradle
index 3ffa3ea6859c..1768b5b755a0 100644
--- a/spring-boot-project/spring-boot-dependencies/build.gradle
+++ b/spring-boot-project/spring-boot-dependencies/build.gradle
@@ -14,7 +14,7 @@ bom {
 			issueLabels = ["type: dependency-upgrade"]
 		}
 	}
-	library("ActiveMQ", "5.18.2") {
+	library("ActiveMQ", "5.18.3") {
 		group("org.apache.activemq") {
 			modules = [
 					"activemq-amqp",
@@ -50,7 +50,7 @@ bom {
 			]
 		}
 	}
-	library("Angus Mail", "1.1.0") {
+	library("Angus Mail", "2.0.2") {
 		group("org.eclipse.angus") {
 			modules = [
 				"angus-core",
@@ -65,7 +65,7 @@ bom {
 			]
 		}
 	}
-	library("Artemis", "2.28.0") {
+	library("Artemis", "2.31.2") {
 		group("org.apache.activemq") {
 			modules = [
 				"artemis-amqp-protocol",
@@ -83,7 +83,7 @@ bom {
 			]
 		}
 	}
-	library("AspectJ", "1.9.19") {
+	library("AspectJ", "1.9.20.1") {
 		group("org.aspectj") {
 			modules = [
 				"aspectjrt",
@@ -92,7 +92,7 @@ bom {
 			]
 		}
 	}
-	library("AssertJ", "3.24.2") {
+	library("AssertJ", "${assertjVersion}") {
 		group("org.assertj") {
 			imports = [
 				"assertj-bom"
@@ -109,21 +109,21 @@ bom {
 			]
 		}
 	}
-	library("Brave", "5.15.1") {
+	library("Brave", "5.16.0") {
 		group("io.zipkin.brave") {
 			imports = [
 				"brave-bom"
 			]
 		}
 	}
-	library("Build Helper Maven Plugin", "3.3.0") {
+	library("Build Helper Maven Plugin", "3.4.0") {
 		group("org.codehaus.mojo") {
 			plugins = [
 				"build-helper-maven-plugin"
 			]
 		}
 	}
-	library("Byte Buddy", "1.14.5") {
+	library("Byte Buddy", "1.14.10") {
 		group("net.bytebuddy") {
 			modules = [
 				"byte-buddy",
@@ -143,7 +143,7 @@ bom {
 			]
 		}
 	}
-	library("Caffeine", "3.1.6") {
+	library("Caffeine", "3.1.8") {
 		group("com.github.ben-manes.caffeine") {
 			modules = [
 				"caffeine",
@@ -153,33 +153,31 @@ bom {
 			]
 		}
 	}
-	library("Cassandra Driver", "4.15.0") {
+	library("Cassandra Driver", "4.17.0") {
 		group("com.datastax.oss") {
 			imports = [
 				"java-driver-bom"
 			]
 			modules = [
-				"java-driver-core" {
-					exclude group: "org.slf4j", module: "jcl-over-slf4j"
-				}
+				"java-driver-core"
 			]
 		}
 	}
-	library("Classmate", "1.5.1") {
+	library("Classmate", "1.6.0") {
 		group("com.fasterxml") {
 			modules = [
 				"classmate"
 			]
 		}
 	}
-	library("Commons Codec", "1.15") {
+	library("Commons Codec", "${commonsCodecVersion}") {
 		group("commons-codec") {
 			modules = [
 				"commons-codec"
 			]
 		}
 	}
-	library("Commons DBCP2", "2.9.0") {
+	library("Commons DBCP2", "2.10.0") {
 		group("org.apache.commons") {
 			modules = [
 				"commons-dbcp2" {
@@ -188,7 +186,7 @@ bom {
 			]
 		}
 	}
-	library("Commons Lang3", "3.12.0") {
+	library("Commons Lang3", "3.13.0") {
 		group("org.apache.commons") {
 			modules = [
 				"commons-lang3"
@@ -202,28 +200,35 @@ bom {
 			]
 		}
 	}
-	library("Commons Pool2", "2.11.1") {
+	library("Commons Pool2", "2.12.0") {
 		group("org.apache.commons") {
 			modules = [
 				"commons-pool2"
 			]
 		}
 	}
-	library("Couchbase Client", "3.4.7") {
+	library("Couchbase Client", "3.4.11") {
 		group("com.couchbase.client") {
 			modules = [
 				"java-client"
 			]
 		}
 	}
-	library("DB2 JDBC", "11.5.8.0") {
+	library("Crac", "1.4.0") {
+		group("org.crac") {
+			modules = [
+				"crac"
+			]
+		}
+	}
+	library("DB2 JDBC", "11.5.9.0") {
 		group("com.ibm.db2") {
 			modules = [
 				"jcc"
 			]
 		}
 	}
-	library("Dependency Management Plugin", "1.1.2") {
+	library("Dependency Management Plugin", "1.1.4") {
 		group("io.spring.gradle") {
 			modules = [
 				"dependency-management-plugin"
@@ -242,7 +247,7 @@ bom {
 			]
 		}
 	}
-	library("Dropwizard Metrics", "4.2.19") {
+	library("Dropwizard Metrics", "4.2.22") {
 		group("io.dropwizard.metrics") {
 			imports = [
 				"metrics-bom"
@@ -252,17 +257,19 @@ bom {
 	library("Ehcache3", "3.10.8") {
 		group("org.ehcache") {
 			modules = [
+					"ehcache",
 					"ehcache" {
 						classifier = 'jakarta'
 					},
 					"ehcache-clustered",
+					"ehcache-transactions",
 					"ehcache-transactions" {
 						classifier = 'jakarta'
 					}
 			]
 		}
 	}
-	library("Elasticsearch Client", "8.7.1") {
+	library("Elasticsearch Client", "8.10.4") {
 		group("org.elasticsearch.client") {
 			modules = [
 				"elasticsearch-rest-client" {
@@ -279,10 +286,11 @@ bom {
 			]
 		}
 	}
-	library("Flyway", "9.16.3") {
+	library("Flyway", "9.22.3") {
 		group("org.flywaydb") {
 			modules = [
 				"flyway-core",
+				"flyway-database-oracle",
 				"flyway-firebird",
 				"flyway-mysql",
 				"flyway-sqlserver"
@@ -299,14 +307,14 @@ bom {
 			]
 		}
 	}
-	library("Git Commit ID Maven Plugin", "5.0.1") {
+	library("Git Commit ID Maven Plugin", "6.0.0") {
 		group("io.github.git-commit-id") {
 			plugins = [
 				"git-commit-id-maven-plugin"
 			]
 		}
 	}
-	library("Glassfish JAXB", "4.0.3") {
+	library("Glassfish JAXB", "4.0.4") {
 		group("org.glassfish.jaxb") {
 			imports = [
 				"jaxb-bom"
@@ -320,14 +328,18 @@ bom {
 			]
 		}
 	}
-	library("GraphQL Java", "20.2") {
+	library("GraphQL Java", "21.3") {
+		prohibit {
+			startsWith(["2018-", "2019-", "2020-", "2021-", "230521-"])
+			because "These are snapshots that we don't want to see"
+		}
 		group("com.graphql-java") {
 			modules = [
 					"graphql-java"
 			]
 		}
 	}
-	library("Groovy", "4.0.13") {
+	library("Groovy", "4.0.15") {
 		group("org.apache.groovy") {
 			imports = [
 				"groovy-bom"
@@ -341,14 +353,14 @@ bom {
 			]
 		}
 	}
-	library("H2", "2.1.214") {
+	library("H2", "2.2.224") {
 		group("com.h2database") {
 			modules = [
 				"h2"
 			]
 		}
 	}
-	library("Hamcrest", "2.2") {
+	library("Hamcrest", "${hamcrestVersion}") {
 		group("org.hamcrest") {
 			modules = [
 				"hamcrest",
@@ -357,7 +369,7 @@ bom {
 			]
 		}
 	}
-	library("Hazelcast", "5.2.4") {
+	library("Hazelcast", "5.3.6") {
 		group("com.hazelcast") {
 			modules = [
 				"hazelcast",
@@ -365,7 +377,7 @@ bom {
 			]
 		}
 	}
-	library("Hibernate", "6.2.6.Final") {
+	library("Hibernate", "6.3.1.Final") {
 		group("org.hibernate.orm") {
 			modules = [
 				"hibernate-agroal",
@@ -444,7 +456,7 @@ bom {
 			]
 		}
 	}
-	library("HttpCore5", "5.2.2") {
+	library("HttpCore5", "5.2.3") {
 		group("org.apache.httpcomponents.core5") {
 			modules = [
 				"httpcore5",
@@ -453,7 +465,7 @@ bom {
 			]
 		}
 	}
-	library("Infinispan", "14.0.12.Final") {
+	library("Infinispan", "14.0.21.Final") {
 		group("org.infinispan") {
 			imports = [
 				"infinispan-bom"
@@ -467,7 +479,7 @@ bom {
 			]
 		}
 	}
-	library("Jackson Bom", "2.15.2") {
+	library("Jackson Bom", "${jacksonVersion}") {
 		group("com.fasterxml.jackson") {
 			imports = [
 				"jackson-bom"
@@ -495,7 +507,7 @@ bom {
 			]
 		}
 	}
-	library("Jakarta Json", "2.1.2") {
+	library("Jakarta Json", "2.1.3") {
 		group("jakarta.json") {
 			modules = [
 				"jakarta.json-api"
@@ -573,21 +585,21 @@ bom {
 			]
 		}
 	}
-	library("Jakarta XML Bind", "4.0.0") {
+	library("Jakarta XML Bind", "4.0.1") {
 		group("jakarta.xml.bind") {
 			modules = [
 				"jakarta.xml.bind-api"
 			]
 		}
 	}
-	library("Jakarta XML SOAP", "3.0.0") {
+	library("Jakarta XML SOAP", "3.0.1") {
 		group("jakarta.xml.soap") {
 			modules = [
 					"jakarta.xml.soap-api"
 			]
 		}
 	}
-	library("Jakarta XML WS", "4.0.0") {
+	library("Jakarta XML WS", "4.0.1") {
 		group("jakarta.xml.ws") {
 			modules = [
 				"jakarta.xml.ws-api"
@@ -645,28 +657,33 @@ bom {
 			]
 		}
 	}
-	library("Jedis", "4.3.2") {
+	library("Jedis", "5.0.2") {
 		group("redis.clients") {
 			modules = [
 				"jedis"
 			]
 		}
 	}
-	library("Jersey", "3.1.2") {
+	library("Jersey", "3.1.3") {
 		group("org.glassfish.jersey") {
 			imports = [
 				"jersey-bom"
 			]
 		}
 	}
-	library("Jetty Reactive HTTPClient", "3.0.8") {
+	library("Jetty Reactive HTTPClient", "4.0.1") {
 		group("org.eclipse.jetty") {
 			modules = [
 				"jetty-reactive-httpclient"
 			]
 		}
 	}
-	library("Jetty", "11.0.15") {
+	library("Jetty", "12.0.3") {
+		group("org.eclipse.jetty.ee10") {
+			imports = [
+				"jetty-ee10-bom"
+			]
+		}
 		group("org.eclipse.jetty") {
 			imports = [
 				"jetty-bom"
@@ -680,7 +697,7 @@ bom {
 			]
 		}
 	}
-	library("jOOQ", "3.18.5") {
+	library("jOOQ", "3.18.7") {
 		group("org.jooq") {
 			modules = [
 				"jooq",
@@ -701,7 +718,7 @@ bom {
 			]
 		}
 	}
-	library("Json-smart", "2.4.11") {
+	library("Json-smart", "2.5.0") {
 		group("net.minidev") {
 			modules = [
 				"json-smart"
@@ -729,14 +746,14 @@ bom {
 			]
 		}
 	}
-	library("JUnit Jupiter", "5.9.3") {
+	library("JUnit Jupiter", "${junitJupiterVersion}") {
 		group("org.junit") {
 			imports = [
 				"junit-bom"
 			]
 		}
 	}
-	library("Kafka", "3.4.1") {
+	library("Kafka", "3.6.0") {
 		group("org.apache.kafka") {
 			modules = [
 				"connect",
@@ -757,6 +774,9 @@ bom {
 				"kafka-metadata",
 				"kafka-raft",
 				"kafka-server-common",
+				"kafka-server-common" {
+					classifier = "test"
+				},
 				"kafka-shell",
 				"kafka-storage",
 				"kafka-storage-api",
@@ -787,25 +807,28 @@ bom {
 			]
 		}
 	}
-	library("Kotlin Coroutines", "1.6.4") {
+	library("Kotlin Coroutines", "1.7.3") {
 		group("org.jetbrains.kotlinx") {
 			imports = [
 				"kotlinx-coroutines-bom"
 			]
 		}
 	}
-	library("Lettuce", "6.2.5.RELEASE") {
+	library("Kotlin Serialization", "1.6.1") {
+		group("org.jetbrains.kotlinx") {
+			imports = [
+				"kotlinx-serialization-bom"
+			]
+		}
+	}
+	library("Lettuce", "6.3.0.RELEASE") {
 		group("io.lettuce") {
 			modules = [
 				"lettuce-core"
 			]
 		}
 	}
-	library("Liquibase", "4.20.0") {
-		prohibit {
-			versionRange "[4.21.0,4.21.2)"
-			because "https://github.com/liquibase/liquibase/issues/4135"
-		}
+	library("Liquibase", "4.24.0") {
 		group("org.liquibase") {
 			modules = [
 				"liquibase-cdi",
@@ -816,14 +839,14 @@ bom {
 			]
 		}
 	}
-	library("Log4j2", "2.20.0") {
+	library("Log4j2", "2.21.1") {
 		group("org.apache.logging.log4j") {
 			imports = [
 				"log4j-bom"
 			]
 		}
 	}
-	library("Logback", "1.4.8") {
+	library("Logback", "1.4.11") {
 		group("ch.qos.logback") {
 			modules = [
 				"logback-access",
@@ -832,14 +855,14 @@ bom {
 			]
 		}
 	}
-	library("Lombok", "1.18.28") {
+	library("Lombok", "1.18.30") {
 		group("org.projectlombok") {
 			modules = [
 				"lombok"
 			]
 		}
 	}
-	library("MariaDB", "3.1.4") {
+	library("MariaDB", "3.2.0") {
 		group("org.mariadb.jdbc") {
 			modules = [
 				"mariadb-java-client"
@@ -853,14 +876,14 @@ bom {
 			]
 		}
 	}
-	library("Maven Assembly Plugin", "3.5.0") {
+	library("Maven Assembly Plugin", "3.6.0") {
 		group("org.apache.maven.plugins") {
 			plugins = [
 				"maven-assembly-plugin"
 			]
 		}
 	}
-	library("Maven Clean Plugin", "3.2.0") {
+	library("Maven Clean Plugin", "3.3.2") {
 		group("org.apache.maven.plugins") {
 			plugins = [
 				"maven-clean-plugin"
@@ -874,7 +897,7 @@ bom {
 			]
 		}
 	}
-	library("Maven Dependency Plugin", "3.5.0") {
+	library("Maven Dependency Plugin", "3.6.1") {
 		group("org.apache.maven.plugins") {
 			plugins = [
 				"maven-dependency-plugin"
@@ -888,14 +911,14 @@ bom {
 			]
 		}
 	}
-	library("Maven Enforcer Plugin", "3.3.0") {
+	library("Maven Enforcer Plugin", "3.4.1") {
 		group("org.apache.maven.plugins") {
 			plugins = [
 				"maven-enforcer-plugin"
 			]
 		}
 	}
-	library("Maven Failsafe Plugin", "3.0.0") {
+	library("Maven Failsafe Plugin", "3.1.2") {
 		group("org.apache.maven.plugins") {
 			plugins = [
 				"maven-failsafe-plugin"
@@ -916,7 +939,7 @@ bom {
 			]
 		}
 	}
-	library("Maven Invoker Plugin", "3.5.1") {
+	library("Maven Invoker Plugin", "3.6.0") {
 		group("org.apache.maven.plugins") {
 			plugins = [
 				"maven-invoker-plugin"
@@ -930,7 +953,7 @@ bom {
 			]
 		}
 	}
-	library("Maven Javadoc Plugin", "3.5.0") {
+	library("Maven Javadoc Plugin", "3.6.2") {
 		group("org.apache.maven.plugins") {
 			plugins = [
 				"maven-javadoc-plugin"
@@ -944,35 +967,36 @@ bom {
 			]
 		}
 	}
-	library("Maven Shade Plugin", "3.4.1") {
+	library("Maven Shade Plugin", "3.5.1") {
 		group("org.apache.maven.plugins") {
 			plugins = [
 				"maven-shade-plugin"
 			]
 		}
 	}
-	library("Maven Source Plugin", "3.2.1") {
+	library("Maven Source Plugin", "3.3.0") {
 		group("org.apache.maven.plugins") {
 			plugins = [
 				"maven-source-plugin"
 			]
 		}
 	}
-	library("Maven Surefire Plugin", "3.0.0") {
+	library("Maven Surefire Plugin", "3.1.2") {
 		group("org.apache.maven.plugins") {
 			plugins = [
 				"maven-surefire-plugin"
 			]
 		}
 	}
-	library("Maven War Plugin", "3.3.2") {
+	library("Maven War Plugin", "3.4.0") {
 		group("org.apache.maven.plugins") {
 			plugins = [
 				"maven-war-plugin"
 			]
 		}
 	}
-	library("Micrometer", "1.11.2") {
+	library("Micrometer", "1.12.0") {
+		considerSnapshots()
 		group("io.micrometer") {
 			modules = [
 				"micrometer-registry-stackdriver" {
@@ -984,21 +1008,23 @@ bom {
 			]
 		}
 	}
-	library("Micrometer Tracing", "1.1.3") {
+	library("Micrometer Tracing", "1.2.0") {
+		considerSnapshots()
+		calendarName = "Tracing"
 		group("io.micrometer") {
 			imports = [
 				"micrometer-tracing-bom"
 			]
 		}
 	}
-	library("Mockito", "5.3.1") {
+	library("Mockito", "5.7.0") {
 		group("org.mockito") {
 			imports = [
 				"mockito-bom"
 			]
 		}
 	}
-	library("MongoDB", "4.9.1") {
+	library("MongoDB", "4.11.1") {
 		group("org.mongodb") {
 			modules = [
 				"bson",
@@ -1010,10 +1036,10 @@ bom {
 			]
 		}
 	}
-	library("MSSQL JDBC", "11.2.3.jre17") {
+	library("MSSQL JDBC", "12.4.2.jre11") {
 		prohibit {
-			endsWith([".jre8", ".jre11", ".jre18"])
-			because "we use the .jre17 version"
+			endsWith([".jre8", "-preview"])
+			because "we use the non-preview .jre11 version"
 		}
 		group("com.microsoft.sqlserver") {
 			modules = [
@@ -1021,7 +1047,7 @@ bom {
 			]
 		}
 	}
-	library("MySQL", "8.0.33") {
+	library("MySQL", "8.1.0") {
 		group("com.mysql") {
 			modules = [
 				"mysql-connector-j" {
@@ -1044,35 +1070,35 @@ bom {
 			]
 		}
 	}
-	library("Neo4j Java Driver", "5.9.0") {
+	library("Neo4j Java Driver", "5.13.0") {
 		group("org.neo4j.driver") {
 			modules = [
 				"neo4j-java-driver"
 			]
 		}
 	}
-	library("Netty", "4.1.94.Final") {
+	library("Netty", "4.1.101.Final") {
 		group("io.netty") {
 			imports = [
 				"netty-bom"
 			]
 		}
 	}
-	library("OkHttp", "4.10.0") {
+	library("OkHttp", "4.12.0") {
 		group("com.squareup.okhttp3") {
 			imports = [
 				"okhttp-bom"
 			]
 		}
 	}
-	library("OpenTelemetry", "1.25.0") {
+	library("OpenTelemetry", "1.31.0") {
 		group("io.opentelemetry") {
 			imports = [
 				"opentelemetry-bom"
 			]
 		}
 	}
-	library("Oracle Database", "21.9.0.0") {
+	library("Oracle Database", "23.3.0.23.09") {
 		group("com.oracle.database.jdbc") {
 			imports = [
 				"ojdbc-bom"
@@ -1086,7 +1112,7 @@ bom {
 			]
 		}
 	}
-	library("Pooled JMS", "3.1.0") {
+	library("Pooled JMS", "3.1.5") {
 		group("org.messaginghub") {
 			modules = [
 				"pooled-jms"
@@ -1107,6 +1133,96 @@ bom {
 			]
 		}
 	}
+	library("Pulsar", "3.1.1") {
+		group("org.apache.pulsar") {
+			modules = [
+				"bouncy-castle-bc",
+				"bouncy-castle-bcfips",
+				"pulsar-client-1x-base",
+				"pulsar-client-1x",
+				"pulsar-client-2x-shaded",
+				"pulsar-client-admin-api",
+				"pulsar-client-admin-original",
+				"pulsar-client-admin",
+				"pulsar-client-all",
+				"pulsar-client-api",
+				"pulsar-client-auth-athenz",
+				"pulsar-client-auth-sasl",
+				"pulsar-client-messagecrypto-bc",
+				"pulsar-client-original",
+				"pulsar-client-tools-api",
+				"pulsar-client-tools",
+				"pulsar-client",
+				"pulsar-common",
+				"pulsar-config-validation",
+				"pulsar-functions-api",
+				"pulsar-functions-proto",
+				"pulsar-functions-utils",
+				"pulsar-io-aerospike",
+				"pulsar-io-alluxio",
+				"pulsar-io-aws",
+				"pulsar-io-batch-data-generator",
+				"pulsar-io-batch-discovery-triggerers",
+				"pulsar-io-canal",
+				"pulsar-io-cassandra",
+				"pulsar-io-common",
+				"pulsar-io-core",
+				"pulsar-io-data-generator",
+				"pulsar-io-debezium-core",
+				"pulsar-io-debezium-mongodb",
+				"pulsar-io-debezium-mssql",
+				"pulsar-io-debezium-mysql",
+				"pulsar-io-debezium-oracle",
+				"pulsar-io-debezium-postgres",
+				"pulsar-io-debezium",
+				"pulsar-io-dynamodb",
+				"pulsar-io-elastic-search",
+				"pulsar-io-file",
+				"pulsar-io-flume",
+				"pulsar-io-hbase",
+				"pulsar-io-hdfs2",
+				"pulsar-io-hdfs3",
+				"pulsar-io-http",
+				"pulsar-io-influxdb",
+				"pulsar-io-jdbc-clickhouse",
+				"pulsar-io-jdbc-core",
+				"pulsar-io-jdbc-mariadb",
+				"pulsar-io-jdbc-openmldb",
+				"pulsar-io-jdbc-postgres",
+				"pulsar-io-jdbc-sqlite",
+				"pulsar-io-jdbc",
+				"pulsar-io-kafka-connect-adaptor-nar",
+				"pulsar-io-kafka-connect-adaptor",
+				"pulsar-io-kafka",
+				"pulsar-io-kinesis",
+				"pulsar-io-mongo",
+				"pulsar-io-netty",
+				"pulsar-io-nsq",
+				"pulsar-io-rabbitmq",
+				"pulsar-io-redis",
+				"pulsar-io-solr",
+				"pulsar-io-twitter",
+				"pulsar-io",
+				"pulsar-metadata",
+				"pulsar-presto-connector-original",
+				"pulsar-presto-connector",
+				"pulsar-sql",
+				"pulsar-transaction-common",
+				"pulsar-websocket"
+			]
+		}
+	}
+	library("Pulsar Reactive", "0.5.0") {
+		group("org.apache.pulsar") {
+			modules = [
+				"pulsar-client-reactive-adapter",
+				"pulsar-client-reactive-api",
+				"pulsar-client-reactive-jackson",
+				"pulsar-client-reactive-producer-cache-caffeine-shaded",
+				"pulsar-client-reactive-producer-cache-caffeine"
+			]
+		}
+	}
 	library("Quartz", "2.3.2") {
 		group("org.quartz-scheduler") {
 			modules = [
@@ -1126,6 +1242,7 @@ bom {
 		}
 	}
 	library("R2DBC H2", "1.0.0.RELEASE") {
+		considerSnapshots()
 		group("io.r2dbc") {
 			modules = [
 				"r2dbc-h2"
@@ -1146,14 +1263,15 @@ bom {
 			]
 		}
 	}
-	library("R2DBC MySQL", "1.0.2") {
+	library("R2DBC MySQL", "1.0.5") {
 		group("io.asyncer") {
 			modules = [
 				"r2dbc-mysql"
 			]
 		}
 	}
-	library("R2DBC Pool", "1.0.0.RELEASE") {
+	library("R2DBC Pool", "1.0.1.RELEASE") {
+		considerSnapshots()
 		group("io.r2dbc") {
 			modules = [
 				"r2dbc-pool"
@@ -1161,13 +1279,15 @@ bom {
 		}
 	}
 	library("R2DBC Postgresql", "1.0.2.RELEASE") {
+		considerSnapshots()
 		group("org.postgresql") {
 			modules = [
 				"r2dbc-postgresql"
 			]
 		}
 	}
-	library("R2DBC Proxy", "1.1.1.RELEASE") {
+	library("R2DBC Proxy", "1.1.2.RELEASE") {
+		considerSnapshots()
 		group("io.r2dbc") {
 			modules = [
 				"r2dbc-proxy"
@@ -1175,20 +1295,21 @@ bom {
 		}
 	}
 	library("R2DBC SPI", "1.0.0.RELEASE") {
+		considerSnapshots()
 		group("io.r2dbc") {
 			modules = [
 				"r2dbc-spi"
 			]
 		}
 	}
-	library("Rabbit AMQP Client", "5.17.1") {
+	library("Rabbit AMQP Client", "5.19.0") {
 		group("com.rabbitmq") {
 			modules = [
 				"amqp-client"
 			]
 		}
 	}
-	library("Rabbit Stream Client", "0.9.0") {
+	library("Rabbit Stream Client", "0.14.0") {
 		group("com.rabbitmq") {
 			modules = [
 					"stream-client"
@@ -1202,14 +1323,16 @@ bom {
 			]
 		}
 	}
-	library("Reactor Bom", "2022.0.9") {
+	library("Reactor Bom", "2023.0.0") {
+		considerSnapshots()
+		calendarName = "Reactor"
 		group("io.projectreactor") {
 			imports = [
 				"reactor-bom"
 			]
 		}
 	}
-	library("REST Assured", "5.3.1") {
+	library("REST Assured", "5.3.2") {
 		group("io.rest-assured") {
 			imports = [
 				"rest-assured-bom"
@@ -1218,7 +1341,7 @@ bom {
 	}
 	library("RSocket", "1.1.3") {
 		prohibit {
-			versionRange "1.1.4"
+			versionRange "[1.1.4]"
 			because "it contains a regression (https://github.com/rsocket/rsocket-java/issues/1092)"
 		}
 		group("io.rsocket") {
@@ -1227,7 +1350,7 @@ bom {
 			]
 		}
 	}
-	library("RxJava3", "3.1.6") {
+	library("RxJava3", "3.1.8") {
 		group("io.reactivex.rxjava3") {
 			modules = [
 				"rxjava"
@@ -1252,6 +1375,7 @@ bom {
 				"spring-boot-docker-compose",
 				"spring-boot-jarmode-layertools",
 				"spring-boot-loader",
+				"spring-boot-loader-classic",
 				"spring-boot-loader-tools",
 				"spring-boot-properties-migrator",
 				"spring-boot-starter",
@@ -1294,6 +1418,8 @@ bom {
 				"spring-boot-starter-oauth2-authorization-server",
 				"spring-boot-starter-oauth2-client",
 				"spring-boot-starter-oauth2-resource-server",
+				"spring-boot-starter-pulsar",
+				"spring-boot-starter-pulsar-reactive",
 				"spring-boot-starter-quartz",
 				"spring-boot-starter-reactor-netty",
 				"spring-boot-starter-rsocket",
@@ -1313,21 +1439,21 @@ bom {
 			]
 		}
 	}
-	library("SAAJ Impl", "3.0.2") {
+	library("SAAJ Impl", "3.0.3") {
 		group("com.sun.xml.messaging.saaj") {
 			modules = [
 				"saaj-impl"
 			]
 		}
 	}
-	library("Selenium", "4.8.3") {
+	library("Selenium", "4.14.1") {
 		group("org.seleniumhq.selenium") {
 			imports = [
 				"selenium-bom"
 			]
 		}
 	}
-	library("Selenium HtmlUnit", "4.8.3") {
+	library("Selenium HtmlUnit", "4.13.0") {
 		group("org.seleniumhq.selenium") {
 			modules = [
 				"htmlunit-driver"
@@ -1341,7 +1467,7 @@ bom {
 			]
 		}
 	}
-	library("SLF4J", "2.0.7") {
+	library("SLF4J", "2.0.9") {
 		group("org.slf4j") {
 			modules = [
 				"jcl-over-slf4j",
@@ -1358,49 +1484,57 @@ bom {
 			]
 		}
 	}
-	library("SnakeYAML", "1.33") {
+	library("SnakeYAML", "2.2") {
 		group("org.yaml") {
 			modules = [
 				"snakeyaml"
 			]
 		}
 	}
-	library("Spring AMQP", "3.0.6") {
+	library("Spring AMQP", "3.1.0") {
+		considerSnapshots()
 		group("org.springframework.amqp") {
 			imports = [
 				"spring-amqp-bom"
 			]
 		}
 	}
-	library("Spring Authorization Server", "1.1.1") {
+	library("Spring Authorization Server", "1.2.0") {
+		considerSnapshots()
 		group("org.springframework.security") {
 			modules = [
 					"spring-security-oauth2-authorization-server"
 			]
 		}
 	}
-	library("Spring Batch", "5.0.2") {
+	library("Spring Batch", "5.1.0") {
+		considerSnapshots()
 		group("org.springframework.batch") {
 			imports = [
 				"spring-batch-bom"
 			]
 		}
 	}
-	library("Spring Data Bom", "2023.0.2") {
+	library("Spring Data Bom", "2023.1.0") {
+		considerSnapshots()
+		calendarName = "Spring Data Release"
 		group("org.springframework.data") {
 			imports = [
 				"spring-data-bom"
 			]
 		}
+
 	}
 	library("Spring Framework", "${springFrameworkVersion}") {
+		considerSnapshots()
 		group("org.springframework") {
 			imports = [
 				"spring-framework-bom"
 			]
 		}
 	}
-	library("Spring GraphQL", "1.2.2") {
+	library("Spring GraphQL", "1.2.4") {
+		considerSnapshots()
 		group("org.springframework.graphql") {
 			modules = [
 					"spring-graphql",
@@ -1408,21 +1542,24 @@ bom {
 			]
 		}
 	}
-	library("Spring HATEOAS", "2.1.2") {
+	library("Spring HATEOAS", "2.2.0") {
+		considerSnapshots()
 		group("org.springframework.hateoas") {
 			modules = [
 				"spring-hateoas"
 			]
 		}
 	}
-	library("Spring Integration", "6.1.2") {
+	library("Spring Integration", "6.2.0") {
+		considerSnapshots()
 		group("org.springframework.integration") {
 			imports = [
 				"spring-integration-bom"
 			]
 		}
 	}
-	library("Spring Kafka", "3.0.9") {
+	library("Spring Kafka", "3.1.0") {
+		considerSnapshots()
 		group("org.springframework.kafka") {
 			modules = [
 				"spring-kafka",
@@ -1430,7 +1567,8 @@ bom {
 			]
 		}
 	}
-	library("Spring LDAP", "3.1.0") {
+	library("Spring LDAP", "3.2.0") {
+		considerSnapshots()
 		group("org.springframework.ldap") {
 			modules = [
 				"spring-ldap-core",
@@ -1440,28 +1578,42 @@ bom {
 			]
 		}
 	}
-	library("Spring RESTDocs", "3.0.0") {
+	library("Spring Pulsar", "1.0.0") {
+		group("org.springframework.pulsar") {
+			modules = [
+				"spring-pulsar",
+				"spring-pulsar-cache-provider",
+				"spring-pulsar-cache-provider-caffeine",
+				"spring-pulsar-reactive"
+			]
+		}
+	}
+	library("Spring RESTDocs", "3.0.1") {
+		considerSnapshots()
 		group("org.springframework.restdocs") {
 			imports = [
 				"spring-restdocs-bom"
 			]
 		}
 	}
-	library("Spring Retry", "2.0.2") {
+	library("Spring Retry", "2.0.4") {
+		considerSnapshots()
 		group("org.springframework.retry") {
 			modules = [
 				"spring-retry"
 			]
 		}
 	}
-	library("Spring Security", "6.1.2") {
+	library("Spring Security", "6.2.0") {
+		considerSnapshots()
 		group("org.springframework.security") {
 			imports = [
 				"spring-security-bom"
 			]
 		}
 	}
-	library("Spring Session", "3.1.1") {
+	library("Spring Session", "3.2.0") {
+		considerSnapshots()
 		prohibit {
 			startsWith(["Apple-", "Bean-", "Corn-", "Dragonfruit-"])
 			because "Spring Session switched to numeric version numbers"
@@ -1472,28 +1624,29 @@ bom {
 			]
 		}
 	}
-	library("Spring WS", "4.0.5") {
+	library("Spring WS", "4.0.8") {
+		considerSnapshots()
 		group("org.springframework.ws") {
 			imports = [
 				"spring-ws-bom"
 			]
 		}
 	}
-	library("SQLite JDBC", "3.41.2.2") {
+	library("SQLite JDBC", "3.43.2.0") {
 		group("org.xerial") {
 			modules = [
 				"sqlite-jdbc"
 			]
 		}
 	}
-	library("Testcontainers", "1.18.3") {
+	library("Testcontainers", "1.19.3") {
 		group("org.testcontainers") {
 			imports = [
 				"testcontainers-bom"
 			]
 		}
 	}
-	library("Thymeleaf", "3.1.1.RELEASE") {
+	library("Thymeleaf", "3.1.2.RELEASE") {
 		group("org.thymeleaf") {
 			modules = [
 				"thymeleaf",
@@ -1508,14 +1661,14 @@ bom {
 			]
 		}
 	}
-	library("Thymeleaf Extras SpringSecurity", "3.1.1.RELEASE") {
+	library("Thymeleaf Extras SpringSecurity", "3.1.2.RELEASE") {
 		group("org.thymeleaf.extras") {
 			modules = [
 				"thymeleaf-extras-springsecurity6"
 			]
 		}
 	}
-	library("Thymeleaf Layout Dialect", "3.2.1") {
+	library("Thymeleaf Layout Dialect", "3.3.0") {
 		group("nz.net.ultraq.thymeleaf") {
 			modules = [
 				"thymeleaf-layout-dialect"
@@ -1539,14 +1692,14 @@ bom {
 			]
 		}
 	}
-	library("UnboundID LDAPSDK", "6.0.9") {
+	library("UnboundID LDAPSDK", "6.0.10") {
 		group("com.unboundid") {
 			modules = [
 				"unboundid-ldapsdk"
 			]
 		}
 	}
-	library("Undertow", "2.3.7.Final") {
+	library("Undertow", "2.3.10.Final") {
 		group("io.undertow") {
 			modules = [
 				"undertow-core",
@@ -1555,14 +1708,14 @@ bom {
 			]
 		}
 	}
-	library("Versions Maven Plugin", "2.15.0") {
+	library("Versions Maven Plugin", "2.16.2") {
 		group("org.codehaus.mojo") {
 			plugins = [
 				"versions-maven-plugin"
 			]
 		}
 	}
-	library("WebJars Locator Core", "0.52") {
+	library("WebJars Locator Core", "0.55") {
 		group("org.webjars") {
 			modules = [
 				"webjars-locator-core"
@@ -1576,7 +1729,7 @@ bom {
 			]
 		}
 	}
-	library("XML Maven Plugin", "1.0.2") {
+	library("XML Maven Plugin", "1.1.0") {
 		group("org.codehaus.mojo") {
 			plugins = [
 				"xml-maven-plugin"
diff --git a/spring-boot-project/spring-boot-devtools/build.gradle b/spring-boot-project/spring-boot-devtools/build.gradle
index 7e33304df021..b3047fd5d52d 100644
--- a/spring-boot-project/spring-boot-devtools/build.gradle
+++ b/spring-boot-project/spring-boot-devtools/build.gradle
@@ -65,9 +65,7 @@ dependencies {
 	testImplementation("org.apache.tomcat.embed:tomcat-embed-jasper")
 	testImplementation("org.assertj:assertj-core")
 	testImplementation("org.awaitility:awaitility")
-	testImplementation("org.eclipse.jetty.websocket:websocket-jakarta-client") {
-		exclude group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-websocket-api"
-	}
+	testImplementation("org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jakarta-client")
 	testImplementation("org.hamcrest:hamcrest-library")
 	testImplementation("org.hsqldb:hsqldb")
 	testImplementation("org.junit.jupiter:junit-jupiter")
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/filewatch/FileSystemWatcher.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/filewatch/FileSystemWatcher.java
index 99ef112ea3b5..35a950c6e3b1 100644
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/filewatch/FileSystemWatcher.java
+++ b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/filewatch/FileSystemWatcher.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -158,9 +158,7 @@ public void setTriggerFilter(FileFilter triggerFilter) {
 	}
 
 	private void checkNotStarted() {
-		synchronized (this.monitor) {
-			Assert.state(this.watchThread == null, "FileSystemWatcher already started");
-		}
+		Assert.state(this.watchThread == null, "FileSystemWatcher already started");
 	}
 
 	/**
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/livereload/LiveReloadServer.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/livereload/LiveReloadServer.java
index 773fcabc87fe..d656bdc3f854 100644
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/livereload/LiveReloadServer.java
+++ b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/livereload/LiveReloadServer.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/remote/client/ClassPathChangeUploader.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/remote/client/ClassPathChangeUploader.java
index 8cfe3144db9f..44bf7d24d58d 100644
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/remote/client/ClassPathChangeUploader.java
+++ b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/remote/client/ClassPathChangeUploader.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -92,15 +92,14 @@ public void onApplicationEvent(ClassPathChangedEvent event) {
 		try {
 			ClassLoaderFiles classLoaderFiles = getClassLoaderFiles(event);
 			byte[] bytes = serialize(classLoaderFiles);
-			performUpload(classLoaderFiles, bytes, event);
+			performUpload(bytes, event);
 		}
 		catch (IOException ex) {
 			throw new IllegalStateException(ex);
 		}
 	}
 
-	private void performUpload(ClassLoaderFiles classLoaderFiles, byte[] bytes, ClassPathChangedEvent event)
-			throws IOException {
+	private void performUpload(byte[] bytes, ClassPathChangedEvent event) throws IOException {
 		try {
 			while (true) {
 				try {
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/RestartApplicationListener.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/RestartApplicationListener.java
index 11d8b8b70027..e42e095be386 100644
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/RestartApplicationListener.java
+++ b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/RestartApplicationListener.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -23,6 +23,7 @@
 import org.springframework.boot.context.event.ApplicationPreparedEvent;
 import org.springframework.boot.context.event.ApplicationReadyEvent;
 import org.springframework.boot.context.event.ApplicationStartingEvent;
+import org.springframework.boot.devtools.system.DevToolsEnablementDeducer;
 import org.springframework.context.ApplicationEvent;
 import org.springframework.context.ApplicationListener;
 import org.springframework.core.Ordered;
@@ -66,7 +67,14 @@ private void onApplicationStartingEvent(ApplicationStartingEvent event) {
 		String enabled = System.getProperty(ENABLED_PROPERTY);
 		RestartInitializer restartInitializer = null;
 		if (enabled == null) {
-			restartInitializer = new DefaultRestartInitializer();
+			if (implicitlyEnableRestart()) {
+				restartInitializer = new DefaultRestartInitializer();
+			}
+			else {
+				logger.info("Restart disabled due to context in which it is running");
+				Restarter.disable();
+				return;
+			}
 		}
 		else if (Boolean.parseBoolean(enabled)) {
 			restartInitializer = new DefaultRestartInitializer() {
@@ -96,6 +104,10 @@ protected boolean isDevelopmentClassLoader(ClassLoader classLoader) {
 		}
 	}
 
+	boolean implicitlyEnableRestart() {
+		return DevToolsEnablementDeducer.shouldEnable(Thread.currentThread());
+	}
+
 	private void onApplicationPreparedEvent(ApplicationPreparedEvent event) {
 		Restarter.getInstance().prepare(event.getApplicationContext());
 	}
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/Restarter.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/Restarter.java
index bdc69f02d84d..869e93699f9d 100644
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/Restarter.java
+++ b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/Restarter.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -22,7 +22,6 @@
 import java.net.URL;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.HashMap;
 import java.util.LinkedHashSet;
 import java.util.LinkedList;
 import java.util.List;
@@ -30,6 +29,7 @@
 import java.util.Set;
 import java.util.concurrent.BlockingDeque;
 import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.LinkedBlockingDeque;
 import java.util.concurrent.ThreadFactory;
@@ -68,9 +68,9 @@
  * {@link #initialize(String[])} directly if your SpringApplication arguments are not
  * identical to your main method arguments.
  * <p>
- * By default, applications running in an IDE (i.e. those not packaged as "fat jars") will
- * automatically detect URLs that can change. It's also possible to manually configure
- * URLs or class file updates for remote restart scenarios.
+ * By default, applications running in an IDE (i.e. those not packaged as "uber jars")
+ * will automatically detect URLs that can change. It's also possible to manually
+ * configure URLs or class file updates for remote restart scenarios.
  *
  * @author Phillip Webb
  * @author Andy Wilkinson
@@ -92,7 +92,7 @@ public class Restarter {
 
 	private final ClassLoaderFiles classLoaderFiles = new ClassLoaderFiles();
 
-	private final Map<String, Object> attributes = new HashMap<>();
+	private final Map<String, Object> attributes = new ConcurrentHashMap<>();
 
 	private final BlockingDeque<LeakSafeThread> leakSafeThreads = new LinkedBlockingDeque<>();
 
@@ -408,7 +408,7 @@ boolean isFinished() {
 	}
 
 	void prepare(ConfigurableApplicationContext applicationContext) {
-		if (applicationContext != null && applicationContext.getParent() != null) {
+		if (!this.enabled || (applicationContext != null && applicationContext.getParent() != null)) {
 			return;
 		}
 		if (applicationContext instanceof GenericApplicationContext genericContext) {
@@ -440,18 +440,11 @@ private LeakSafeThread getLeakSafeThread() {
 	}
 
 	public Object getOrAddAttribute(String name, final ObjectFactory<?> objectFactory) {
-		synchronized (this.attributes) {
-			if (!this.attributes.containsKey(name)) {
-				this.attributes.put(name, objectFactory.getObject());
-			}
-			return this.attributes.get(name);
-		}
+		return this.attributes.computeIfAbsent(name, (ignore) -> objectFactory.getObject());
 	}
 
 	public Object removeAttribute(String name) {
-		synchronized (this.attributes) {
-			return this.attributes.remove(name);
-		}
+		return this.attributes.remove(name);
 	}
 
 	/**
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFiles.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFiles.java
index e611e5f39827..06e7d8f3f97b 100644
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFiles.java
+++ b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFiles.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -108,12 +108,7 @@ private void removeAll(String name) {
 	 * @return an existing or newly added {@link SourceDirectory}
 	 */
 	protected final SourceDirectory getOrCreateSourceDirectory(String name) {
-		SourceDirectory sourceDirectory = this.sourceDirectories.get(name);
-		if (sourceDirectory == null) {
-			sourceDirectory = new SourceDirectory(name);
-			this.sourceDirectories.put(name, sourceDirectory);
-		}
-		return sourceDirectory;
+		return this.sourceDirectories.computeIfAbsent(name, (key) -> new SourceDirectory(name));
 	}
 
 	/**
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/client/HttpTunnelConnection.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/client/HttpTunnelConnection.java
deleted file mode 100644
index 11c26ae84caa..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/client/HttpTunnelConnection.java
+++ /dev/null
@@ -1,219 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.devtools.tunnel.client;
-
-import java.io.Closeable;
-import java.io.IOException;
-import java.net.ConnectException;
-import java.net.MalformedURLException;
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.net.URL;
-import java.nio.ByteBuffer;
-import java.nio.channels.WritableByteChannel;
-import java.util.concurrent.Executor;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ThreadFactory;
-import java.util.concurrent.atomic.AtomicLong;
-
-import org.apache.commons.logging.Log;
-import org.apache.commons.logging.LogFactory;
-
-import org.springframework.boot.devtools.tunnel.payload.HttpTunnelPayload;
-import org.springframework.boot.devtools.tunnel.payload.HttpTunnelPayloadForwarder;
-import org.springframework.core.log.LogMessage;
-import org.springframework.http.HttpMethod;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.client.ClientHttpRequest;
-import org.springframework.http.client.ClientHttpRequestFactory;
-import org.springframework.http.client.ClientHttpResponse;
-import org.springframework.util.Assert;
-
-/**
- * {@link TunnelConnection} implementation that uses HTTP to transfer data.
- *
- * @author Phillip Webb
- * @author Rob Winch
- * @author Andy Wilkinson
- * @since 1.3.0
- * @see TunnelClient
- * @see org.springframework.boot.devtools.tunnel.server.HttpTunnelServer
- */
-public class HttpTunnelConnection implements TunnelConnection {
-
-	private static final Log logger = LogFactory.getLog(HttpTunnelConnection.class);
-
-	private final URI uri;
-
-	private final ClientHttpRequestFactory requestFactory;
-
-	private final Executor executor;
-
-	/**
-	 * Create a new {@link HttpTunnelConnection} instance.
-	 * @param url the URL to connect to
-	 * @param requestFactory the HTTP request factory
-	 */
-	public HttpTunnelConnection(String url, ClientHttpRequestFactory requestFactory) {
-		this(url, requestFactory, null);
-	}
-
-	/**
-	 * Create a new {@link HttpTunnelConnection} instance.
-	 * @param url the URL to connect to
-	 * @param requestFactory the HTTP request factory
-	 * @param executor the executor used to handle connections
-	 */
-	protected HttpTunnelConnection(String url, ClientHttpRequestFactory requestFactory, Executor executor) {
-		Assert.hasLength(url, "URL must not be empty");
-		Assert.notNull(requestFactory, "RequestFactory must not be null");
-		try {
-			this.uri = new URL(url).toURI();
-		}
-		catch (URISyntaxException | MalformedURLException ex) {
-			throw new IllegalArgumentException("Malformed URL '" + url + "'");
-		}
-		this.requestFactory = requestFactory;
-		this.executor = (executor != null) ? executor : Executors.newCachedThreadPool(new TunnelThreadFactory());
-	}
-
-	@Override
-	public TunnelChannel open(WritableByteChannel incomingChannel, Closeable closeable) throws Exception {
-		logger.trace(LogMessage.format("Opening HTTP tunnel to %s", this.uri));
-		return new TunnelChannel(incomingChannel, closeable);
-	}
-
-	protected final ClientHttpRequest createRequest(boolean hasPayload) throws IOException {
-		HttpMethod method = hasPayload ? HttpMethod.POST : HttpMethod.GET;
-		return this.requestFactory.createRequest(this.uri, method);
-	}
-
-	/**
-	 * A {@link WritableByteChannel} used to transfer traffic.
-	 */
-	protected class TunnelChannel implements WritableByteChannel {
-
-		private final HttpTunnelPayloadForwarder forwarder;
-
-		private final Closeable closeable;
-
-		private boolean open = true;
-
-		private final AtomicLong requestSeq = new AtomicLong();
-
-		public TunnelChannel(WritableByteChannel incomingChannel, Closeable closeable) {
-			this.forwarder = new HttpTunnelPayloadForwarder(incomingChannel);
-			this.closeable = closeable;
-			openNewConnection(null);
-		}
-
-		@Override
-		public boolean isOpen() {
-			return this.open;
-		}
-
-		@Override
-		public void close() throws IOException {
-			if (this.open) {
-				this.open = false;
-				this.closeable.close();
-			}
-		}
-
-		@Override
-		public int write(ByteBuffer src) throws IOException {
-			int size = src.remaining();
-			if (size > 0) {
-				openNewConnection(new HttpTunnelPayload(this.requestSeq.incrementAndGet(), src));
-			}
-			return size;
-		}
-
-		private void openNewConnection(HttpTunnelPayload payload) {
-			HttpTunnelConnection.this.executor.execute(new Runnable() {
-
-				@Override
-				public void run() {
-					try {
-						sendAndReceive(payload);
-					}
-					catch (IOException ex) {
-						if (ex instanceof ConnectException) {
-							logger.warn(LogMessage.format("Failed to connect to remote application at %s",
-									HttpTunnelConnection.this.uri));
-						}
-						else {
-							logger.trace("Unexpected connection error", ex);
-						}
-						closeQuietly();
-					}
-				}
-
-				private void closeQuietly() {
-					try {
-						close();
-					}
-					catch (IOException ex) {
-						// Ignore
-					}
-				}
-
-			});
-		}
-
-		private void sendAndReceive(HttpTunnelPayload payload) throws IOException {
-			ClientHttpRequest request = createRequest(payload != null);
-			if (payload != null) {
-				payload.logIncoming();
-				payload.assignTo(request);
-			}
-			handleResponse(request.execute());
-		}
-
-		private void handleResponse(ClientHttpResponse response) throws IOException {
-			if (response.getStatusCode() == HttpStatus.GONE) {
-				close();
-				return;
-			}
-			if (response.getStatusCode() == HttpStatus.OK) {
-				HttpTunnelPayload payload = HttpTunnelPayload.get(response);
-				if (payload != null) {
-					this.forwarder.forward(payload);
-				}
-			}
-			if (response.getStatusCode() != HttpStatus.TOO_MANY_REQUESTS) {
-				openNewConnection(null);
-			}
-		}
-
-	}
-
-	/**
-	 * {@link ThreadFactory} used to create the tunnel thread.
-	 */
-	private static class TunnelThreadFactory implements ThreadFactory {
-
-		@Override
-		public Thread newThread(Runnable runnable) {
-			Thread thread = new Thread(runnable, "HTTP Tunnel Connection");
-			thread.setDaemon(true);
-			return thread;
-		}
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/client/TunnelClient.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/client/TunnelClient.java
deleted file mode 100644
index e4a439492d3c..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/client/TunnelClient.java
+++ /dev/null
@@ -1,223 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.devtools.tunnel.client;
-
-import java.io.Closeable;
-import java.io.IOException;
-import java.net.InetSocketAddress;
-import java.net.ServerSocket;
-import java.nio.ByteBuffer;
-import java.nio.channels.AsynchronousCloseException;
-import java.nio.channels.ServerSocketChannel;
-import java.nio.channels.SocketChannel;
-import java.nio.channels.WritableByteChannel;
-
-import org.apache.commons.logging.Log;
-import org.apache.commons.logging.LogFactory;
-
-import org.springframework.beans.factory.SmartInitializingSingleton;
-import org.springframework.core.log.LogMessage;
-import org.springframework.util.Assert;
-
-/**
- * The client side component of a socket tunnel. Starts a {@link ServerSocket} of the
- * specified port for local clients to connect to.
- *
- * @author Phillip Webb
- * @author Andy Wilkinson
- * @since 1.3.0
- */
-public class TunnelClient implements SmartInitializingSingleton {
-
-	private static final int BUFFER_SIZE = 1024 * 100;
-
-	private static final Log logger = LogFactory.getLog(TunnelClient.class);
-
-	private final TunnelClientListeners listeners = new TunnelClientListeners();
-
-	private final Object monitor = new Object();
-
-	private final int listenPort;
-
-	private final TunnelConnection tunnelConnection;
-
-	private ServerThread serverThread;
-
-	public TunnelClient(int listenPort, TunnelConnection tunnelConnection) {
-		Assert.isTrue(listenPort >= 0, "ListenPort must be greater than or equal to 0");
-		Assert.notNull(tunnelConnection, "TunnelConnection must not be null");
-		this.listenPort = listenPort;
-		this.tunnelConnection = tunnelConnection;
-	}
-
-	@Override
-	public void afterSingletonsInstantiated() {
-		synchronized (this.monitor) {
-			if (this.serverThread == null) {
-				try {
-					start();
-				}
-				catch (IOException ex) {
-					throw new IllegalStateException(ex);
-				}
-			}
-		}
-	}
-
-	/**
-	 * Start the client and accept incoming connections.
-	 * @return the port on which the client is listening
-	 * @throws IOException in case of I/O errors
-	 */
-	public int start() throws IOException {
-		synchronized (this.monitor) {
-			Assert.state(this.serverThread == null, "Server already started");
-			ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
-			serverSocketChannel.socket().bind(new InetSocketAddress(this.listenPort));
-			int port = serverSocketChannel.socket().getLocalPort();
-			logger.trace(LogMessage.format("Listening for TCP traffic to tunnel on port %s", port));
-			this.serverThread = new ServerThread(serverSocketChannel);
-			this.serverThread.start();
-			return port;
-		}
-	}
-
-	/**
-	 * Stop the client, disconnecting any servers.
-	 * @throws IOException in case of I/O errors
-	 */
-	public void stop() throws IOException {
-		synchronized (this.monitor) {
-			if (this.serverThread != null) {
-				this.serverThread.close();
-				try {
-					this.serverThread.join(2000);
-				}
-				catch (InterruptedException ex) {
-					Thread.currentThread().interrupt();
-				}
-				this.serverThread = null;
-			}
-		}
-	}
-
-	protected final ServerThread getServerThread() {
-		synchronized (this.monitor) {
-			return this.serverThread;
-		}
-	}
-
-	public void addListener(TunnelClientListener listener) {
-		this.listeners.addListener(listener);
-	}
-
-	public void removeListener(TunnelClientListener listener) {
-		this.listeners.removeListener(listener);
-	}
-
-	/**
-	 * The main server thread.
-	 */
-	protected class ServerThread extends Thread {
-
-		private final ServerSocketChannel serverSocketChannel;
-
-		private boolean acceptConnections = true;
-
-		public ServerThread(ServerSocketChannel serverSocketChannel) {
-			this.serverSocketChannel = serverSocketChannel;
-			setName("Tunnel Server");
-			setDaemon(true);
-		}
-
-		public void close() throws IOException {
-			logger.trace(LogMessage.format("Closing tunnel client on port %s",
-					this.serverSocketChannel.socket().getLocalPort()));
-			this.serverSocketChannel.close();
-			this.acceptConnections = false;
-			interrupt();
-		}
-
-		@Override
-		public void run() {
-			try {
-				while (this.acceptConnections) {
-					try (SocketChannel socket = this.serverSocketChannel.accept()) {
-						handleConnection(socket);
-					}
-					catch (AsynchronousCloseException ex) {
-						// Connection has been closed. Keep the server running
-					}
-				}
-			}
-			catch (Exception ex) {
-				logger.trace("Unexpected exception from tunnel client", ex);
-			}
-		}
-
-		private void handleConnection(SocketChannel socketChannel) throws Exception {
-			Closeable closeable = new SocketCloseable(socketChannel);
-			TunnelClient.this.listeners.fireOpenEvent(socketChannel);
-			try (WritableByteChannel outputChannel = TunnelClient.this.tunnelConnection.open(socketChannel,
-					closeable)) {
-				logger.trace(
-						"Accepted connection to tunnel client from " + socketChannel.socket().getRemoteSocketAddress());
-				while (true) {
-					ByteBuffer buffer = ByteBuffer.allocate(BUFFER_SIZE);
-					int amountRead = socketChannel.read(buffer);
-					if (amountRead == -1) {
-						return;
-					}
-					if (amountRead > 0) {
-						buffer.flip();
-						outputChannel.write(buffer);
-					}
-				}
-			}
-		}
-
-		protected void stopAcceptingConnections() {
-			this.acceptConnections = false;
-		}
-
-	}
-
-	/**
-	 * {@link Closeable} used to close a {@link SocketChannel} and fire an event.
-	 */
-	private class SocketCloseable implements Closeable {
-
-		private final SocketChannel socketChannel;
-
-		private boolean closed = false;
-
-		SocketCloseable(SocketChannel socketChannel) {
-			this.socketChannel = socketChannel;
-		}
-
-		@Override
-		public void close() throws IOException {
-			if (!this.closed) {
-				this.socketChannel.close();
-				TunnelClient.this.listeners.fireCloseEvent(this.socketChannel);
-				this.closed = true;
-			}
-		}
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/client/TunnelClientListener.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/client/TunnelClientListener.java
deleted file mode 100644
index 78b2e85c6881..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/client/TunnelClientListener.java
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.devtools.tunnel.client;
-
-import java.nio.channels.SocketChannel;
-
-/**
- * Listener that can be used to receive {@link TunnelClient} events.
- *
- * @author Phillip Webb
- * @since 1.3.0
- */
-public interface TunnelClientListener {
-
-	/**
-	 * Called when a socket channel is opened.
-	 * @param socket the socket channel
-	 */
-	void onOpen(SocketChannel socket);
-
-	/**
-	 * Called when a socket channel is closed.
-	 * @param socket the socket channel
-	 */
-	void onClose(SocketChannel socket);
-
-}
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/client/TunnelClientListeners.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/client/TunnelClientListeners.java
deleted file mode 100644
index 699ce900f42f..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/client/TunnelClientListeners.java
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.devtools.tunnel.client;
-
-import java.nio.channels.SocketChannel;
-import java.util.List;
-import java.util.concurrent.CopyOnWriteArrayList;
-
-import org.springframework.util.Assert;
-
-/**
- * A collection of {@link TunnelClientListener}.
- *
- * @author Phillip Webb
- */
-class TunnelClientListeners {
-
-	private final List<TunnelClientListener> listeners = new CopyOnWriteArrayList<>();
-
-	void addListener(TunnelClientListener listener) {
-		Assert.notNull(listener, "Listener must not be null");
-		this.listeners.add(listener);
-	}
-
-	void removeListener(TunnelClientListener listener) {
-		Assert.notNull(listener, "Listener must not be null");
-		this.listeners.remove(listener);
-	}
-
-	void fireOpenEvent(SocketChannel socket) {
-		for (TunnelClientListener listener : this.listeners) {
-			listener.onOpen(socket);
-		}
-	}
-
-	void fireCloseEvent(SocketChannel socket) {
-		for (TunnelClientListener listener : this.listeners) {
-			listener.onClose(socket);
-		}
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/client/TunnelConnection.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/client/TunnelConnection.java
deleted file mode 100644
index 555ef4730bbf..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/client/TunnelConnection.java
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.devtools.tunnel.client;
-
-import java.io.Closeable;
-import java.nio.channels.WritableByteChannel;
-
-/**
- * Interface used to manage socket tunnel connections.
- *
- * @author Phillip Webb
- * @since 1.3.0
- */
-@FunctionalInterface
-public interface TunnelConnection {
-
-	/**
-	 * Open the tunnel connection.
-	 * @param incomingChannel a {@link WritableByteChannel} that should be used to write
-	 * any incoming data received from the remote server
-	 * @param closeable a closeable to call when the channel is closed
-	 * @return a {@link WritableByteChannel} that should be used to send any outgoing data
-	 * destined for the remote server
-	 * @throws Exception in case of errors
-	 */
-	WritableByteChannel open(WritableByteChannel incomingChannel, Closeable closeable) throws Exception;
-
-}
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/client/package-info.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/client/package-info.java
deleted file mode 100644
index c60d12df8f44..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/client/package-info.java
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * Client side TCP tunnel support.
- */
-package org.springframework.boot.devtools.tunnel.client;
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/package-info.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/package-info.java
deleted file mode 100644
index 9ce796447134..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/package-info.java
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * Provides support for tunneling TCP traffic over HTTP. Tunneling is primarily designed
- * for the Java Debug Wire Protocol (JDWP) and as such only expects a single connection
- * and isn't particularly worried about resource usage.
- */
-package org.springframework.boot.devtools.tunnel;
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/payload/HttpTunnelPayload.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/payload/HttpTunnelPayload.java
deleted file mode 100644
index 3bd763a78f7e..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/payload/HttpTunnelPayload.java
+++ /dev/null
@@ -1,180 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.devtools.tunnel.payload;
-
-import java.io.IOException;
-import java.io.InterruptedIOException;
-import java.nio.ByteBuffer;
-import java.nio.channels.Channels;
-import java.nio.channels.ReadableByteChannel;
-import java.nio.channels.WritableByteChannel;
-import java.util.HexFormat;
-
-import org.apache.commons.logging.Log;
-import org.apache.commons.logging.LogFactory;
-
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.HttpInputMessage;
-import org.springframework.http.HttpOutputMessage;
-import org.springframework.http.MediaType;
-import org.springframework.util.Assert;
-import org.springframework.util.StringUtils;
-
-/**
- * Encapsulates a payload data sent over a HTTP tunnel.
- *
- * @author Phillip Webb
- * @since 1.3.0
- */
-public class HttpTunnelPayload {
-
-	private static final String SEQ_HEADER = "x-seq";
-
-	private static final int BUFFER_SIZE = 1024 * 100;
-
-	private static final HexFormat HEX_FORMAT = HexFormat.of().withUpperCase();
-
-	private static final Log logger = LogFactory.getLog(HttpTunnelPayload.class);
-
-	private final long sequence;
-
-	private final ByteBuffer data;
-
-	/**
-	 * Create a new {@link HttpTunnelPayload} instance.
-	 * @param sequence the sequence number of the payload
-	 * @param data the payload data
-	 */
-	public HttpTunnelPayload(long sequence, ByteBuffer data) {
-		Assert.isTrue(sequence > 0, "Sequence must be positive");
-		Assert.notNull(data, "Data must not be null");
-		this.sequence = sequence;
-		this.data = data;
-	}
-
-	/**
-	 * Return the sequence number of the payload.
-	 * @return the sequence
-	 */
-	public long getSequence() {
-		return this.sequence;
-	}
-
-	/**
-	 * Assign this payload to the given {@link HttpOutputMessage}.
-	 * @param message the message to assign this payload to
-	 * @throws IOException in case of I/O errors
-	 */
-	public void assignTo(HttpOutputMessage message) throws IOException {
-		Assert.notNull(message, "Message must not be null");
-		HttpHeaders headers = message.getHeaders();
-		headers.setContentLength(this.data.remaining());
-		headers.add(SEQ_HEADER, Long.toString(getSequence()));
-		headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
-		try (WritableByteChannel body = Channels.newChannel(message.getBody())) {
-			while (this.data.hasRemaining()) {
-				body.write(this.data);
-			}
-		}
-	}
-
-	/**
-	 * Write the content of this payload to the given target channel.
-	 * @param channel the channel to write to
-	 * @throws IOException in case of I/O errors
-	 */
-	public void writeTo(WritableByteChannel channel) throws IOException {
-		Assert.notNull(channel, "Channel must not be null");
-		while (this.data.hasRemaining()) {
-			channel.write(this.data);
-		}
-	}
-
-	/**
-	 * Return the {@link HttpTunnelPayload} for the given message or {@code null} if there
-	 * is no payload.
-	 * @param message the HTTP message
-	 * @return the payload or {@code null}
-	 * @throws IOException in case of I/O errors
-	 */
-	public static HttpTunnelPayload get(HttpInputMessage message) throws IOException {
-		long length = message.getHeaders().getContentLength();
-		if (length <= 0) {
-			return null;
-		}
-		String seqHeader = message.getHeaders().getFirst(SEQ_HEADER);
-		Assert.state(StringUtils.hasLength(seqHeader), "Missing sequence header");
-		try (ReadableByteChannel body = Channels.newChannel(message.getBody())) {
-			ByteBuffer payload = ByteBuffer.allocate((int) length);
-			while (payload.hasRemaining()) {
-				body.read(payload);
-			}
-			payload.flip();
-			return new HttpTunnelPayload(Long.parseLong(seqHeader), payload);
-		}
-	}
-
-	/**
-	 * Return the payload data for the given source {@link ReadableByteChannel} or null if
-	 * the channel timed out whilst reading.
-	 * @param channel the source channel
-	 * @return payload data or {@code null}
-	 * @throws IOException in case of I/O errors
-	 */
-	public static ByteBuffer getPayloadData(ReadableByteChannel channel) throws IOException {
-		ByteBuffer buffer = ByteBuffer.allocate(BUFFER_SIZE);
-		try {
-			int amountRead = channel.read(buffer);
-			Assert.state(amountRead != -1, "Target server connection closed");
-			buffer.flip();
-			return buffer;
-		}
-		catch (InterruptedIOException ex) {
-			return null;
-		}
-	}
-
-	/**
-	 * Log incoming payload information at trace level to aid diagnostics.
-	 */
-	public void logIncoming() {
-		log("< ");
-	}
-
-	/**
-	 * Log incoming payload information at trace level to aid diagnostics.
-	 */
-	public void logOutgoing() {
-		log("> ");
-	}
-
-	private void log(String prefix) {
-		if (logger.isTraceEnabled()) {
-			logger.trace(prefix + toHexString());
-		}
-	}
-
-	/**
-	 * Return the payload as a hexadecimal string.
-	 * @return the payload as a hex string
-	 */
-	public String toHexString() {
-		byte[] bytes = this.data.array();
-		return HEX_FORMAT.formatHex(bytes);
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/payload/HttpTunnelPayloadForwarder.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/payload/HttpTunnelPayloadForwarder.java
deleted file mode 100644
index 0bf486fcaa2d..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/payload/HttpTunnelPayloadForwarder.java
+++ /dev/null
@@ -1,72 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.devtools.tunnel.payload;
-
-import java.io.IOException;
-import java.nio.channels.WritableByteChannel;
-import java.util.HashMap;
-import java.util.Map;
-
-import org.springframework.util.Assert;
-
-/**
- * Utility class that forwards {@link HttpTunnelPayload} instances to a destination
- * channel, respecting sequence order.
- *
- * @author Phillip Webb
- * @since 1.3.0
- */
-public class HttpTunnelPayloadForwarder {
-
-	private static final int MAXIMUM_QUEUE_SIZE = 100;
-
-	private final Map<Long, HttpTunnelPayload> queue = new HashMap<>();
-
-	private final Object monitor = new Object();
-
-	private final WritableByteChannel targetChannel;
-
-	private long lastRequestSeq = 0;
-
-	/**
-	 * Create a new {@link HttpTunnelPayloadForwarder} instance.
-	 * @param targetChannel the target channel
-	 */
-	public HttpTunnelPayloadForwarder(WritableByteChannel targetChannel) {
-		Assert.notNull(targetChannel, "TargetChannel must not be null");
-		this.targetChannel = targetChannel;
-	}
-
-	public void forward(HttpTunnelPayload payload) throws IOException {
-		synchronized (this.monitor) {
-			long seq = payload.getSequence();
-			if (this.lastRequestSeq != seq - 1) {
-				Assert.state(this.queue.size() < MAXIMUM_QUEUE_SIZE, "Too many messages queued");
-				this.queue.put(seq, payload);
-				return;
-			}
-			payload.logOutgoing();
-			payload.writeTo(this.targetChannel);
-			this.lastRequestSeq = seq;
-			HttpTunnelPayload queuedItem = this.queue.get(seq + 1);
-			if (queuedItem != null) {
-				forward(queuedItem);
-			}
-		}
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/payload/package-info.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/payload/package-info.java
deleted file mode 100644
index e67868b3563b..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/payload/package-info.java
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * Copyright 2012-2021 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * Classes to deal with payloads sent over an HTTP tunnel.
- */
-package org.springframework.boot.devtools.tunnel.payload;
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/server/HttpTunnelServer.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/server/HttpTunnelServer.java
deleted file mode 100644
index b03e724dfbd8..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/server/HttpTunnelServer.java
+++ /dev/null
@@ -1,488 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.devtools.tunnel.server;
-
-import java.io.IOException;
-import java.net.ConnectException;
-import java.nio.ByteBuffer;
-import java.nio.channels.ByteChannel;
-import java.util.ArrayDeque;
-import java.util.Deque;
-import java.util.Iterator;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicLong;
-
-import org.apache.commons.logging.Log;
-import org.apache.commons.logging.LogFactory;
-
-import org.springframework.boot.devtools.tunnel.payload.HttpTunnelPayload;
-import org.springframework.boot.devtools.tunnel.payload.HttpTunnelPayloadForwarder;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
-import org.springframework.http.server.ServerHttpAsyncRequestControl;
-import org.springframework.http.server.ServerHttpRequest;
-import org.springframework.http.server.ServerHttpResponse;
-import org.springframework.util.Assert;
-
-/**
- * A server that can be used to tunnel TCP traffic over HTTP. Similar in design to the
- * <a href="https://xmpp.org/extensions/xep-0124.html">Bidirectional-streams Over
- * Synchronous HTTP (BOSH)</a> XMPP extension protocol, the server uses long polling with
- * HTTP requests held open until a response is available. A typical traffic pattern would
- * be as follows:
- *
- * <pre>
- * [ CLIENT ]                      [ SERVER ]
- *     | (a) Initial empty request     |
- *     |------------------------------&gt;|
- *     | (b) Data I                    |
- *  --&gt;|------------------------------&gt;|---&gt;
- *     | Response I (a)                |
- *  &lt;--|&lt;------------------------------|&lt;---
- *     |                               |
- *     | (c) Data II                   |
- *  --&gt;|------------------------------&gt;|---&gt;
- *     | Response II (b)               |
- *  &lt;--|&lt;------------------------------|&lt;---
- *     .                               .
- *     .                               .
- * </pre>
- *
- * Each incoming request is held open to be used to carry the next available response. The
- * server will hold at most two connections open at any given time.
- * <p>
- * Requests should be made using HTTP GET or POST (depending if there is a payload), with
- * any payload contained in the body. The following response codes can be returned from
- * the server:
- * <table>
- * <caption>Response Codes</caption>
- * <tr>
- * <th>Status</th>
- * <th>Meaning</th>
- * </tr>
- * <tr>
- * <td>200 (OK)</td>
- * <td>Data payload response.</td>
- * </tr>
- * <tr>
- * <td>204 (No Content)</td>
- * <td>The long poll has timed out and the client should start a new request.</td>
- * </tr>
- * <tr>
- * <td>429 (Too many requests)</td>
- * <td>There are already enough connections open, this one can be dropped.</td>
- * </tr>
- * <tr>
- * <td>410 (Gone)</td>
- * <td>The target server has disconnected.</td>
- * </tr>
- * <tr>
- * <td>503 (Service Unavailable)</td>
- * <td>The target server is unavailable</td>
- * </tr>
- * </table>
- * <p>
- * Requests and responses that contain payloads include a {@code x-seq} header that
- * contains a running sequence number (used to ensure data is applied in the correct
- * order). The first request containing a payload should have a {@code x-seq} value of
- * {@code 1}.
- *
- * @author Phillip Webb
- * @author Andy Wilkinson
- * @since 1.3.0
- * @see org.springframework.boot.devtools.tunnel.client.HttpTunnelConnection
- */
-public class HttpTunnelServer {
-
-	private static final long DEFAULT_LONG_POLL_TIMEOUT = TimeUnit.SECONDS.toMillis(10);
-
-	private static final long DEFAULT_DISCONNECT_TIMEOUT = TimeUnit.SECONDS.toMillis(30);
-
-	private static final MediaType DISCONNECT_MEDIA_TYPE = new MediaType("application", "x-disconnect");
-
-	private static final Log logger = LogFactory.getLog(HttpTunnelServer.class);
-
-	private final TargetServerConnection serverConnection;
-
-	private int longPollTimeout = (int) DEFAULT_LONG_POLL_TIMEOUT;
-
-	private long disconnectTimeout = DEFAULT_DISCONNECT_TIMEOUT;
-
-	private volatile ServerThread serverThread;
-
-	/**
-	 * Creates a new {@link HttpTunnelServer} instance.
-	 * @param serverConnection the connection to the target server
-	 */
-	public HttpTunnelServer(TargetServerConnection serverConnection) {
-		Assert.notNull(serverConnection, "ServerConnection must not be null");
-		this.serverConnection = serverConnection;
-	}
-
-	/**
-	 * Handle an incoming HTTP connection.
-	 * @param request the HTTP request
-	 * @param response the HTTP response
-	 * @throws IOException in case of I/O errors
-	 */
-	public void handle(ServerHttpRequest request, ServerHttpResponse response) throws IOException {
-		handle(new HttpConnection(request, response));
-	}
-
-	/**
-	 * Handle an incoming HTTP connection.
-	 * @param httpConnection the HTTP connection
-	 * @throws IOException in case of I/O errors
-	 */
-	protected void handle(HttpConnection httpConnection) throws IOException {
-		try {
-			getServerThread().handleIncomingHttp(httpConnection);
-			httpConnection.waitForResponse();
-		}
-		catch (ConnectException ex) {
-			httpConnection.respond(HttpStatus.GONE);
-		}
-	}
-
-	/**
-	 * Returns the active server thread, creating and starting it if necessary.
-	 * @return the {@code ServerThread} (never {@code null})
-	 * @throws IOException in case of I/O errors
-	 */
-	protected ServerThread getServerThread() throws IOException {
-		synchronized (this) {
-			if (this.serverThread == null) {
-				ByteChannel channel = this.serverConnection.open(this.longPollTimeout);
-				this.serverThread = new ServerThread(channel);
-				this.serverThread.start();
-			}
-			return this.serverThread;
-		}
-	}
-
-	/**
-	 * Called when the server thread exits.
-	 */
-	void clearServerThread() {
-		synchronized (this) {
-			this.serverThread = null;
-		}
-	}
-
-	/**
-	 * Set the long poll timeout for the server.
-	 * @param longPollTimeout the long poll timeout in milliseconds
-	 */
-	public void setLongPollTimeout(int longPollTimeout) {
-		Assert.isTrue(longPollTimeout > 0, "LongPollTimeout must be a positive value");
-		this.longPollTimeout = longPollTimeout;
-	}
-
-	/**
-	 * Set the maximum amount of time to wait for a client before closing the connection.
-	 * @param disconnectTimeout the disconnect timeout in milliseconds
-	 */
-	public void setDisconnectTimeout(long disconnectTimeout) {
-		Assert.isTrue(disconnectTimeout > 0, "DisconnectTimeout must be a positive value");
-		this.disconnectTimeout = disconnectTimeout;
-	}
-
-	/**
-	 * The main server thread used to transfer tunnel traffic.
-	 */
-	protected class ServerThread extends Thread {
-
-		private final ByteChannel targetServer;
-
-		private final Deque<HttpConnection> httpConnections;
-
-		private final HttpTunnelPayloadForwarder payloadForwarder;
-
-		private boolean closed;
-
-		private final AtomicLong responseSeq = new AtomicLong();
-
-		private long lastHttpRequestTime;
-
-		public ServerThread(ByteChannel targetServer) {
-			Assert.notNull(targetServer, "TargetServer must not be null");
-			this.targetServer = targetServer;
-			this.httpConnections = new ArrayDeque<>(2);
-			this.payloadForwarder = new HttpTunnelPayloadForwarder(targetServer);
-		}
-
-		@Override
-		public void run() {
-			try {
-				try {
-					readAndForwardTargetServerData();
-				}
-				catch (Exception ex) {
-					logger.trace("Unexpected exception from tunnel server", ex);
-				}
-			}
-			finally {
-				this.closed = true;
-				closeHttpConnections();
-				closeTargetServer();
-				HttpTunnelServer.this.clearServerThread();
-			}
-		}
-
-		private void readAndForwardTargetServerData() throws IOException {
-			while (this.targetServer.isOpen()) {
-				closeStaleHttpConnections();
-				ByteBuffer data = HttpTunnelPayload.getPayloadData(this.targetServer);
-				synchronized (this.httpConnections) {
-					if (data != null) {
-						HttpTunnelPayload payload = new HttpTunnelPayload(this.responseSeq.incrementAndGet(), data);
-						payload.logIncoming();
-						HttpConnection connection = getOrWaitForHttpConnection();
-						connection.respond(payload);
-					}
-				}
-			}
-		}
-
-		private HttpConnection getOrWaitForHttpConnection() {
-			synchronized (this.httpConnections) {
-				HttpConnection httpConnection = this.httpConnections.pollFirst();
-				while (httpConnection == null) {
-					try {
-						this.httpConnections.wait(HttpTunnelServer.this.longPollTimeout);
-					}
-					catch (InterruptedException ex) {
-						Thread.currentThread().interrupt();
-						closeHttpConnections();
-					}
-					httpConnection = this.httpConnections.pollFirst();
-				}
-				return httpConnection;
-			}
-		}
-
-		private void closeStaleHttpConnections() throws IOException {
-			synchronized (this.httpConnections) {
-				checkNotDisconnected();
-				Iterator<HttpConnection> iterator = this.httpConnections.iterator();
-				while (iterator.hasNext()) {
-					HttpConnection httpConnection = iterator.next();
-					if (httpConnection.isOlderThan(HttpTunnelServer.this.longPollTimeout)) {
-						httpConnection.respond(HttpStatus.NO_CONTENT);
-						iterator.remove();
-					}
-				}
-			}
-		}
-
-		private void checkNotDisconnected() {
-			if (this.lastHttpRequestTime > 0) {
-				long timeout = HttpTunnelServer.this.disconnectTimeout;
-				long duration = System.currentTimeMillis() - this.lastHttpRequestTime;
-				Assert.state(duration < timeout, () -> "Disconnect timeout: " + timeout + " " + duration);
-			}
-		}
-
-		private void closeHttpConnections() {
-			synchronized (this.httpConnections) {
-				while (!this.httpConnections.isEmpty()) {
-					try {
-						this.httpConnections.removeFirst().respond(HttpStatus.GONE);
-					}
-					catch (Exception ex) {
-						logger.trace("Unable to close remote HTTP connection");
-					}
-				}
-			}
-		}
-
-		private void closeTargetServer() {
-			try {
-				this.targetServer.close();
-			}
-			catch (IOException ex) {
-				logger.trace("Unable to target server connection");
-			}
-		}
-
-		/**
-		 * Handle an incoming {@link HttpConnection}.
-		 * @param httpConnection the connection to handle.
-		 * @throws IOException in case of I/O errors
-		 */
-		public void handleIncomingHttp(HttpConnection httpConnection) throws IOException {
-			if (this.closed) {
-				httpConnection.respond(HttpStatus.GONE);
-			}
-			synchronized (this.httpConnections) {
-				while (this.httpConnections.size() > 1) {
-					this.httpConnections.removeFirst().respond(HttpStatus.TOO_MANY_REQUESTS);
-				}
-				this.lastHttpRequestTime = System.currentTimeMillis();
-				this.httpConnections.addLast(httpConnection);
-				this.httpConnections.notify();
-			}
-			forwardToTargetServer(httpConnection);
-		}
-
-		private void forwardToTargetServer(HttpConnection httpConnection) throws IOException {
-			if (httpConnection.isDisconnectRequest()) {
-				this.targetServer.close();
-				interrupt();
-			}
-			ServerHttpRequest request = httpConnection.getRequest();
-			HttpTunnelPayload payload = HttpTunnelPayload.get(request);
-			if (payload != null) {
-				this.payloadForwarder.forward(payload);
-			}
-		}
-
-	}
-
-	/**
-	 * Encapsulates an HTTP request/response pair.
-	 */
-	protected static class HttpConnection {
-
-		private final long createTime;
-
-		private final ServerHttpRequest request;
-
-		private final ServerHttpResponse response;
-
-		private final ServerHttpAsyncRequestControl async;
-
-		private volatile boolean complete = false;
-
-		public HttpConnection(ServerHttpRequest request, ServerHttpResponse response) {
-			this.createTime = System.currentTimeMillis();
-			this.request = request;
-			this.response = response;
-			this.async = startAsync();
-		}
-
-		/**
-		 * Start asynchronous support or if unavailable return {@code null} to cause
-		 * {@link #waitForResponse()} to block.
-		 * @return the async request control
-		 */
-		protected ServerHttpAsyncRequestControl startAsync() {
-			try {
-				// Try to use async to save blocking
-				ServerHttpAsyncRequestControl async = this.request.getAsyncRequestControl(this.response);
-				async.start();
-				return async;
-			}
-			catch (Exception ex) {
-				return null;
-			}
-		}
-
-		/**
-		 * Return the underlying request.
-		 * @return the request
-		 */
-		public final ServerHttpRequest getRequest() {
-			return this.request;
-		}
-
-		/**
-		 * Return the underlying response.
-		 * @return the response
-		 */
-		protected final ServerHttpResponse getResponse() {
-			return this.response;
-		}
-
-		/**
-		 * Determine if a connection is older than the specified time.
-		 * @param time the time to check
-		 * @return {@code true} if the request is older than the time
-		 */
-		public boolean isOlderThan(int time) {
-			long runningTime = System.currentTimeMillis() - this.createTime;
-			return (runningTime > time);
-		}
-
-		/**
-		 * Cause the request to block or use asynchronous methods to wait until a response
-		 * is available.
-		 */
-		public void waitForResponse() {
-			if (this.async == null) {
-				while (!this.complete) {
-					try {
-						synchronized (this) {
-							wait(1000);
-						}
-					}
-					catch (InterruptedException ex) {
-						Thread.currentThread().interrupt();
-					}
-				}
-			}
-		}
-
-		/**
-		 * Detect if the request is actually a signal to disconnect.
-		 * @return if the request is a signal to disconnect
-		 */
-		public boolean isDisconnectRequest() {
-			return DISCONNECT_MEDIA_TYPE.equals(this.request.getHeaders().getContentType());
-		}
-
-		/**
-		 * Send an HTTP status response.
-		 * @param status the status to send
-		 * @throws IOException in case of I/O errors
-		 */
-		public void respond(HttpStatus status) throws IOException {
-			Assert.notNull(status, "Status must not be null");
-			this.response.setStatusCode(status);
-			complete();
-		}
-
-		/**
-		 * Send a payload response.
-		 * @param payload the payload to send
-		 * @throws IOException in case of I/O errors
-		 */
-		public void respond(HttpTunnelPayload payload) throws IOException {
-			Assert.notNull(payload, "Payload must not be null");
-			this.response.setStatusCode(HttpStatus.OK);
-			payload.assignTo(this.response);
-			complete();
-		}
-
-		/**
-		 * Called when a request is complete.
-		 */
-		protected void complete() {
-			if (this.async != null) {
-				this.async.complete();
-			}
-			else {
-				synchronized (this) {
-					this.complete = true;
-					notifyAll();
-				}
-			}
-		}
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/server/HttpTunnelServerHandler.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/server/HttpTunnelServerHandler.java
deleted file mode 100644
index e3b3f517429c..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/server/HttpTunnelServerHandler.java
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.devtools.tunnel.server;
-
-import java.io.IOException;
-
-import org.springframework.boot.devtools.remote.server.Handler;
-import org.springframework.http.server.ServerHttpRequest;
-import org.springframework.http.server.ServerHttpResponse;
-import org.springframework.util.Assert;
-
-/**
- * Adapts a {@link HttpTunnelServer} to a {@link Handler}.
- *
- * @author Phillip Webb
- * @since 1.3.0
- */
-public class HttpTunnelServerHandler implements Handler {
-
-	private final HttpTunnelServer server;
-
-	/**
-	 * Create a new {@link HttpTunnelServerHandler} instance.
-	 * @param server the server to adapt
-	 */
-	public HttpTunnelServerHandler(HttpTunnelServer server) {
-		Assert.notNull(server, "Server must not be null");
-		this.server = server;
-	}
-
-	@Override
-	public void handle(ServerHttpRequest request, ServerHttpResponse response) throws IOException {
-		this.server.handle(request, response);
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/server/PortProvider.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/server/PortProvider.java
deleted file mode 100644
index 80ac4a207634..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/server/PortProvider.java
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.devtools.tunnel.server;
-
-/**
- * Strategy interface to provide access to a port (which may change if an existing
- * connection is closed).
- *
- * @author Phillip Webb
- * @since 1.3.0
- */
-@FunctionalInterface
-public interface PortProvider {
-
-	/**
-	 * Return the port number.
-	 * @return the port number
-	 */
-	int getPort();
-
-}
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/server/SocketTargetServerConnection.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/server/SocketTargetServerConnection.java
deleted file mode 100644
index 78aac9c1f89b..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/server/SocketTargetServerConnection.java
+++ /dev/null
@@ -1,101 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.devtools.tunnel.server;
-
-import java.io.IOException;
-import java.net.InetSocketAddress;
-import java.net.SocketAddress;
-import java.nio.ByteBuffer;
-import java.nio.channels.ByteChannel;
-import java.nio.channels.Channels;
-import java.nio.channels.ReadableByteChannel;
-import java.nio.channels.SocketChannel;
-
-import org.apache.commons.logging.Log;
-import org.apache.commons.logging.LogFactory;
-
-import org.springframework.core.log.LogMessage;
-import org.springframework.util.Assert;
-
-/**
- * Socket based {@link TargetServerConnection}.
- *
- * @author Phillip Webb
- * @since 1.3.0
- */
-public class SocketTargetServerConnection implements TargetServerConnection {
-
-	private static final Log logger = LogFactory.getLog(SocketTargetServerConnection.class);
-
-	private final PortProvider portProvider;
-
-	/**
-	 * Create a new {@link SocketTargetServerConnection}.
-	 * @param portProvider the port provider
-	 */
-	public SocketTargetServerConnection(PortProvider portProvider) {
-		Assert.notNull(portProvider, "PortProvider must not be null");
-		this.portProvider = portProvider;
-	}
-
-	@Override
-	public ByteChannel open(int socketTimeout) throws IOException {
-		SocketAddress address = new InetSocketAddress(this.portProvider.getPort());
-		logger.trace(LogMessage.format("Opening tunnel connection to target server on %s", address));
-		SocketChannel channel = SocketChannel.open(address);
-		channel.socket().setSoTimeout(socketTimeout);
-		return new TimeoutAwareChannel(channel);
-	}
-
-	/**
-	 * Wrapper to expose the {@link SocketChannel} in such a way that
-	 * {@code SocketTimeoutExceptions} are still thrown from read methods.
-	 */
-	private static class TimeoutAwareChannel implements ByteChannel {
-
-		private final SocketChannel socketChannel;
-
-		private final ReadableByteChannel readChannel;
-
-		TimeoutAwareChannel(SocketChannel socketChannel) throws IOException {
-			this.socketChannel = socketChannel;
-			this.readChannel = Channels.newChannel(socketChannel.socket().getInputStream());
-		}
-
-		@Override
-		public int read(ByteBuffer dst) throws IOException {
-			return this.readChannel.read(dst);
-		}
-
-		@Override
-		public int write(ByteBuffer src) throws IOException {
-			return this.socketChannel.write(src);
-		}
-
-		@Override
-		public boolean isOpen() {
-			return this.socketChannel.isOpen();
-		}
-
-		@Override
-		public void close() throws IOException {
-			this.socketChannel.close();
-		}
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/server/StaticPortProvider.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/server/StaticPortProvider.java
deleted file mode 100644
index 599ecb47bc91..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/server/StaticPortProvider.java
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.devtools.tunnel.server;
-
-import org.springframework.util.Assert;
-
-/**
- * {@link PortProvider} for a static port that won't change.
- *
- * @author Phillip Webb
- * @since 1.3.0
- */
-public class StaticPortProvider implements PortProvider {
-
-	private final int port;
-
-	public StaticPortProvider(int port) {
-		Assert.isTrue(port > 0, "Port must be positive");
-		this.port = port;
-	}
-
-	@Override
-	public int getPort() {
-		return this.port;
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/server/TargetServerConnection.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/server/TargetServerConnection.java
deleted file mode 100644
index 4fcf7dd83c55..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/server/TargetServerConnection.java
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.devtools.tunnel.server;
-
-import java.io.IOException;
-import java.nio.channels.ByteChannel;
-
-/**
- * Manages the connection to the ultimate tunnel target server.
- *
- * @author Phillip Webb
- * @since 1.3.0
- */
-@FunctionalInterface
-public interface TargetServerConnection {
-
-	/**
-	 * Open a connection to the target server with the specified timeout.
-	 * @param timeout the read timeout
-	 * @return a {@link ByteChannel} providing read/write access to the server
-	 * @throws IOException in case of I/O errors
-	 */
-	ByteChannel open(int timeout) throws IOException;
-
-}
diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/server/package-info.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/server/package-info.java
deleted file mode 100644
index 38e55f026056..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/tunnel/server/package-info.java
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * Server side TCP tunnel support.
- */
-package org.springframework.boot.devtools.tunnel.server;
diff --git a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/autoconfigure/DevToolsR2dbcAutoConfigurationTests.java b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/autoconfigure/DevToolsR2dbcAutoConfigurationTests.java
index 8da0ed983e55..0e9d28f7c884 100644
--- a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/autoconfigure/DevToolsR2dbcAutoConfigurationTests.java
+++ b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/autoconfigure/DevToolsR2dbcAutoConfigurationTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -152,12 +152,12 @@ private void onEvent(R2dbcDatabaseShutdownEvent event) {
 
 	@Nested
 	@ClassPathExclusions("r2dbc-pool*.jar")
-	static class Embedded extends Common {
+	class Embedded extends Common {
 
 	}
 
 	@Nested
-	static class Pooled extends Common {
+	class Pooled extends Common {
 
 	}
 
diff --git a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/integrationtest/HttpTunnelIntegrationTests.java b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/integrationtest/HttpTunnelIntegrationTests.java
deleted file mode 100644
index e55734367e1e..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/integrationtest/HttpTunnelIntegrationTests.java
+++ /dev/null
@@ -1,163 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.devtools.integrationtest;
-
-import java.io.IOException;
-import java.util.Collection;
-import java.util.Collections;
-
-import org.junit.jupiter.api.Test;
-
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.boot.devtools.integrationtest.HttpTunnelIntegrationTests.TunnelConfiguration.TestTunnelClient;
-import org.springframework.boot.devtools.remote.server.AccessManager;
-import org.springframework.boot.devtools.remote.server.Dispatcher;
-import org.springframework.boot.devtools.remote.server.DispatcherFilter;
-import org.springframework.boot.devtools.remote.server.HandlerMapper;
-import org.springframework.boot.devtools.remote.server.UrlHandlerMapper;
-import org.springframework.boot.devtools.tunnel.client.HttpTunnelConnection;
-import org.springframework.boot.devtools.tunnel.client.TunnelClient;
-import org.springframework.boot.devtools.tunnel.client.TunnelConnection;
-import org.springframework.boot.devtools.tunnel.server.HttpTunnelServer;
-import org.springframework.boot.devtools.tunnel.server.HttpTunnelServerHandler;
-import org.springframework.boot.devtools.tunnel.server.SocketTargetServerConnection;
-import org.springframework.boot.devtools.tunnel.server.TargetServerConnection;
-import org.springframework.boot.test.util.TestPropertyValues;
-import org.springframework.boot.test.web.client.TestRestTemplate;
-import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
-import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext;
-import org.springframework.boot.web.servlet.server.ServletWebServerFactory;
-import org.springframework.context.annotation.AnnotationConfigApplicationContext;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
-import org.springframework.http.client.SimpleClientHttpRequestFactory;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
-import org.springframework.web.servlet.DispatcherServlet;
-import org.springframework.web.servlet.config.annotation.EnableWebMvc;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Simple integration tests for HTTP tunneling.
- *
- * @author Phillip Webb
- */
-class HttpTunnelIntegrationTests {
-
-	@Test
-	void httpServerDirect() {
-		AnnotationConfigServletWebServerApplicationContext context = new AnnotationConfigServletWebServerApplicationContext();
-		context.register(ServerConfiguration.class);
-		context.refresh();
-		String url = "http://localhost:" + context.getWebServer().getPort() + "/hello";
-		ResponseEntity<String> entity = new TestRestTemplate().getForEntity(url, String.class);
-		assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK);
-		assertThat(entity.getBody()).isEqualTo("Hello World");
-		context.close();
-	}
-
-	@Test
-	void viaTunnel() {
-		AnnotationConfigServletWebServerApplicationContext serverContext = new AnnotationConfigServletWebServerApplicationContext();
-		serverContext.register(ServerConfiguration.class);
-		serverContext.refresh();
-		AnnotationConfigApplicationContext tunnelContext = new AnnotationConfigApplicationContext();
-		TestPropertyValues.of("server.port:" + serverContext.getWebServer().getPort()).applyTo(tunnelContext);
-		tunnelContext.register(TunnelConfiguration.class);
-		tunnelContext.refresh();
-		String url = "http://localhost:" + tunnelContext.getBean(TestTunnelClient.class).port + "/hello";
-		ResponseEntity<String> entity = new TestRestTemplate().getForEntity(url, String.class);
-		assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK);
-		assertThat(entity.getBody()).isEqualTo("Hello World");
-		serverContext.close();
-		tunnelContext.close();
-	}
-
-	@Configuration(proxyBeanMethods = false)
-	@EnableWebMvc
-	static class ServerConfiguration {
-
-		@Bean
-		ServletWebServerFactory container() {
-			return new TomcatServletWebServerFactory(0);
-		}
-
-		@Bean
-		DispatcherServlet dispatcherServlet() {
-			return new DispatcherServlet();
-		}
-
-		@Bean
-		MyController myController() {
-			return new MyController();
-		}
-
-		@Bean
-		DispatcherFilter filter(AnnotationConfigServletWebServerApplicationContext context) {
-			TargetServerConnection connection = new SocketTargetServerConnection(
-					() -> context.getWebServer().getPort());
-			HttpTunnelServer server = new HttpTunnelServer(connection);
-			HandlerMapper mapper = new UrlHandlerMapper("/httptunnel", new HttpTunnelServerHandler(server));
-			Collection<HandlerMapper> mappers = Collections.singleton(mapper);
-			Dispatcher dispatcher = new Dispatcher(AccessManager.PERMIT_ALL, mappers);
-			return new DispatcherFilter(dispatcher);
-		}
-
-	}
-
-	@org.springframework.context.annotation.Configuration(proxyBeanMethods = false)
-	static class TunnelConfiguration {
-
-		@Bean
-		TunnelClient tunnelClient(@Value("${server.port}") int serverPort) {
-			String url = "http://localhost:" + serverPort + "/httptunnel";
-			TunnelConnection connection = new HttpTunnelConnection(url, new SimpleClientHttpRequestFactory());
-			return new TestTunnelClient(0, connection);
-		}
-
-		static class TestTunnelClient extends TunnelClient {
-
-			private int port;
-
-			TestTunnelClient(int listenPort, TunnelConnection tunnelConnection) {
-				super(listenPort, tunnelConnection);
-			}
-
-			@Override
-			public int start() throws IOException {
-				this.port = super.start();
-				return this.port;
-			}
-
-		}
-
-	}
-
-	@RestController
-	static class MyController {
-
-		@RequestMapping("/hello")
-		String hello() {
-			return "Hello World";
-		}
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/ChangeableUrlsTests.java b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/ChangeableUrlsTests.java
index 354fd1d7e301..35504c892648 100644
--- a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/ChangeableUrlsTests.java
+++ b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/ChangeableUrlsTests.java
@@ -48,7 +48,7 @@ class ChangeableUrlsTests {
 	@Test
 	void directoryUrl() throws Exception {
 		URL url = makeUrl("myproject");
-		assertThat(ChangeableUrls.fromUrls(url).size()).isOne();
+		assertThat(ChangeableUrls.fromUrls(url)).hasSize(1);
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/RestartApplicationListenerTests.java b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/RestartApplicationListenerTests.java
index 308642994ca7..23468ef6dd49 100644
--- a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/RestartApplicationListenerTests.java
+++ b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/RestartApplicationListenerTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -36,7 +36,6 @@
 import org.springframework.test.util.ReflectionTestUtils;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.hamcrest.Matchers.nullValue;
 import static org.mockito.Mockito.mock;
 
 /**
@@ -66,7 +65,7 @@ void isHighestPriority() {
 
 	@Test
 	void initializeWithReady() {
-		testInitialize(false);
+		testInitialize(false, new ImplicitlyEnabledRestartApplicationListener());
 		assertThat(Restarter.getInstance()).hasFieldOrPropertyWithValue("args", ARGS);
 		assertThat(Restarter.getInstance().isFinished()).isTrue();
 		assertThat((List<?>) ReflectionTestUtils.getField(Restarter.getInstance(), "rootContexts")).isNotEmpty();
@@ -74,7 +73,7 @@ void initializeWithReady() {
 
 	@Test
 	void initializeWithFail() {
-		testInitialize(true);
+		testInitialize(true, new ImplicitlyEnabledRestartApplicationListener());
 		assertThat(Restarter.getInstance()).hasFieldOrPropertyWithValue("args", ARGS);
 		assertThat(Restarter.getInstance().isFinished()).isTrue();
 		assertThat((List<?>) ReflectionTestUtils.getField(Restarter.getInstance(), "rootContexts")).isEmpty();
@@ -83,7 +82,7 @@ void initializeWithFail() {
 	@Test
 	void disableWithSystemProperty(CapturedOutput output) {
 		System.setProperty(ENABLED_PROPERTY, "false");
-		testInitialize(false);
+		testInitialize(false, new ImplicitlyEnabledRestartApplicationListener());
 		assertThat(Restarter.getInstance()).hasFieldOrPropertyWithValue("enabled", false);
 		assertThat(output).contains("Restart disabled due to System property");
 	}
@@ -91,19 +90,33 @@ void disableWithSystemProperty(CapturedOutput output) {
 	@Test
 	void enableWithSystemProperty(CapturedOutput output) {
 		System.setProperty(ENABLED_PROPERTY, "true");
-		testInitialize(false);
+		testInitialize(false, new ImplicitlyEnabledRestartApplicationListener());
 		assertThat(Restarter.getInstance()).hasFieldOrPropertyWithValue("enabled", true);
 		assertThat(output).contains("Restart enabled irrespective of application packaging due to System property");
 	}
 
-	private void testInitialize(boolean failed) {
+	@Test
+	void enableWithSystemPropertyWhenImplicitlyDisabled(CapturedOutput output) {
+		System.setProperty(ENABLED_PROPERTY, "true");
+		testInitialize(false, new RestartApplicationListener());
+		assertThat(Restarter.getInstance()).hasFieldOrPropertyWithValue("enabled", true);
+		assertThat(output).contains("Restart enabled irrespective of application packaging due to System property");
+	}
+
+	@Test
+	void implicitlyDisabledInTests(CapturedOutput output) {
+		testInitialize(false, new RestartApplicationListener());
+		assertThat(Restarter.getInstance()).hasFieldOrPropertyWithValue("enabled", false);
+		assertThat(output).contains("Restart disabled due to context in which it is running");
+	}
+
+	private void testInitialize(boolean failed, RestartApplicationListener listener) {
 		Restarter.clearInstance();
-		RestartApplicationListener listener = new RestartApplicationListener();
 		DefaultBootstrapContext bootstrapContext = new DefaultBootstrapContext();
 		SpringApplication application = new SpringApplication();
 		ConfigurableApplicationContext context = mock(ConfigurableApplicationContext.class);
 		listener.onApplicationEvent(new ApplicationStartingEvent(bootstrapContext, application, ARGS));
-		assertThat(Restarter.getInstance()).isNotEqualTo(nullValue());
+		assertThat(Restarter.getInstance()).isNotNull();
 		assertThat(Restarter.getInstance().isFinished()).isFalse();
 		listener.onApplicationEvent(new ApplicationPreparedEvent(application, ARGS, context));
 		if (failed) {
@@ -114,4 +127,13 @@ private void testInitialize(boolean failed) {
 		}
 	}
 
+	private static class ImplicitlyEnabledRestartApplicationListener extends RestartApplicationListener {
+
+		@Override
+		boolean implicitlyEnableRestart() {
+			return true;
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/RestarterTests.java b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/RestarterTests.java
index 7c329dd78691..75689433fff6 100644
--- a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/RestarterTests.java
+++ b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/RestarterTests.java
@@ -24,6 +24,7 @@
 import java.util.concurrent.ThreadFactory;
 import java.util.concurrent.atomic.AtomicBoolean;
 
+import org.assertj.core.api.InstanceOfAssertFactories;
 import org.awaitility.Awaitility;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
@@ -37,6 +38,7 @@
 import org.springframework.boot.test.system.CapturedOutput;
 import org.springframework.boot.test.system.OutputCaptureExtension;
 import org.springframework.context.ApplicationListener;
+import org.springframework.context.ConfigurableApplicationContext;
 import org.springframework.context.annotation.AnnotationConfigApplicationContext;
 import org.springframework.context.event.ContextClosedEvent;
 import org.springframework.scheduling.annotation.EnableScheduling;
@@ -44,6 +46,7 @@
 import org.springframework.stereotype.Component;
 import org.springframework.util.StringUtils;
 
+import static org.assertj.core.api.Assertions.as;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
 import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
@@ -90,6 +93,14 @@ void testRestart(CapturedOutput output) {
 		});
 	}
 
+	@Test
+	void testDisabled() {
+		Restarter.disable();
+		ConfigurableApplicationContext context = mock(ConfigurableApplicationContext.class);
+		Restarter.getInstance().prepare(context);
+		assertThat(Restarter.getInstance()).extracting("rootContexts", as(InstanceOfAssertFactories.LIST)).isEmpty();
+	}
+
 	@Test
 	@SuppressWarnings("rawtypes")
 	void getOrAddAttributeWithNewAttribute() {
diff --git a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/SilentExitExceptionHandlerTests.java b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/SilentExitExceptionHandlerTests.java
index 9b15295a6999..1e430b091ad4 100644
--- a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/SilentExitExceptionHandlerTests.java
+++ b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/SilentExitExceptionHandlerTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -21,7 +21,7 @@
 import org.junit.jupiter.api.Test;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.fail;
+import static org.assertj.core.api.Assertions.fail;
 
 /**
  * Tests for {@link SilentExitExceptionHandler}.
diff --git a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/client/HttpTunnelConnectionTests.java b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/client/HttpTunnelConnectionTests.java
deleted file mode 100644
index b44fafba53da..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/client/HttpTunnelConnectionTests.java
+++ /dev/null
@@ -1,167 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.devtools.tunnel.client;
-
-import java.io.ByteArrayOutputStream;
-import java.io.Closeable;
-import java.io.IOException;
-import java.net.ConnectException;
-import java.nio.ByteBuffer;
-import java.nio.channels.Channels;
-import java.nio.channels.WritableByteChannel;
-import java.util.concurrent.Executor;
-
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-
-import org.springframework.boot.devtools.test.MockClientHttpRequestFactory;
-import org.springframework.boot.devtools.tunnel.client.HttpTunnelConnection.TunnelChannel;
-import org.springframework.boot.test.system.CapturedOutput;
-import org.springframework.boot.test.system.OutputCaptureExtension;
-import org.springframework.http.HttpStatus;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
-import static org.mockito.BDDMockito.then;
-import static org.mockito.Mockito.never;
-
-/**
- * Tests for {@link HttpTunnelConnection}.
- *
- * @author Phillip Webb
- * @author Rob Winch
- * @author Andy Wilkinson
- */
-@ExtendWith({ OutputCaptureExtension.class, MockitoExtension.class })
-class HttpTunnelConnectionTests {
-
-	private String url;
-
-	private ByteArrayOutputStream incomingData;
-
-	private WritableByteChannel incomingChannel;
-
-	@Mock
-	private Closeable closeable;
-
-	private final MockClientHttpRequestFactory requestFactory = new MockClientHttpRequestFactory();
-
-	@BeforeEach
-	void setup() {
-		this.url = "http://localhost:12345";
-		this.incomingData = new ByteArrayOutputStream();
-		this.incomingChannel = Channels.newChannel(this.incomingData);
-	}
-
-	@Test
-	void urlMustNotBeNull() {
-		assertThatIllegalArgumentException().isThrownBy(() -> new HttpTunnelConnection(null, this.requestFactory))
-			.withMessageContaining("URL must not be empty");
-	}
-
-	@Test
-	void urlMustNotBeEmpty() {
-		assertThatIllegalArgumentException().isThrownBy(() -> new HttpTunnelConnection("", this.requestFactory))
-			.withMessageContaining("URL must not be empty");
-	}
-
-	@Test
-	void urlMustNotBeMalformed() {
-		assertThatIllegalArgumentException()
-			.isThrownBy(() -> new HttpTunnelConnection("htttttp:///ttest", this.requestFactory))
-			.withMessageContaining("Malformed URL 'htttttp:///ttest'");
-	}
-
-	@Test
-	void requestFactoryMustNotBeNull() {
-		assertThatIllegalArgumentException().isThrownBy(() -> new HttpTunnelConnection(this.url, null))
-			.withMessageContaining("RequestFactory must not be null");
-	}
-
-	@Test
-	void closeTunnelChangesIsOpen() throws Exception {
-		this.requestFactory.willRespondAfterDelay(1000, HttpStatus.GONE);
-		WritableByteChannel channel = openTunnel(false);
-		assertThat(channel.isOpen()).isTrue();
-		channel.close();
-		assertThat(channel.isOpen()).isFalse();
-	}
-
-	@Test
-	void closeTunnelCallsCloseableOnce() throws Exception {
-		this.requestFactory.willRespondAfterDelay(1000, HttpStatus.GONE);
-		WritableByteChannel channel = openTunnel(false);
-		then(this.closeable).should(never()).close();
-		channel.close();
-		channel.close();
-		then(this.closeable).should().close();
-	}
-
-	@Test
-	void typicalTraffic() throws Exception {
-		this.requestFactory.willRespond("hi", "=2", "=3");
-		TunnelChannel channel = openTunnel(true);
-		write(channel, "hello");
-		write(channel, "1+1");
-		write(channel, "1+2");
-		assertThat(this.incomingData).hasToString("hi=2=3");
-	}
-
-	@Test
-	void trafficWithLongPollTimeouts() throws Exception {
-		for (int i = 0; i < 10; i++) {
-			this.requestFactory.willRespond(HttpStatus.NO_CONTENT);
-		}
-		this.requestFactory.willRespond("hi");
-		TunnelChannel channel = openTunnel(true);
-		write(channel, "hello");
-		assertThat(this.incomingData).hasToString("hi");
-		assertThat(this.requestFactory.getExecutedRequests()).hasSizeGreaterThan(10);
-	}
-
-	@Test
-	void connectFailureLogsWarning(CapturedOutput output) throws Exception {
-		this.requestFactory.willRespond(new ConnectException());
-		try (TunnelChannel tunnel = openTunnel(true)) {
-			assertThat(tunnel.isOpen()).isFalse();
-			assertThat(output).contains("Failed to connect to remote application at http://localhost:12345");
-		}
-	}
-
-	private void write(TunnelChannel channel, String string) throws IOException {
-		channel.write(ByteBuffer.wrap(string.getBytes()));
-	}
-
-	private TunnelChannel openTunnel(boolean singleThreaded) throws Exception {
-		HttpTunnelConnection connection = new HttpTunnelConnection(this.url, this.requestFactory,
-				singleThreaded ? new CurrentThreadExecutor() : null);
-		return connection.open(this.incomingChannel, this.closeable);
-	}
-
-	static class CurrentThreadExecutor implements Executor {
-
-		@Override
-		public void execute(Runnable command) {
-			command.run();
-		}
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/client/TunnelClientTests.java b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/client/TunnelClientTests.java
deleted file mode 100644
index 491193368b7a..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/client/TunnelClientTests.java
+++ /dev/null
@@ -1,220 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.devtools.tunnel.client;
-
-import java.io.ByteArrayOutputStream;
-import java.io.Closeable;
-import java.io.IOException;
-import java.net.InetSocketAddress;
-import java.net.SocketException;
-import java.nio.ByteBuffer;
-import java.nio.channels.Channels;
-import java.nio.channels.SocketChannel;
-import java.nio.channels.WritableByteChannel;
-import java.time.Duration;
-import java.util.concurrent.atomic.AtomicInteger;
-
-import org.awaitility.Awaitility;
-import org.junit.jupiter.api.Test;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
-
-/**
- * Tests for {@link TunnelClient}.
- *
- * @author Phillip Webb
- */
-class TunnelClientTests {
-
-	private final MockTunnelConnection tunnelConnection = new MockTunnelConnection();
-
-	@Test
-	void listenPortMustNotBeNegative() {
-		assertThatIllegalArgumentException().isThrownBy(() -> new TunnelClient(-5, this.tunnelConnection))
-			.withMessageContaining("ListenPort must be greater than or equal to 0");
-	}
-
-	@Test
-	void tunnelConnectionMustNotBeNull() {
-		assertThatIllegalArgumentException().isThrownBy(() -> new TunnelClient(1, null))
-			.withMessageContaining("TunnelConnection must not be null");
-	}
-
-	@Test
-	void typicalTraffic() throws Exception {
-		TunnelClient client = new TunnelClient(0, this.tunnelConnection);
-		int port = client.start();
-		SocketChannel channel = SocketChannel.open(new InetSocketAddress(port));
-		channel.write(ByteBuffer.wrap("hello".getBytes()));
-		ByteBuffer buffer = ByteBuffer.allocate(5);
-		channel.read(buffer);
-		channel.close();
-		this.tunnelConnection.verifyWritten("hello");
-		assertThat(new String(buffer.array())).isEqualTo("olleh");
-	}
-
-	@Test
-	void socketChannelClosedTriggersTunnelClose() throws Exception {
-		TunnelClient client = new TunnelClient(0, this.tunnelConnection);
-		int port = client.start();
-		SocketChannel channel = SocketChannel.open(new InetSocketAddress(port));
-		Awaitility.await()
-			.atMost(Duration.ofSeconds(30))
-			.until(this.tunnelConnection::getOpenedTimes, (open) -> open == 1);
-		channel.close();
-		client.getServerThread().stopAcceptingConnections();
-		client.getServerThread().join(2000);
-		assertThat(this.tunnelConnection.getOpenedTimes()).isOne();
-		assertThat(this.tunnelConnection.isOpen()).isFalse();
-	}
-
-	@Test
-	void stopTriggersTunnelClose() throws Exception {
-		TunnelClient client = new TunnelClient(0, this.tunnelConnection);
-		int port = client.start();
-		SocketChannel channel = SocketChannel.open(new InetSocketAddress(port));
-		Awaitility.await()
-			.atMost(Duration.ofSeconds(30))
-			.until(this.tunnelConnection::getOpenedTimes, (times) -> times == 1);
-		assertThat(this.tunnelConnection.isOpen()).isTrue();
-		client.stop();
-		assertThat(this.tunnelConnection.isOpen()).isFalse();
-		assertThat(readWithPossibleFailure(channel)).satisfiesAnyOf((result) -> assertThat(result).isEqualTo(-1),
-				(result) -> assertThat(result).isInstanceOf(SocketException.class));
-	}
-
-	private Object readWithPossibleFailure(SocketChannel channel) {
-		try {
-			return channel.read(ByteBuffer.allocate(1));
-		}
-		catch (Exception ex) {
-			return ex;
-		}
-	}
-
-	@Test
-	void addListener() throws Exception {
-		TunnelClient client = new TunnelClient(0, this.tunnelConnection);
-		MockTunnelClientListener listener = new MockTunnelClientListener();
-		client.addListener(listener);
-		int port = client.start();
-		SocketChannel channel = SocketChannel.open(new InetSocketAddress(port));
-		Awaitility.await().atMost(Duration.ofSeconds(30)).until(listener.onOpen::get, (open) -> open == 1);
-		assertThat(listener.onClose).hasValue(0);
-		client.getServerThread().stopAcceptingConnections();
-		channel.close();
-		Awaitility.await().atMost(Duration.ofSeconds(30)).until(listener.onClose::get, (close) -> close == 1);
-		client.getServerThread().join(2000);
-	}
-
-	static class MockTunnelClientListener implements TunnelClientListener {
-
-		private final AtomicInteger onOpen = new AtomicInteger();
-
-		private final AtomicInteger onClose = new AtomicInteger();
-
-		@Override
-		public void onOpen(SocketChannel socket) {
-			this.onOpen.incrementAndGet();
-		}
-
-		@Override
-		public void onClose(SocketChannel socket) {
-			this.onClose.incrementAndGet();
-		}
-
-	}
-
-	static class MockTunnelConnection implements TunnelConnection {
-
-		private final ByteArrayOutputStream written = new ByteArrayOutputStream();
-
-		private boolean open;
-
-		private int openedTimes;
-
-		@Override
-		public WritableByteChannel open(WritableByteChannel incomingChannel, Closeable closeable) {
-			this.openedTimes++;
-			this.open = true;
-			return new TunnelChannel(incomingChannel, closeable);
-		}
-
-		void verifyWritten(String expected) {
-			verifyWritten(expected.getBytes());
-		}
-
-		void verifyWritten(byte[] expected) {
-			synchronized (this.written) {
-				assertThat(this.written.toByteArray()).isEqualTo(expected);
-				this.written.reset();
-			}
-		}
-
-		boolean isOpen() {
-			return this.open;
-		}
-
-		int getOpenedTimes() {
-			return this.openedTimes;
-		}
-
-		private class TunnelChannel implements WritableByteChannel {
-
-			private final WritableByteChannel incomingChannel;
-
-			private final Closeable closeable;
-
-			TunnelChannel(WritableByteChannel incomingChannel, Closeable closeable) {
-				this.incomingChannel = incomingChannel;
-				this.closeable = closeable;
-			}
-
-			@Override
-			public boolean isOpen() {
-				return MockTunnelConnection.this.open;
-			}
-
-			@Override
-			public void close() throws IOException {
-				MockTunnelConnection.this.open = false;
-				this.closeable.close();
-			}
-
-			@Override
-			public int write(ByteBuffer src) throws IOException {
-				int remaining = src.remaining();
-				ByteArrayOutputStream stream = new ByteArrayOutputStream();
-				Channels.newChannel(stream).write(src);
-				byte[] bytes = stream.toByteArray();
-				synchronized (MockTunnelConnection.this.written) {
-					MockTunnelConnection.this.written.write(bytes);
-				}
-				byte[] reversed = new byte[bytes.length];
-				for (int i = 0; i < reversed.length; i++) {
-					reversed[i] = bytes[bytes.length - 1 - i];
-				}
-				this.incomingChannel.write(ByteBuffer.wrap(reversed));
-				return remaining;
-			}
-
-		}
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/payload/HttpTunnelPayloadForwarderTests.java b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/payload/HttpTunnelPayloadForwarderTests.java
deleted file mode 100644
index 5907ee18456c..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/payload/HttpTunnelPayloadForwarderTests.java
+++ /dev/null
@@ -1,80 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.devtools.tunnel.payload;
-
-import java.io.ByteArrayOutputStream;
-import java.nio.ByteBuffer;
-import java.nio.channels.Channels;
-import java.nio.channels.WritableByteChannel;
-
-import org.junit.jupiter.api.Test;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
-import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
-
-/**
- * Tests for {@link HttpTunnelPayloadForwarder}.
- *
- * @author Phillip Webb
- */
-class HttpTunnelPayloadForwarderTests {
-
-	@Test
-	void targetChannelMustNotBeNull() {
-		assertThatIllegalArgumentException().isThrownBy(() -> new HttpTunnelPayloadForwarder(null))
-			.withMessageContaining("TargetChannel must not be null");
-	}
-
-	@Test
-	void forwardInSequence() throws Exception {
-		ByteArrayOutputStream out = new ByteArrayOutputStream();
-		WritableByteChannel channel = Channels.newChannel(out);
-		HttpTunnelPayloadForwarder forwarder = new HttpTunnelPayloadForwarder(channel);
-		forwarder.forward(payload(1, "he"));
-		forwarder.forward(payload(2, "ll"));
-		forwarder.forward(payload(3, "o"));
-		assertThat(out.toByteArray()).isEqualTo("hello".getBytes());
-	}
-
-	@Test
-	void forwardOutOfSequence() throws Exception {
-		ByteArrayOutputStream out = new ByteArrayOutputStream();
-		WritableByteChannel channel = Channels.newChannel(out);
-		HttpTunnelPayloadForwarder forwarder = new HttpTunnelPayloadForwarder(channel);
-		forwarder.forward(payload(3, "o"));
-		forwarder.forward(payload(2, "ll"));
-		forwarder.forward(payload(1, "he"));
-		assertThat(out.toByteArray()).isEqualTo("hello".getBytes());
-	}
-
-	@Test
-	void overflow() {
-		WritableByteChannel channel = Channels.newChannel(new ByteArrayOutputStream());
-		HttpTunnelPayloadForwarder forwarder = new HttpTunnelPayloadForwarder(channel);
-		assertThatIllegalStateException().isThrownBy(() -> {
-			for (int i = 2; i < 130; i++) {
-				forwarder.forward(payload(i, "data" + i));
-			}
-		}).withMessageContaining("Too many messages queued");
-	}
-
-	private HttpTunnelPayload payload(long sequence, String data) {
-		return new HttpTunnelPayload(sequence, ByteBuffer.wrap(data.getBytes()));
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/payload/HttpTunnelPayloadTests.java b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/payload/HttpTunnelPayloadTests.java
deleted file mode 100644
index ca36ddaa7051..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/payload/HttpTunnelPayloadTests.java
+++ /dev/null
@@ -1,142 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.devtools.tunnel.payload;
-
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.net.SocketTimeoutException;
-import java.nio.ByteBuffer;
-import java.nio.channels.Channels;
-import java.nio.channels.ReadableByteChannel;
-import java.nio.channels.WritableByteChannel;
-
-import org.junit.jupiter.api.Test;
-
-import org.springframework.http.HttpInputMessage;
-import org.springframework.http.HttpOutputMessage;
-import org.springframework.http.server.ServletServerHttpRequest;
-import org.springframework.http.server.ServletServerHttpResponse;
-import org.springframework.mock.web.MockHttpServletRequest;
-import org.springframework.mock.web.MockHttpServletResponse;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
-import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.BDDMockito.given;
-import static org.mockito.Mockito.mock;
-
-/**
- * Tests for {@link HttpTunnelPayload}.
- *
- * @author Phillip Webb
- */
-class HttpTunnelPayloadTests {
-
-	@Test
-	void sequenceMustBePositive() {
-		assertThatIllegalArgumentException().isThrownBy(() -> new HttpTunnelPayload(0, ByteBuffer.allocate(1)))
-			.withMessageContaining("Sequence must be positive");
-	}
-
-	@Test
-	void dataMustNotBeNull() {
-		assertThatIllegalArgumentException().isThrownBy(() -> new HttpTunnelPayload(1, null))
-			.withMessageContaining("Data must not be null");
-	}
-
-	@Test
-	void getSequence() {
-		HttpTunnelPayload payload = new HttpTunnelPayload(1, ByteBuffer.allocate(1));
-		assertThat(payload.getSequence()).isOne();
-	}
-
-	@Test
-	void getData() throws Exception {
-		ByteBuffer data = ByteBuffer.wrap("hello".getBytes());
-		HttpTunnelPayload payload = new HttpTunnelPayload(1, data);
-		assertThat(getData(payload)).isEqualTo(data.array());
-	}
-
-	@Test
-	void assignTo() throws Exception {
-		ByteBuffer data = ByteBuffer.wrap("hello".getBytes());
-		HttpTunnelPayload payload = new HttpTunnelPayload(2, data);
-		MockHttpServletResponse servletResponse = new MockHttpServletResponse();
-		HttpOutputMessage response = new ServletServerHttpResponse(servletResponse);
-		payload.assignTo(response);
-		assertThat(servletResponse.getHeader("x-seq")).isEqualTo("2");
-		assertThat(servletResponse.getContentAsString()).isEqualTo("hello");
-	}
-
-	@Test
-	void getNoData() throws Exception {
-		MockHttpServletRequest servletRequest = new MockHttpServletRequest();
-		HttpInputMessage request = new ServletServerHttpRequest(servletRequest);
-		HttpTunnelPayload payload = HttpTunnelPayload.get(request);
-		assertThat(payload).isNull();
-	}
-
-	@Test
-	void getWithMissingHeader() {
-		MockHttpServletRequest servletRequest = new MockHttpServletRequest();
-		servletRequest.setContent("hello".getBytes());
-		HttpInputMessage request = new ServletServerHttpRequest(servletRequest);
-		assertThatIllegalStateException().isThrownBy(() -> HttpTunnelPayload.get(request))
-			.withMessageContaining("Missing sequence header");
-	}
-
-	@Test
-	void getWithData() throws Exception {
-		MockHttpServletRequest servletRequest = new MockHttpServletRequest();
-		servletRequest.setContent("hello".getBytes());
-		servletRequest.addHeader("x-seq", 123);
-		HttpInputMessage request = new ServletServerHttpRequest(servletRequest);
-		HttpTunnelPayload payload = HttpTunnelPayload.get(request);
-		assertThat(payload.getSequence()).isEqualTo(123L);
-		assertThat(getData(payload)).isEqualTo("hello".getBytes());
-	}
-
-	@Test
-	void getPayloadData() throws Exception {
-		ReadableByteChannel channel = Channels.newChannel(new ByteArrayInputStream("hello".getBytes()));
-		ByteBuffer payloadData = HttpTunnelPayload.getPayloadData(channel);
-		ByteArrayOutputStream out = new ByteArrayOutputStream();
-		WritableByteChannel writeChannel = Channels.newChannel(out);
-		while (payloadData.hasRemaining()) {
-			writeChannel.write(payloadData);
-		}
-		assertThat(out.toByteArray()).isEqualTo("hello".getBytes());
-	}
-
-	@Test
-	void getPayloadDataWithTimeout() throws Exception {
-		ReadableByteChannel channel = mock(ReadableByteChannel.class);
-		given(channel.read(any(ByteBuffer.class))).willThrow(new SocketTimeoutException());
-		ByteBuffer payload = HttpTunnelPayload.getPayloadData(channel);
-		assertThat(payload).isNull();
-	}
-
-	private byte[] getData(HttpTunnelPayload payload) throws IOException {
-		ByteArrayOutputStream out = new ByteArrayOutputStream();
-		WritableByteChannel channel = Channels.newChannel(out);
-		payload.writeTo(channel);
-		return out.toByteArray();
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/server/HttpTunnelServerHandlerTests.java b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/server/HttpTunnelServerHandlerTests.java
deleted file mode 100644
index b04624839355..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/server/HttpTunnelServerHandlerTests.java
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.devtools.tunnel.server;
-
-import org.junit.jupiter.api.Test;
-
-import org.springframework.http.server.ServerHttpRequest;
-import org.springframework.http.server.ServerHttpResponse;
-
-import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
-import static org.mockito.BDDMockito.then;
-import static org.mockito.Mockito.mock;
-
-/**
- * Tests for {@link HttpTunnelServerHandler}.
- *
- * @author Phillip Webb
- */
-class HttpTunnelServerHandlerTests {
-
-	@Test
-	void serverMustNotBeNull() {
-		assertThatIllegalArgumentException().isThrownBy(() -> new HttpTunnelServerHandler(null))
-			.withMessageContaining("Server must not be null");
-	}
-
-	@Test
-	void handleDelegatesToServer() throws Exception {
-		HttpTunnelServer server = mock(HttpTunnelServer.class);
-		HttpTunnelServerHandler handler = new HttpTunnelServerHandler(server);
-		ServerHttpRequest request = mock(ServerHttpRequest.class);
-		ServerHttpResponse response = mock(ServerHttpResponse.class);
-		handler.handle(request, response);
-		then(server).should().handle(request, response);
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/server/HttpTunnelServerTests.java b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/server/HttpTunnelServerTests.java
deleted file mode 100644
index 24f1268d04ec..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/server/HttpTunnelServerTests.java
+++ /dev/null
@@ -1,479 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.devtools.tunnel.server;
-
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.net.SocketTimeoutException;
-import java.nio.ByteBuffer;
-import java.nio.channels.ByteChannel;
-import java.nio.channels.Channels;
-import java.time.Duration;
-import java.util.concurrent.BlockingDeque;
-import java.util.concurrent.LinkedBlockingDeque;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicBoolean;
-
-import org.awaitility.Awaitility;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-
-import org.springframework.boot.devtools.tunnel.payload.HttpTunnelPayload;
-import org.springframework.boot.devtools.tunnel.server.HttpTunnelServer.HttpConnection;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.server.ServerHttpAsyncRequestControl;
-import org.springframework.http.server.ServerHttpRequest;
-import org.springframework.http.server.ServerHttpResponse;
-import org.springframework.http.server.ServletServerHttpRequest;
-import org.springframework.http.server.ServletServerHttpResponse;
-import org.springframework.mock.web.MockHttpServletRequest;
-import org.springframework.mock.web.MockHttpServletResponse;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
-import static org.mockito.ArgumentMatchers.anyInt;
-import static org.mockito.BDDMockito.given;
-import static org.mockito.BDDMockito.then;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.never;
-
-/**
- * Tests for {@link HttpTunnelServer}.
- *
- * @author Phillip Webb
- */
-@ExtendWith(MockitoExtension.class)
-class HttpTunnelServerTests {
-
-	private static final int DEFAULT_LONG_POLL_TIMEOUT = 10000;
-
-	private static final int JOIN_TIMEOUT = 5000;
-
-	private static final byte[] NO_DATA = {};
-
-	private static final String SEQ_HEADER = "x-seq";
-
-	private HttpTunnelServer server;
-
-	@Mock
-	private TargetServerConnection serverConnection;
-
-	private MockHttpServletRequest servletRequest;
-
-	private MockHttpServletResponse servletResponse;
-
-	private ServerHttpRequest request;
-
-	private ServerHttpResponse response;
-
-	private MockServerChannel serverChannel;
-
-	@BeforeEach
-	void setup() {
-		this.server = new HttpTunnelServer(this.serverConnection);
-		this.servletRequest = new MockHttpServletRequest();
-		this.servletRequest.setAsyncSupported(true);
-		this.servletResponse = new MockHttpServletResponse();
-		this.request = new ServletServerHttpRequest(this.servletRequest);
-		this.response = new ServletServerHttpResponse(this.servletResponse);
-		this.serverChannel = new MockServerChannel();
-	}
-
-	@Test
-	void serverConnectionIsRequired() {
-		assertThatIllegalArgumentException().isThrownBy(() -> new HttpTunnelServer(null))
-			.withMessageContaining("ServerConnection must not be null");
-	}
-
-	@Test
-	void serverConnectedOnFirstRequest() throws Exception {
-		then(this.serverConnection).should(never()).open(anyInt());
-		givenServerConnectionOpenWillAnswerWithServerChannel();
-		this.server.handle(this.request, this.response);
-		then(this.serverConnection).should().open(DEFAULT_LONG_POLL_TIMEOUT);
-	}
-
-	@Test
-	void longPollTimeout() throws Exception {
-		givenServerConnectionOpenWillAnswerWithServerChannel();
-		this.server.setLongPollTimeout(800);
-		this.server.handle(this.request, this.response);
-		then(this.serverConnection).should().open(800);
-	}
-
-	@Test
-	void longPollTimeoutMustBePositiveValue() {
-		assertThatIllegalArgumentException().isThrownBy(() -> this.server.setLongPollTimeout(0))
-			.withMessageContaining("LongPollTimeout must be a positive value");
-	}
-
-	@Test
-	void initialRequestIsSentToServer() throws Exception {
-		givenServerConnectionOpenWillAnswerWithServerChannel();
-		this.servletRequest.addHeader(SEQ_HEADER, "1");
-		this.servletRequest.setContent("hello".getBytes());
-		this.server.handle(this.request, this.response);
-		this.serverChannel.disconnect();
-		this.server.getServerThread().join(JOIN_TIMEOUT);
-		this.serverChannel.verifyReceived("hello");
-	}
-
-	@Test
-	void initialRequestIsUsedForFirstServerResponse() throws Exception {
-		givenServerConnectionOpenWillAnswerWithServerChannel();
-		this.servletRequest.addHeader(SEQ_HEADER, "1");
-		this.servletRequest.setContent("hello".getBytes());
-		this.server.handle(this.request, this.response);
-		System.out.println("sending");
-		this.serverChannel.send("hello");
-		this.serverChannel.disconnect();
-		this.server.getServerThread().join(JOIN_TIMEOUT);
-		assertThat(this.servletResponse.getContentAsString()).isEqualTo("hello");
-		this.serverChannel.verifyReceived("hello");
-	}
-
-	@Test
-	void initialRequestHasNoPayload() throws Exception {
-		givenServerConnectionOpenWillAnswerWithServerChannel();
-		this.server.handle(this.request, this.response);
-		this.serverChannel.disconnect();
-		this.server.getServerThread().join(JOIN_TIMEOUT);
-		this.serverChannel.verifyReceived(NO_DATA);
-	}
-
-	@Test
-	void typicalRequestResponseTraffic() throws Exception {
-		givenServerConnectionOpenWillAnswerWithServerChannel();
-		MockHttpConnection h1 = new MockHttpConnection();
-		this.server.handle(h1);
-		MockHttpConnection h2 = new MockHttpConnection("hello server", 1);
-		this.server.handle(h2);
-		this.serverChannel.verifyReceived("hello server");
-		this.serverChannel.send("hello client");
-		h1.verifyReceived("hello client", 1);
-		MockHttpConnection h3 = new MockHttpConnection("1+1", 2);
-		this.server.handle(h3);
-		this.serverChannel.send("=2");
-		h2.verifyReceived("=2", 2);
-		MockHttpConnection h4 = new MockHttpConnection("1+2", 3);
-		this.server.handle(h4);
-		this.serverChannel.send("=3");
-		h3.verifyReceived("=3", 3);
-		this.serverChannel.disconnect();
-		this.server.getServerThread().join(JOIN_TIMEOUT);
-	}
-
-	@Test
-	void clientIsAwareOfServerClose() throws Exception {
-		givenServerConnectionOpenWillAnswerWithServerChannel();
-		MockHttpConnection h1 = new MockHttpConnection("1", 1);
-		this.server.handle(h1);
-		this.serverChannel.disconnect();
-		this.server.getServerThread().join(JOIN_TIMEOUT);
-		assertThat(h1.getServletResponse().getStatus()).isEqualTo(410);
-	}
-
-	@Test
-	void clientCanCloseServer() throws Exception {
-		givenServerConnectionOpenWillAnswerWithServerChannel();
-		MockHttpConnection h1 = new MockHttpConnection();
-		this.server.handle(h1);
-		MockHttpConnection h2 = new MockHttpConnection("DISCONNECT", 1);
-		h2.getServletRequest().addHeader("Content-Type", "application/x-disconnect");
-		this.server.handle(h2);
-		this.server.getServerThread().join(JOIN_TIMEOUT);
-		assertThat(h1.getServletResponse().getStatus()).isEqualTo(410);
-		assertThat(this.serverChannel.isOpen()).isFalse();
-	}
-
-	@Test
-	void neverMoreThanTwoHttpConnections() throws Exception {
-		givenServerConnectionOpenWillAnswerWithServerChannel();
-		MockHttpConnection h1 = new MockHttpConnection();
-		this.server.handle(h1);
-		MockHttpConnection h2 = new MockHttpConnection("1", 2);
-		this.server.handle(h2);
-		MockHttpConnection h3 = new MockHttpConnection("2", 3);
-		this.server.handle(h3);
-		h1.waitForResponse();
-		assertThat(h1.getServletResponse().getStatus()).isEqualTo(429);
-		this.serverChannel.disconnect();
-		this.server.getServerThread().join(JOIN_TIMEOUT);
-	}
-
-	@Test
-	void requestReceivedOutOfOrder() throws Exception {
-		givenServerConnectionOpenWillAnswerWithServerChannel();
-		MockHttpConnection h1 = new MockHttpConnection();
-		MockHttpConnection h2 = new MockHttpConnection("1+2", 1);
-		MockHttpConnection h3 = new MockHttpConnection("+3", 2);
-		this.server.handle(h1);
-		this.server.handle(h3);
-		this.server.handle(h2);
-		this.serverChannel.verifyReceived("1+2+3");
-		this.serverChannel.disconnect();
-		this.server.getServerThread().join(JOIN_TIMEOUT);
-	}
-
-	@Test
-	void httpConnectionsAreClosedAfterLongPollTimeout() throws Exception {
-		givenServerConnectionOpenWillAnswerWithServerChannel();
-		this.server.setDisconnectTimeout(1000);
-		this.server.setLongPollTimeout(100);
-		MockHttpConnection h1 = new MockHttpConnection();
-		this.server.handle(h1);
-		Awaitility.await()
-			.atMost(Duration.ofSeconds(30))
-			.until(h1.getServletResponse()::getStatus, (status) -> status == 204);
-		MockHttpConnection h2 = new MockHttpConnection();
-		this.server.handle(h2);
-		Awaitility.await()
-			.atMost(Duration.ofSeconds(30))
-			.until(h2.getServletResponse()::getStatus, (status) -> status == 204);
-		this.serverChannel.disconnect();
-		this.server.getServerThread().join(JOIN_TIMEOUT);
-	}
-
-	@Test
-	void disconnectTimeout() throws Exception {
-		givenServerConnectionOpenWillAnswerWithServerChannel();
-		this.server.setDisconnectTimeout(100);
-		this.server.setLongPollTimeout(100);
-		MockHttpConnection h1 = new MockHttpConnection();
-		this.server.handle(h1);
-		this.serverChannel.send("hello");
-		this.server.getServerThread().join(JOIN_TIMEOUT);
-		assertThat(this.serverChannel.isOpen()).isFalse();
-	}
-
-	@Test
-	void disconnectTimeoutMustBePositive() {
-		assertThatIllegalArgumentException().isThrownBy(() -> this.server.setDisconnectTimeout(0))
-			.withMessageContaining("DisconnectTimeout must be a positive value");
-	}
-
-	@Test
-	void httpConnectionRespondWithPayload() throws Exception {
-		HttpConnection connection = new HttpConnection(this.request, this.response);
-		connection.waitForResponse();
-		connection.respond(new HttpTunnelPayload(1, ByteBuffer.wrap("hello".getBytes())));
-		assertThat(this.servletResponse.getStatus()).isEqualTo(200);
-		assertThat(this.servletResponse.getContentAsString()).isEqualTo("hello");
-		assertThat(this.servletResponse.getHeader(SEQ_HEADER)).isEqualTo("1");
-	}
-
-	@Test
-	void httpConnectionRespondWithStatus() throws Exception {
-		HttpConnection connection = new HttpConnection(this.request, this.response);
-		connection.waitForResponse();
-		connection.respond(HttpStatus.I_AM_A_TEAPOT);
-		assertThat(this.servletResponse.getStatus()).isEqualTo(418);
-		assertThat(this.servletResponse.getContentLength()).isZero();
-	}
-
-	@Test
-	void httpConnectionAsync() throws Exception {
-		ServerHttpAsyncRequestControl async = mock(ServerHttpAsyncRequestControl.class);
-		ServerHttpRequest request = mock(ServerHttpRequest.class);
-		given(request.getAsyncRequestControl(this.response)).willReturn(async);
-		HttpConnection connection = new HttpConnection(request, this.response);
-		connection.waitForResponse();
-		then(async).should().start();
-		connection.respond(HttpStatus.NO_CONTENT);
-		then(async).should().complete();
-	}
-
-	@Test
-	void httpConnectionNonAsync() throws Exception {
-		testHttpConnectionNonAsync(0);
-		testHttpConnectionNonAsync(100);
-	}
-
-	private void testHttpConnectionNonAsync(long sleepBeforeResponse) throws IOException, InterruptedException {
-		ServerHttpRequest request = mock(ServerHttpRequest.class);
-		given(request.getAsyncRequestControl(this.response)).willThrow(new IllegalArgumentException());
-		final HttpConnection connection = new HttpConnection(request, this.response);
-		final AtomicBoolean responded = new AtomicBoolean();
-		Thread connectionThread = new Thread(() -> {
-			connection.waitForResponse();
-			responded.set(true);
-		});
-		connectionThread.start();
-		assertThat(responded.get()).isFalse();
-		Thread.sleep(sleepBeforeResponse);
-		connection.respond(HttpStatus.NO_CONTENT);
-		connectionThread.join();
-		assertThat(responded.get()).isTrue();
-	}
-
-	@Test
-	void httpConnectionRunning() throws Exception {
-		HttpConnection connection = new HttpConnection(this.request, this.response);
-		assertThat(connection.isOlderThan(100)).isFalse();
-		Thread.sleep(200);
-		assertThat(connection.isOlderThan(100)).isTrue();
-	}
-
-	private void givenServerConnectionOpenWillAnswerWithServerChannel() throws IOException {
-		given(this.serverConnection.open(anyInt())).willAnswer((invocation) -> {
-			MockServerChannel channel = HttpTunnelServerTests.this.serverChannel;
-			channel.setTimeout(invocation.getArgument(0));
-			return channel;
-		});
-	}
-
-	/**
-	 * Mock {@link ByteChannel} used to simulate the server connection.
-	 */
-	static class MockServerChannel implements ByteChannel {
-
-		private static final ByteBuffer DISCONNECT = ByteBuffer.wrap(NO_DATA);
-
-		private int timeout;
-
-		private final BlockingDeque<ByteBuffer> outgoing = new LinkedBlockingDeque<>();
-
-		private final ByteArrayOutputStream written = new ByteArrayOutputStream();
-
-		private final AtomicBoolean open = new AtomicBoolean(true);
-
-		void setTimeout(int timeout) {
-			this.timeout = timeout;
-		}
-
-		void send(String content) {
-			send(content.getBytes());
-		}
-
-		void send(byte[] bytes) {
-			this.outgoing.addLast(ByteBuffer.wrap(bytes));
-		}
-
-		void disconnect() {
-			this.outgoing.addLast(DISCONNECT);
-		}
-
-		void verifyReceived(String expected) {
-			verifyReceived(expected.getBytes());
-		}
-
-		void verifyReceived(byte[] expected) {
-			synchronized (this.written) {
-				assertThat(this.written.toByteArray()).isEqualTo(expected);
-				this.written.reset();
-			}
-		}
-
-		@Override
-		public int read(ByteBuffer dst) throws IOException {
-			try {
-				ByteBuffer bytes = this.outgoing.pollFirst(this.timeout, TimeUnit.MILLISECONDS);
-				if (bytes == null) {
-					throw new SocketTimeoutException();
-				}
-				if (bytes == DISCONNECT) {
-					this.open.set(false);
-					return -1;
-				}
-				int initialRemaining = dst.remaining();
-				bytes.limit(Math.min(bytes.limit(), initialRemaining));
-				dst.put(bytes);
-				bytes.limit(bytes.capacity());
-				return initialRemaining - dst.remaining();
-			}
-			catch (InterruptedException ex) {
-				throw new IllegalStateException(ex);
-			}
-		}
-
-		@Override
-		public int write(ByteBuffer src) throws IOException {
-			int remaining = src.remaining();
-			synchronized (this.written) {
-				Channels.newChannel(this.written).write(src);
-			}
-			return remaining;
-		}
-
-		@Override
-		public boolean isOpen() {
-			return this.open.get();
-		}
-
-		@Override
-		public void close() {
-			this.open.set(false);
-		}
-
-	}
-
-	/**
-	 * Mock {@link HttpConnection}.
-	 */
-	static class MockHttpConnection extends HttpConnection {
-
-		MockHttpConnection() {
-			super(new ServletServerHttpRequest(new MockHttpServletRequest()),
-					new ServletServerHttpResponse(new MockHttpServletResponse()));
-		}
-
-		MockHttpConnection(String content, int seq) {
-			this();
-			MockHttpServletRequest request = getServletRequest();
-			request.setContent(content.getBytes());
-			request.addHeader(SEQ_HEADER, String.valueOf(seq));
-		}
-
-		@Override
-		protected ServerHttpAsyncRequestControl startAsync() {
-			getServletRequest().setAsyncSupported(true);
-			return super.startAsync();
-		}
-
-		@Override
-		protected void complete() {
-			super.complete();
-			getServletResponse().setCommitted(true);
-		}
-
-		MockHttpServletRequest getServletRequest() {
-			return (MockHttpServletRequest) ((ServletServerHttpRequest) getRequest()).getServletRequest();
-		}
-
-		MockHttpServletResponse getServletResponse() {
-			return (MockHttpServletResponse) ((ServletServerHttpResponse) getResponse()).getServletResponse();
-		}
-
-		void verifyReceived(String expectedContent, int expectedSeq) throws Exception {
-			waitForServletResponse();
-			MockHttpServletResponse resp = getServletResponse();
-			assertThat(resp.getContentAsString()).isEqualTo(expectedContent);
-			assertThat(resp.getHeader(SEQ_HEADER)).isEqualTo(String.valueOf(expectedSeq));
-		}
-
-		void waitForServletResponse() throws InterruptedException {
-			while (!getServletResponse().isCommitted()) {
-				Thread.sleep(10);
-			}
-		}
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/server/SocketTargetServerConnectionTests.java b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/server/SocketTargetServerConnectionTests.java
deleted file mode 100644
index 4862af3f5d73..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/server/SocketTargetServerConnectionTests.java
+++ /dev/null
@@ -1,172 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.devtools.tunnel.server;
-
-import java.io.IOException;
-import java.net.InetSocketAddress;
-import java.net.SocketTimeoutException;
-import java.nio.ByteBuffer;
-import java.nio.channels.ByteChannel;
-import java.nio.channels.ServerSocketChannel;
-import java.nio.channels.SocketChannel;
-
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
-
-/**
- * Tests for {@link SocketTargetServerConnection}.
- *
- * @author Phillip Webb
- */
-class SocketTargetServerConnectionTests {
-
-	private static final int DEFAULT_TIMEOUT = 5000;
-
-	private MockServer server;
-
-	private SocketTargetServerConnection connection;
-
-	@BeforeEach
-	void setup() throws IOException {
-		this.server = new MockServer();
-		this.connection = new SocketTargetServerConnection(() -> this.server.getPort());
-	}
-
-	@Test
-	void readData() throws Exception {
-		this.server.willSend("hello".getBytes());
-		this.server.start();
-		try (ByteChannel channel = this.connection.open(DEFAULT_TIMEOUT)) {
-			ByteBuffer buffer = ByteBuffer.allocate(5);
-			channel.read(buffer);
-			assertThat(buffer.array()).isEqualTo("hello".getBytes());
-		}
-	}
-
-	@Test
-	void writeData() throws Exception {
-		this.server.expect("hello".getBytes());
-		this.server.start();
-		try (ByteChannel channel = this.connection.open(DEFAULT_TIMEOUT)) {
-			ByteBuffer buffer = ByteBuffer.wrap("hello".getBytes());
-			channel.write(buffer);
-			this.server.closeAndVerify();
-		}
-	}
-
-	@Test
-	void timeout() throws Exception {
-		this.server.delay(1000);
-		this.server.start();
-		try (ByteChannel channel = this.connection.open(10)) {
-			long startTime = System.currentTimeMillis();
-			assertThatExceptionOfType(SocketTimeoutException.class)
-				.isThrownBy(() -> channel.read(ByteBuffer.allocate(5)))
-				.satisfies((ex) -> {
-					long runTime = System.currentTimeMillis() - startTime;
-					assertThat(runTime).isGreaterThanOrEqualTo(10L);
-					assertThat(runTime).isLessThan(10000L);
-				});
-		}
-	}
-
-	static class MockServer {
-
-		private final ServerSocketChannel serverSocket;
-
-		private byte[] send;
-
-		private byte[] expect;
-
-		private int delay;
-
-		private ByteBuffer actualRead;
-
-		private ServerThread thread;
-
-		MockServer() throws IOException {
-			this.serverSocket = ServerSocketChannel.open();
-			this.serverSocket.bind(new InetSocketAddress(0));
-		}
-
-		int getPort() {
-			return this.serverSocket.socket().getLocalPort();
-		}
-
-		void delay(int delay) {
-			this.delay = delay;
-		}
-
-		void willSend(byte[] send) {
-			this.send = send;
-		}
-
-		void expect(byte[] expect) {
-			this.expect = expect;
-		}
-
-		void start() {
-			this.thread = new ServerThread();
-			this.thread.start();
-		}
-
-		void closeAndVerify() throws InterruptedException {
-			close();
-			assertThat(this.actualRead.array()).isEqualTo(this.expect);
-		}
-
-		void close() throws InterruptedException {
-			while (this.thread.isAlive()) {
-				Thread.sleep(10);
-			}
-		}
-
-		private class ServerThread extends Thread {
-
-			@Override
-			public void run() {
-				try {
-					SocketChannel channel = MockServer.this.serverSocket.accept();
-					Thread.sleep(MockServer.this.delay);
-					if (MockServer.this.send != null) {
-						ByteBuffer buffer = ByteBuffer.wrap(MockServer.this.send);
-						while (buffer.hasRemaining()) {
-							channel.write(buffer);
-						}
-					}
-					if (MockServer.this.expect != null) {
-						ByteBuffer buffer = ByteBuffer.allocate(MockServer.this.expect.length);
-						while (buffer.hasRemaining()) {
-							channel.read(buffer);
-						}
-						MockServer.this.actualRead = buffer;
-					}
-					channel.close();
-				}
-				catch (Exception ex) {
-					throw new RuntimeException(ex);
-				}
-			}
-
-		}
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/server/StaticPortProviderTests.java b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/server/StaticPortProviderTests.java
deleted file mode 100644
index 961074e8e3a7..000000000000
--- a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/tunnel/server/StaticPortProviderTests.java
+++ /dev/null
@@ -1,43 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.devtools.tunnel.server;
-
-import org.junit.jupiter.api.Test;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
-
-/**
- * Tests for {@link StaticPortProvider}.
- *
- * @author Phillip Webb
- */
-class StaticPortProviderTests {
-
-	@Test
-	void portMustBePositive() {
-		assertThatIllegalArgumentException().isThrownBy(() -> new StaticPortProvider(0))
-			.withMessageContaining("Port must be positive");
-	}
-
-	@Test
-	void getPort() {
-		StaticPortProvider provider = new StaticPortProvider(123);
-		assertThat(provider.getPort()).isEqualTo(123);
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-docker-compose/build.gradle b/spring-boot-project/spring-boot-docker-compose/build.gradle
index 371a0d207309..ec1665712dec 100644
--- a/spring-boot-project/spring-boot-docker-compose/build.gradle
+++ b/spring-boot-project/spring-boot-docker-compose/build.gradle
@@ -18,6 +18,7 @@ dependencies {
 	optional(project(":spring-boot-project:spring-boot-actuator-autoconfigure"))
 	optional("io.r2dbc:r2dbc-spi")
 	optional("org.mongodb:mongodb-driver-core")
+	optional("org.neo4j.driver:neo4j-java-driver")
 	optional("org.springframework.data:spring-data-r2dbc")
 
 	testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support"))
@@ -29,6 +30,7 @@ dependencies {
 	testImplementation("org.mockito:mockito-core")
 	testImplementation("org.springframework:spring-core-test")
 	testImplementation("org.springframework:spring-test")
+	testImplementation("org.testcontainers:testcontainers")
 
 	testRuntimeOnly("com.microsoft.sqlserver:mssql-jdbc")
 	testRuntimeOnly("com.oracle.database.r2dbc:oracle-r2dbc")
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DefaultDockerCompose.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DefaultDockerCompose.java
index c969cf440585..ff13762364ff 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DefaultDockerCompose.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DefaultDockerCompose.java
@@ -21,10 +21,12 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.Map.Entry;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 
 import org.springframework.boot.logging.LogLevel;
+import org.springframework.util.Assert;
 
 /**
  * Default {@link DockerCompose} implementation backed by {@link DockerCli}.
@@ -79,7 +81,8 @@ public List<RunningService> getRunningServices() {
 		List<RunningService> result = new ArrayList<>();
 		Map<String, DockerCliInspectResponse> inspected = inspect(runningPsResponses);
 		for (DockerCliComposePsResponse psResponse : runningPsResponses) {
-			DockerCliInspectResponse inspectResponse = inspected.get(psResponse.id());
+			DockerCliInspectResponse inspectResponse = inspectContainer(psResponse.id(), inspected);
+			Assert.notNull(inspectResponse, () -> "Failed to inspect container '%s'".formatted(psResponse.id()));
 			result.add(new DefaultRunningService(this.hostname, dockerComposeFile, psResponse, inspectResponse));
 		}
 		return Collections.unmodifiableList(result);
@@ -91,6 +94,20 @@ private Map<String, DockerCliInspectResponse> inspect(List<DockerCliComposePsRes
 		return inspectResponses.stream().collect(Collectors.toMap(DockerCliInspectResponse::id, Function.identity()));
 	}
 
+	private DockerCliInspectResponse inspectContainer(String id, Map<String, DockerCliInspectResponse> inspected) {
+		DockerCliInspectResponse inspect = inspected.get(id);
+		if (inspect != null) {
+			return inspect;
+		}
+		// Docker Compose v2.23.0 returns truncated ids, so we have to do a prefix match
+		for (Entry<String, DockerCliInspectResponse> entry : inspected.entrySet()) {
+			if (entry.getKey().startsWith(id)) {
+				return entry.getValue();
+			}
+		}
+		return null;
+	}
+
 	private List<DockerCliComposePsResponse> runComposePs() {
 		return this.cli.run(new DockerCliCommand.ComposePs());
 	}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DockerCliCommand.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DockerCliCommand.java
index a47f2234b44f..feb2d8dd6aa8 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DockerCliCommand.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DockerCliCommand.java
@@ -184,7 +184,7 @@ static final class ComposeDown extends DockerCliCommand<Void> {
 	static final class ComposeStart extends DockerCliCommand<Void> {
 
 		ComposeStart(LogLevel logLevel) {
-			super(Type.DOCKER_COMPOSE, logLevel, Void.class, false, "start", "--no-color", "--detach", "--wait");
+			super(Type.DOCKER_COMPOSE, logLevel, Void.class, false, "start");
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DockerJson.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DockerJson.java
index 526089f04912..612f850d41ca 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DockerJson.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DockerJson.java
@@ -18,6 +18,7 @@
 
 import java.io.IOException;
 import java.util.List;
+import java.util.Locale;
 
 import com.fasterxml.jackson.databind.DeserializationFeature;
 import com.fasterxml.jackson.databind.JavaType;
@@ -36,6 +37,7 @@
 final class DockerJson {
 
 	private static final ObjectMapper objectMapper = JsonMapper.builder()
+		.defaultLocale(Locale.ENGLISH)
 		.enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES)
 		.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
 		.addModule(new ParameterNamesModule())
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/lifecycle/DockerComposeLifecycleManager.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/lifecycle/DockerComposeLifecycleManager.java
index 4fbf7b1b63da..b73a9d40d48b 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/lifecycle/DockerComposeLifecycleManager.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/lifecycle/DockerComposeLifecycleManager.java
@@ -121,7 +121,9 @@ void start() {
 		if (lifecycleManagement.shouldStart() && runningServices.isEmpty()) {
 			start.getCommand().applyTo(dockerCompose, start.getLogLevel());
 			runningServices = dockerCompose.getRunningServices();
-			wait = (wait != Wait.ONLY_IF_STARTED) ? wait : Wait.ALWAYS;
+			if (wait == Wait.ONLY_IF_STARTED) {
+				wait = Wait.ALWAYS;
+			}
 			if (lifecycleManagement.shouldStop()) {
 				this.shutdownHandlers.add(() -> stop.getCommand().applyTo(dockerCompose, stop.getTimeout()));
 			}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicate.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicate.java
index 626c3262bdaf..1990f9b27134 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicate.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicate.java
@@ -16,28 +16,33 @@
 
 package org.springframework.boot.docker.compose.service.connection;
 
+import java.util.Arrays;
+import java.util.Set;
 import java.util.function.Predicate;
+import java.util.stream.Collectors;
 
 import org.springframework.boot.docker.compose.core.ImageReference;
 import org.springframework.boot.docker.compose.core.RunningService;
+import org.springframework.util.Assert;
 
 /**
- * {@link Predicate} that matches against connection names.
+ * {@link Predicate} that matches against connection name.
  *
  * @author Phillip Webb
  */
 class ConnectionNamePredicate implements Predicate<DockerComposeConnectionSource> {
 
-	private final String required;
+	private final Set<String> required;
 
-	ConnectionNamePredicate(String required) {
-		this.required = asCanonicalName(required);
+	ConnectionNamePredicate(String... required) {
+		Assert.notEmpty(required, "Required must not be empty");
+		this.required = Arrays.stream(required).map(this::asCanonicalName).collect(Collectors.toSet());
 	}
 
 	@Override
 	public boolean test(DockerComposeConnectionSource source) {
 		String actual = getActual(source.getRunningService());
-		return this.required.equals(actual);
+		return this.required.contains(actual);
 	}
 
 	private String getActual(RunningService service) {
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/DockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/DockerComposeConnectionDetailsFactory.java
index 6d29c8bb0247..302a3ba35817 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/DockerComposeConnectionDetailsFactory.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/DockerComposeConnectionDetailsFactory.java
@@ -54,6 +54,16 @@ protected DockerComposeConnectionDetailsFactory(String connectionName, String...
 		this(new ConnectionNamePredicate(connectionName), requiredClassNames);
 	}
 
+	/**
+	 * Create a new {@link DockerComposeConnectionDetailsFactory} instance.
+	 * @param connectionNames the required connection name
+	 * @param requiredClassNames the names of classes that must be present
+	 * @since 3.2.0
+	 */
+	protected DockerComposeConnectionDetailsFactory(String[] connectionNames, String... requiredClassNames) {
+		this(new ConnectionNamePredicate(connectionNames), requiredClassNames);
+	}
+
 	/**
 	 * Create a new {@link DockerComposeConnectionDetailsFactory} instance.
 	 * @param predicate a predicate used to check when a service is accepted
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQDockerComposeConnectionDetailsFactory.java
new file mode 100644
index 000000000000..ac3809d8da21
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQDockerComposeConnectionDetailsFactory.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.activemq;
+
+import org.springframework.boot.autoconfigure.amqp.RabbitConnectionDetails;
+import org.springframework.boot.autoconfigure.jms.activemq.ActiveMQConnectionDetails;
+import org.springframework.boot.docker.compose.core.RunningService;
+import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory;
+import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionSource;
+
+/**
+ * {@link DockerComposeConnectionDetailsFactory} to create
+ * {@link ActiveMQConnectionDetails} for an {@code activemq} service.
+ *
+ * @author Stephane Nicoll
+ */
+class ActiveMQDockerComposeConnectionDetailsFactory
+		extends DockerComposeConnectionDetailsFactory<ActiveMQConnectionDetails> {
+
+	private static final int ACTIVEMQ_PORT = 61616;
+
+	protected ActiveMQDockerComposeConnectionDetailsFactory() {
+		super("symptoma/activemq");
+	}
+
+	@Override
+	protected ActiveMQConnectionDetails getDockerComposeConnectionDetails(DockerComposeConnectionSource source) {
+		return new ActiveMQDockerComposeConnectionDetails(source.getRunningService());
+	}
+
+	/**
+	 * {@link RabbitConnectionDetails} backed by a {@code rabbitmq}
+	 * {@link RunningService}.
+	 */
+	static class ActiveMQDockerComposeConnectionDetails extends DockerComposeConnectionDetails
+			implements ActiveMQConnectionDetails {
+
+		private final ActiveMQEnvironment environment;
+
+		private final String brokerUrl;
+
+		protected ActiveMQDockerComposeConnectionDetails(RunningService service) {
+			super(service);
+			this.environment = new ActiveMQEnvironment(service.env());
+			this.brokerUrl = "tcp://" + service.host() + ":" + service.ports().get(ACTIVEMQ_PORT);
+		}
+
+		@Override
+		public String getBrokerUrl() {
+			return this.brokerUrl;
+		}
+
+		@Override
+		public String getUser() {
+			return this.environment.getUser();
+		}
+
+		@Override
+		public String getPassword() {
+			return this.environment.getPassword();
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQEnvironment.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQEnvironment.java
new file mode 100644
index 000000000000..742389e80a7e
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQEnvironment.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.activemq;
+
+import java.util.Map;
+
+/**
+ * ActiveMQ environment details.
+ *
+ * @author Stephane Nicoll
+ */
+class ActiveMQEnvironment {
+
+	private final String user;
+
+	private final String password;
+
+	ActiveMQEnvironment(Map<String, String> env) {
+		this.user = env.get("ACTIVEMQ_USERNAME");
+		this.password = env.get("ACTIVEMQ_PASSWORD");
+	}
+
+	String getUser() {
+		return this.user;
+	}
+
+	String getPassword() {
+		return this.password;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/activemq/package-info.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/activemq/package-info.java
new file mode 100644
index 000000000000..5cb2e75cf5b4
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/activemq/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Auto-configuration for docker compose ActiveMQ service connections.
+ */
+package org.springframework.boot.docker.compose.service.connection.activemq;
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jDockerComposeConnectionDetailsFactory.java
new file mode 100644
index 000000000000..33bd622e23ab
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jDockerComposeConnectionDetailsFactory.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.neo4j;
+
+import java.net.URI;
+
+import org.neo4j.driver.AuthToken;
+
+import org.springframework.boot.autoconfigure.neo4j.Neo4jConnectionDetails;
+import org.springframework.boot.docker.compose.core.RunningService;
+import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory;
+import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionSource;
+
+/**
+ * {@link DockerComposeConnectionDetailsFactory} to create {@link Neo4jConnectionDetails}
+ * for a {@code Neo4j} service.
+ *
+ * @author Andy Wilkinson
+ */
+class Neo4jDockerComposeConnectionDetailsFactory extends DockerComposeConnectionDetailsFactory<Neo4jConnectionDetails> {
+
+	Neo4jDockerComposeConnectionDetailsFactory() {
+		super("neo4j");
+	}
+
+	@Override
+	protected Neo4jConnectionDetails getDockerComposeConnectionDetails(DockerComposeConnectionSource source) {
+		return new Neo4jDockerComposeConnectionDetails(source.getRunningService());
+	}
+
+	/**
+	 * {@link Neo4jConnectionDetails} backed by a {@code Neo4j} {@link RunningService}.
+	 */
+	static class Neo4jDockerComposeConnectionDetails extends DockerComposeConnectionDetails
+			implements Neo4jConnectionDetails {
+
+		private static final int BOLT_PORT = 7687;
+
+		private final AuthToken authToken;
+
+		private final URI uri;
+
+		Neo4jDockerComposeConnectionDetails(RunningService service) {
+			super(service);
+			Neo4jEnvironment neo4jEnvironment = new Neo4jEnvironment(service.env());
+			this.authToken = neo4jEnvironment.getAuthToken();
+			this.uri = URI.create("neo4j://%s:%d".formatted(service.host(), service.ports().get(BOLT_PORT)));
+		}
+
+		@Override
+		public URI getUri() {
+			return this.uri;
+		}
+
+		@Override
+		public AuthToken getAuthToken() {
+			return this.authToken;
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jEnvironment.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jEnvironment.java
new file mode 100644
index 000000000000..59e5a90e9230
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jEnvironment.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.neo4j;
+
+import java.util.Map;
+
+import org.neo4j.driver.AuthToken;
+import org.neo4j.driver.AuthTokens;
+
+/**
+ * Neo4j environment details.
+ *
+ * @author Andy Wilkinson
+ */
+class Neo4jEnvironment {
+
+	private final AuthToken authToken;
+
+	Neo4jEnvironment(Map<String, String> env) {
+		this.authToken = parse(env.get("NEO4J_AUTH"));
+	}
+
+	private AuthToken parse(String neo4jAuth) {
+		if (neo4jAuth == null) {
+			return null;
+		}
+		if ("none".equals(neo4jAuth)) {
+			return AuthTokens.none();
+		}
+		if (neo4jAuth.startsWith("neo4j/")) {
+			return AuthTokens.basic("neo4j", neo4jAuth.substring(6));
+		}
+		throw new IllegalStateException(
+				"Cannot extract auth token from NEO4J_AUTH environment variable with value '" + neo4jAuth + "'."
+						+ " Value should be 'none' to disable authentication or start with 'neo4j/' to specify"
+						+ " the neo4j user's password");
+	}
+
+	AuthToken getAuthToken() {
+		return this.authToken;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/neo4j/package-info.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/neo4j/package-info.java
new file mode 100644
index 000000000000..afea67c3cf5c
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/neo4j/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Auto-configuration for docker compose Neo4j service connections.
+ */
+package org.springframework.boot.docker.compose.service.connection.neo4j;
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleContainer.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleContainer.java
new file mode 100644
index 000000000000..55776fd3a132
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleContainer.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.oracle;
+
+/**
+ * Enumeration of supported Oracle containers.
+ *
+ * @author Andy Wilkinson
+ */
+enum OracleContainer {
+
+	FREE("gvenzl/oracle-free", "freepdb1"),
+
+	XE("gvenzl/oracle-xe", "xepdb1");
+
+	private final String imageName;
+
+	private final String defaultDatabase;
+
+	OracleContainer(String imageName, String defaultDatabase) {
+		this.imageName = imageName;
+		this.defaultDatabase = defaultDatabase;
+	}
+
+	String getImageName() {
+		return this.imageName;
+	}
+
+	String getDefaultDatabase() {
+		return this.defaultDatabase;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleEnvironment.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleEnvironment.java
index b06fc5aa8cb7..c38b595263c6 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleEnvironment.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleEnvironment.java
@@ -34,20 +34,20 @@ class OracleEnvironment {
 
 	private final String database;
 
-	OracleEnvironment(Map<String, String> env) {
+	OracleEnvironment(Map<String, String> env, String defaultDatabase) {
 		this.username = env.getOrDefault("APP_USER", "system");
 		this.password = extractPassword(env);
-		this.database = env.getOrDefault("ORACLE_DATABASE", "xepdb1");
+		this.database = env.getOrDefault("ORACLE_DATABASE", defaultDatabase);
 	}
 
 	private String extractPassword(Map<String, String> env) {
 		if (env.containsKey("APP_USER")) {
-			String password = env.get("APP_PASSWORD");
+			String password = env.get("APP_USER_PASSWORD");
 			Assert.state(StringUtils.hasLength(password), "No Oracle app password found");
 			return password;
 		}
 		Assert.state(!env.containsKey("ORACLE_RANDOM_PASSWORD"),
-				"ORACLE_RANDOM_PASSWORD is not supported without APP_USER and APP_PASSWORD");
+				"ORACLE_RANDOM_PASSWORD is not supported without APP_USER and APP_USER_PASSWORD");
 		String password = env.get("ORACLE_PASSWORD");
 		Assert.state(StringUtils.hasLength(password), "No Oracle password found");
 		return password;
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeJdbcDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeJdbcDockerComposeConnectionDetailsFactory.java
new file mode 100644
index 000000000000..85e017a47d74
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeJdbcDockerComposeConnectionDetailsFactory.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.oracle;
+
+import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails;
+import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory;
+
+/**
+ * {@link DockerComposeConnectionDetailsFactory} to create {@link JdbcConnectionDetails}
+ * for an {@link OracleContainer#FREE} service.
+ *
+ * @author Andy Wilkinson
+ */
+class OracleFreeJdbcDockerComposeConnectionDetailsFactory extends OracleJdbcDockerComposeConnectionDetailsFactory {
+
+	protected OracleFreeJdbcDockerComposeConnectionDetailsFactory() {
+		super(OracleContainer.FREE);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeR2dbcDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeR2dbcDockerComposeConnectionDetailsFactory.java
new file mode 100644
index 000000000000..3e4ae171b928
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeR2dbcDockerComposeConnectionDetailsFactory.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.oracle;
+
+import org.springframework.boot.autoconfigure.r2dbc.R2dbcConnectionDetails;
+import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory;
+
+/**
+ * {@link DockerComposeConnectionDetailsFactory} to create {@link R2dbcConnectionDetails}
+ * for an {@link OracleContainer#FREE} service.
+ *
+ * @author Andy Wilkinson
+ */
+class OracleFreeR2dbcDockerComposeConnectionDetailsFactory extends OracleR2dbcDockerComposeConnectionDetailsFactory {
+
+	protected OracleFreeR2dbcDockerComposeConnectionDetailsFactory() {
+		super(OracleContainer.FREE);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleJdbcDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleJdbcDockerComposeConnectionDetailsFactory.java
index 9104a9ff267a..924195ab4d90 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleJdbcDockerComposeConnectionDetailsFactory.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleJdbcDockerComposeConnectionDetailsFactory.java
@@ -23,27 +23,30 @@
 import org.springframework.util.StringUtils;
 
 /**
- * {@link DockerComposeConnectionDetailsFactory} to create {@link JdbcConnectionDetails}
- * for an {@code oracle-xe} service.
+ * Base class for a {@link DockerComposeConnectionDetailsFactory} to create
+ * {@link JdbcConnectionDetails} for an {@code oracle-free} or {@code oracle-xe} service.
  *
  * @author Moritz Halbritter
  * @author Andy Wilkinson
  * @author Phillip Webb
  */
-class OracleJdbcDockerComposeConnectionDetailsFactory
+abstract class OracleJdbcDockerComposeConnectionDetailsFactory
 		extends DockerComposeConnectionDetailsFactory<JdbcConnectionDetails> {
 
-	protected OracleJdbcDockerComposeConnectionDetailsFactory() {
-		super("gvenzl/oracle-xe");
+	private final String defaultDatabase;
+
+	protected OracleJdbcDockerComposeConnectionDetailsFactory(OracleContainer container) {
+		super(container.getImageName());
+		this.defaultDatabase = container.getDefaultDatabase();
 	}
 
 	@Override
 	protected JdbcConnectionDetails getDockerComposeConnectionDetails(DockerComposeConnectionSource source) {
-		return new OracleJdbcDockerComposeConnectionDetails(source.getRunningService());
+		return new OracleJdbcDockerComposeConnectionDetails(source.getRunningService(), this.defaultDatabase);
 	}
 
 	/**
-	 * {@link JdbcConnectionDetails} backed by an {@code oracle-xe}
+	 * {@link JdbcConnectionDetails} backed by an {@code oracle-xe} or {@code oracle-free}
 	 * {@link RunningService}.
 	 */
 	static class OracleJdbcDockerComposeConnectionDetails extends DockerComposeConnectionDetails
@@ -55,9 +58,9 @@ static class OracleJdbcDockerComposeConnectionDetails extends DockerComposeConne
 
 		private final String jdbcUrl;
 
-		OracleJdbcDockerComposeConnectionDetails(RunningService service) {
+		OracleJdbcDockerComposeConnectionDetails(RunningService service, String defaultDatabase) {
 			super(service);
-			this.environment = new OracleEnvironment(service.env());
+			this.environment = new OracleEnvironment(service.env(), defaultDatabase);
 			this.jdbcUrl = "jdbc:oracle:thin:@" + service.host() + ":" + service.ports().get(1521) + "/"
 					+ this.environment.getDatabase() + getParameters(service);
 		}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleR2dbcDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleR2dbcDockerComposeConnectionDetailsFactory.java
index 5ebf98956e9b..9d54bee83f67 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleR2dbcDockerComposeConnectionDetailsFactory.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleR2dbcDockerComposeConnectionDetailsFactory.java
@@ -25,23 +25,26 @@
 import org.springframework.boot.docker.compose.service.connection.r2dbc.ConnectionFactoryOptionsBuilder;
 
 /**
- * {@link DockerComposeConnectionDetailsFactory} to create {@link R2dbcConnectionDetails}
- * for an {@code oracle-xe} service.
+ * Base class for a {@link DockerComposeConnectionDetailsFactory} to create
+ * {@link R2dbcConnectionDetails} for an {@code oracle-free} or {@code oracle-xe} service.
  *
  * @author Moritz Halbritter
  * @author Andy Wilkinson
  * @author Phillip Webb
  */
-class OracleR2dbcDockerComposeConnectionDetailsFactory
+abstract class OracleR2dbcDockerComposeConnectionDetailsFactory
 		extends DockerComposeConnectionDetailsFactory<R2dbcConnectionDetails> {
 
-	OracleR2dbcDockerComposeConnectionDetailsFactory() {
-		super("gvenzl/oracle-xe", "io.r2dbc.spi.ConnectionFactoryOptions");
+	private final String defaultDatabase;
+
+	OracleR2dbcDockerComposeConnectionDetailsFactory(OracleContainer container) {
+		super(container.getImageName(), "io.r2dbc.spi.ConnectionFactoryOptions");
+		this.defaultDatabase = container.getDefaultDatabase();
 	}
 
 	@Override
 	protected R2dbcConnectionDetails getDockerComposeConnectionDetails(DockerComposeConnectionSource source) {
-		return new OracleDbR2dbcDockerComposeConnectionDetails(source.getRunningService());
+		return new OracleDbR2dbcDockerComposeConnectionDetails(source.getRunningService(), this.defaultDatabase);
 	}
 
 	/**
@@ -56,9 +59,9 @@ static class OracleDbR2dbcDockerComposeConnectionDetails extends DockerComposeCo
 
 		private final ConnectionFactoryOptions connectionFactoryOptions;
 
-		OracleDbR2dbcDockerComposeConnectionDetails(RunningService service) {
+		OracleDbR2dbcDockerComposeConnectionDetails(RunningService service, String defaultDatabase) {
 			super(service);
-			OracleEnvironment environment = new OracleEnvironment(service.env());
+			OracleEnvironment environment = new OracleEnvironment(service.env(), defaultDatabase);
 			this.connectionFactoryOptions = connectionFactoryOptionsBuilder.build(service, environment.getDatabase(),
 					environment.getUsername(), environment.getPassword());
 		}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeJdbcDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeJdbcDockerComposeConnectionDetailsFactory.java
new file mode 100644
index 000000000000..75da136d567e
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeJdbcDockerComposeConnectionDetailsFactory.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.oracle;
+
+import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails;
+import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory;
+
+/**
+ * {@link DockerComposeConnectionDetailsFactory} to create {@link JdbcConnectionDetails}
+ * for an {@link OracleContainer#XE} service.
+ *
+ * @author Andy Wilkinson
+ */
+class OracleXeJdbcDockerComposeConnectionDetailsFactory extends OracleJdbcDockerComposeConnectionDetailsFactory {
+
+	protected OracleXeJdbcDockerComposeConnectionDetailsFactory() {
+		super(OracleContainer.XE);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeR2dbcDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeR2dbcDockerComposeConnectionDetailsFactory.java
new file mode 100644
index 000000000000..f5b02edde660
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeR2dbcDockerComposeConnectionDetailsFactory.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.oracle;
+
+import org.springframework.boot.autoconfigure.r2dbc.R2dbcConnectionDetails;
+import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory;
+
+/**
+ * {@link DockerComposeConnectionDetailsFactory} to create {@link R2dbcConnectionDetails}
+ * for an {@link OracleContainer#XE} service.
+ *
+ * @author Andy Wilkinson
+ */
+class OracleXeR2dbcDockerComposeConnectionDetailsFactory extends OracleR2dbcDockerComposeConnectionDetailsFactory {
+
+	protected OracleXeR2dbcDockerComposeConnectionDetailsFactory() {
+		super(OracleContainer.XE);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryMetricsDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryMetricsDockerComposeConnectionDetailsFactory.java
new file mode 100644
index 000000000000..49913297040c
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryMetricsDockerComposeConnectionDetailsFactory.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.otlp;
+
+import org.springframework.boot.actuate.autoconfigure.metrics.export.otlp.OtlpMetricsConnectionDetails;
+import org.springframework.boot.docker.compose.core.RunningService;
+import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory;
+import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionSource;
+
+/**
+ * {@link DockerComposeConnectionDetailsFactory} to create
+ * {@link OtlpMetricsConnectionDetails} for an OTLP service.
+ *
+ * @author EddĂș MelĂ©ndez
+ */
+class OpenTelemetryMetricsDockerComposeConnectionDetailsFactory
+		extends DockerComposeConnectionDetailsFactory<OtlpMetricsConnectionDetails> {
+
+	private static final int OTLP_PORT = 4318;
+
+	OpenTelemetryMetricsDockerComposeConnectionDetailsFactory() {
+		super("otel/opentelemetry-collector-contrib",
+				"org.springframework.boot.actuate.autoconfigure.metrics.export.otlp.OtlpMetricsExportAutoConfiguration");
+	}
+
+	@Override
+	protected OtlpMetricsConnectionDetails getDockerComposeConnectionDetails(DockerComposeConnectionSource source) {
+		return new OpenTelemetryMetricsDockerComposeConnectionDetails(source.getRunningService());
+	}
+
+	private static final class OpenTelemetryMetricsDockerComposeConnectionDetails extends DockerComposeConnectionDetails
+			implements OtlpMetricsConnectionDetails {
+
+		private final String host;
+
+		private final int port;
+
+		private OpenTelemetryMetricsDockerComposeConnectionDetails(RunningService source) {
+			super(source);
+			this.host = source.host();
+			this.port = source.ports().get(OTLP_PORT);
+		}
+
+		@Override
+		public String getUrl() {
+			return "http://%s:%d/v1/metrics".formatted(this.host, this.port);
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryTracingDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryTracingDockerComposeConnectionDetailsFactory.java
new file mode 100644
index 000000000000..20e5b06b3daa
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryTracingDockerComposeConnectionDetailsFactory.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.otlp;
+
+import org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpTracingConnectionDetails;
+import org.springframework.boot.docker.compose.core.RunningService;
+import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory;
+import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionSource;
+
+/**
+ * {@link DockerComposeConnectionDetailsFactory} to create
+ * {@link OtlpTracingConnectionDetails} for an OTLP service.
+ *
+ * @author EddĂș MelĂ©ndez
+ */
+class OpenTelemetryTracingDockerComposeConnectionDetailsFactory
+		extends DockerComposeConnectionDetailsFactory<OtlpTracingConnectionDetails> {
+
+	private static final int OTLP_PORT = 4318;
+
+	OpenTelemetryTracingDockerComposeConnectionDetailsFactory() {
+		super("otel/opentelemetry-collector-contrib",
+				"org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpAutoConfiguration");
+	}
+
+	@Override
+	protected OtlpTracingConnectionDetails getDockerComposeConnectionDetails(DockerComposeConnectionSource source) {
+		return new OpenTelemetryTracingDockerComposeConnectionDetails(source.getRunningService());
+	}
+
+	private static final class OpenTelemetryTracingDockerComposeConnectionDetails extends DockerComposeConnectionDetails
+			implements OtlpTracingConnectionDetails {
+
+		private final String host;
+
+		private final int port;
+
+		private OpenTelemetryTracingDockerComposeConnectionDetails(RunningService source) {
+			super(source);
+			this.host = source.host();
+			this.port = source.ports().get(OTLP_PORT);
+		}
+
+		@Override
+		public String getUrl() {
+			return "http://%s:%d/v1/traces".formatted(this.host, this.port);
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/otlp/package-info.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/otlp/package-info.java
new file mode 100644
index 000000000000..cbac91d2c639
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/otlp/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Support for docker compose OpenTelemetry service connections.
+ */
+package org.springframework.boot.docker.compose.service.connection.otlp;
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/pulsar/PulsarDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/pulsar/PulsarDockerComposeConnectionDetailsFactory.java
new file mode 100644
index 000000000000..0568a9811933
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/pulsar/PulsarDockerComposeConnectionDetailsFactory.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.pulsar;
+
+import org.springframework.boot.autoconfigure.pulsar.PulsarConnectionDetails;
+import org.springframework.boot.docker.compose.core.ConnectionPorts;
+import org.springframework.boot.docker.compose.core.RunningService;
+import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory;
+import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionSource;
+
+/**
+ * {@link DockerComposeConnectionDetailsFactory} to create {@link PulsarConnectionDetails}
+ * for a {@code pulsar} service.
+ *
+ * @author Chris Bono
+ */
+class PulsarDockerComposeConnectionDetailsFactory
+		extends DockerComposeConnectionDetailsFactory<PulsarConnectionDetails> {
+
+	private static final int BROKER_PORT = 6650;
+
+	private static final int ADMIN_PORT = 8080;
+
+	PulsarDockerComposeConnectionDetailsFactory() {
+		super("apachepulsar/pulsar");
+	}
+
+	@Override
+	protected PulsarConnectionDetails getDockerComposeConnectionDetails(DockerComposeConnectionSource source) {
+		return new PulsarDockerComposeConnectionDetails(source.getRunningService());
+	}
+
+	/**
+	 * {@link PulsarConnectionDetails} backed by a {@code pulsar} {@link RunningService}.
+	 */
+	static class PulsarDockerComposeConnectionDetails extends DockerComposeConnectionDetails
+			implements PulsarConnectionDetails {
+
+		private final String brokerUrl;
+
+		private final String adminUrl;
+
+		PulsarDockerComposeConnectionDetails(RunningService service) {
+			super(service);
+			ConnectionPorts ports = service.ports();
+			this.brokerUrl = "pulsar://%s:%s".formatted(service.host(), ports.get(BROKER_PORT));
+			this.adminUrl = "http://%s:%s".formatted(service.host(), ports.get(ADMIN_PORT));
+		}
+
+		@Override
+		public String getBrokerUrl() {
+			return this.brokerUrl;
+		}
+
+		@Override
+		public String getAdminUrl() {
+			return this.adminUrl;
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/pulsar/package-info.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/pulsar/package-info.java
new file mode 100644
index 000000000000..7d8c4d1b1a56
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/pulsar/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Auto-configuration for docker compose Pulsar service connections.
+ */
+package org.springframework.boot.docker.compose.service.connection.pulsar;
diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-docker-compose/src/main/resources/META-INF/spring.factories
index 7c6623fbe5c9..f00fc1938155 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/main/resources/META-INF/spring.factories
+++ b/spring-boot-project/spring-boot-docker-compose/src/main/resources/META-INF/spring.factories
@@ -5,6 +5,7 @@ org.springframework.boot.docker.compose.service.connection.DockerComposeServiceC
 
 # Connection Details Factories
 org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory=\
+org.springframework.boot.docker.compose.service.connection.activemq.ActiveMQDockerComposeConnectionDetailsFactory,\
 org.springframework.boot.docker.compose.service.connection.cassandra.CassandraDockerComposeConnectionDetailsFactory,\
 org.springframework.boot.docker.compose.service.connection.elasticsearch.ElasticsearchDockerComposeConnectionDetailsFactory,\
 org.springframework.boot.docker.compose.service.connection.flyway.JdbcAdaptingFlywayConnectionDetailsFactory,\
@@ -14,13 +15,18 @@ org.springframework.boot.docker.compose.service.connection.mariadb.MariaDbR2dbcD
 org.springframework.boot.docker.compose.service.connection.mongo.MongoDockerComposeConnectionDetailsFactory,\
 org.springframework.boot.docker.compose.service.connection.mysql.MySqlJdbcDockerComposeConnectionDetailsFactory,\
 org.springframework.boot.docker.compose.service.connection.mysql.MySqlR2dbcDockerComposeConnectionDetailsFactory,\
-org.springframework.boot.docker.compose.service.connection.oracle.OracleJdbcDockerComposeConnectionDetailsFactory,\
-org.springframework.boot.docker.compose.service.connection.oracle.OracleR2dbcDockerComposeConnectionDetailsFactory,\
+org.springframework.boot.docker.compose.service.connection.neo4j.Neo4jDockerComposeConnectionDetailsFactory,\
+org.springframework.boot.docker.compose.service.connection.oracle.OracleFreeJdbcDockerComposeConnectionDetailsFactory,\
+org.springframework.boot.docker.compose.service.connection.oracle.OracleXeJdbcDockerComposeConnectionDetailsFactory,\
+org.springframework.boot.docker.compose.service.connection.oracle.OracleFreeR2dbcDockerComposeConnectionDetailsFactory,\
+org.springframework.boot.docker.compose.service.connection.oracle.OracleXeR2dbcDockerComposeConnectionDetailsFactory,\
+org.springframework.boot.docker.compose.service.connection.otlp.OpenTelemetryMetricsDockerComposeConnectionDetailsFactory,\
+org.springframework.boot.docker.compose.service.connection.otlp.OpenTelemetryTracingDockerComposeConnectionDetailsFactory,\
 org.springframework.boot.docker.compose.service.connection.postgres.PostgresJdbcDockerComposeConnectionDetailsFactory,\
 org.springframework.boot.docker.compose.service.connection.postgres.PostgresR2dbcDockerComposeConnectionDetailsFactory,\
+org.springframework.boot.docker.compose.service.connection.pulsar.PulsarDockerComposeConnectionDetailsFactory,\
 org.springframework.boot.docker.compose.service.connection.rabbit.RabbitDockerComposeConnectionDetailsFactory,\
 org.springframework.boot.docker.compose.service.connection.redis.RedisDockerComposeConnectionDetailsFactory,\
 org.springframework.boot.docker.compose.service.connection.sqlserver.SqlServerJdbcDockerComposeConnectionDetailsFactory,\
 org.springframework.boot.docker.compose.service.connection.sqlserver.SqlServerR2dbcDockerComposeConnectionDetailsFactory,\
 org.springframework.boot.docker.compose.service.connection.zipkin.ZipkinDockerComposeConnectionDetailsFactory
-
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DefaultDockerComposeTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DefaultDockerComposeTests.java
index c6c297d3a9b2..cb1bbf13d219 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DefaultDockerComposeTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DefaultDockerComposeTests.java
@@ -46,7 +46,7 @@ class DefaultDockerComposeTests {
 
 	private static final String HOST = "192.168.1.1";
 
-	private DockerCli cli = mock(DockerCli.class);
+	private final DockerCli cli = mock(DockerCli.class);
 
 	@Test
 	void upRunsUpCommand() {
@@ -141,4 +141,20 @@ void getRunningServicesWhenNoHostUsesHostFromContext() {
 		assertThat(runningService.host()).isEqualTo("192.168.1.1");
 	}
 
+	@Test
+	void worksWithTruncatedIds() {
+		String shortId = "123";
+		String longId = "123456";
+		DockerCliComposePsResponse psResponse = new DockerCliComposePsResponse(shortId, "name", "redis", "running");
+		Config config = new Config("redis", Collections.emptyMap(), Collections.emptyMap(), Collections.emptyList());
+		DockerCliInspectResponse inspectResponse = new DockerCliInspectResponse(longId, config, null, null);
+		willReturn(List.of(new DockerCliContextResponse("test", true, "https://192.168.1.1"))).given(this.cli)
+			.run(new DockerCliCommand.Context());
+		willReturn(List.of(psResponse)).given(this.cli).run(new DockerCliCommand.ComposePs());
+		willReturn(List.of(inspectResponse)).given(this.cli).run(new DockerCliCommand.Inspect(List.of(shortId)));
+		DefaultDockerCompose compose = new DefaultDockerCompose(this.cli, null);
+		List<RunningService> runningServices = compose.getRunningServices();
+		assertThat(runningServices).hasSize(1);
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerCliCommandTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerCliCommandTests.java
index 8954dd91677b..4ed1d983bd17 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerCliCommandTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerCliCommandTests.java
@@ -88,7 +88,7 @@ void composeStart() {
 		DockerCliCommand<?> command = new DockerCliCommand.ComposeStart(LogLevel.INFO);
 		assertThat(command.getType()).isEqualTo(DockerCliCommand.Type.DOCKER_COMPOSE);
 		assertThat(command.getLogLevel()).isEqualTo(LogLevel.INFO);
-		assertThat(command.getCommand()).containsExactly("start", "--no-color", "--detach", "--wait");
+		assertThat(command.getCommand()).containsExactly("start");
 		assertThat(command.deserialize("[]")).isNull();
 	}
 
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerCliIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerCliIntegrationTests.java
new file mode 100644
index 000000000000..fe00279095b9
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerCliIntegrationTests.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.core;
+
+import java.io.File;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.springframework.boot.docker.compose.core.DockerCliCommand.ComposeConfig;
+import org.springframework.boot.docker.compose.core.DockerCliCommand.ComposeDown;
+import org.springframework.boot.docker.compose.core.DockerCliCommand.ComposePs;
+import org.springframework.boot.docker.compose.core.DockerCliCommand.ComposeStart;
+import org.springframework.boot.docker.compose.core.DockerCliCommand.ComposeStop;
+import org.springframework.boot.docker.compose.core.DockerCliCommand.ComposeUp;
+import org.springframework.boot.docker.compose.core.DockerCliCommand.Inspect;
+import org.springframework.boot.logging.LogLevel;
+import org.springframework.boot.testsupport.process.DisabledIfProcessUnavailable;
+import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.util.FileCopyUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link DockerCli}.
+ *
+ * @author Moritz Halbritter
+ * @author Andy Wilkinson
+ * @author Phillip Webb
+ */
+@DisabledIfDockerUnavailable
+@DisabledIfProcessUnavailable({ "docker", "compose" })
+class DockerCliIntegrationTests {
+
+	@TempDir
+	private static Path tempDir;
+
+	@Test
+	void runBasicCommand() {
+		DockerCli cli = new DockerCli(null, null, Collections.emptySet());
+		List<DockerCliContextResponse> context = cli.run(new DockerCliCommand.Context());
+		assertThat(context).isNotEmpty();
+	}
+
+	@Test
+	void runLifecycle() throws IOException {
+		File composeFile = createComposeFile();
+		DockerCli cli = new DockerCli(null, DockerComposeFile.of(composeFile), Collections.emptySet());
+		try {
+			// Verify that no services are running (this is a fresh compose project)
+			List<DockerCliComposePsResponse> ps = cli.run(new ComposePs());
+			assertThat(ps).isEmpty();
+			// List the config and verify that redis is there
+			DockerCliComposeConfigResponse config = cli.run(new ComposeConfig());
+			assertThat(config.services()).containsOnlyKeys("redis");
+			// Run up
+			cli.run(new ComposeUp(LogLevel.INFO));
+			// Run ps and use id to run inspect on the id
+			ps = cli.run(new ComposePs());
+			assertThat(ps).hasSize(1);
+			String id = ps.get(0).id();
+			List<DockerCliInspectResponse> inspect = cli.run(new Inspect(List.of(id)));
+			assertThat(inspect).isNotEmpty();
+			assertThat(inspect.get(0).id()).startsWith(id);
+			// Run stop, then run ps and verify the services are stopped
+			cli.run(new ComposeStop(Duration.ofSeconds(10)));
+			ps = cli.run(new ComposePs());
+			assertThat(ps).isEmpty();
+			// Run start, verify service is there, then run down and verify they are gone
+			cli.run(new ComposeStart(LogLevel.INFO));
+			ps = cli.run(new ComposePs());
+			assertThat(ps).hasSize(1);
+			cli.run(new ComposeDown(Duration.ofSeconds(10)));
+			ps = cli.run(new ComposePs());
+			assertThat(ps).isEmpty();
+		}
+		finally {
+			// Clean up in any case
+			quietComposeDown(cli);
+		}
+	}
+
+	private static void quietComposeDown(DockerCli cli) {
+		try {
+			cli.run(new ComposeDown(Duration.ZERO));
+		}
+		catch (RuntimeException ex) {
+			// Ignore
+		}
+	}
+
+	private static File createComposeFile() throws IOException {
+		File composeFile = new ClassPathResource("redis-compose.yaml", DockerCliIntegrationTests.class).getFile();
+		File tempComposeFile = Path.of(tempDir.toString(), composeFile.getName()).toFile();
+		String composeFileContent = FileCopyUtils.copyToString(new FileReader(composeFile));
+		composeFileContent = composeFileContent.replace("{imageName}",
+				DockerImageNames.redis().asCanonicalNameString());
+		FileCopyUtils.copy(composeFileContent, new FileWriter(tempComposeFile));
+		return tempComposeFile;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerCliTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerCliTests.java
deleted file mode 100644
index be801fb04d24..000000000000
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerCliTests.java
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.docker.compose.core;
-
-import java.util.Collections;
-import java.util.List;
-
-import org.junit.jupiter.api.Test;
-
-import org.springframework.boot.testsupport.process.DisabledIfProcessUnavailable;
-import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Tests for {@link DockerCli}.
- *
- * @author Moritz Halbritter
- * @author Andy Wilkinson
- * @author Phillip Webb
- */
-@DisabledIfDockerUnavailable
-@DisabledIfProcessUnavailable({ "docker", "compose" })
-class DockerCliTests {
-
-	@Test
-	void runBasicCommand() {
-		DockerCli cli = new DockerCli(null, null, Collections.emptySet());
-		List<DockerCliContextResponse> context = cli.run(new DockerCliCommand.Context());
-		assertThat(context).isNotEmpty();
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerComposeFileTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerComposeFileTests.java
index 4d1692e247fd..02bab15eb46c 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerComposeFileTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerComposeFileTests.java
@@ -106,7 +106,7 @@ void ofReturnsDockerComposeFile() throws Exception {
 		FileCopyUtils.copy(new byte[0], file);
 		DockerComposeFile composeFile = DockerComposeFile.of(file);
 		assertThat(composeFile).isNotNull();
-		assertThat(composeFile.toString()).isEqualTo(file.getCanonicalPath());
+		assertThat(composeFile).hasToString(file.getCanonicalPath());
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerJsonTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerJsonTests.java
index 0ff2135dc0ac..eb82617ea79f 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerJsonTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerJsonTests.java
@@ -17,6 +17,7 @@
 package org.springframework.boot.docker.compose.core;
 
 import java.util.List;
+import java.util.Locale;
 
 import org.junit.jupiter.api.Test;
 
@@ -68,7 +69,33 @@ void deserializeToListWhenMultipleLines() {
 		assertThat(response).containsExactly(new TestResponse(1), new TestResponse(2));
 	}
 
+	@Test
+	void shouldBeLocaleAgnostic() {
+		// Turkish locale lower cases the 'I' to a 'ı', not to an 'i'
+		withLocale(Locale.forLanguageTag("tr-TR"), () -> {
+			String json = """
+					{ "INTEGER": 42 }
+					""";
+			TestLowercaseResponse response = DockerJson.deserialize(json, TestLowercaseResponse.class);
+			assertThat(response.integer()).isEqualTo(42);
+		});
+	}
+
+	private void withLocale(Locale locale, Runnable runnable) {
+		Locale defaultLocale = Locale.getDefault();
+		try {
+			Locale.setDefault(locale);
+			runnable.run();
+		}
+		finally {
+			Locale.setDefault(defaultLocale);
+		}
+	}
+
 	record TestResponse(int value) {
 	}
 
+	record TestLowercaseResponse(int integer) {
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicateTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicateTests.java
index 183ca38ec177..76b1128232db 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicateTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicateTests.java
@@ -74,7 +74,14 @@ void labeled() {
 			.accepts(sourceOf("internalhost:8080/libs/libs/mzipkin", "openzipkin/zipkin"));
 	}
 
-	private Predicate<DockerComposeConnectionSource> predicateOf(String required) {
+	@Test
+	void multiple() {
+		assertThat(predicateOf("elasticsearch1", "elasticsearch2")).accepts(sourceOf("elasticsearch1"))
+			.accepts(sourceOf("elasticsearch2"));
+
+	}
+
+	private Predicate<DockerComposeConnectionSource> predicateOf(String... required) {
 		return new ConnectionNamePredicate(required);
 	}
 
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQDockerComposeConnectionDetailsFactoryIntegrationTests.java
new file mode 100644
index 000000000000..0fd0852991ea
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.activemq;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.autoconfigure.jms.activemq.ActiveMQConnectionDetails;
+import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests for {@link ActiveMQDockerComposeConnectionDetailsFactory}.
+ *
+ * @author Stephane Nicoll
+ */
+class ActiveMQDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests {
+
+	ActiveMQDockerComposeConnectionDetailsFactoryIntegrationTests() {
+		super("activemq-compose.yaml", DockerImageNames.activeMq());
+	}
+
+	@Test
+	void runCreatesConnectionDetails() {
+		ActiveMQConnectionDetails connectionDetails = run(ActiveMQConnectionDetails.class);
+		assertThat(connectionDetails.getBrokerUrl()).isNotNull().startsWith("tcp://");
+		assertThat(connectionDetails.getUser()).isEqualTo("root");
+		assertThat(connectionDetails.getPassword()).isEqualTo("secret");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQEnvironmentTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQEnvironmentTests.java
new file mode 100644
index 000000000000..04ee5929788f
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQEnvironmentTests.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.activemq;
+
+import java.util.Collections;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ActiveMQEnvironment}.
+ *
+ * @author Stephane Nicoll
+ */
+class ActiveMQEnvironmentTests {
+
+	@Test
+	void getUserWhenHasNoActiveMqUser() {
+		ActiveMQEnvironment environment = new ActiveMQEnvironment(Collections.emptyMap());
+		assertThat(environment.getUser()).isNull();
+	}
+
+	@Test
+	void getUserWhenHasActiveMqUser() {
+		ActiveMQEnvironment environment = new ActiveMQEnvironment(Map.of("ACTIVEMQ_USERNAME", "me"));
+		assertThat(environment.getUser()).isEqualTo("me");
+	}
+
+	@Test
+	void getPasswordWhenHasNoActiveMqPassword() {
+		ActiveMQEnvironment environment = new ActiveMQEnvironment(Collections.emptyMap());
+		assertThat(environment.getPassword()).isNull();
+	}
+
+	@Test
+	void getPasswordWhenHasActiveMqPassword() {
+		ActiveMQEnvironment environment = new ActiveMQEnvironment(Map.of("ACTIVEMQ_PASSWORD", "secret"));
+		assertThat(environment.getPassword()).isEqualTo("secret");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/cassandra/CassandraDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/cassandra/CassandraDockerComposeConnectionDetailsFactoryIntegrationTests.java
index c4cf1c637b2b..8df562e23111 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/cassandra/CassandraDockerComposeConnectionDetailsFactoryIntegrationTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/cassandra/CassandraDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -23,6 +23,7 @@
 import org.springframework.boot.autoconfigure.cassandra.CassandraConnectionDetails;
 import org.springframework.boot.autoconfigure.cassandra.CassandraConnectionDetails.Node;
 import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -34,14 +35,14 @@
 class CassandraDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests {
 
 	CassandraDockerComposeConnectionDetailsFactoryIntegrationTests() {
-		super("cassandra-compose.yaml");
+		super("cassandra-compose.yaml", DockerImageNames.cassandra());
 	}
 
 	@Test
 	void runCreatesConnectionDetails() {
 		CassandraConnectionDetails connectionDetails = run(CassandraConnectionDetails.class);
 		List<Node> contactPoints = connectionDetails.getContactPoints();
-		assertThat(contactPoints.size()).isEqualTo(1);
+		assertThat(contactPoints).hasSize(1);
 		Node node = contactPoints.get(0);
 		assertThat(node.host()).isNotNull();
 		assertThat(node.port()).isGreaterThan(0);
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/elasticsearch/ElasticsearchDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/elasticsearch/ElasticsearchDockerComposeConnectionDetailsFactoryIntegrationTests.java
index 936825889571..c373f26f9ac7 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/elasticsearch/ElasticsearchDockerComposeConnectionDetailsFactoryIntegrationTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/elasticsearch/ElasticsearchDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -22,6 +22,7 @@
 import org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchConnectionDetails.Node;
 import org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchConnectionDetails.Node.Protocol;
 import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -35,7 +36,7 @@
 class ElasticsearchDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests {
 
 	ElasticsearchDockerComposeConnectionDetailsFactoryIntegrationTests() {
-		super("elasticsearch-compose.yaml");
+		super("elasticsearch-compose.yaml", DockerImageNames.elasticsearch8());
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/flyway/JdbcAdaptingFlywayConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/flyway/JdbcAdaptingFlywayConnectionDetailsFactoryIntegrationTests.java
index 16bfb98cf9bb..a87d73f347a4 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/flyway/JdbcAdaptingFlywayConnectionDetailsFactoryIntegrationTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/flyway/JdbcAdaptingFlywayConnectionDetailsFactoryIntegrationTests.java
@@ -20,6 +20,7 @@
 
 import org.springframework.boot.autoconfigure.flyway.FlywayConnectionDetails;
 import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -31,7 +32,7 @@
 class JdbcAdaptingFlywayConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests {
 
 	JdbcAdaptingFlywayConnectionDetailsFactoryIntegrationTests() {
-		super("flyway-compose.yaml");
+		super("flyway-compose.yaml", DockerImageNames.postgresql());
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/liquibase/JdbcAdaptingLiquibaseConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/liquibase/JdbcAdaptingLiquibaseConnectionDetailsFactoryIntegrationTests.java
index a9085f9329f5..9530bdeb567d 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/liquibase/JdbcAdaptingLiquibaseConnectionDetailsFactoryIntegrationTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/liquibase/JdbcAdaptingLiquibaseConnectionDetailsFactoryIntegrationTests.java
@@ -20,6 +20,7 @@
 
 import org.springframework.boot.autoconfigure.liquibase.LiquibaseConnectionDetails;
 import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -31,7 +32,7 @@
 class JdbcAdaptingLiquibaseConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests {
 
 	JdbcAdaptingLiquibaseConnectionDetailsFactoryIntegrationTests() {
-		super("liquibase-compose.yaml");
+		super("liquibase-compose.yaml", DockerImageNames.postgresql());
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mariadb/MariaDbEnvironmentTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mariadb/MariaDbEnvironmentTests.java
index 5d05239c23ed..d291bfab1d82 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mariadb/MariaDbEnvironmentTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mariadb/MariaDbEnvironmentTests.java
@@ -30,6 +30,7 @@
  * @author Moritz Halbritter
  * @author Andy Wilkinson
  * @author Phillip Webb
+ * @author Jinseong Hwang
  */
 class MariaDbEnvironmentTests {
 
@@ -74,21 +75,21 @@ void getUsernameWhenHasMariadbUser() {
 	}
 
 	@Test
-	void getUsernameWhenHasMySqlUser() {
+	void getUsernameWhenHasMysqlUser() {
 		MariaDbEnvironment environment = new MariaDbEnvironment(
 				Map.of("MYSQL_USER", "myself", "MARIADB_PASSWORD", "secret", "MARIADB_DATABASE", "db"));
 		assertThat(environment.getUsername()).isEqualTo("myself");
 	}
 
 	@Test
-	void getUsernameWhenHasMariadbUserAndMySqlUser() {
+	void getUsernameWhenHasMariadbUserAndMysqlUser() {
 		MariaDbEnvironment environment = new MariaDbEnvironment(Map.of("MARIADB_USER", "myself", "MYSQL_USER", "me",
 				"MARIADB_PASSWORD", "secret", "MARIADB_DATABASE", "db"));
 		assertThat(environment.getUsername()).isEqualTo("myself");
 	}
 
 	@Test
-	void getUsernameWhenHasNoMariadbUserOrMySqlUser() {
+	void getUsernameWhenHasNoMariadbUserOrMysqlUser() {
 		MariaDbEnvironment environment = new MariaDbEnvironment(
 				Map.of("MARIADB_PASSWORD", "secret", "MARIADB_DATABASE", "db"));
 		assertThat(environment.getUsername()).isEqualTo("root");
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mariadb/MariaDbJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mariadb/MariaDbJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
index e3b4dbc1cb09..61b072771470 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mariadb/MariaDbJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mariadb/MariaDbJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -20,6 +20,7 @@
 
 import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails;
 import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -33,7 +34,7 @@
 class MariaDbJdbcDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests {
 
 	MariaDbJdbcDockerComposeConnectionDetailsFactoryIntegrationTests() {
-		super("mariadb-compose.yaml");
+		super("mariadb-compose.yaml", DockerImageNames.mariadb());
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mariadb/MariaDbR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mariadb/MariaDbR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
index c72f7d8f8e94..0e69f50f9cc9 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mariadb/MariaDbR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mariadb/MariaDbR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -21,6 +21,7 @@
 
 import org.springframework.boot.autoconfigure.r2dbc.R2dbcConnectionDetails;
 import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -34,7 +35,7 @@
 class MariaDbR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests {
 
 	MariaDbR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests() {
-		super("mariadb-compose.yaml");
+		super("mariadb-compose.yaml", DockerImageNames.mariadb());
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mongo/MongoDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mongo/MongoDockerComposeConnectionDetailsFactoryIntegrationTests.java
index 01777d1a74f0..e863d424e648 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mongo/MongoDockerComposeConnectionDetailsFactoryIntegrationTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mongo/MongoDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -21,6 +21,7 @@
 
 import org.springframework.boot.autoconfigure.mongo.MongoConnectionDetails;
 import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -35,7 +36,7 @@
 class MongoDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests {
 
 	MongoDockerComposeConnectionDetailsFactoryIntegrationTests() {
-		super("mongo-compose.yaml");
+		super("mongo-compose.yaml", DockerImageNames.mongo());
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mysql/MySqlEnvironmentTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mysql/MySqlEnvironmentTests.java
index 98cb7e4f8ef4..819d2ebd1359 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mysql/MySqlEnvironmentTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mysql/MySqlEnvironmentTests.java
@@ -30,6 +30,7 @@
  * @author Moritz Halbritter
  * @author Andy Wilkinson
  * @author Phillip Webb
+ * @author Jinseong Hwang
  */
 class MySqlEnvironmentTests {
 
@@ -53,14 +54,14 @@ void createWhenHasNoDatabaseThrowsException() {
 	}
 
 	@Test
-	void getUsernameWhenHasMySqlUser() {
+	void getUsernameWhenHasMysqlUser() {
 		MySqlEnvironment environment = new MySqlEnvironment(
 				Map.of("MYSQL_USER", "myself", "MYSQL_PASSWORD", "secret", "MYSQL_DATABASE", "db"));
 		assertThat(environment.getUsername()).isEqualTo("myself");
 	}
 
 	@Test
-	void getUsernameWhenHasNoMySqlUser() {
+	void getUsernameWhenHasNoMysqlUser() {
 		MySqlEnvironment environment = new MySqlEnvironment(Map.of("MYSQL_PASSWORD", "secret", "MYSQL_DATABASE", "db"));
 		assertThat(environment.getUsername()).isEqualTo("root");
 	}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mysql/MySqlJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mysql/MySqlJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
index b33d7dc6ed82..418ccd85387d 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mysql/MySqlJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mysql/MySqlJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -20,6 +20,7 @@
 
 import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails;
 import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -33,7 +34,7 @@
 class MySqlJdbcDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests {
 
 	MySqlJdbcDockerComposeConnectionDetailsFactoryIntegrationTests() {
-		super("mysql-compose.yaml");
+		super("mysql-compose.yaml", DockerImageNames.mysql());
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mysql/MySqlR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mysql/MySqlR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
index e5718bbfc060..cf21913c926c 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mysql/MySqlR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/mysql/MySqlR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -21,6 +21,7 @@
 
 import org.springframework.boot.autoconfigure.r2dbc.R2dbcConnectionDetails;
 import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -34,7 +35,7 @@
 class MySqlR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests {
 
 	MySqlR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests() {
-		super("mysql-compose.yaml");
+		super("mysql-compose.yaml", DockerImageNames.mysql());
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jDockerComposeConnectionDetailsFactoryIntegrationTests.java
new file mode 100644
index 000000000000..ca95c13efa11
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.neo4j;
+
+import org.junit.jupiter.api.Test;
+import org.neo4j.driver.AuthTokens;
+import org.neo4j.driver.Driver;
+import org.neo4j.driver.GraphDatabase;
+
+import org.springframework.boot.autoconfigure.neo4j.Neo4jConnectionDetails;
+import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNoException;
+
+/**
+ * Integration tests for {@link Neo4jDockerComposeConnectionDetailsFactory}.
+ *
+ * @author Andy Wilkinson
+ */
+class Neo4jDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests {
+
+	Neo4jDockerComposeConnectionDetailsFactoryIntegrationTests() {
+		super("neo4j-compose.yaml", DockerImageNames.neo4j());
+	}
+
+	@Test
+	void runCreatesConnectionDetailsThatCanAccessNeo4j() {
+		Neo4jConnectionDetails connectionDetails = run(Neo4jConnectionDetails.class);
+		assertThat(connectionDetails.getAuthToken()).isEqualTo(AuthTokens.basic("neo4j", "secret"));
+		try (Driver driver = GraphDatabase.driver(connectionDetails.getUri(), connectionDetails.getAuthToken())) {
+			assertThatNoException().isThrownBy(driver::verifyConnectivity);
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jEnvironmentTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jEnvironmentTests.java
new file mode 100644
index 000000000000..4cbb02d0b608
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jEnvironmentTests.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.neo4j;
+
+import java.util.Collections;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.neo4j.driver.AuthTokens;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+
+/**
+ * Tests for {@link Neo4jEnvironment}.
+ *
+ * @author Andy Wilkinson
+ */
+class Neo4jEnvironmentTests {
+
+	@Test
+	void whenNeo4jAuthIsNullThenAuthTokenIsNull() {
+		Neo4jEnvironment environment = new Neo4jEnvironment(Collections.emptyMap());
+		assertThat(environment.getAuthToken()).isNull();
+	}
+
+	@Test
+	void whenNeo4jAuthIsNoneThenAuthTokenIsNone() {
+		Neo4jEnvironment environment = new Neo4jEnvironment(Map.of("NEO4J_AUTH", "none"));
+		assertThat(environment.getAuthToken()).isEqualTo(AuthTokens.none());
+	}
+
+	@Test
+	void whenNeo4jAuthIsNeo4jSlashPasswordThenAuthTokenIsBasic() {
+		Neo4jEnvironment environment = new Neo4jEnvironment(Map.of("NEO4J_AUTH", "neo4j/custom-password"));
+		assertThat(environment.getAuthToken()).isEqualTo(AuthTokens.basic("neo4j", "custom-password"));
+	}
+
+	@Test
+	void whenNeo4jAuthIsNeitherNoneNorNeo4jSlashPasswordEnvironmentCreationThrows() {
+		assertThatIllegalStateException()
+			.isThrownBy(() -> new Neo4jEnvironment(Map.of("NEO4J_AUTH", "graphdb/custom-password")));
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleEnvironmentTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleEnvironmentTests.java
index 334ee0812d44..b66b55196736 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleEnvironmentTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleEnvironmentTests.java
@@ -34,76 +34,81 @@ class OracleEnvironmentTests {
 
 	@Test
 	void getUsernameWhenHasAppUser() {
-		OracleEnvironment environment = new OracleEnvironment(Map.of("APP_USER", "alice", "APP_PASSWORD", "secret"));
+		OracleEnvironment environment = new OracleEnvironment(
+				Map.of("APP_USER", "alice", "APP_USER_PASSWORD", "secret"), "defaultDb");
 		assertThat(environment.getUsername()).isEqualTo("alice");
 	}
 
 	@Test
 	void getUsernameWhenHasNoAppUser() {
-		OracleEnvironment environment = new OracleEnvironment(Map.of("ORACLE_PASSWORD", "secret"));
+		OracleEnvironment environment = new OracleEnvironment(Map.of("ORACLE_PASSWORD", "secret"), "defaultDb");
 		assertThat(environment.getUsername()).isEqualTo("system");
 	}
 
 	@Test
 	void getPasswordWhenHasAppPassword() {
-		OracleEnvironment environment = new OracleEnvironment(Map.of("APP_USER", "alice", "APP_PASSWORD", "secret"));
+		OracleEnvironment environment = new OracleEnvironment(
+				Map.of("APP_USER", "alice", "APP_USER_PASSWORD", "secret"), "defaultDb");
 		assertThat(environment.getPassword()).isEqualTo("secret");
 	}
 
 	@Test
 	void getPasswordWhenHasOraclePassword() {
-		OracleEnvironment environment = new OracleEnvironment(Map.of("ORACLE_PASSWORD", "secret"));
+		OracleEnvironment environment = new OracleEnvironment(Map.of("ORACLE_PASSWORD", "secret"), "defaultDb");
 		assertThat(environment.getPassword()).isEqualTo("secret");
 	}
 
 	@Test
 	void createWhenRandomPasswordAndAppPasswordDoesNotThrow() {
 		assertThatNoException().isThrownBy(() -> new OracleEnvironment(
-				Map.of("APP_USER", "alice", "APP_PASSWORD", "secret", "ORACLE_RANDOM_PASSWORD", "true")));
+				Map.of("APP_USER", "alice", "APP_USER_PASSWORD", "secret", "ORACLE_RANDOM_PASSWORD", "true"),
+				"defaultDb"));
 	}
 
 	@Test
 	void createWhenRandomPasswordThrowsException() {
 		assertThatIllegalStateException()
-			.isThrownBy(() -> new OracleEnvironment(Map.of("ORACLE_RANDOM_PASSWORD", "true")))
-			.withMessage("ORACLE_RANDOM_PASSWORD is not supported without APP_USER and APP_PASSWORD");
+			.isThrownBy(() -> new OracleEnvironment(Map.of("ORACLE_RANDOM_PASSWORD", "true"), "defaultDb"))
+			.withMessage("ORACLE_RANDOM_PASSWORD is not supported without APP_USER and APP_USER_PASSWORD");
 	}
 
 	@Test
 	void createWhenAppUserAndNoAppPasswordThrowsException() {
-		assertThatIllegalStateException().isThrownBy(() -> new OracleEnvironment(Map.of("APP_USER", "alice")))
+		assertThatIllegalStateException()
+			.isThrownBy(() -> new OracleEnvironment(Map.of("APP_USER", "alice"), "defaultDb"))
 			.withMessage("No Oracle app password found");
 	}
 
 	@Test
 	void createWhenAppUserAndEmptyAppPasswordThrowsException() {
 		assertThatIllegalStateException()
-			.isThrownBy(() -> new OracleEnvironment(Map.of("APP_USER", "alice", "APP_PASSWORD", "")))
+			.isThrownBy(() -> new OracleEnvironment(Map.of("APP_USER", "alice", "APP_USER_PASSWORD", ""), "defaultDb"))
 			.withMessage("No Oracle app password found");
 	}
 
 	@Test
 	void createWhenHasNoPasswordThrowsException() {
-		assertThatIllegalStateException().isThrownBy(() -> new OracleEnvironment(Collections.emptyMap()))
+		assertThatIllegalStateException().isThrownBy(() -> new OracleEnvironment(Collections.emptyMap(), "defaultDb"))
 			.withMessage("No Oracle password found");
 	}
 
 	@Test
 	void createWhenHasEmptyPasswordThrowsException() {
-		assertThatIllegalStateException().isThrownBy(() -> new OracleEnvironment(Map.of("ORACLE_PASSWORD", "")))
+		assertThatIllegalStateException()
+			.isThrownBy(() -> new OracleEnvironment(Map.of("ORACLE_PASSWORD", ""), "defaultDb"))
 			.withMessage("No Oracle password found");
 	}
 
 	@Test
 	void getDatabaseWhenHasNoOracleDatabase() {
-		OracleEnvironment environment = new OracleEnvironment(Map.of("ORACLE_PASSWORD", "secret"));
-		assertThat(environment.getDatabase()).isEqualTo("xepdb1");
+		OracleEnvironment environment = new OracleEnvironment(Map.of("ORACLE_PASSWORD", "secret"), "defaultDb");
+		assertThat(environment.getDatabase()).isEqualTo("defaultDb");
 	}
 
 	@Test
 	void getDatabaseWhenHasOracleDatabase() {
 		OracleEnvironment environment = new OracleEnvironment(
-				Map.of("ORACLE_PASSWORD", "secret", "ORACLE_DATABASE", "db"));
+				Map.of("ORACLE_PASSWORD", "secret", "ORACLE_DATABASE", "db"), "defaultDb");
 		assertThat(environment.getDatabase()).isEqualTo("db");
 	}
 
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
new file mode 100644
index 000000000000..127fe0f57467
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.oracle;
+
+import java.sql.Driver;
+import java.time.Duration;
+
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.OS;
+
+import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails;
+import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.jdbc.DatabaseDriver;
+import org.springframework.boot.testsupport.junit.DisabledOnOs;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.SimpleDriverDataSource;
+import org.springframework.util.ClassUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests for {@link OracleFreeJdbcDockerComposeConnectionDetailsFactory}
+ *
+ * @author Andy Wilkinson
+ */
+@DisabledOnOs(os = { OS.LINUX, OS.MAC }, architecture = "aarch64",
+		disabledReason = "The Oracle image has no ARM support")
+class OracleFreeJdbcDockerComposeConnectionDetailsFactoryIntegrationTests
+		extends AbstractDockerComposeIntegrationTests {
+
+	OracleFreeJdbcDockerComposeConnectionDetailsFactoryIntegrationTests() {
+		super("oracle-compose.yaml", DockerImageNames.oracleFree());
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	void runCreatesConnectionDetailsThatCanBeUsedToAccessDatabase() throws Exception {
+		JdbcConnectionDetails connectionDetails = run(JdbcConnectionDetails.class);
+		assertThat(connectionDetails.getUsername()).isEqualTo("app_user");
+		assertThat(connectionDetails.getPassword()).isEqualTo("app_user_secret");
+		assertThat(connectionDetails.getJdbcUrl()).startsWith("jdbc:oracle:thin:@").endsWith("/freepdb1");
+		SimpleDriverDataSource dataSource = new SimpleDriverDataSource();
+		dataSource.setUrl(connectionDetails.getJdbcUrl());
+		dataSource.setUsername(connectionDetails.getUsername());
+		dataSource.setPassword(connectionDetails.getPassword());
+		dataSource.setDriverClass((Class<? extends Driver>) ClassUtils.forName(connectionDetails.getDriverClassName(),
+				getClass().getClassLoader()));
+		Awaitility.await().atMost(Duration.ofMinutes(1)).ignoreExceptions().untilAsserted(() -> {
+			JdbcTemplate template = new JdbcTemplate(dataSource);
+			assertThat(template.queryForObject(DatabaseDriver.ORACLE.getValidationQuery(), String.class))
+				.isEqualTo("Hello");
+		});
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
new file mode 100644
index 000000000000..dea004833c4c
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.oracle;
+
+import java.time.Duration;
+
+import io.r2dbc.spi.ConnectionFactories;
+import io.r2dbc.spi.ConnectionFactoryOptions;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.OS;
+
+import org.springframework.boot.autoconfigure.r2dbc.R2dbcConnectionDetails;
+import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.jdbc.DatabaseDriver;
+import org.springframework.boot.testsupport.junit.DisabledOnOs;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
+import org.springframework.r2dbc.core.DatabaseClient;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests for {@link OracleFreeR2dbcDockerComposeConnectionDetailsFactory}
+ *
+ * @author Andy Wilkinson
+ */
+@DisabledOnOs(os = { OS.LINUX, OS.MAC }, architecture = "aarch64",
+		disabledReason = "The Oracle image has no ARM support")
+class OracleFreeR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests
+		extends AbstractDockerComposeIntegrationTests {
+
+	OracleFreeR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests() {
+		super("oracle-compose.yaml", DockerImageNames.oracleFree());
+	}
+
+	@Test
+	void runCreatesConnectionDetailsThatCanBeUsedToAccessDatabase() {
+		R2dbcConnectionDetails connectionDetails = run(R2dbcConnectionDetails.class);
+		ConnectionFactoryOptions connectionFactoryOptions = connectionDetails.getConnectionFactoryOptions();
+		assertThat(connectionFactoryOptions.toString()).contains("database=freepdb1", "driver=oracle",
+				"password=REDACTED", "user=app_user");
+		assertThat(connectionFactoryOptions.getRequiredValue(ConnectionFactoryOptions.PASSWORD))
+			.isEqualTo("app_user_secret");
+		Awaitility.await().atMost(Duration.ofMinutes(1)).ignoreExceptions().untilAsserted(() -> {
+			Object result = DatabaseClient.create(ConnectionFactories.get(connectionFactoryOptions))
+				.sql(DatabaseDriver.ORACLE.getValidationQuery())
+				.map((row, metadata) -> row.get(0))
+				.first()
+				.block(Duration.ofSeconds(30));
+			assertThat(result).isEqualTo("Hello");
+		});
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
deleted file mode 100644
index 43666f3b545e..000000000000
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.docker.compose.service.connection.oracle;
-
-import java.sql.Driver;
-import java.time.Duration;
-
-import org.awaitility.Awaitility;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.condition.OS;
-
-import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails;
-import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
-import org.springframework.boot.jdbc.DatabaseDriver;
-import org.springframework.boot.testsupport.junit.DisabledOnOs;
-import org.springframework.jdbc.core.JdbcTemplate;
-import org.springframework.jdbc.datasource.SimpleDriverDataSource;
-import org.springframework.util.ClassUtils;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Integration tests for {@link OracleJdbcDockerComposeConnectionDetailsFactory}
- *
- * @author Andy Wilkinson
- */
-@DisabledOnOs(os = { OS.LINUX, OS.MAC }, architecture = "aarch64",
-		disabledReason = "The Oracle image has no ARM support")
-class OracleJdbcDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests {
-
-	OracleJdbcDockerComposeConnectionDetailsFactoryIntegrationTests() {
-		super("oracle-compose.yaml");
-	}
-
-	@Test
-	@SuppressWarnings("unchecked")
-	void runCreatesConnectionDetailsThatCanBeUsedToAccessDatabase() throws Exception {
-		JdbcConnectionDetails connectionDetails = run(JdbcConnectionDetails.class);
-		assertThat(connectionDetails.getUsername()).isEqualTo("system");
-		assertThat(connectionDetails.getPassword()).isEqualTo("secret");
-		assertThat(connectionDetails.getJdbcUrl()).startsWith("jdbc:oracle:thin:@").endsWith("/xepdb1");
-		SimpleDriverDataSource dataSource = new SimpleDriverDataSource();
-		dataSource.setUrl(connectionDetails.getJdbcUrl());
-		dataSource.setUsername(connectionDetails.getUsername());
-		dataSource.setPassword(connectionDetails.getPassword());
-		dataSource.setDriverClass((Class<? extends Driver>) ClassUtils.forName(connectionDetails.getDriverClassName(),
-				getClass().getClassLoader()));
-		Awaitility.await().atMost(Duration.ofMinutes(1)).ignoreExceptions().untilAsserted(() -> {
-			JdbcTemplate template = new JdbcTemplate(dataSource);
-			assertThat(template.queryForObject(DatabaseDriver.ORACLE.getValidationQuery(), String.class))
-				.isEqualTo("Hello");
-		});
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
deleted file mode 100644
index c16778806867..000000000000
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.docker.compose.service.connection.oracle;
-
-import java.time.Duration;
-
-import io.r2dbc.spi.ConnectionFactories;
-import io.r2dbc.spi.ConnectionFactoryOptions;
-import org.awaitility.Awaitility;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.condition.OS;
-
-import org.springframework.boot.autoconfigure.r2dbc.R2dbcConnectionDetails;
-import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
-import org.springframework.boot.jdbc.DatabaseDriver;
-import org.springframework.boot.testsupport.junit.DisabledOnOs;
-import org.springframework.r2dbc.core.DatabaseClient;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Integration tests for {@link OracleR2dbcDockerComposeConnectionDetailsFactory}
- *
- * @author Andy Wilkinson
- */
-@DisabledOnOs(os = { OS.LINUX, OS.MAC }, architecture = "aarch64",
-		disabledReason = "The Oracle image has no ARM support")
-class OracleR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests {
-
-	OracleR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests() {
-		super("oracle-compose.yaml");
-	}
-
-	@Test
-	void runCreatesConnectionDetailsThatCanBeUsedToAccessDatabase() {
-		R2dbcConnectionDetails connectionDetails = run(R2dbcConnectionDetails.class);
-		ConnectionFactoryOptions connectionFactoryOptions = connectionDetails.getConnectionFactoryOptions();
-		assertThat(connectionFactoryOptions.toString()).contains("database=xepdb1", "driver=oracle",
-				"password=REDACTED", "user=system");
-		assertThat(connectionFactoryOptions.getRequiredValue(ConnectionFactoryOptions.PASSWORD)).isEqualTo("secret");
-		Awaitility.await().atMost(Duration.ofMinutes(1)).ignoreExceptions().untilAsserted(() -> {
-			Object result = DatabaseClient.create(ConnectionFactories.get(connectionFactoryOptions))
-				.sql(DatabaseDriver.ORACLE.getValidationQuery())
-				.map((row, metadata) -> row.get(0))
-				.first()
-				.block(Duration.ofSeconds(30));
-			assertThat(result).isEqualTo("Hello");
-		});
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
new file mode 100644
index 000000000000..19ebd9262605
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.oracle;
+
+import java.sql.Driver;
+import java.time.Duration;
+
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.OS;
+
+import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails;
+import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.jdbc.DatabaseDriver;
+import org.springframework.boot.testsupport.junit.DisabledOnOs;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.SimpleDriverDataSource;
+import org.springframework.util.ClassUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests for {@link OracleXeJdbcDockerComposeConnectionDetailsFactory}
+ *
+ * @author Andy Wilkinson
+ */
+@DisabledOnOs(os = { OS.LINUX, OS.MAC }, architecture = "aarch64",
+		disabledReason = "The Oracle image has no ARM support")
+class OracleXeJdbcDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests {
+
+	OracleXeJdbcDockerComposeConnectionDetailsFactoryIntegrationTests() {
+		super("oracle-compose.yaml", DockerImageNames.oracleXe());
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	void runCreatesConnectionDetailsThatCanBeUsedToAccessDatabase() throws Exception {
+		JdbcConnectionDetails connectionDetails = run(JdbcConnectionDetails.class);
+		assertThat(connectionDetails.getUsername()).isEqualTo("app_user");
+		assertThat(connectionDetails.getPassword()).isEqualTo("app_user_secret");
+		assertThat(connectionDetails.getJdbcUrl()).startsWith("jdbc:oracle:thin:@").endsWith("/xepdb1");
+		SimpleDriverDataSource dataSource = new SimpleDriverDataSource();
+		dataSource.setUrl(connectionDetails.getJdbcUrl());
+		dataSource.setUsername(connectionDetails.getUsername());
+		dataSource.setPassword(connectionDetails.getPassword());
+		dataSource.setDriverClass((Class<? extends Driver>) ClassUtils.forName(connectionDetails.getDriverClassName(),
+				getClass().getClassLoader()));
+		Awaitility.await().atMost(Duration.ofMinutes(1)).ignoreExceptions().untilAsserted(() -> {
+			JdbcTemplate template = new JdbcTemplate(dataSource);
+			assertThat(template.queryForObject(DatabaseDriver.ORACLE.getValidationQuery(), String.class))
+				.isEqualTo("Hello");
+		});
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
new file mode 100644
index 000000000000..2b044d3fb36b
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.oracle;
+
+import java.time.Duration;
+
+import io.r2dbc.spi.ConnectionFactories;
+import io.r2dbc.spi.ConnectionFactoryOptions;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.OS;
+
+import org.springframework.boot.autoconfigure.r2dbc.R2dbcConnectionDetails;
+import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.jdbc.DatabaseDriver;
+import org.springframework.boot.testsupport.junit.DisabledOnOs;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
+import org.springframework.r2dbc.core.DatabaseClient;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests for {@link OracleXeR2dbcDockerComposeConnectionDetailsFactory}
+ *
+ * @author Andy Wilkinson
+ */
+@DisabledOnOs(os = { OS.LINUX, OS.MAC }, architecture = "aarch64",
+		disabledReason = "The Oracle image has no ARM support")
+class OracleXeR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests {
+
+	OracleXeR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests() {
+		super("oracle-compose.yaml", DockerImageNames.oracleXe());
+	}
+
+	@Test
+	void runCreatesConnectionDetailsThatCanBeUsedToAccessDatabase() {
+		R2dbcConnectionDetails connectionDetails = run(R2dbcConnectionDetails.class);
+		ConnectionFactoryOptions connectionFactoryOptions = connectionDetails.getConnectionFactoryOptions();
+		assertThat(connectionFactoryOptions.toString()).contains("database=xepdb1", "driver=oracle",
+				"password=REDACTED", "user=app_user");
+		assertThat(connectionFactoryOptions.getRequiredValue(ConnectionFactoryOptions.PASSWORD))
+			.isEqualTo("app_user_secret");
+		Awaitility.await().atMost(Duration.ofMinutes(1)).ignoreExceptions().untilAsserted(() -> {
+			Object result = DatabaseClient.create(ConnectionFactories.get(connectionFactoryOptions))
+				.sql(DatabaseDriver.ORACLE.getValidationQuery())
+				.map((row, metadata) -> row.get(0))
+				.first()
+				.block(Duration.ofSeconds(30));
+			assertThat(result).isEqualTo("Hello");
+		});
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryMetricsDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryMetricsDockerComposeConnectionDetailsFactoryIntegrationTests.java
new file mode 100644
index 000000000000..7f303d5082f9
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryMetricsDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.otlp;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.actuate.autoconfigure.metrics.export.otlp.OtlpMetricsConnectionDetails;
+import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests for
+ * {@link OpenTelemetryMetricsDockerComposeConnectionDetailsFactory}.
+ *
+ * @author EddĂș MelĂ©ndez
+ */
+class OpenTelemetryMetricsDockerComposeConnectionDetailsFactoryIntegrationTests
+		extends AbstractDockerComposeIntegrationTests {
+
+	OpenTelemetryMetricsDockerComposeConnectionDetailsFactoryIntegrationTests() {
+		super("otlp-compose.yaml", DockerImageNames.opentelemetry());
+	}
+
+	@Test
+	void runCreatesConnectionDetails() {
+		OtlpMetricsConnectionDetails connectionDetails = run(OtlpMetricsConnectionDetails.class);
+		assertThat(connectionDetails.getUrl()).startsWith("http://").endsWith("/v1/metrics");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryTracingDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryTracingDockerComposeConnectionDetailsFactoryIntegrationTests.java
new file mode 100644
index 000000000000..720b90a014f2
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryTracingDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.otlp;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpTracingConnectionDetails;
+import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests for
+ * {@link OpenTelemetryTracingDockerComposeConnectionDetailsFactory}.
+ *
+ * @author EddĂș MelĂ©ndez
+ */
+class OpenTelemetryTracingDockerComposeConnectionDetailsFactoryIntegrationTests
+		extends AbstractDockerComposeIntegrationTests {
+
+	OpenTelemetryTracingDockerComposeConnectionDetailsFactoryIntegrationTests() {
+		super("otlp-compose.yaml", DockerImageNames.opentelemetry());
+	}
+
+	@Test
+	void runCreatesConnectionDetails() {
+		OtlpTracingConnectionDetails connectionDetails = run(OtlpTracingConnectionDetails.class);
+		assertThat(connectionDetails.getUrl()).startsWith("http://").endsWith("/v1/traces");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/postgres/PostgresEnvironmentTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/postgres/PostgresEnvironmentTests.java
index 1d57b2d5f435..58d590de53ed 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/postgres/PostgresEnvironmentTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/postgres/PostgresEnvironmentTests.java
@@ -19,7 +19,6 @@
 import java.util.Collections;
 import java.util.Map;
 
-import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -32,7 +31,6 @@
  * @author Andy Wilkinson
  * @author Phillip Webb
  */
-@Disabled
 class PostgresEnvironmentTests {
 
 	@Test
@@ -61,13 +59,13 @@ void getPasswordWhenHasPostgresPassword() {
 	}
 
 	@Test
-	void getDatabaseWhenNoPostgresDbOrPostgressUser() {
+	void getDatabaseWhenNoPostgresDbOrPostgresUser() {
 		PostgresEnvironment environment = new PostgresEnvironment(Map.of("POSTGRES_PASSWORD", "secret"));
-		assertThat(environment.getDatabase()).isEqualTo("postgress");
+		assertThat(environment.getDatabase()).isEqualTo("postgres");
 	}
 
 	@Test
-	void getDatabaseWhenNoPostgresDbAndPostgressUser() {
+	void getDatabaseWhenNoPostgresDbAndPostgresUser() {
 		PostgresEnvironment environment = new PostgresEnvironment(
 				Map.of("POSTGRES_USER", "me", "POSTGRES_PASSWORD", "secret"));
 		assertThat(environment.getDatabase()).isEqualTo("me");
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/postgres/PostgresJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/postgres/PostgresJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
index ff1a9466c08a..b0a3873cdf58 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/postgres/PostgresJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/postgres/PostgresJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -20,6 +20,7 @@
 
 import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails;
 import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -33,7 +34,7 @@
 class PostgresJdbcDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests {
 
 	PostgresJdbcDockerComposeConnectionDetailsFactoryIntegrationTests() {
-		super("postgres-compose.yaml");
+		super("postgres-compose.yaml", DockerImageNames.postgresql());
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/postgres/PostgresR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/postgres/PostgresR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
index d741d3e1bcfa..ba152f37c8cf 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/postgres/PostgresR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/postgres/PostgresR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -21,6 +21,7 @@
 
 import org.springframework.boot.autoconfigure.r2dbc.R2dbcConnectionDetails;
 import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -34,7 +35,7 @@
 class PostgresR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests {
 
 	PostgresR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests() {
-		super("postgres-compose.yaml");
+		super("postgres-compose.yaml", DockerImageNames.postgresql());
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/pulsar/PulsarDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/pulsar/PulsarDockerComposeConnectionDetailsFactoryIntegrationTests.java
new file mode 100644
index 000000000000..c4c18d55ba0e
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/pulsar/PulsarDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2023-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docker.compose.service.connection.pulsar;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.autoconfigure.pulsar.PulsarConnectionDetails;
+import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration test for {@link PulsarDockerComposeConnectionDetailsFactory}.
+ *
+ * @author Chris Bono
+ */
+class PulsarDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests {
+
+	PulsarDockerComposeConnectionDetailsFactoryIntegrationTests() {
+		super("pulsar-compose.yaml", DockerImageNames.pulsar());
+	}
+
+	@Test
+	void runCreatesConnectionDetails() {
+		PulsarConnectionDetails connectionDetails = run(PulsarConnectionDetails.class);
+		assertThat(connectionDetails).isNotNull();
+		assertThat(connectionDetails.getBrokerUrl()).matches("^pulsar:\\/\\/\\S+:\\d+");
+		assertThat(connectionDetails.getAdminUrl()).matches("^http:\\/\\/\\S+:\\d+");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/rabbit/RabbitDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/rabbit/RabbitDockerComposeConnectionDetailsFactoryIntegrationTests.java
index 2a3930ed2fdd..96ccf959aaa3 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/rabbit/RabbitDockerComposeConnectionDetailsFactoryIntegrationTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/rabbit/RabbitDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -21,6 +21,7 @@
 import org.springframework.boot.autoconfigure.amqp.RabbitConnectionDetails;
 import org.springframework.boot.autoconfigure.amqp.RabbitConnectionDetails.Address;
 import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -34,7 +35,7 @@
 class RabbitDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests {
 
 	RabbitDockerComposeConnectionDetailsFactoryIntegrationTests() {
-		super("rabbit-compose.yaml");
+		super("rabbit-compose.yaml", DockerImageNames.rabbit());
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/rabbit/RabbitEnvironmentTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/rabbit/RabbitEnvironmentTests.java
index 8aaf45a3425f..fdac67e5cedc 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/rabbit/RabbitEnvironmentTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/rabbit/RabbitEnvironmentTests.java
@@ -19,7 +19,6 @@
 import java.util.Collections;
 import java.util.Map;
 
-import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -31,7 +30,6 @@
  * @author Andy Wilkinson
  * @author Phillip Webb
  */
-@Disabled
 class RabbitEnvironmentTests {
 
 	@Test
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/redis/RedisDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/redis/RedisDockerComposeConnectionDetailsFactoryIntegrationTests.java
index 1373362056d1..720f6a1d940f 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/redis/RedisDockerComposeConnectionDetailsFactoryIntegrationTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/redis/RedisDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -21,6 +21,7 @@
 import org.springframework.boot.autoconfigure.data.redis.RedisConnectionDetails;
 import org.springframework.boot.autoconfigure.data.redis.RedisConnectionDetails.Standalone;
 import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -34,7 +35,7 @@
 class RedisDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests {
 
 	RedisDockerComposeConnectionDetailsFactoryIntegrationTests() {
-		super("redis-compose.yaml");
+		super("redis-compose.yaml", DockerImageNames.redis());
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/sqlserver/SqlServerJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/sqlserver/SqlServerJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
index 3e954dfc9374..f9f020e4e066 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/sqlserver/SqlServerJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/sqlserver/SqlServerJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -25,6 +25,7 @@
 import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
 import org.springframework.boot.jdbc.DatabaseDriver;
 import org.springframework.boot.testsupport.junit.DisabledOnOs;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.jdbc.datasource.SimpleDriverDataSource;
 import org.springframework.util.ClassUtils;
@@ -41,7 +42,7 @@
 class SqlServerJdbcDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests {
 
 	SqlServerJdbcDockerComposeConnectionDetailsFactoryIntegrationTests() {
-		super("mssqlserver-compose.yaml");
+		super("mssqlserver-compose.yaml", DockerImageNames.sqlserver());
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/sqlserver/SqlServerR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/sqlserver/SqlServerR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
index 09382daa55e8..91bf322bd1a9 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/sqlserver/SqlServerR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/sqlserver/SqlServerR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -27,6 +27,7 @@
 import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
 import org.springframework.boot.jdbc.DatabaseDriver;
 import org.springframework.boot.testsupport.junit.DisabledOnOs;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
 import org.springframework.r2dbc.core.DatabaseClient;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -42,7 +43,7 @@ class SqlServerR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests
 		extends AbstractDockerComposeIntegrationTests {
 
 	SqlServerR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests() {
-		super("mssqlserver-compose.yaml");
+		super("mssqlserver-compose.yaml", DockerImageNames.sqlserver());
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/test/AbstractDockerComposeIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/test/AbstractDockerComposeIntegrationTests.java
index d59742b86314..2bfb001bdaba 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/test/AbstractDockerComposeIntegrationTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/test/AbstractDockerComposeIntegrationTests.java
@@ -16,10 +16,17 @@
 
 package org.springframework.boot.docker.compose.service.connection.test;
 
+import java.io.File;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.nio.file.Path;
 import java.util.LinkedHashMap;
 import java.util.Map;
 
 import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.io.TempDir;
+import org.testcontainers.utility.DockerImageName;
 
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.SpringApplicationShutdownHandlers;
@@ -28,28 +35,38 @@
 import org.springframework.context.annotation.Configuration;
 import org.springframework.core.io.ClassPathResource;
 import org.springframework.core.io.Resource;
+import org.springframework.util.FileCopyUtils;
 import org.springframework.util.function.ThrowingSupplier;
 
+import static org.assertj.core.api.Assertions.fail;
+
 /**
  * Abstract base class for integration tests.
  *
  * @author Moritz Halbritter
  * @author Andy Wilkinson
+ * @author Scott Frederick
  */
 @DisabledIfProcessUnavailable({ "docker", "version" })
 @DisabledIfProcessUnavailable({ "docker", "compose" })
 public abstract class AbstractDockerComposeIntegrationTests {
 
+	@TempDir
+	private static Path tempDir;
+
 	private final Resource composeResource;
 
+	private final DockerImageName dockerImageName;
+
 	@AfterAll
 	static void shutDown() {
 		SpringApplicationShutdownHandlers shutdownHandlers = SpringApplication.getShutdownHandlers();
 		((Runnable) shutdownHandlers).run();
 	}
 
-	protected AbstractDockerComposeIntegrationTests(String composeResource) {
+	protected AbstractDockerComposeIntegrationTests(String composeResource, DockerImageName dockerImageName) {
 		this.composeResource = new ClassPathResource(composeResource, getClass());
+		this.dockerImageName = dockerImageName;
 	}
 
 	protected final <T extends ConnectionDetails> T run(Class<T> type) {
@@ -57,12 +74,26 @@ protected final <T extends ConnectionDetails> T run(Class<T> type) {
 		Map<String, Object> properties = new LinkedHashMap<>();
 		properties.put("spring.docker.compose.skip.in-tests", "false");
 		properties.put("spring.docker.compose.file",
-				ThrowingSupplier.of(this.composeResource::getFile).get().getAbsolutePath());
+				transformedComposeFile(ThrowingSupplier.of(this.composeResource::getFile).get(), this.dockerImageName));
 		properties.put("spring.docker.compose.stop.command", "down");
 		application.setDefaultProperties(properties);
 		return application.run().getBean(type);
 	}
 
+	private File transformedComposeFile(File composeFile, DockerImageName imageName) {
+		File tempComposeFile = Path.of(tempDir.toString(), composeFile.getName()).toFile();
+		try {
+			String composeFileContent = FileCopyUtils.copyToString(new FileReader(composeFile));
+			composeFileContent = composeFileContent.replace("{imageName}", imageName.asCanonicalNameString());
+			FileCopyUtils.copy(composeFileContent, new FileWriter(tempComposeFile));
+		}
+		catch (IOException ex) {
+			fail("Error transforming Docker compose file '" + composeFile + "' to '" + tempComposeFile + "': "
+					+ ex.getMessage());
+		}
+		return tempComposeFile;
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class Config {
 
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/test/AbstractIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/test/AbstractIntegrationTests.java
deleted file mode 100644
index d2e081d88a7a..000000000000
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/test/AbstractIntegrationTests.java
+++ /dev/null
@@ -1,102 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.docker.compose.service.connection.test;
-
-import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable;
-
-/**
- * Abstract base class for integration tests.
- *
- * @author Moritz Halbritter
- * @author Andy Wilkinson
- */
-@DisabledIfDockerUnavailable
-public abstract class AbstractIntegrationTests {
-
-	//// @formatter:off
-
-	/*
-
-	@TempDir
-	static Path tempDir;
-
-	private static List<Runnable> shutdownHandler;
-
-	@BeforeAll
-	static void beforeAll() {
-		shutdownHandler = new ArrayList<>();
-	}
-
-	@AfterAll
-	static void afterAll() {
-		for (Runnable runnable : shutdownHandler) {
-			runnable.run();
-		}
-	}
-
-	@BeforeEach
-	void setUp() throws IOException {
-		createComposeYaml();
-	}
-
-	protected abstract InputStream getComposeContent();
-
-	protected final <T extends ConnectionDetails> T runProvider(Class<T> serviceConnectionClass) {
-		return runProvider(new MockEnvironment(), serviceConnectionClass);
-	}
-
-	protected final <T extends ConnectionDetails> T runProvider(MockEnvironment environment,
-			Class<T> serviceConnectionClass) {
-		environment.setProperty("spring.dev-services.docker-compose.stop-mode", "down");
-		DockerComposeListener dockerComposeListener = createProvider(environment);
-		GenericApplicationContext context = new GenericApplicationContext();
-		context.setEnvironment(environment);
-		dockerComposeListener
-			.onApplicationEvent(new ApplicationPreparedEvent(new SpringApplication(), new String[0], context));
-		context.refresh();
-		T serviceConnection = context.getBean(serviceConnectionClass);
-		assertThat(serviceConnection.getOrigin()).isInstanceOf(DockerComposeOrigin.class);
-		return serviceConnection;
-	}
-
-	private DockerComposeListener createProvider(Environment environment) {
-		return new DockerComposeListener(getClass().getClassLoader(), environment, null, null, null, tempDir,
-				new SpringApplicationShutdownHandlers() {
-
-					@Override
-					public void add(Runnable action) {
-						shutdownHandler.add(action);
-					}
-
-					@Override
-					public void remove(Runnable action) {
-					}
-
-				});
-	}
-
-	private void createComposeYaml() throws IOException {
-		try (InputStream stream = getComposeContent()) {
-			byte[] content = stream.readAllBytes();
-			Files.write(tempDir.resolve("compose.yaml"), content);
-		}
-	}
-
-	*/
-	// @formatter:on
-
-}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/zipkin/XZipkinDockerComposeConnectionDetailsFactoryTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/zipkin/XZipkinDockerComposeConnectionDetailsFactoryTests.java
deleted file mode 100644
index 8dca340be48d..000000000000
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/zipkin/XZipkinDockerComposeConnectionDetailsFactoryTests.java
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.docker.compose.service.connection.zipkin;
-
-import org.junit.jupiter.api.Disabled;
-import org.junit.jupiter.api.Test;
-
-import static org.junit.jupiter.api.Assertions.fail;
-
-/**
- * Tests for {@link ZipkinDockerComposeConnectionDetailsFactory}.
- *
- * @author Moritz Halbritter
- * @author Andy Wilkinson
- * @author Phillip Webb
- */
-@Disabled
-class XZipkinDockerComposeConnectionDetailsFactoryTests {
-
-	@Test
-	void test() {
-		fail("Not yet implemented");
-	}
-
-	// @formatter:off
-
-	/*
-
-
-	@Test
-	void getPort() {
-		RunningService service = createService(Collections.emptyMap());
-		ZipkinService zipkinService = new ZipkinService(service);
-		assertThat(zipkinService.getPort()).isEqualTo(19411);
-	}
-
-	@Test
-	void matches() {
-		assertThat(ZipkinService.matches(createService(Collections.emptyMap()))).isTrue();
-		assertThat(ZipkinService.matches(createService(ImageReference.parse("postgres:15.2"), Collections.emptyMap())))
-			.isFalse();
-	}
-
-	private RunningService createService(Map<String, String> env) {
-		return createService(ImageReference.parse("openzipkin/zipkin:2.24"), env);
-	}
-
-	private RunningService createService(ImageReference image, Map<String, String> env) {
-		return RunningServiceBuilder.create("service-1", image).addTcpPort(9411, 19411).env(env).build();
-	}
-
-
-	 */
-
-	// @formatter:on
-
-}
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/zipkin/ZipkinDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/zipkin/ZipkinDockerComposeConnectionDetailsFactoryIntegrationTests.java
index 6eeffc25029d..b77d558789b3 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/zipkin/ZipkinDockerComposeConnectionDetailsFactoryIntegrationTests.java
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/zipkin/ZipkinDockerComposeConnectionDetailsFactoryIntegrationTests.java
@@ -20,6 +20,7 @@
 
 import org.springframework.boot.actuate.autoconfigure.tracing.zipkin.ZipkinConnectionDetails;
 import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -33,7 +34,7 @@
 class ZipkinDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests {
 
 	ZipkinDockerComposeConnectionDetailsFactoryIntegrationTests() {
-		super("zipkin-compose.yaml");
+		super("zipkin-compose.yaml", DockerImageNames.zipkin());
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/core/redis-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/core/redis-compose.yaml
new file mode 100644
index 000000000000..9511c464d9f3
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/core/redis-compose.yaml
@@ -0,0 +1,5 @@
+services:
+  redis:
+    image: '{imageName}'
+    ports:
+      - '6379'
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/activemq/activemq-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/activemq/activemq-compose.yaml
new file mode 100644
index 000000000000..9ae6911655e9
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/activemq/activemq-compose.yaml
@@ -0,0 +1,8 @@
+services:
+  activemq:
+    image: '{imageName}'
+    ports:
+      - '61616'
+    environment:
+      ACTIVEMQ_USERNAME: 'root'
+      ACTIVEMQ_PASSWORD: 'secret'
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/cassandra/cassandra-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/cassandra/cassandra-compose.yaml
index b1d466ebdce1..b8d5ffd528e4 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/cassandra/cassandra-compose.yaml
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/cassandra/cassandra-compose.yaml
@@ -1,7 +1,12 @@
 services:
   cassandra:
-    image: 'cassandra:3.11.10'
+    image: '{imageName}'
     ports:
       - '9042'
     environment:
+      - 'CASSANDRA_SNITCH=GossipingPropertyFileSnitch'
+      - 'JVM_OPTS=-Dcassandra.skip_wait_for_gossip_to_settle=0 -Dcassandra.initial_token=0'
+      - 'HEAP_NEWSIZE=128M'
+      - 'MAX_HEAP_SIZE=1024M'
+      - 'CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch'
       - 'CASSANDRA_DC=dc1'
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/elasticsearch/elasticsearch-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/elasticsearch/elasticsearch-compose.yaml
index b5d214f54455..31ac2461ab8a 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/elasticsearch/elasticsearch-compose.yaml
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/elasticsearch/elasticsearch-compose.yaml
@@ -1,6 +1,6 @@
 services:
   elasticsearch:
-    image: 'elasticsearch:8.6.1'
+    image: '{imageName}'
     environment:
       - 'ELASTIC_PASSWORD=secret'
       - 'ES_JAVA_OPTS=-Xmx512m'
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/flyway/flyway-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/flyway/flyway-compose.yaml
index 5f781d980f30..cb721c823b23 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/flyway/flyway-compose.yaml
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/flyway/flyway-compose.yaml
@@ -1,6 +1,6 @@
 services:
   database:
-    image: 'postgres:15.2'
+    image: '{imageName}'
     ports:
       - '5432'
     environment:
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/liquibase/liquibase-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/liquibase/liquibase-compose.yaml
index 5f781d980f30..cb721c823b23 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/liquibase/liquibase-compose.yaml
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/liquibase/liquibase-compose.yaml
@@ -1,6 +1,6 @@
 services:
   database:
-    image: 'postgres:15.2'
+    image: '{imageName}'
     ports:
       - '5432'
     environment:
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/mariadb/mariadb-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/mariadb/mariadb-compose.yaml
index 7902b194f443..c63fd81224b7 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/mariadb/mariadb-compose.yaml
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/mariadb/mariadb-compose.yaml
@@ -1,6 +1,6 @@
 services:
   database:
-    image: 'mariadb:10.10'
+    image: '{imageName}'
     ports:
       - '3306'
     environment:
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/mongo/mongo-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/mongo/mongo-compose.yaml
index 279af9795883..135b54ec52a5 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/mongo/mongo-compose.yaml
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/mongo/mongo-compose.yaml
@@ -1,6 +1,6 @@
 services:
   mongo:
-    image: 'mongo:6.0'
+    image: '{imageName}'
     ports:
       - '27017'
     environment:
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/mysql/mysql-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/mysql/mysql-compose.yaml
index 6aa9f4da0925..b0340ed3ed48 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/mysql/mysql-compose.yaml
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/mysql/mysql-compose.yaml
@@ -1,6 +1,6 @@
 services:
   database:
-    image: 'mysql:8.0'
+    image: '{imageName}'
     ports:
       - '3306'
     environment:
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/neo4j/neo4j-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/neo4j/neo4j-compose.yaml
new file mode 100644
index 000000000000..313cce779274
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/neo4j/neo4j-compose.yaml
@@ -0,0 +1,8 @@
+services:
+  neo4j:
+    image: '{imageName}'
+    ports:
+      - '7687'
+    environment:
+      - 'NEO4J_AUTH=neo4j/secret'
+
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/oracle/oracle-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/oracle/oracle-compose.yaml
index 4775f6c11bcd..1cfa3ca87a15 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/oracle/oracle-compose.yaml
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/oracle/oracle-compose.yaml
@@ -1,9 +1,11 @@
 services:
   database:
-    image: 'gvenzl/oracle-xe:18.4.0-slim'
+    image: '{imageName}'
     ports:
       - '1521'
     environment:
+      - 'APP_USER=app_user'
+      - 'APP_USER_PASSWORD=app_user_secret'
       - 'ORACLE_PASSWORD=secret'
     healthcheck:
       test: ["CMD-SHELL", "healthcheck.sh"]
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/otlp/otlp-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/otlp/otlp-compose.yaml
new file mode 100644
index 000000000000..258e73e333ee
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/otlp/otlp-compose.yaml
@@ -0,0 +1,5 @@
+services:
+  otlp:
+    image: '{imageName}'
+    ports:
+      - '4318'
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/postgres/postgres-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/postgres/postgres-compose.yaml
index 5f781d980f30..cb721c823b23 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/postgres/postgres-compose.yaml
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/postgres/postgres-compose.yaml
@@ -1,6 +1,6 @@
 services:
   database:
-    image: 'postgres:15.2'
+    image: '{imageName}'
     ports:
       - '5432'
     environment:
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/pulsar/pulsar-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/pulsar/pulsar-compose.yaml
new file mode 100644
index 000000000000..76cdd274f431
--- /dev/null
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/pulsar/pulsar-compose.yaml
@@ -0,0 +1,9 @@
+services:
+  pulsar:
+    image: '{imageName}'
+    ports:
+      - '8080'
+      - '6650'
+    command: bin/pulsar standalone
+    healthcheck:
+      test: curl http://127.0.0.1:8080/admin/v2/namespaces/public/default
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/rabbit/rabbit-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/rabbit/rabbit-compose.yaml
index 4808c8432e5d..1951fba4bb08 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/rabbit/rabbit-compose.yaml
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/rabbit/rabbit-compose.yaml
@@ -1,6 +1,6 @@
 services:
   rabbitmq:
-    image: 'rabbitmq:3.11'
+    image: '{imageName}'
     environment:
       - 'RABBITMQ_DEFAULT_USER=myuser'
       - 'RABBITMQ_DEFAULT_PASS=secret'
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/redis/redis-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/redis/redis-compose.yaml
index 411e25696999..9511c464d9f3 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/redis/redis-compose.yaml
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/redis/redis-compose.yaml
@@ -1,5 +1,5 @@
 services:
   redis:
-    image: 'redis:7.0'
+    image: '{imageName}'
     ports:
       - '6379'
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/sqlserver/mssqlserver-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/sqlserver/mssqlserver-compose.yaml
index 15e3d2cf4a5e..672b27ad78fb 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/sqlserver/mssqlserver-compose.yaml
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/sqlserver/mssqlserver-compose.yaml
@@ -1,6 +1,6 @@
 services:
   database:
-    image: 'mcr.microsoft.com/mssql/server'
+    image: '{imageName}'
     ports:
       - '1433'
     environment:
diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/zipkin/zipkin-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/zipkin/zipkin-compose.yaml
index d5ebe65d4d52..686f841b4cb6 100644
--- a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/zipkin/zipkin-compose.yaml
+++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/zipkin/zipkin-compose.yaml
@@ -1,5 +1,5 @@
 services:
   zipkin:
-    image: 'openzipkin/zipkin:2.24'
+    image: '{imageName}'
     ports:
       - '9411'
diff --git a/spring-boot-project/spring-boot-docs/build.gradle b/spring-boot-project/spring-boot-docs/build.gradle
index 5dacea7b8274..dba2031dd9b1 100644
--- a/spring-boot-project/spring-boot-docs/build.gradle
+++ b/spring-boot-project/spring-boot-docs/build.gradle
@@ -17,6 +17,17 @@ configurations {
 	remoteSpringApplicationExample
 	springApplicationExample
 	testSlices
+	asciidoctorExtensions {
+		resolutionStrategy {
+			eachDependency { dependency ->
+				// Downgrade SnakeYAML as Asciidoctor fails due to an incompatibility
+				// in the Pysch gem
+				if (dependency.requested.group.equals("org.yaml")) {
+					dependency.useVersion("1.33")
+				}
+			}
+		}
+	}
 }
 
 jar {
@@ -50,6 +61,7 @@ dependencies {
 	asciidoctorExtensions(project(path: ":spring-boot-project:spring-boot-autoconfigure"))
 	asciidoctorExtensions(project(path: ":spring-boot-project:spring-boot-devtools"))
 	asciidoctorExtensions(project(path: ":spring-boot-project:spring-boot-docker-compose"))
+	asciidoctorExtensions(project(path: ":spring-boot-project:spring-boot-testcontainers"))
 
 	autoConfiguration(project(path: ":spring-boot-project:spring-boot-autoconfigure", configuration: "autoConfigurationMetadata"))
 	autoConfiguration(project(path: ":spring-boot-project:spring-boot-actuator-autoconfigure", configuration: "autoConfigurationMetadata"))
@@ -63,6 +75,7 @@ dependencies {
 	configurationProperties(project(path: ":spring-boot-project:spring-boot-docker-compose", configuration: "configurationPropertiesMetadata"))
 	configurationProperties(project(path: ":spring-boot-project:spring-boot-devtools", configuration: "configurationPropertiesMetadata"))
 	configurationProperties(project(path: ":spring-boot-project:spring-boot-test-autoconfigure", configuration: "configurationPropertiesMetadata"))
+	configurationProperties(project(path: ":spring-boot-project:spring-boot-testcontainers", configuration: "configurationPropertiesMetadata"))
 
 	gradlePluginDocumentation(project(path: ":spring-boot-project:spring-boot-tools:spring-boot-gradle-plugin", configuration: "documentation"))
 
@@ -78,7 +91,7 @@ dependencies {
 	implementation(project(path: ":spring-boot-project:spring-boot-devtools"))
 	implementation("ch.qos.logback:logback-classic")
 	implementation("com.zaxxer:HikariCP")
-	implementation("io.micrometer:micrometer-core")
+	implementation("io.micrometer:micrometer-jakarta9")
 	implementation("io.micrometer:micrometer-tracing")
 	implementation("io.micrometer:micrometer-registry-graphite")
 	implementation("io.micrometer:micrometer-registry-jmx")
@@ -151,7 +164,11 @@ dependencies {
 	implementation("org.springframework.graphql:spring-graphql")
 	implementation("org.springframework.graphql:spring-graphql-test")
 	implementation("org.springframework.kafka:spring-kafka")
-	implementation("org.springframework.kafka:spring-kafka-test")
+	implementation("org.springframework.kafka:spring-kafka-test") {
+		exclude group: "commons-logging", module: "commons-logging"
+	}
+	implementation("org.springframework.pulsar:spring-pulsar")
+	implementation("org.springframework.pulsar:spring-pulsar-reactive")
 	implementation("org.springframework.restdocs:spring-restdocs-mockmvc")
 	implementation("org.springframework.restdocs:spring-restdocs-restassured")
 	implementation("org.springframework.restdocs:spring-restdocs-webtestclient")
@@ -273,7 +290,7 @@ task runRemoteSpringApplicationExample(type: org.springframework.boot.build.docs
 
 task runSpringApplicationExample(type: org.springframework.boot.build.docs.ApplicationRunner) {
 	classpath = configurations.springApplicationExample + sourceSets.main.output
-	mainClass = "org.springframework.boot.docs.features.springapplication.MyApplication"
+	mainClass = "org.springframework.boot.docs.features.logexample.MyApplication"
 	args = ["--server.port=0"]
 	output = file("$buildDir/example-output/spring-application.txt")
 	expectedLogging = "Started MyApplication in "
@@ -282,8 +299,8 @@ task runSpringApplicationExample(type: org.springframework.boot.build.docs.Appli
 
 task runLoggingFormatExample(type: org.springframework.boot.build.docs.ApplicationRunner) {
 	classpath = configurations.springApplicationExample + sourceSets.main.output
-	mainClass = "org.springframework.boot.docs.features.springapplication.MyApplication"
-	args = ["--spring.main.banner-mode=off", "--server.port=0"]
+	mainClass = "org.springframework.boot.docs.features.logexample.MyApplication"
+	args = ["--spring.main.banner-mode=off", "--server.port=0", "--spring.application.name=myapp"]
 	output = file("$buildDir/example-output/logging-format.txt")
 	expectedLogging = "Started MyApplication in "
 	normalizeTomcatPort()
@@ -300,9 +317,9 @@ tasks.withType(org.asciidoctor.gradle.jvm.AbstractAsciidoctorTask) {
 	}
 	doFirst {
 		def versionConstraints = dependencyVersions.versionConstraints
-		def securityVersion = versionConstraints["org.springframework.security:spring-security-core"]
-		if (securityVersion.endsWith("-SNAPSHOT")) {
-			securityVersion = securityVersion.substring(0, securityVersion.length() - "-SNAPSHOT".length())
+		def toAntoraVersion = version -> {
+			String formatted = version.split("\\.").take(2).join('.')
+			return version.endsWith("-SNAPSHOT") ? formatted + "-SNAPSHOT" : formatted
 		}
 		attributes  "hibernate-version": versionConstraints["org.hibernate.orm:hibernate-core"].split("\\.").take(2).join('.'),
 					"jetty-version": versionConstraints["org.eclipse.jetty:jetty-server"],
@@ -311,10 +328,10 @@ tasks.withType(org.asciidoctor.gradle.jvm.AbstractAsciidoctorTask) {
 					"native-build-tools-version": nativeBuildToolsVersion,
 					"spring-amqp-version": versionConstraints["org.springframework.amqp:spring-amqp"],
 					"spring-batch-version": versionConstraints["org.springframework.batch:spring-batch-core"],
+					"spring-batch-version-antora": toAntoraVersion(versionConstraints["org.springframework.batch:spring-batch-core"]),
 					"spring-boot-version": project.version,
 					"spring-data-commons-version": versionConstraints["org.springframework.data:spring-data-commons"],
 					"spring-data-couchbase-version": versionConstraints["org.springframework.data:spring-data-couchbase"],
-					"spring-data-envers-version": versionConstraints["org.springframework.data:spring-data-envers"],
 					"spring-data-jdbc-version": versionConstraints["org.springframework.data:spring-data-jdbc"],
 					"spring-data-jpa-version": versionConstraints["org.springframework.data:spring-data-jpa"],
 					"spring-data-mongodb-version": versionConstraints["org.springframework.data:spring-data-mongodb"],
@@ -322,11 +339,13 @@ tasks.withType(org.asciidoctor.gradle.jvm.AbstractAsciidoctorTask) {
 					"spring-data-r2dbc-version": versionConstraints["org.springframework.data:spring-data-r2dbc"],
 					"spring-data-rest-version": versionConstraints["org.springframework.data:spring-data-rest-core"],
 					"spring-framework-version": versionConstraints["org.springframework:spring-core"],
-					"spring-graphql-version": versionConstraints["org.springframework.graphql:spring-graphql"],
-					"spring-integration-version": versionConstraints["org.springframework.integration:spring-integration-core"],
+					"spring-framework-version-antora": toAntoraVersion(versionConstraints["org.springframework:spring-core"]),
+					"spring-graphql-version-antora": toAntoraVersion(versionConstraints["org.springframework.graphql:spring-graphql"]),
+					"spring-integration-version-antora": toAntoraVersion(versionConstraints["org.springframework.integration:spring-integration-core"]),
 					"spring-kafka-version": versionConstraints["org.springframework.kafka:spring-kafka"],
-					"spring-security-version": securityVersion,
-					"spring-authorization-server-version": versionConstraints["org.springframework.security:spring-security-oauth2-authorization-server"],
+					"spring-pulsar-version": versionConstraints["org.springframework.pulsar:spring-pulsar"],
+					"spring-security-version-antora": toAntoraVersion(versionConstraints["org.springframework.security:spring-security-core"]),
+					"spring-authorization-server-version-antora": toAntoraVersion(versionConstraints["org.springframework.security:spring-security-oauth2-authorization-server"]),
 					"spring-webservices-version": versionConstraints["org.springframework.ws:spring-ws-core"],
 					"tomcat-version": tomcatVersion.split("\\.").take(2).join('.'),
 					"remote-spring-application-output": runRemoteSpringApplicationExample.outputs.files.singleFile,
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/cloud-foundry.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/cloud-foundry.adoc
index 0e90877e583a..97e81386fd68 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/cloud-foundry.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/cloud-foundry.adoc
@@ -48,3 +48,7 @@ The configuration differs, depending on the web server in use.
 For Tomcat, you can add the following configuration:
 
 include::code:MyCloudFoundryConfiguration[]
+
+If you're using a Webflux based application, you can use the following configuration:
+
+include::code:MyReactiveCloudFoundryConfiguration[]
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/endpoints.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/endpoints.adoc
index e925d8e47e0e..ad3742aaa06e 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/endpoints.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/endpoints.adoc
@@ -33,9 +33,11 @@ The following technology-agnostic endpoints are available:
 
 | `configprops`
 | Displays a collated list of all `@ConfigurationProperties`.
+Subject to <<actuator#actuator.endpoints.sanitization, sanitization>>.
 
 | `env`
 | Exposes properties from Spring's `ConfigurableEnvironment`.
+Subject to <<actuator#actuator.endpoints.sanitization, sanitization>>.
 
 | `flyway`
 | Shows any Flyway database migrations that have been applied.
@@ -70,6 +72,7 @@ The following technology-agnostic endpoints are available:
 
 |`quartz`
 |Shows information about Quartz Scheduler jobs.
+Subject to <<actuator#actuator.endpoints.sanitization, sanitization>>.
 
 | `scheduledtasks`
 | Displays the scheduled tasks in your application.
@@ -277,6 +280,36 @@ NOTE: The `management.endpoint.<name>` prefix uniquely identifies the endpoint t
 
 
 
+[[actuator.endpoints.sanitization]]
+=== Sanitize Sensitive Values
+Information returned by the `/env`, `/configprops` and `/quartz` endpoints can be somewhat sensitive.
+All values are sanitized by default (that is replaced by `+******+`).
+Viewing original values in the unsanitized form can be configured per endpoint using the `showValues` property for that endpoint.
+This property can be configured to have the following values:
+
+- `ALWAYS` - all values are shown in their unsanitized form to all users
+- `NEVER`  - all values are always sanitized (that is replaced by `+******+`)
+- `WHEN_AUTHORIZED` - all values are shown in their unsanitized form to authorized users
+
+For HTTP endpoints, a user is considered to be authorized if they have authenticated and have the roles configured by the endpoint's roles property.
+By default, any authenticated user is authorized.
+For JMX endpoints, all users are always authorized.
+
+[source,yaml,indent=0,subs="verbatim",configprops,configblocks]
+----
+	management:
+	  endpoint:
+	    env:
+	      show-values: WHEN_AUTHORIZED
+	      roles: "admin"
+----
+
+The configuration above enables the ability for all users with the `admin` role to view all values in their original form from the `/env` endpoint.
+
+NOTE: When `show-values` is set to `ALWAYS` or `WHEN_AUTHORIZED` any sanitization applied by a `<<howto#howto.actuator.customizing-sanitization, SanitizingFunction>>` will still be applied.
+
+
+
 [[actuator.endpoints.hypermedia]]
 === Hypermedia for Actuator Web Endpoints
 A "`discovery page`" is added with links to all the endpoints.
@@ -565,7 +598,7 @@ with the `key` listed in the following table:
 | Checks for low disk space.
 
 | `elasticsearch`
-| {spring-boot-actuator-module-code}/elasticsearch/ElasticsearchRestHealthIndicator.java[`ElasticsearchRestHealthIndicator`]
+| {spring-boot-actuator-module-code}/elasticsearch/ElasticsearchRestClientHealthIndicator.java[`ElasticsearchRestClientHealthIndicator`]
 | Checks that an Elasticsearch cluster is up.
 
 | `hazelcast`
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/metrics.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/metrics.adoc
index 2f9ce53ec614..9248822b142e 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/metrics.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/metrics.adoc
@@ -230,6 +230,8 @@ If tags with the same key are specified with Micrometer, they overwrite the defa
 In Micrometer 1.9.x, this was fixed by introducing Dynatrace-specific summary instruments.
 Setting this toggle to `false` forces Micrometer to fall back to the behavior that was the default before 1.9.x.
 It should only be used when encountering problems while migrating from Micrometer 1.8.x to 1.9.x.
+* Export meter metadata: Starting from Micrometer 1.12.0, the Dynatrace exporter will also export meter metadata, such as unit and description by default.
+Use the `export-meter-metadata` toggle to turn this feature off.
 
 It is possible to not specify a URI and API token, as shown in the following example.
 In this scenario, the automatically configured endpoint is used:
@@ -248,6 +250,7 @@ In this scenario, the automatically configured endpoint is used:
 	            key1: "value1"
 	            key2: "value2"
 	          use-dynatrace-summary-instruments: true # (default: true)
+	          export-meter-metadata: true             # (default: true)
 ----
 
 
@@ -740,6 +743,14 @@ Metrics are tagged by the name of the executor, which is derived from the bean n
 
 
 
+[[actuator.metrics.supported.jms]]
+==== JMS Metrics
+Auto-configuration enables the instrumentation of all available `JmsTemplate` beans.
+`JmsMessagingTemplate` instances built with instrumented `JmsTemplate` beans will also record observations.
+See the {spring-framework-docs}/integration/observability.html#observability.jms.publish[Spring Framework reference documentation for more information on produced observations].
+
+
+
 [[actuator.metrics.supported.spring-mvc]]
 ==== Spring MVC Metrics
 
@@ -747,7 +758,7 @@ Auto-configuration enables the instrumentation of all requests handled by Spring
 By default, metrics are generated with the name, `http.server.requests`.
 You can customize the name by setting the configprop:management.observations.http.server.requests.name[] property.
 
-See the {spring-framework-docs}/integration.html#integration.observability.http-server.servlet[Spring Framework reference documentation for more information on produced observations].
+See the {spring-framework-docs}/integration/observability.html#observability.http-server.servlet[Spring Framework reference documentation for more information on produced observations].
 
 To add to the default tags, provide a `@Bean` that extends `DefaultServerRequestObservationConvention` from the `org.springframework.http.server.observation` package.
 To replace the default tags, provide a `@Bean` that implements `ServerRequestObservationConvention`.
@@ -767,7 +778,7 @@ Auto-configuration enables the instrumentation of all requests handled by Spring
 By default, metrics are generated with the name, `http.server.requests`.
 You can customize the name by setting the configprop:management.observations.http.server.requests.name[] property.
 
-See the {spring-framework-docs}/integration.html#integration.observability.http-server.reactive[Spring Framework reference documentation for more information on produced observations].
+See the {spring-framework-docs}/integration/observability.html#observability.http-server.reactive[Spring Framework reference documentation for more information on produced observations].
 
 To add to the default tags, provide a `@Bean` that extends `DefaultServerRequestObservationConvention` from the `org.springframework.http.server.reactive.observation` package.
 To replace the default tags, provide a `@Bean` that implements `ServerRequestObservationConvention`.
@@ -811,20 +822,21 @@ To customize the tags, provide a `@Bean` that implements `JerseyTagsProvider`.
 
 [[actuator.metrics.supported.http-clients]]
 ==== HTTP Client Metrics
-Spring Boot Actuator manages the instrumentation of both `RestTemplate` and `WebClient`.
+Spring Boot Actuator manages the instrumentation of `RestTemplate`, `WebClient` and `RestClient`.
 For that, you have to inject the auto-configured builder and use it to create instances:
 
 * `RestTemplateBuilder` for `RestTemplate`
 * `WebClient.Builder` for `WebClient`
+* `RestClient.Builder` for `RestClient`
 
-You can also manually apply the customizers responsible for this instrumentation, namely `ObservationRestTemplateCustomizer` and `ObservationWebClientCustomizer`.
+You can also manually apply the customizers responsible for this instrumentation, namely `ObservationRestTemplateCustomizer`, `ObservationWebClientCustomizer` and `ObservationRestClientCustomizer`.
 
 By default, metrics are generated with the name, `http.client.requests`.
 You can customize the name by setting the configprop:management.observations.http.client.requests.name[] property.
 
-See the {spring-framework-docs}/integration.html#integration.observability.http-client[Spring Framework reference documentation for more information on produced observations].
+See the {spring-framework-docs}/integration/observability.html#observability.http-client[Spring Framework reference documentation for more information on produced observations].
 
-To customize the tags when using `RestTemplate`, provide a `@Bean` that implements `ClientRequestObservationConvention` from the `org.springframework.http.client.observation` package.
+To customize the tags when using `RestTemplate` or `RestClient`, provide a `@Bean` that implements `ClientRequestObservationConvention` from the `org.springframework.http.client.observation` package.
 To customize the tags when using `WebClient`, provide a `@Bean` that implements `ClientRequestObservationConvention` from the `org.springframework.web.reactive.function.client` package.
 
 
@@ -863,14 +875,14 @@ A `CacheMetricsRegistrar` bean is made available to make that process easier.
 [[actuator.metrics.supported.spring-batch]]
 ==== Spring Batch Metrics
 
-See the {spring-batch-docs}monitoring-and-metrics.html[Spring Batch reference documentation].
+See the {spring-batch-docs}/monitoring-and-metrics.html[Spring Batch reference documentation].
 
 
 
 [[actuator.metrics.supported.spring-graphql]]
 ==== Spring GraphQL Metrics
 
-See the {spring-graphql-docs}[Spring GraphQL reference documentation].
+See the {spring-graphql-docs}/observability.html[Spring GraphQL reference documentation].
 
 
 
@@ -951,7 +963,7 @@ Auto-configuration enables the instrumentation of all available RabbitMQ connect
 
 [[actuator.metrics.supported.spring-integration]]
 ==== Spring Integration Metrics
-Spring Integration automatically provides {spring-integration-docs}system-management.html#micrometer-integration[Micrometer support] whenever a `MeterRegistry` bean is available.
+Spring Integration automatically provides {spring-integration-docs}/metrics.html#micrometer-integration[Micrometer support] whenever a `MeterRegistry` bean is available.
 Metrics are published under the `spring.integration.` meter name.
 
 
@@ -960,7 +972,7 @@ Metrics are published under the `spring.integration.` meter name.
 ==== Kafka Metrics
 Auto-configuration registers a `MicrometerConsumerListener` and `MicrometerProducerListener` for the auto-configured consumer factory and producer factory, respectively.
 It also registers a `KafkaStreamsMicrometerListener` for `StreamsBuilderFactoryBean`.
-For more detail, see the {spring-kafka-docs}#micrometer-native[Micrometer Native Metrics] section of the Spring Kafka documentation.
+For more detail, see the {spring-kafka-docs}kafka/micrometer.html#micrometer-native[Micrometer Native Metrics] section of the Spring Kafka documentation.
 
 
 
@@ -1100,19 +1112,8 @@ These use the global registry that is not Spring-managed.
 
 [[actuator.metrics.customizing.common-tags]]
 ==== Common Tags
-Common tags are generally used for dimensional drill-down on the operating environment, such as host, instance, region, stack, and others.
-Commons tags are applied to all meters and can be configured, as the following example shows:
-
-[source,yaml,indent=0,subs="verbatim",configprops,configblocks]
-----
-	management:
-	  metrics:
-	    tags:
-	      region: "us-east-1"
-	      stack: "prod"
-----
 
-The preceding example adds `region` and `stack` tags to all meters with a value of `us-east-1` and `prod`, respectively.
+You can configure common tags using the <<actuator#actuator.observability.common-key-values, configprop:management.observations.key-values[] property>>.
 
 NOTE: The order of common tags is important if you use Graphite.
 As the order of common tags cannot be guaranteed by using this approach, Graphite users are advised to define a custom `MeterFilter` instead.
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/observability.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/observability.adoc
index b7db70d50219..dbf2e3259768 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/observability.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/observability.adoc
@@ -9,16 +9,79 @@ To create your own observations (which will lead to metrics and traces), you can
 
 include::code:MyCustomObservation[]
 
-NOTE: Low cardinality tags will be added to metrics and traces, while high cardinality tags will only be added to traces.
+NOTE: Low cardinality key-values will be added to metrics and traces, while high cardinality key-values will only be added to traces.
 
-Beans of type `ObservationPredicate`, `GlobalObservationConvention` and `ObservationHandler` will be automatically registered on the `ObservationRegistry`.
+Beans of type `ObservationPredicate`, `GlobalObservationConvention`, `ObservationFilter` and `ObservationHandler` will be automatically registered on the `ObservationRegistry`.
 You can additionally register any number of `ObservationRegistryCustomizer` beans to further configure the registry.
 
-For more details please see the https://micrometer.io/docs/observation[Micrometer Observation documentation].
+Observability support relies on the https://github.com/micrometer-metrics/context-propagation[Context Propagation library] for forwarding the current observation across threads and reactive pipelines.
+By default, `ThreadLocal` values are not automatically reinstated in reactive operators.
+This behavior is controlled with the configprop:spring.reactor.context-propagation[] property, which can be set to `auto` to enable automatic propagation.
 
-TIP: Observability for JDBC and R2DBC can be configured using separate projects.
-For JDBC, the https://github.com/jdbc-observations/datasource-micrometer[Datasource Micrometer project] provides a Spring Boot starter which automatically creates observations when JDBC operations are invoked.
+For more details about observations please see the https://micrometer.io/docs/observation[Micrometer Observation documentation].
+
+TIP: Observability for JDBC can be configured using a separate project.
+The https://github.com/jdbc-observations/datasource-micrometer[Datasource Micrometer project] provides a Spring Boot starter which automatically creates observations when JDBC operations are invoked.
 Read more about it https://jdbc-observations.github.io/datasource-micrometer/docs/current/docs/html/[in the reference documentation].
-For R2DBC, the https://github.com/spring-projects-experimental/r2dbc-micrometer-spring-boot[Spring Boot Auto Configuration for R2DBC Observation] creates observations for R2DBC query invocations.
+
+TIP: Observability for R2DBC is built into Spring Boot.
+To enable it, add the `io.r2dbc:r2dbc-proxy` dependency to your project.
+
+[[actuator.observability.common-key-values]]
+=== Common Key-Values
+Common key-values are generally used for dimensional drill-down on the operating environment, such as host, instance, region, stack, and others.
+Common key-values are applied to all observations as low cardinality key-values and can be configured, as the following example shows:
+
+[source,yaml,indent=0,subs="verbatim",configprops,configblocks]
+----
+	management:
+	  observations:
+	    key-values:
+	      region: "us-east-1"
+	      stack: "prod"
+----
+
+The preceding example adds `region` and `stack` key-values to all observations with a value of `us-east-1` and `prod`, respectively.
+
+[[actuator.observability.preventing-observations]]
+=== Preventing Observations
+
+If you'd like to prevent some observations from being reported, you can use the configprop:management.observations.enable[] properties:
+
+[source,yaml,indent=0,subs="verbatim",configprops,configblocks]
+----
+	management:
+	  observations:
+	    enable:
+	      denied:
+	        prefix: false
+	      another:
+	        denied:
+	          prefix: false
+----
+
+The preceding example will prevent all observations with a name starting with `denied.prefix` or `another.denied.prefix`.
+
+TIP: If you want to prevent Spring Security from reporting observations, set the property configprop:management.observations.enable.spring.security[] to `false`.
+
+If you need greater control over the prevention of observations, you can register beans of type `ObservationPredicate`.
+Observations are only reported if all the `ObservationPredicate` beans return `true` for that observation.
+
+include::code:MyObservationPredicate[]
+
+The preceding example will prevent all observations whose name contains "denied".
+
+
+
+[[actuator.observability.opentelemetry]]
+=== OpenTelemetry Support
+Spring Boot's actuator module includes basic support for https://opentelemetry.io/[OpenTelemetry].
+
+It provides a bean of type `OpenTelemetry`, and if there are beans of type `SdkTracerProvider`, `ContextPropagators`, `SdkLoggerProvider` or `SdkMeterProvider` in the application context, they automatically get registered.
+Additionally, it provides a `Resource` bean.
+The attributes of the `Resource` can be configured via the configprop:management.opentelemetry.resource-attributes[] configuration property.
+
+NOTE: Spring Boot does not provide auto-configuration for OpenTelemetry metrics or logging.
+OpenTelemetry tracing is only auto-configured when used together with <<actuator#actuator.micrometer-tracing, Micrometer Tracing>>.
 
 The next sections will provide more details about logging, metrics and traces.
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/tracing.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/tracing.adoc
index 467bfb670a04..b3034f104629 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/tracing.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/tracing.adoc
@@ -65,7 +65,29 @@ Now open the Zipkin UI at `http://localhost:9411` and press the "Run Query" butt
 You should see one trace.
 Press the "Show" button to see the details of that trace.
 
-TIP: You can include the current trace and span id in the logs by setting the configprop:logging.pattern.level[] property to `%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]`
+
+
+[[actuator.micrometer-tracing.logging]]
+=== Logging Correlation IDs
+Correlation IDs provide a helpful way to link lines in your log files to spans/traces.
+By default, as long as configprop:management.tracing.enabled[] has not been set to `false`, Spring Boot will include correlation IDs in your logs whenever you are using Micrometer Tracing.
+
+The default correlation ID is built from `traceId` and `spanId` https://logback.qos.ch/manual/mdc.html[MDC] values.
+For example, if Micrometer Tracing has added an MDC `traceId` of `803B448A0489F84084905D3093480352` and an MDC `spanId` of `3425F23BB2432450` the log output will include the correlation ID `[803B448A0489F84084905D3093480352-3425F23BB2432450]`.
+
+If you prefer to use a different format for your correlation ID, you can use the configprop:logging.pattern.correlation[] property to define one.
+For example, the following will provide a correlation ID for Logback in format previously used by Spring Cloud Sleuth:
+
+[source,yaml,indent=0,subs="verbatim",configprops,configblocks]
+----
+	logging:
+	  pattern:
+	    correlation: "[${spring.application.name:},%X{traceId:-},%X{spanId:-}] "
+	  include-application-name: false
+----
+
+NOTE: In the example above, configprop:logging.include-application-name[] is set to `false` to avoid the application name being duplicated in the log messages (configprop:logging.pattern.correlation[] already contains it).
+It's also worth mentioning that configprop:logging.pattern.correlation[] contains a trailing space so that it is separated from the logger name that comes right after it by default.
 
 
 
@@ -174,3 +196,11 @@ For the example above, setting this property to `baggage1` results in an HTTP he
 
 If you want to propagate the baggage to the MDC, use the configprop:management.tracing.baggage.correlation.fields[] configuration property.
 For the example above, setting this property to `baggage1` results in an MDC entry named `baggage1`.
+
+
+
+[[actuator.micrometer-tracing.tests]]
+=== Tests
+
+Tracing components which are reporting data are not auto-configured when using `@SpringBootTest`.
+See <<features#features.testing.spring-boot-applications.tracing, the testing section>> for more details.
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/anchor-rewrite.properties b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/anchor-rewrite.properties
index 0c84776b2f36..783ff820a907 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/anchor-rewrite.properties
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/anchor-rewrite.properties
@@ -306,7 +306,6 @@ boot-features-webclient-customization=features.webclient.customization
 boot-features-validation=features.validation
 boot-features-email=features.email
 boot-features-jta=features.jta
-boot-features-jta-atomikos=features.jta.atomikos
 boot-features-jta-javaee=features.jta.javaee
 boot-features-jta-mixed-jms=features.jta.mixing-xa-and-non-xa-connections
 boot-features-jta-supporting-alternative-embedded=features.jta.supporting-alternative-embedded-transaction-manager
@@ -1020,3 +1019,27 @@ howto.testing.testcontainers.dynamic-properties=features.testing.testcontainers.
 
 # gh-32905
 container-images.efficient-images.unpacking=deployment.efficient.unpacking
+
+# Spring Boot 3.1 - 3.2 migrations
+io.rest-client.resttemplate.http-client=io.rest-client.clienthttprequestfactory
+
+# gh-35917
+howto.actuator.sanitize-sensitive-values=actuator.endpoints.sanitization
+howto.actuator.sanitize-sensitive-values.customizing-sanitization=howto.actuator.customizing-sanitization
+
+# gh-28453
+deployment.installing.supported-operating-systems=deployment.installing
+deployment.installing.nix-services=deployment.installing
+deployment.installing.nix-services.init-d=deployment.installing.init-d
+deployment.installing.nix-services.init-d.securing=deployment.installing.init-d.securing
+deployment.installing.nix-services.system-d=deployment.installing.system-d
+deployment.installing.nix-services.script-customization=deployment.installing.init-d.script-customization
+deployment.installing.nix-services.script-customization.when-written=deployment.installing.init-d.script-customization.when-written
+deployment.installing.nix-services.script-customization.when-running=deployment.installing.init-d.script-customization.when-running
+deployment.installing.nix-services.script-customization.when-running.conf-file=deployment.installing.init-d.script-customization.when-running.conf-file
+
+# gh-35856
+features.testing.testcontainers.at-development-time=features.testcontainers.at-development-time
+features.testing.testcontainers.at-development-time.dynamic-properties=features.testcontainers.at-development-time.dynamic-properties
+features.testing.testcontainers.at-development-time.importing-container-declarations=features.testcontainers.at-development-time.importing-container-declarations
+features.testing.testcontainers.at-development-time.devtools=features.testcontainers.at-development-time.devtools
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/application-properties.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/application-properties.adoc
index 7c51da9f53fc..ab3b79f7eb71 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/application-properties.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/application-properties.adoc
@@ -47,4 +47,6 @@ include::application-properties/devtools.adoc[]
 
 include::application-properties/docker-compose.adoc[]
 
+include::application-properties/testcontainers.adoc[]
+
 include::application-properties/testing.adoc[]
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/attributes.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/attributes.adoc
index 6393f3bed2fe..a84cb976851f 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/attributes.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/attributes.adoc
@@ -55,8 +55,8 @@
 :spring-boot-test-autoconfigure-module-api: {spring-boot-api}/org/springframework/boot/test/autoconfigure
 :spring-amqp-api: https://docs.spring.io/spring-amqp/docs/{spring-amqp-version}/api/org/springframework/amqp
 :spring-batch: https://spring.io/projects/spring-batch
-:spring-batch-api: https://docs.spring.io/spring-batch/docs/{spring-batch-version}/api/org/springframework/batch
-:spring-batch-docs: https://docs.spring.io/spring-batch/docs/{spring-batch-version}/reference/html/
+:spring-batch-api: https://docs.spring.io/spring-batch/docs/{spring-batch-version}/org/springframework/batch
+:spring-batch-docs: https://docs.spring.io/spring-batch/reference/{spring-batch-version-antora}
 :spring-data: https://spring.io/projects/spring-data
 :spring-data-cassandra: https://spring.io/projects/spring-data-cassandra
 :spring-data-commons-api: https://docs.spring.io/spring-data/commons/docs/{spring-data-commons-version}/api/org/springframework/data
@@ -65,12 +65,11 @@
 :spring-data-elasticsearch: https://spring.io/projects/spring-data-elasticsearch
 :spring-data-elasticsearch-docs: https://docs.spring.io/spring-data/elasticsearch/docs/current/reference/html/
 :spring-data-envers: https://spring.io/projects/spring-data-envers
-:spring-data-envers-doc: https://docs.spring.io/spring-data/envers/docs/{spring-data-envers-version}/reference/html/
 :spring-data-gemfire: https://spring.io/projects/spring-data-gemfire
 :spring-data-geode: https://spring.io/projects/spring-data-geode
 :spring-data-jpa: https://spring.io/projects/spring-data-jpa
 :spring-data-jpa-api: https://docs.spring.io/spring-data/jpa/docs/{spring-data-jpa-version}/api/org/springframework/data/jpa
-:spring-data-jpa-docs: https://docs.spring.io/spring-data/jpa/docs/{spring-data-jpa-version}/reference/html
+:spring-data-jpa-docs: https://docs.spring.io/spring-data/jpa/reference/{spring-data-jpa-version}/
 :spring-data-jdbc-docs: https://docs.spring.io/spring-data/jdbc/docs/{spring-data-jdbc-version}/reference/html/
 :spring-data-ldap: https://spring.io/projects/spring-data-ldap
 :spring-data-mongodb: https://spring.io/projects/spring-data-mongodb
@@ -83,18 +82,18 @@
 :spring-data-rest-api: https://docs.spring.io/spring-data/rest/docs/{spring-data-rest-version}/api/org/springframework/data/rest
 :spring-framework: https://spring.io/projects/spring-framework
 :spring-framework-api: https://docs.spring.io/spring-framework/docs/{spring-framework-version}/javadoc-api/org/springframework
-:spring-framework-docs: https://docs.spring.io/spring-framework/docs/{spring-framework-version}/reference/html
+:spring-framework-docs: https://docs.spring.io/spring-framework/reference/{spring-framework-version-antora}
 :spring-graphql: https://spring.io/projects/spring-graphql
-:spring-graphql-api: https://docs.spring.io/spring-graphql/docs/{spring-graphql-version}/api/
-:spring-graphql-docs: https://docs.spring.io/spring-graphql/docs/{spring-graphql-version}/reference/html/
+:spring-graphql-docs: https://docs.spring.io/spring-graphql/reference/{spring-graphql-version-antora}
 :spring-integration: https://spring.io/projects/spring-integration
-:spring-integration-docs: https://docs.spring.io/spring-integration/docs/{spring-integration-version}/reference/html/
-:spring-kafka-docs: https://docs.spring.io/spring-kafka/docs/{spring-kafka-version}/reference/html/
+:spring-integration-docs: https://docs.spring.io/spring-integration/reference/{spring-integration-version-antora}
+:spring-kafka-docs: https://docs.spring.io/spring-kafka/docs/{spring-kafka-version}/reference/
+:spring-pulsar-docs: https://docs.spring.io/spring-pulsar/docs/{spring-pulsar-version}/reference/
 :spring-restdocs: https://spring.io/projects/spring-restdocs
 :spring-security: https://spring.io/projects/spring-security
-:spring-security-docs: https://docs.spring.io/spring-security/reference/{spring-security-version}
+:spring-security-docs: https://docs.spring.io/spring-security/reference/{spring-security-version-antora}
 :spring-authorization-server: https://spring.io/projects/spring-authorization-server
-:spring-authorization-server-docs: https://docs.spring.io/spring-authorization-server/docs/{spring-authorization-server-version}/reference/html
+:spring-authorization-server-docs: https://docs.spring.io/spring-authorization-server/reference/{spring-authorization-server-version-antora}
 :spring-session: https://spring.io/projects/spring-session
 :spring-webservices-docs: https://docs.spring.io/spring-ws/docs/{spring-webservices-version}/reference/html/
 :ant-docs: https://ant.apache.org/manual
@@ -102,7 +101,7 @@
 :dynatrace-help: https://www.dynatrace.com/support/help
 :gradle-docs: https://docs.gradle.org/current/userguide
 :hibernate-docs: https://docs.jboss.org/hibernate/orm/{hibernate-version}/userguide/html_single/Hibernate_User_Guide.html
-:java-api: https://docs.oracle.com/javase/17/docs/api
+:java-api: https://docs.oracle.com/en/java/javase/17/docs/api
 :jooq-docs: https://www.jooq.org/doc/{jooq-version}/manual-single-page
 :junit5-docs: https://junit.org/junit5/docs/current/user-guide
 :kotlin-docs: https://kotlinlang.org/docs/reference/
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/configuration-metadata/format.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/configuration-metadata/format.adoc
index 589851084265..b2ce6c5dae9f 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/configuration-metadata/format.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/configuration-metadata/format.adoc
@@ -198,6 +198,11 @@ The JSON object contained in the `deprecation` attribute of each `properties` el
 | String
 | The full name of the property that _replaces_ this deprecated property.
   If there is no replacement for this property, it may be omitted.
+
+| `since`
+| String
+| The version in which the property became deprecated.
+  Can be omitted.
 |===
 
 NOTE: Prior to Spring Boot 1.3, a single `deprecated` boolean attribute can be used instead of the `deprecation` element.
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/container-images/cloud-native-buildpacks.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/container-images/cloud-native-buildpacks.adoc
index 9c8dd5981537..020eac38324c 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/container-images/cloud-native-buildpacks.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/container-images/cloud-native-buildpacks.adoc
@@ -12,7 +12,7 @@ This means you can just type a single command and quickly get a sensible image i
 
 See the individual plugin documentation on how to use buildpacks with {spring-boot-maven-plugin-docs}#build-image[Maven] and {spring-boot-gradle-plugin-docs}#build-image[Gradle].
 
-NOTE: The https://github.com/paketo-buildpacks/spring-boot[Paketo Spring Boot buildpack] has also been updated to support the `layers.idx` file so any customization that is applied to it will be reflected in the image created by the buildpack.
+NOTE: The https://github.com/paketo-buildpacks/spring-boot[Paketo Spring Boot buildpack] supports the `layers.idx` file, so any customization that is applied to it will be reflected in the image created by the buildpack.
 
 NOTE: In order to achieve reproducible builds and container image caching, Buildpacks can manipulate the application resources metadata (such as the file "last modified" information).
 You should ensure that your application does not rely on that metadata at runtime.
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/container-images/dockerfiles.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/container-images/dockerfiles.adoc
index 1c3d77cff254..1956cc4a3188 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/container-images/dockerfiles.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/container-images/dockerfiles.adoc
@@ -1,6 +1,6 @@
 [[container-images.dockerfiles]]
 == Dockerfiles
-While it is possible to convert a Spring Boot fat jar into a docker image with just a few lines in the Dockerfile, we will use the <<container-images#container-images.efficient-images.layering,layering feature>> to create an optimized docker image.
+While it is possible to convert a Spring Boot uber jar into a docker image with just a few lines in the Dockerfile, we will use the <<container-images#container-images.efficient-images.layering,layering feature>> to create an optimized docker image.
 When you create a jar containing the layers index file, the `spring-boot-jarmode-layertools` jar will be added as a dependency to your jar.
 With this jar on the classpath, you can launch your application in a special mode which allows the bootstrap code to run something entirely different from your application, for example, something that extracts the layers.
 
@@ -44,7 +44,7 @@ COPY --from=builder application/dependencies/ ./
 COPY --from=builder application/spring-boot-loader/ ./
 COPY --from=builder application/snapshot-dependencies/ ./
 COPY --from=builder application/application/ ./
-ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]
+ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
 ----
 
 Assuming the above `Dockerfile` is in the current directory, your docker image can be built with `docker build .`, or optionally specifying the path to your application jar, as shown in the following example:
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/container-images/efficient-images.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/container-images/efficient-images.adoc
index ad70d6a75eb5..d06327bb05f3 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/container-images/efficient-images.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/container-images/efficient-images.adoc
@@ -1,8 +1,8 @@
 [[container-images.efficient-images]]
 == Efficient Container Images
-It is easily possible to package a Spring Boot fat jar as a docker image.
-However, there are various downsides to copying and running the fat jar as is in the docker image.
-There’s always a certain amount of overhead when running a fat jar without unpacking it, and in a containerized environment this can be noticeable.
+It is easily possible to package a Spring Boot uber jar as a docker image.
+However, there are various downsides to copying and running the uber jar as is in the docker image.
+There’s always a certain amount of overhead when running a uber jar without unpacking it, and in a containerized environment this can be noticeable.
 The other issue is that putting your application's code and all its dependencies in one layer in the Docker image is sub-optimal.
 Since you probably recompile your code more often than you upgrade the version of Spring Boot you use, it’s often better to separate things a bit more.
 If you put jar files in the layer before your application classes, Docker often only needs to change the very bottom layer and can pick others up from its cache.
@@ -28,8 +28,8 @@ The following shows an example of a `layers.idx` file:
 	  - BOOT-INF/lib/library1.jar
 	  - BOOT-INF/lib/library2.jar
 	- "spring-boot-loader":
-	  - org/springframework/boot/loader/JarLauncher.class
-	  - org/springframework/boot/loader/jar/JarEntry.class
+	  - org/springframework/boot/loader/launch/JarLauncher.class
+	  - ... <other classes>
 	- "snapshot-dependencies":
 	  - BOOT-INF/lib/library3-SNAPSHOT.jar
 	- "application":
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/data/nosql.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/data/nosql.adoc
index be74814f2651..c1816b18d9c7 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/data/nosql.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/data/nosql.adoc
@@ -2,20 +2,22 @@
 == Working with NoSQL Technologies
 Spring Data provides additional projects that help you access a variety of NoSQL technologies, including:
 
-* {spring-data-mongodb}[MongoDB]
-* {spring-data-neo4j}[Neo4J]
-* {spring-data-elasticsearch}[Elasticsearch]
-* {spring-data-redis}[Redis]
-* {spring-data-gemfire}[GemFire] or {spring-data-geode}[Geode]
 * {spring-data-cassandra}[Cassandra]
 * {spring-data-couchbase}[Couchbase]
+* {spring-data-elasticsearch}[Elasticsearch]
+* {spring-data-gemfire}[GemFire] or {spring-data-geode}[Geode]
 * {spring-data-ldap}[LDAP]
+* {spring-data-mongodb}[MongoDB]
+* {spring-data-neo4j}[Neo4J]
+* {spring-data-redis}[Redis]
 
-Spring Boot provides auto-configuration for Redis, MongoDB, Neo4j, Elasticsearch, Cassandra, Couchbase, LDAP and InfluxDB.
+Of these, Spring Boot provides auto-configuration for Cassandra, Couchbase, Elasticsearch, LDAP, MongoDB, Neo4J and Redis.
 Additionally, {spring-boot-for-apache-geode}[Spring Boot for Apache Geode] provides {spring-boot-for-apache-geode-docs}#geode-repositories[auto-configuration for Apache Geode].
 You can make use of the other projects, but you must configure them yourself.
 See the appropriate reference documentation at {spring-data}.
 
+Spring Boot also provides auto-configuration for the InfluxDB client but it is deprecated in favor of https://github.com/influxdata/influxdb-client-java[the new InfluxDB Java client] that provides its own Spring Boot integration.
+
 
 
 [[data.nosql.redis]]
@@ -198,7 +200,9 @@ You could take the JPA example from earlier and, assuming that `City` is now a M
 
 include::code:CityRepository[]
 
-TIP: You can customize document scanning locations by using the `@EntityScan` annotation.
+Repositories and documents are found through scanning.
+By default, the <<using#using.auto-configuration.packages,auto-configuration packages>> are scanned.
+You can customize the locations to look for repositories and documents by using `@EnableMongoRepositories` and `@EntityScan` respectively.
 
 TIP: For complete details of Spring Data MongoDB, including its rich object mapping technologies, see its {spring-data-mongodb}[reference documentation].
 
@@ -252,7 +256,9 @@ The `spring-boot-starter-data-neo4j` "`Starter`" enables the repository support
 Spring Boot supports both classic and reactive Neo4j repositories, using the `Neo4jTemplate` or `ReactiveNeo4jTemplate` beans.
 When Project Reactor is available on the classpath, the reactive style is also auto-configured.
 
-You can customize the locations to look for repositories and entities by using `@EnableNeo4jRepositories` and `@EntityScan` respectively on a `@Configuration`-bean.
+Repositories and entities are found through scanning.
+By default, the <<using#using.auto-configuration.packages,auto-configuration packages>> are scanned.
+You can customize the locations to look for repositories and entities by using `@EnableNeo4jRepositories` and `@EntityScan` respectively.
 
 [NOTE]
 ====
@@ -324,7 +330,7 @@ If you have `co.elastic.clients:elasticsearch-java` on the classpath, Spring Boo
 
 The `ElasticsearchClient` uses a transport that depends upon the previously described `RestClient`.
 Therefore, the properties described previously can be used to configure the `ElasticsearchClient`.
-Furthermore, you can define a `TransportOptions` bean to take further control of the behavior of the transport.
+Furthermore, you can define a `RestClientOptions` bean to take further control of the behavior of the transport.
 
 
 
@@ -335,7 +341,7 @@ If you have Spring Data Elasticsearch and Reactor on the classpath, Spring Boot
 
 The `ReactiveElasticsearchclient` uses a transport that depends upon the previously described `RestClient`.
 Therefore, the properties described previously can be used to configure the `ReactiveElasticsearchClient`.
-Furthermore, you can define a `TransportOptions` bean to take further control of the behavior of the transport.
+Furthermore, you can define a `RestClientOptions` bean to take further control of the behavior of the transport.
 
 
 
@@ -362,6 +368,10 @@ As with the JPA repositories discussed earlier, the basic principle is that quer
 In fact, both Spring Data JPA and Spring Data Elasticsearch share the same common infrastructure.
 You could take the JPA example from earlier and, assuming that `City` is now an Elasticsearch `@Document` class rather than a JPA `@Entity`, it works in the same way.
 
+Repositories and documents are found through scanning.
+By default, the <<using#using.auto-configuration.packages,auto-configuration packages>> are scanned.
+You can customize the locations to look for repositories and documents by using `@EnableElasticsearchRepositories` and `@EntityScan` respectively.
+
 TIP: For complete details of Spring Data Elasticsearch, see the {spring-data-elasticsearch-docs}[reference documentation].
 
 Spring Boot supports both classic and reactive Elasticsearch repositories, using the `ElasticsearchRestTemplate` or `ReactiveElasticsearchTemplate` beans.
@@ -473,6 +483,10 @@ If you add your own `@Bean` of type `CassandraTemplate`, it replaces the default
 Spring Data includes basic repository support for Cassandra.
 Currently, this is more limited than the JPA repositories discussed earlier and needs `@Query` annotated finder methods.
 
+Repositories and entities are found through scanning.
+By default, the <<using#using.auto-configuration.packages,auto-configuration packages>> are scanned.
+You can customize the locations to look for repositories and entities by using `@EnableCassandraRepositories` and `@EntityScan` respectively.
+
 TIP: For complete details of Spring Data Cassandra, see the https://docs.spring.io/spring-data/cassandra/docs/[reference documentation].
 
 
@@ -522,6 +536,11 @@ To take more control, one or more `ClusterEnvironmentBuilderCustomizer` beans ca
 [[data.nosql.couchbase.repositories]]
 ==== Spring Data Couchbase Repositories
 Spring Data includes repository support for Couchbase.
+
+Repositories and documents are found through scanning.
+By default, the <<using#using.auto-configuration.packages,auto-configuration packages>> are scanned.
+You can customize the locations to look for repositories and documents by using `@EnableCouchbaseRepositories` and `@EntityScan` respectively.
+
 For complete details of Spring Data Couchbase, see the {spring-data-couchbase-docs}[reference documentation].
 
 You can inject an auto-configured `CouchbaseTemplate` instance as you would with any other Spring Bean, provided a `CouchbaseClientFactory` bean is available.
@@ -587,6 +606,11 @@ Make sure to flag your customized `ContextSource` as `@Primary` so that the auto
 [[data.nosql.ldap.repositories]]
 ==== Spring Data LDAP Repositories
 Spring Data includes repository support for LDAP.
+
+Repositories and documents are found through scanning.
+By default, the <<using#using.auto-configuration.packages,auto-configuration packages>> are scanned.
+You can customize the locations to look for repositories and documents by using `@EnableLdapRepositories` and `@EntityScan` respectively.
+
 For complete details of Spring Data LDAP, see the https://docs.spring.io/spring-data/ldap/docs/1.0.x/reference/html/[reference documentation].
 
 You can also inject an auto-configured `LdapTemplate` instance as you would with any other Spring Bean, as shown in the following example:
@@ -637,22 +661,17 @@ If you have custom attributes, you can use configprop:spring.ldap.embedded.valid
 
 [[data.nosql.influxdb]]
 === InfluxDB
+WARNING: Auto-configuration for InfluxDB is deprecated and scheduled for removal in Spring Boot 3.4 in favor of https://github.com/influxdata/influxdb-client-java[the new InfluxDB Java client] that provides its own Spring Boot integration.
+
 https://www.influxdata.com/[InfluxDB] is an open-source time series database optimized for fast, high-availability storage and retrieval of time series data in fields such as operations monitoring, application metrics, Internet-of-Things sensor data, and real-time analytics.
 
 
 
 [[data.nosql.influxdb.connecting]]
 ==== Connecting to InfluxDB
-Spring Boot auto-configures an `InfluxDB` instance, provided the `influxdb-java` client is on the classpath and the URL of the database is set, as shown in the following example:
-
-[source,yaml,indent=0,subs="verbatim",configprops,configblocks]
-----
-	spring:
-	  influx:
-	    url: "https://172.0.0.1:8086"
-----
+Spring Boot auto-configures an `InfluxDB` instance, provided the `influxdb-java` client is on the classpath and the URL of the database is set using configprop:spring.influx.url[deprecated].
 
-If the connection to InfluxDB requires a user and password, you can set the `spring.influx.user` and `spring.influx.password` properties accordingly.
+If the connection to InfluxDB requires a user and password, you can set the configprop:spring.influx.user[deprecated] and configprop:spring.influx.password[deprecated] properties accordingly.
 
 InfluxDB relies on OkHttp.
 If you need to tune the http client `InfluxDB` uses behind the scenes, you can register an `InfluxDbOkHttpClientBuilderProvider` bean.
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/data/sql.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/data/sql.adoc
index 86fc5ac8016f..17e506da15c6 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/data/sql.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/data/sql.adoc
@@ -1,6 +1,6 @@
 [[data.sql]]
 == SQL Databases
-The {spring-framework}[Spring Framework] provides extensive support for working with SQL databases, from direct JDBC access using `JdbcTemplate` to complete "`object relational mapping`" technologies such as Hibernate.
+The {spring-framework}[Spring Framework] provides extensive support for working with SQL databases, from direct JDBC access using `JdbcClient` or `JdbcTemplate` to complete "`object relational mapping`" technologies such as Hibernate.
 {spring-data}[Spring Data] provides an additional level of functionality: creating `Repository` implementations directly from interfaces and using conventions to generate queries from your method names.
 
 
@@ -176,6 +176,17 @@ If more than one `JdbcTemplate` is defined and no primary candidate exists, the
 
 
 
+[[data.sql.jdbc-client]]
+=== Using JdbcClient
+Spring's `JdbcClient` is auto-configured based on the presence of a `NamedParameterJdbcTemplate`.
+You can inject it directly in your own beans as well, as shown in the following example:
+
+include::code:MyBean[]
+
+If you rely on auto-configuration to create the underlying `JdbcTemplate`, any customization using `spring.jdbc.template.*` properties is taken into account in the client as well.
+
+
+
 [[data.sql.jpa-and-spring-data]]
 === JPA and Spring Data JPA
 The Java Persistence API is a standard technology that lets you "`map`" objects to relational databases.
@@ -195,7 +206,7 @@ You can follow the https://spring.io/guides/gs/accessing-data-jpa/["`Accessing D
 ==== Entity Classes
 Traditionally, JPA "`Entity`" classes are specified in a `persistence.xml` file.
 With Spring Boot, this file is not necessary and "`Entity Scanning`" is used instead.
-By default, all packages below your main configuration class (the one annotated with `@EnableAutoConfiguration` or `@SpringBootApplication`) are searched.
+By default the <<using#using.auto-configuration.packages,auto-configuration packages>> are scanned.
 
 Any classes annotated with `@Entity`, `@Embeddable`, or `@MappedSuperclass` are considered.
 A typical entity class resembles the following example:
@@ -216,7 +227,9 @@ For example, a `CityRepository` interface might declare a `findAllByState(String
 For more complex queries, you can annotate your method with Spring Data's {spring-data-jpa-api}/repository/Query.html[`Query`] annotation.
 
 Spring Data repositories usually extend from the {spring-data-commons-api}/repository/Repository.html[`Repository`] or {spring-data-commons-api}/repository/CrudRepository.html[`CrudRepository`] interfaces.
-If you use auto-configuration, repositories are searched from the package containing your main configuration class (the one annotated with `@EnableAutoConfiguration` or `@SpringBootApplication`) down.
+If you use auto-configuration, the <<using#using.auto-configuration.packages,auto-configuration packages>> are searched for repositories.
+
+TIP: You can customize the locations to look for repositories using `@EnableJpaRepositories`.
 
 The following example shows a typical Spring Data repository interface definition:
 
@@ -247,7 +260,7 @@ To use Spring Data Envers, make sure your repository extends from `RevisionRepos
 
 include::code:CountryRepository[]
 
-NOTE: For more details, check the {spring-data-envers-doc}[Spring Data Envers reference documentation].
+NOTE: For more details, check the {spring-data-jpa-docs}/#envers[Spring Data Envers reference documentation].
 
 
 
@@ -504,7 +517,7 @@ For example, a `CityRepository` interface might declare a `findAllByState(String
 For more complex queries, you can annotate your method with Spring Data's {spring-data-r2dbc-api}/repository/Query.html[`Query`] annotation.
 
 Spring Data repositories usually extend from the {spring-data-commons-api}/repository/Repository.html[`Repository`] or {spring-data-commons-api}/repository/CrudRepository.html[`CrudRepository`] interfaces.
-If you use auto-configuration, repositories are searched from the package containing your main configuration class (the one annotated with `@EnableAutoConfiguration` or `@SpringBootApplication`) down.
+If you use auto-configuration, the <<using#using.auto-configuration.packages,auto-configuration packages>> are searched for repositories.
 
 The following example shows a typical Spring Data repository interface definition:
 
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/deployment/cloud.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/deployment/cloud.adoc
index 079b71415172..7d175ddf5427 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/deployment/cloud.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/deployment/cloud.adoc
@@ -365,11 +365,7 @@ This https://spring.io/guides/gs/spring-boot-for-azure/[Getting Started guide] w
 Google Cloud has several options that can be used to launch Spring Boot applications.
 The easiest to get started with is probably App Engine, but you could also find ways to run Spring Boot in a container with Container Engine or on a virtual machine with Compute Engine.
 
-To run in App Engine, you can create a project in the UI first, which sets up a unique identifier for you and also sets up HTTP routes.
-Add a Java app to the project and leave it empty and then use the https://cloud.google.com/sdk/install[Google Cloud SDK] to push your Spring Boot app into that slot from the command line or CI build.
-
-App Engine Standard requires you to use WAR packaging.
-Follow https://github.com/GoogleCloudPlatform/java-docs-samples/tree/master/appengine-java8/springboot-helloworld/README.md[these steps] to deploy App Engine Standard application to Google Cloud.
+To deploy your first app to App Engine standard environment, follow https://codelabs.developers.google.com/codelabs/cloud-app-engine-springboot#0[this tutorial].
 
 Alternatively, App Engine Flex requires you to create an `app.yaml` file to describe the resources your app requires.
 Normally, you put this file in `src/main/appengine`, and it should resemble the following file:
@@ -378,12 +374,9 @@ Normally, you put this file in `src/main/appengine`, and it should resemble the
 ----
 	service: "default"
 
-	runtime: "java"
+	runtime: "java17"
 	env: "flex"
 
-	runtime_config:
-	  jdk: "openjdk8"
-
 	handlers:
 	- url: "/.*"
 	  script: "this field is required, but ignored"
@@ -405,7 +398,7 @@ You can deploy the app (for example, with a Maven plugin) by adding the project
 	<plugin>
 		<groupId>com.google.cloud.tools</groupId>
 		<artifactId>appengine-maven-plugin</artifactId>
-		<version>1.3.0</version>
+		<version>2.4.4</version>
 		<configuration>
 			<project>myproject</project>
 		</configuration>
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/deployment/efficient.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/deployment/efficient.adoc
index 9064f844c7f8..1ecbedd6af9c 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/deployment/efficient.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/deployment/efficient.adoc
@@ -13,7 +13,7 @@ One way to run an unpacked archive is by starting the appropriate launcher, as f
 [source,shell,indent=0,subs="verbatim"]
 ----
 	$ jar -xf myapp.jar
-	$ java org.springframework.boot.loader.JarLauncher
+	$ java org.springframework.boot.loader.launch.JarLauncher
 ----
 
 This is actually slightly faster on startup (depending on the size of the jar) than running from an unexploded archive.
@@ -34,7 +34,6 @@ The jar contains a `classpath.idx` file which is used by the `JarLauncher` when
 
 [[deployment.efficient.aot]]
 === Using Ahead-of-time Processing With the JVM
-
 It's beneficial for the startup time to run your application using the AOT generated initialization code.
 First, you need to ensure that the jar you are building includes AOT generated code.
 
@@ -51,9 +50,9 @@ When the JAR has been built, run it with `spring.aot.enabled` system property se
 
 [source,shell,indent=0,subs="verbatim"]
 ----
-    $ java -Dspring.aot.enabled=true -jar myapplication.jar
+	$ java -Dspring.aot.enabled=true -jar myapplication.jar
 
-    ........ Starting AOT-processed MyApplication ...
+	........ Starting AOT-processed MyApplication ...
 ----
 
 Beware that using the ahead-of-time processing has drawbacks.
@@ -65,3 +64,21 @@ It implies the following restrictions:
 - Properties that change if a bean is created are not supported (for example, `@ConditionalOnProperty` and `.enable` properties).
 
 To learn more about ahead-of-time processing, please see the <<native-image#native-image.introducing-graalvm-native-images.understanding-aot-processing,Understanding Spring Ahead-of-Time Processing section>>.
+
+
+
+[[deployment.efficient.checkpoint-restore]]
+=== Checkpoint and Restore With the JVM
+https://wiki.openjdk.org/display/crac/Main[Coordinated Restore at Checkpoint] (CRaC) is an OpenJDK project that defines a new Java API to allow you to checkpoint and restore an application on the HotSpot JVM.
+It is based on https://github.com/checkpoint-restore/criu[CRIU], a project that implements checkpoint/restore functionality on Linux.
+
+The principle is the following: you start your application almost as usual but with a CRaC enabled version of the JDK like https://bell-sw.com/pages/downloads/?package=jdk-crac[Bellsoft Liberica JDK with CRaC] or https://www.azul.com/downloads/?package=jdk-crac#zulu[Azul Zulu JDK with CRaC].
+Then at some point, potentially after some workloads that will warm up your JVM by executing all common code paths, you trigger a checkpoint using an API call, a `jcmd` command, an HTTP endpoint, or a different mechanism.
+
+A memory representation of the running JVM, including its warmness, is then serialized to disk, allowing a fast restoration at a later point, potentially on another machine with a similar operating system and CPU architecture.
+The restored process retains all the capabilities of the HotSpot JVM, including further JIT optimizations at runtime.
+
+Based on the foundations provided by Spring Framework, Spring Boot provides support for checkpointing and restoring your application, and manages out-of-the-box the lifecycle of resources such as socket, files and thread pools https://github.com/spring-projects/spring-checkpoint-restore-smoke-tests/blob/main/STATUS.adoc[on a limited scope].
+Additional lifecycle management is expected for other dependencies and potentially for the application code dealing with such resources.
+
+You can find more details about the two modes supported ("on demand checkpoint/restore of a running application" and "automatic checkpoint/restore at startup"), how to enable checkpoint and restore support and some guidelines in {spring-framework-docs}/integration/checkpoint-restore.html[the Spring Framework JVM Checkpoint Restore support documentation].
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/deployment/installing.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/deployment/installing.adoc
index 27ee4527eead..e4af64af3f02 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/deployment/installing.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/deployment/installing.adoc
@@ -1,8 +1,58 @@
 [[deployment.installing]]
 == Installing Spring Boot Applications
-In addition to running Spring Boot applications by using `java -jar`, it is also possible to make fully executable applications for Unix systems.
-A fully executable jar can be executed like any other executable binary or it can be <<deployment#deployment.installing.nix-services,registered with `init.d` or `systemd`>>.
-This helps when installing and managing Spring Boot applications in common production environments.
+In addition to running Spring Boot applications by using `java -jar` directly, it is also possible to run them as `systemd`, `init.d` or Windows services.
+
+
+
+[[deployment.installing.system-d]]
+=== Installation as a systemd Service
+`systemd` is the successor of the System V init system and is now being used by many modern Linux distributions.
+Spring Boot applications can be launched by using `systemd` '`service`' scripts.
+
+Assuming that you have a Spring Boot application packaged as an uber jar in `/var/myapp`, to install it as a `systemd` service, create a script named `myapp.service` and place it in `/etc/systemd/system` directory.
+The following script offers an example:
+
+[indent=0]
+----
+	[Unit]
+	Description=myapp
+	After=syslog.target network.target
+
+	[Service]
+	User=myapp
+	Group=myapp
+
+	Environment="JAVA_HOME=/path/to/java/home"
+
+	ExecStart=${JAVA_HOME}/bin/java -jar /var/myapp/myapp.jar
+	ExecStop=/bin/kill -15 $MAINPID
+	SuccessExitStatus=143
+
+	[Install]
+	WantedBy=multi-user.target
+----
+
+IMPORTANT: Remember to change the `Description`, `User`, `Group`, `Environment` and `ExecStart` fields for your application.
+
+NOTE: The `ExecStart` field does not declare the script action command, which means that the `run` command is used by default.
+
+The user that runs the application, the PID file, and the console log file are managed by `systemd` itself and therefore must be configured by using appropriate fields in the '`service`' script.
+Consult the https://www.freedesktop.org/software/systemd/man/systemd.service.html[service unit configuration man page] for more details.
+
+To flag the application to start automatically on system boot, use the following command:
+
+[source,shell,indent=0,subs="verbatim"]
+----
+	$ systemctl enable myapp.service
+----
+
+Run `man systemctl` for more details.
+
+
+
+[[deployment.installing.init-d]]
+=== Installation as an init.d Service (System V)
+To use your application as `init.d` service, configure its build to produce a <<deployment#deployment.installing, fully executable jar>>.
 
 CAUTION: Fully executable jars work by embedding an extra script at the front of the file.
 Currently, some tools do not accept this format, so you may not always be able to use this technique.
@@ -35,30 +85,11 @@ The following example shows the equivalent Gradle configuration:
 	}
 ----
 
-You can then run your application by typing `./my-application.jar` (where `my-application` is the name of your artifact).
-The directory containing the jar is used as your application's working directory.
-
-
+It can then be symlinked to `init.d` to support the standard `start`, `stop`, `restart`, and `status` commands.
 
-[[deployment.installing.supported-operating-systems]]
-=== Supported Operating Systems
-The default script supports most Linux distributions and is tested on CentOS and Ubuntu.
-Other platforms, such as OS X and FreeBSD, require the use of a custom `embeddedLaunchScript`.
-
-
-
-[[deployment.installing.nix-services]]
-=== Unix/Linux Services
-Spring Boot application can be easily started as Unix/Linux services by using either `init.d` or `systemd`.
-
-
-
-[[deployment.installing.nix-services.init-d]]
-==== Installation as an init.d Service (System V)
-If you configured Spring Boot's Maven or Gradle plugin to generate a <<deployment#deployment.installing, fully executable jar>>, and you do not use a custom `embeddedLaunchScript`, your application can be used as an `init.d` service.
-To do so, symlink the jar to `init.d` to support the standard `start`, `stop`, `restart`, and `status` commands.
-
-The script supports the following features:
+The default launch script that is added to a fully executable jar supports most Linux distributions and is tested on CentOS and Ubuntu.
+Other platforms, such as OS X and FreeBSD, require the use of a custom script.
+The default scripts supports the following features:
 
 * Starts the services as the user that owns the jar file
 * Tracks the application's PID by using `/var/run/<appname>/<appname>.pid`
@@ -91,8 +122,8 @@ For example, on Debian, you could use the following command:
 
 
 
-[[deployment.installing.nix-services.init-d.securing]]
-===== Securing an init.d Service
+[[deployment.installing.init-d.securing]]
+==== Securing an init.d Service
 NOTE: The following is a set of guidelines on how to secure a Spring Boot application that runs as an init.d service.
 It is not intended to be an exhaustive list of everything that should be done to harden an application and the environment in which it runs.
 
@@ -130,7 +161,7 @@ One way to protect against this is to make it immutable by using `chattr`, as sh
 
 This will prevent any user, including root, from modifying the jar.
 
-If root is used to control the application's service and you <<deployment#deployment.installing.nix-services.script-customization.when-running.conf-file, use a `.conf` file>> to customize its startup, the `.conf` file is read and evaluated by the root user.
+If root is used to control the application's service and you <<deployment#deployment.installing.init-d.script-customization.when-running.conf-file, use a `.conf` file>> to customize its startup, the `.conf` file is read and evaluated by the root user.
 It should be secured accordingly.
 Use `chmod` so that the file can only be read by the owner and use `chown` to make root the owner, as shown in the following example:
 
@@ -142,48 +173,7 @@ Use `chmod` so that the file can only be read by the owner and use `chown` to ma
 
 
 
-[[deployment.installing.nix-services.system-d]]
-==== Installation as a systemd Service
-`systemd` is the successor of the System V init system and is now being used by many modern Linux distributions.
-Although you can continue to use `init.d` scripts with `systemd`, it is also possible to launch Spring Boot applications by using `systemd` '`service`' scripts.
-
-Assuming that you have a Spring Boot application installed in `/var/myapp`, to install a Spring Boot application as a `systemd` service, create a script named `myapp.service` and place it in `/etc/systemd/system` directory.
-The following script offers an example:
-
-[indent=0]
-----
-	[Unit]
-	Description=myapp
-	After=syslog.target
-
-	[Service]
-	User=myapp
-	ExecStart=/var/myapp/myapp.jar
-	SuccessExitStatus=143
-
-	[Install]
-	WantedBy=multi-user.target
-----
-
-IMPORTANT: Remember to change the `Description`, `User`, and `ExecStart` fields for your application.
-
-NOTE: The `ExecStart` field does not declare the script action command, which means that the `run` command is used by default.
-
-Note that, unlike when running as an `init.d` service, the user that runs the application, the PID file, and the console log file are managed by `systemd` itself and therefore must be configured by using appropriate fields in the '`service`' script.
-Consult the https://www.freedesktop.org/software/systemd/man/systemd.service.html[service unit configuration man page] for more details.
-
-To flag the application to start automatically on system boot, use the following command:
-
-[source,shell,indent=0,subs="verbatim"]
-----
-	$ systemctl enable myapp.service
-----
-
-Run `man systemctl` for more details.
-
-
-
-[[deployment.installing.nix-services.script-customization]]
+[[deployment.installing.init-d.script-customization]]
 ==== Customizing the Startup Script
 The default embedded startup script written by the Maven or Gradle plugin can be customized in a number of ways.
 For most people, using the default script along with a few customizations is usually enough.
@@ -191,7 +181,7 @@ If you find you cannot customize something that you need to, use the `embeddedLa
 
 
 
-[[deployment.installing.nix-services.script-customization.when-written]]
+[[deployment.installing.init-d.script-customization.when-written]]
 ===== Customizing the Start Script When It Is Written
 It often makes sense to customize elements of the start script as it is written into the jar file.
 For example, init.d scripts can provide a "`description`".
@@ -299,9 +289,9 @@ The following property substitutions are supported with the default script:
 
 
 
-[[deployment.installing.nix-services.script-customization.when-running]]
+[[deployment.installing.init-d.script-customization.when-running]]
 ===== Customizing a Script When It Runs
-For items of the script that need to be customized _after_ the jar has been written, you can use environment variables or a <<deployment#deployment.installing.nix-services.script-customization.when-running.conf-file, config file>>.
+For items of the script that need to be customized _after_ the jar has been written, you can use environment variables or a <<deployment#deployment.installing.init-d.script-customization.when-running.conf-file, config file>>.
 
 The following environment properties are supported with the default script:
 
@@ -364,7 +354,8 @@ See the https://www.freedesktop.org/software/systemd/man/systemd.service.html[se
 
 
 
-[[deployment.installing.nix-services.script-customization.when-running.conf-file]]
+[[deployment.installing.init-d.script-customization.when-running.conf-file]]
+====== Using a Conf Gile
 With the exception of `JARFILE` and `APP_NAME`, the settings listed in the preceding section can be configured by using a `.conf` file.
 The file is expected to be next to the jar file and have the same name but suffixed with `.conf` rather than `.jar`.
 For example, a jar named `/var/myapp/myapp.jar` uses the configuration file named `/var/myapp/myapp.conf`, as shown in the following example:
@@ -378,7 +369,7 @@ For example, a jar named `/var/myapp/myapp.jar` uses the configuration file name
 
 TIP:  If you do not like having the config file next to the jar file, you can set a `CONF_FOLDER` environment variable to customize the location of the config file.
 
-To learn about securing this file appropriately, see <<deployment#deployment.installing.nix-services.init-d.securing,the guidelines for securing an init.d service>>.
+To learn about securing this file appropriately, see <<deployment#deployment.installing.init-d.securing,the guidelines for securing an init.d service>>.
 
 
 
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/documentation/advanced.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/documentation/advanced.adoc
index 7617561d11cf..a24d2582158e 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/documentation/advanced.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/documentation/advanced.adoc
@@ -2,6 +2,6 @@
 == Advanced Topics
 Finally, we have a few topics for more advanced users:
 
-* *Spring Boot Applications Deployment:* <<deployment#deployment.cloud, Cloud Deployment>> | <<deployment#deployment.installing.nix-services, OS Service>>
+* *Spring Boot Applications Deployment:* <<deployment#deployment.cloud, Cloud Deployment>> | <<deployment#deployment.installing, OS Service>>
 * *Build tool plugins:* <<build-tool-plugins#build-tool-plugins.maven, Maven>> | <<build-tool-plugins#build-tool-plugins.gradle, Gradle>>
 * *Appendix:* <<application-properties#appendix.application-properties,Application Properties>> | <<configuration-metadata#appendix.configuration-metadata,Configuration Metadata>> | <<auto-configuration-classes#appendix.auto-configuration-classes,Auto-configuration Classes>> | <<test-auto-configuration#appendix.test-auto-configuration,Test Auto-configuration Annotations>> | <<executable-jar#appendix.executable-jar,Executable Jars>> | <<dependency-versions#appendix.dependency-versions,Dependency Versions>>
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/documentation/messaging.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/documentation/messaging.adoc
index 51412fde0c9b..0ccc798988bd 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/documentation/messaging.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/documentation/messaging.adoc
@@ -5,5 +5,6 @@ If your application uses any messaging protocol, see one or more of the followin
 * *JMS:* <<messaging#messaging.jms, Auto-configuration for ActiveMQ and Artemis, Sending and Receiving messages through JMS>>
 * *AMQP:* <<messaging#messaging.amqp, Auto-configuration for RabbitMQ>>
 * *Kafka:* <<messaging#messaging.kafka, Auto-configuration for Spring Kafka>>
+* *Pulsar:* <<messaging#messaging.pulsar, Auto-configuration for Spring for Apache Pulsar>>
 * *RSocket:* <<messaging#messaging.rsocket, Auto-configuration for Spring Framework's RSocket Support>>
 * *Spring Integration:* <<messaging#messaging.spring-integration, Auto-configuration for Spring Integration>>
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/jarfile-class.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/jarfile-class.adoc
index da7c616fb314..b1db0c87261a 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/jarfile-class.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/jarfile-class.adoc
@@ -1,7 +1,7 @@
 [[appendix.executable-jar.jarfile-class]]
-== Spring Boot's "`JarFile`" Class
-The core class used to support loading nested jars is `org.springframework.boot.loader.jar.JarFile`.
-It lets you load jar content from a standard jar file or from nested child jar data.
+== Spring Boot's "`NestedJarFile`" Class
+The core class used to support loading nested jars is `org.springframework.boot.loader.jar.NestedJarFile`.
+It lets you load jar content from nested child jar data.
 When first loaded, the location of each `JarEntry` is mapped to a physical file offset of the outer jar, as shown in the following example:
 
 [indent=0]
@@ -28,5 +28,7 @@ We do not need to unpack the archive, and we do not need to read all entry data
 [[appendix.executable-jar.jarfile-class.compatibility]]
 === Compatibility With the Standard Java "`JarFile`"
 Spring Boot Loader strives to remain compatible with existing code and libraries.
-`org.springframework.boot.loader.jar.JarFile` extends from `java.util.jar.JarFile` and should work as a drop-in replacement.
-The `getURL()` method returns a `URL` that opens a connection compatible with `java.net.JarURLConnection` and can be used with Java's `URLClassLoader`.
+`org.springframework.boot.loader.jar.NestedJarFile` extends from `java.util.jar.JarFile` and should work as a drop-in replacement.
+
+Nested JAR URLs of the form `jar:nested:/path/myjar.jar/!BOOT-INF/lib/mylib.jar!/B.class` are supported and open a connection compatible with `java.net.JarURLConnection`.
+These can be used with Java's `URLClassLoader`.
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/launching.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/launching.adoc
index a672c6963495..690b85c438d8 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/launching.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/launching.adoc
@@ -1,13 +1,14 @@
 [[appendix.executable-jar.launching]]
 == Launching Executable Jars
-The `org.springframework.boot.loader.Launcher` class is a special bootstrap class that is used as an executable jar's main entry point.
-It is the actual `Main-Class` in your jar file, and it is used to setup an appropriate `URLClassLoader` and ultimately call your `main()` method.
+The `org.springframework.boot.loader.launch.Launcher` class is a special bootstrap class that is used as an executable jar's main entry point.
+It is the actual `Main-Class` in your jar file, and it is used to setup an appropriate `ClassLoader` and ultimately call your `main()` method.
 
 There are three launcher subclasses (`JarLauncher`, `WarLauncher`, and `PropertiesLauncher`).
 Their purpose is to load resources (`.class` files and so on) from nested jar files or war files in directories (as opposed to those explicitly on the classpath).
 In the case of `JarLauncher` and `WarLauncher`, the nested paths are fixed.
 `JarLauncher` looks in `BOOT-INF/lib/`, and `WarLauncher` looks in `WEB-INF/lib/` and `WEB-INF/lib-provided/`.
 You can add extra jars in those locations if you want more.
+
 The `PropertiesLauncher` looks in `BOOT-INF/lib/` in your application archive by default.
 You can add additional locations by setting an environment variable called `LOADER_PATH` or `loader.path` in `loader.properties` (which is a comma-separated list of directories, archives, or directories within archives).
 
@@ -22,7 +23,7 @@ The following example shows a typical `MANIFEST.MF` for an executable jar file:
 
 [indent=0]
 ----
-	Main-Class: org.springframework.boot.loader.JarLauncher
+	Main-Class: org.springframework.boot.loader.launch.JarLauncher
 	Start-Class: com.mycompany.project.MyApplication
 ----
 
@@ -30,7 +31,7 @@ For a war file, it would be as follows:
 
 [indent=0]
 ----
-	Main-Class: org.springframework.boot.loader.WarLauncher
+	Main-Class: org.springframework.boot.loader.launch.WarLauncher
 	Start-Class: com.mycompany.project.MyApplication
 ----
 
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/nested-jars.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/nested-jars.adoc
index b163b3974194..2c580bb4d965 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/nested-jars.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/nested-jars.adoc
@@ -89,7 +89,9 @@ These files, however, are _not_ parsed internally as YAML and they must be writt
 [[appendix.executable-jar.nested-jars.classpath-index]]
 === Classpath Index
 The classpath index file can be provided in `BOOT-INF/classpath.idx`.
+Typically, it is generated automatically by Spring Boot's Maven and Gradle build plugins.
 It provides a list of jar names (including the directory) in the order that they should be added to the classpath.
+When generated by the build plugins, this classpath ordering matches that used by the build system for running and testing the application.
 Each line must start with dash space (`"-&#183;"`) and names must be in double quotes.
 
 For example, given the following jar:
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/property-launcher.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/property-launcher.adoc
index 675a2bc27801..ba6ae905b834 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/property-launcher.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/property-launcher.adoc
@@ -64,7 +64,7 @@ When specified as environment variables or manifest entries, the following names
 | `LOADER_SYSTEM`
 |===
 
-TIP: Build plugins automatically move the `Main-Class` attribute to `Start-Class` when the fat jar is built.
+TIP: Build plugins automatically move the `Main-Class` attribute to `Start-Class` when the uber jar is built.
 If you use that, specify the name of the class to launch by using the `Main-Class` attribute and leaving out `Start-Class`.
 
 The following rules apply to working with `PropertiesLauncher`:
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features.adoc
index 3cfee4f05ab7..55d96c714ec8 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features.adoc
@@ -20,6 +20,8 @@ include::features/logging.adoc[]
 
 include::features/internationalization.adoc[]
 
+include::features/aop.adoc[]
+
 include::features/json.adoc[]
 
 include::features/task-execution-and-scheduling.adoc[]
@@ -28,6 +30,8 @@ include::features/testing.adoc[]
 
 include::features/docker-compose.adoc[]
 
+include::features/testcontainers.adoc[]
+
 include::features/developing-auto-configuration.adoc[]
 
 include::features/kotlin.adoc[]
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/aop.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/aop.adoc
new file mode 100644
index 000000000000..ad7dcc6c4f7b
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/aop.adoc
@@ -0,0 +1,9 @@
+[[features.aop]]
+== Aspect-Oriented Programming
+Spring Boot provides auto-configuration for aspect-oriented programming (AOP).
+You can learn more about AOP with Spring in the {spring-framework-docs}/core/aop-api.html[Spring Framework reference documentation].
+
+By default, Spring Boot's auto-configuration configures Spring AOP to use CGLib proxies.
+To use JDK proxies instead, set `configprop:spring.aop.proxy-target-class` to `false`.
+
+If AspectJ is on the classpath, Spring Boot's auto-configuration will automatically enable AspectJ auto proxy such that `@EnableAspectJAutoProxy` is not required.
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/developing-auto-configuration.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/developing-auto-configuration.adoc
index 70f020dc5a90..4184cef7eb00 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/developing-auto-configuration.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/developing-auto-configuration.adoc
@@ -136,7 +136,7 @@ This condition will not match for applications that are run with an embedded web
 
 [[features.developing-auto-configuration.condition-annotations.spel-conditions]]
 ==== SpEL Expression Conditions
-The `@ConditionalOnExpression` annotation lets configuration be included based on the result of a {spring-framework-docs}/core.html#expressions[SpEL expression].
+The `@ConditionalOnExpression` annotation lets configuration be included based on the result of a {spring-framework-docs}/core/expressions.html[SpEL expression].
 
 NOTE: Referencing a bean in the expression will cause that bean to be initialized very early in context refresh processing.
 As a result, the bean won't be eligible for post-processing (such as configuration properties binding) and its state may be incomplete.
@@ -149,6 +149,8 @@ An auto-configuration can be affected by many factors: user configuration (`@Bea
 Concretely, each test should create a well defined `ApplicationContext` that represents a combination of those customizations.
 `ApplicationContextRunner` provides a great way to achieve that.
 
+WARNING: `ApplicationContextRunner` doesn't work when running the tests in a native image.
+
 `ApplicationContextRunner` is usually defined as a field of the test class to gather the base, common configuration.
 The following example makes sure that `MyServiceAutoConfiguration` is always invoked:
 
@@ -272,7 +274,7 @@ When building with Maven, it is recommended to add the following dependency in a
 	</dependency>
 ----
 
-If you have defined auto-configurations directly in your application, make sure to configure the `spring-boot-maven-plugin` to prevent the `repackage` goal from adding the dependency into the fat jar:
+If you have defined auto-configurations directly in your application, make sure to configure the `spring-boot-maven-plugin` to prevent the `repackage` goal from adding the dependency into the uber jar:
 
 [source,xml,indent=0,subs="verbatim"]
 ----
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/docker-compose.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/docker-compose.adoc
index 329bfe408e30..eaef6872f361 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/docker-compose.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/docker-compose.adoc
@@ -28,6 +28,8 @@ Add the module dependency to your build, as shown in the following listings for
 	}
 ----
 
+NOTE: The `docker compose` or `docker-compose` CLI application needs to be on your path in order for Spring Boot’s support to work correctly.
+
 When this module is included as a dependency Spring Boot will do the following:
 
 * Search for a `compose.yml` and other common compose filenames in your application directory
@@ -35,7 +37,11 @@ When this module is included as a dependency Spring Boot will do the following:
 * Create service connection beans for each supported container
 * Call `docker compose stop` when the application is shutdown
 
-NOTE: The `docker compose` or `docker-compose` CLI application needs to be on your path in order for Spring Boot’s support to work correctly.
+If the Docker Compose services are already running when starting the application, Spring Boot will only create the service connection beans for each supported container.
+It will not call `docker compose up` again and it will not call `docker compose stop` when the application is shutdown.
+
+NOTE: By default, Spring Boot's Docker Compose support is disabled when running tests.
+To enable it, set configprop:spring.docker.compose.skip.in-tests[] to `false`.
 
 
 
@@ -58,6 +64,9 @@ The following service connections are currently supported:
 |===
 | Connection Details | Matched on
 
+| `ActiveMQConnectionDetails`
+| Containers named "symptoma/activemq"
+
 | `CassandraConnectionDetails`
 | Containers named "cassandra"
 
@@ -65,13 +74,25 @@ The following service connections are currently supported:
 | Containers named "elasticsearch"
 
 | `JdbcConnectionDetails`
-| Containers named "gvenzl/oracle-xe", "mariadb", "mssql/server", "mysql", or "postgres"
+| Containers named "gvenzl/oracle-free", "gvenzl/oracle-xe", "mariadb", "mssql/server", "mysql", or "postgres"
 
 | `MongoConnectionDetails`
 | Containers named "mongo"
 
+| `Neo4jConnectionDetails`
+| Containers named "neo4j"
+
+| `OtlpMetricsConnectionDetails`
+| Containers named "otel/opentelemetry-collector-contrib"
+
+| `OtlpTracingConnectionDetails`
+| Containers named "otel/opentelemetry-collector-contrib"
+
+| `PulsarConnectionDetails`
+| Containers named "apachepulsar/pulsar"
+
 | `R2dbcConnectionDetails`
-| Containers named "gvenzl/oracle-xe", "mariadb", "mssql/server", "mysql", or "postgres"
+| Containers named "gvenzl/oracle-free", "gvenzl/oracle-xe", "mariadb", "mssql/server", "mysql", or "postgres"
 
 | `RabbitConnectionDetails`
 | Containers named "rabbitmq"
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/external-config.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/external-config.adoc
index e5b0ff81840b..a45717e7f447 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/external-config.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/external-config.adoc
@@ -385,7 +385,7 @@ To import these properties, you can add the following to your `application.prope
 
 You can then access or inject `myapp.username` and `myapp.password` properties from the `Environment` in the usual way.
 
-TIP: The folders under the config tree form the property name.
+TIP: The names of the folders and files under the config tree form the property name.
 In the above example, to access the properties as `username` and `password`, you can set `spring.config.import` to `optional:configtree:/etc/config/myapp`.
 
 NOTE: Filenames with dot notation are also correctly mapped.
@@ -395,6 +395,7 @@ TIP: Configuration tree values can be bound to both string `String` and `byte[]`
 
 If you have multiple config trees to import from the same parent folder you can use a wildcard shortcut.
 Any `configtree:` location that ends with `/*/` will import all immediate children as config trees.
+As with a non-wildcard import, the names of the folders and files under each config tree form the property name.
 
 For example, given the following volume:
 
@@ -940,6 +941,13 @@ For example, the configuration property `my.service[0].other` would use an envir
 
 
 
+[[features.external-config.typesafe-configuration-properties.relaxed-binding.caching]]
+===== Caching
+Relaxed binding uses a cache to improve performance. By default, this caching is only applied to immutable property sources.
+To customize this behavior, for example to enable caching for mutable property sources, use `ConfigurationPropertyCaching`.
+
+
+
 [[features.external-config.typesafe-configuration-properties.merging-complex-types]]
 ==== Merging Complex Types
 When lists are configured in more than one place, overriding works by replacing the entire list.
@@ -1048,7 +1056,7 @@ Spring Boot has dedicated support for expressing durations.
 If you expose a `java.time.Duration` property, the following formats in application properties are available:
 
 * A regular `long` representation (using milliseconds as the default unit unless a `@DurationUnit` has been specified)
-* The standard ISO-8601 format {java-api}/java/time/Duration.html#parse-java.lang.CharSequence-[used by `java.time.Duration`]
+* The standard ISO-8601 format {java-api}/java.base/java/time/Duration.html#parse(java.lang.CharSequence)[used by `java.time.Duration`]
 * A more readable format where the value and the unit are coupled (`10s` means 10 seconds)
 
 Consider the following example:
@@ -1087,7 +1095,7 @@ In addition to durations, Spring Boot can also work with `java.time.Period` type
 The following formats can be used in application properties:
 
 * An regular `int` representation (using days as the default unit unless a `@PeriodUnit` has been specified)
-* The standard ISO-8601 format {java-api}/java/time/Period.html#parse-java.lang.CharSequence-[used by `java.time.Period`]
+* The standard ISO-8601 format {java-api}/java.base/java/time/Period.html#parse(java.lang.CharSequence)[used by `java.time.Period`]
 * A simpler format where the value and the unit pairs are coupled (`1y3d` means 1 year and 3 days)
 
 The following units are supported with the simple format:
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/kotlin.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/kotlin.adoc
index 1869964b7c6b..a50eb957cc29 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/kotlin.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/kotlin.adoc
@@ -3,7 +3,7 @@
 https://kotlinlang.org[Kotlin] is a statically-typed language targeting the JVM (and other platforms) which allows writing concise and elegant code while providing {kotlin-docs}java-interop.html[interoperability] with existing libraries written in Java.
 
 Spring Boot provides Kotlin support by leveraging the support in other Spring projects such as Spring Framework, Spring Data, and Reactor.
-See the {spring-framework-docs}/languages.html#kotlin[Spring Framework Kotlin support documentation] for more information.
+See the {spring-framework-docs}/languages/kotlin.html[Spring Framework Kotlin support documentation] for more information.
 
 The easiest way to start with Spring Boot and Kotlin is to follow https://spring.io/guides/tutorials/spring-boot-kotlin/[this comprehensive tutorial].
 You can create new Kotlin projects by using https://start.spring.io/#!language=kotlin[start.spring.io].
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/logging.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/logging.adoc
index 5f36f43f630e..a206537903e2 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/logging.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/logging.adoc
@@ -1,7 +1,7 @@
 [[features.logging]]
 == Logging
 Spring Boot uses https://commons.apache.org/logging[Commons Logging] for all internal logging but leaves the underlying log implementation open.
-Default configurations are provided for {java-api}/java/util/logging/package-summary.html[Java Util Logging], https://logging.apache.org/log4j/2.x/[Log4j2], and https://logback.qos.ch/[Logback].
+Default configurations are provided for {java-api}/java.logging/java/util/logging/package-summary.html[Java Util Logging], https://logging.apache.org/log4j/2.x/[Log4j2], and https://logback.qos.ch/[Logback].
 In each case, loggers are pre-configured to use console output with optional file output also available.
 
 By default, if you use the "`Starters`", Logback is used for logging.
@@ -31,13 +31,16 @@ The following items are output:
 * Log Level: `ERROR`, `WARN`, `INFO`, `DEBUG`, or `TRACE`.
 * Process ID.
 * A `---` separator to distinguish the start of actual log messages.
+* Application name: Enclosed in square brackets (logged by default only if configprop:spring.application.name[] is set)
 * Thread name: Enclosed in square brackets (may be truncated for console output).
+* Correlation ID: If tracing is enabled (not shown in the sample above)
 * Logger name: This is usually the source class name (often abbreviated).
 * The log message.
 
 NOTE: Logback does not have a `FATAL` level.
 It is mapped to `ERROR`.
 
+TIP: If you have a configprop:spring.application.name[] property but don't want it logged you can set configprop:logging.include-application-name[] to `false`.
 
 
 [[features.logging.console-output]]
@@ -307,7 +310,9 @@ If you use standard configuration locations, Spring cannot completely control lo
 WARNING: There are known classloading issues with Java Util Logging that cause problems when running from an 'executable jar'.
 We recommend that you avoid it when running from an 'executable jar' if at all possible.
 
-To help with the customization, some other properties are transferred from the Spring `Environment` to System properties, as described in the following table:
+To help with the customization, some other properties are transferred from the Spring `Environment` to System properties.
+This allows the properties to be consumed by logging system configuration. For example, setting `logging.file.name` in `application.properties` or `LOGGING_FILE_NAME` as an environment variable will result in the `LOG_FILE` System property being set.
+The properties that are transferred are described in the following table:
 
 |===
 | Spring Environment | System Property | Comments
@@ -441,7 +446,7 @@ Profile sections are supported anywhere within the `<configuration>` element.
 Use the `name` attribute to specify which profile accepts the configuration.
 The `<springProfile>` tag can contain a profile name (for example `staging`) or a profile expression.
 A profile expression allows for more complicated profile logic to be expressed, for example `production & (eu-central | eu-west)`.
-Check the {spring-framework-docs}/core.html#beans-definition-profiles-java[Spring Framework reference guide] for more details.
+Check the {spring-framework-docs}/core/beans/environment.html#beans-definition-profiles-java[Spring Framework reference guide] for more details.
 The following listing shows three sample profiles:
 
 [source,xml,subs="verbatim",indent=0]
@@ -506,7 +511,7 @@ Profile sections are supported anywhere within the `<Configuration>` element.
 Use the `name` attribute to specify which profile accepts the configuration.
 The `<SpringProfile>` tag can contain a profile name (for example `staging`) or a profile expression.
 A profile expression allows for more complicated profile logic to be expressed, for example `production & (eu-central | eu-west)`.
-Check the {spring-framework-docs}/core.html#beans-definition-profiles-java[Spring Framework reference guide] for more details.
+Check the {spring-framework-docs}/core/beans/environment.html#beans-definition-profiles-java[Spring Framework reference guide] for more details.
 The following listing shows three sample profiles:
 
 [source,xml,subs="verbatim",indent=0]
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/spring-application.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/spring-application.adoc
index 9aae568be3db..3601cde03b2d 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/spring-application.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/spring-application.adoc
@@ -129,11 +129,12 @@ The printed banner is registered as a singleton bean under the following name: `
 
 [NOTE]
 ====
-The `${application.version}` and `${application.formatted-version}` properties are only available if you are using Spring Boot launchers.
-The values will not be resolved if you are running an unpacked jar and starting it with `java -cp <classpath> <mainclass>`.
+The `application.title`, `application.version`, and `application.formatted-version` properties are only available if you are using `java -jar` or `java -cp` with Spring Boot launchers.
+The values will not be resolved if you are running an unpacked jar and starting it with `java -cp <classpath> <mainclass>`
+or running your application as a native image.
 
-This is why we recommend that you always launch unpacked jars using `java org.springframework.boot.loader.JarLauncher`.
-This will initialize the `application.*` banner variables before building the classpath and launching your app.
+To use the `application.*` properties, launch your application as a packed jar using `java -jar` or as an unpacked jar using `java org.springframework.boot.loader.launch.JarLauncher`.
+This will initialize the `application.*` banner properties before building the classpath and launching your app.
 ====
 
 
@@ -355,7 +356,7 @@ TIP: If you want to know on which HTTP port the application is running, get the
 === Application Startup tracking
 During the application startup, the `SpringApplication` and the `ApplicationContext` perform many tasks related to the application lifecycle,
 the beans lifecycle or even processing application events.
-With {spring-framework-api}/core/metrics/ApplicationStartup.html[`ApplicationStartup`], Spring Framework  {spring-framework-docs}/core.html#context-functionality-startup[allows you to track the application startup sequence with `StartupStep` objects].
+With {spring-framework-api}/core/metrics/ApplicationStartup.html[`ApplicationStartup`], Spring Framework  {spring-framework-docs}/core/beans/context-introduction.html#context-functionality-startup[allows you to track the application startup sequence with `StartupStep` objects].
 This data can be collected for profiling purposes, or just to have a better understanding of an application startup process.
 
 You can choose an `ApplicationStartup` implementation when setting up the `SpringApplication` instance.
@@ -376,3 +377,17 @@ Spring Boot ships with the `BufferingApplicationStartup` variant; this implement
 Applications can ask for the bean of type `BufferingApplicationStartup` in any component.
 
 Spring Boot can also be configured to expose a {spring-boot-actuator-restapi-docs}/#startup[`startup` endpoint] that provides this information as a JSON document.
+
+
+
+[[features.spring-application.virtual-threads]]
+=== Virtual threads
+If you're running on Java 21 or up, you can enable virtual threads by setting the property configprop:spring.threads.virtual.enabled[] to `true`.
+
+WARNING: One side effect of virtual threads is that these threads are daemon threads.
+A JVM will exit if there are no non-daemon threads.
+This behavior can be a problem when you rely on, e.g. `@Scheduled` beans to keep your application alive.
+If you use virtual threads, the scheduler thread is a virtual thread and therefore a daemon thread and won't keep the JVM alive.
+This does not only affect scheduling, but can be the case with other technologies, too!
+To keep the JVM running in all cases, it is recommended to set the property configprop:spring.main.keep-alive[] to `true`.
+This ensures that the JVM is kept alive, even if all threads are virtual threads.
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/ssl.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/ssl.adoc
index 5f15b9720b89..7a7c925adb37 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/ssl.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/ssl.adoc
@@ -77,6 +77,33 @@ When used to secure a client-side connection, a `truststore` is typically config
                 certificate: "classpath:server.crt"
 ----
 
+[TIP]
+====
+PEM content can be used directly for both the `certificate` and `private-key` properties.
+If the property values contains `BEGIN` and `END` markers then they will be treated as PEM content rather than a resource location.
+
+The following example shows how a truststore certificate can be defined:
+
+[source,yaml,indent=0,subs="verbatim",configblocks]
+----
+    spring:
+      ssl:
+        bundle:
+          pem:
+            mybundle:
+              truststore:
+                certificate: |
+                  -----BEGIN CERTIFICATE-----
+                  MIID1zCCAr+gAwIBAgIUNM5QQv8IzVQsgSmmdPQNaqyzWs4wDQYJKoZIhvcNAQEL
+                  BQAwezELMAkGA1UEBhMCWFgxEjAQBgNVBAgMCVN0YXRlTmFtZTERMA8GA1UEBwwI
+                  ...
+                  V0IJjcmYjEZbTvpjFKznvaFiOUv+8L7jHQ1/Yf+9c3C8gSjdUfv88m17pqYXd+Ds
+                  HEmfmNNjht130UyjNCITmLVXyy5p35vWmdf95U3uEbJSnNVtXH8qRmN9oK9mUpDb
+                  ngX6JBJI7fw7tXoqWSLHNiBODM88fUlQSho8
+                  -----END CERTIFICATE-----
+----
+====
+
 See {spring-boot-autoconfigure-module-code}/ssl/PemSslBundleProperties.java[PemSslBundleProperties] for the full set of supported properties.
 
 
@@ -105,3 +132,33 @@ The following example shows retrieving an `SslBundle` and using it to create an
 
 include::code:MyComponent[]
 
+
+
+[[features.ssl.reloading]]
+=== Reloading SSL bundles
+SSL bundles can be reloaded when the key material changes.
+The component consuming the bundle has to be compatible with reloadable SSL bundles.
+Currently the following components are compatible:
+
+* Tomcat web server
+* Netty web server
+
+To enable reloading, you need to opt-in via a configuration property as shown in this example:
+
+[source,yaml,indent=0,subs="verbatim",configblocks]
+----
+    spring:
+      ssl:
+        bundle:
+          pem:
+            mybundle:
+              reload-on-update: true
+              keystore:
+                certificate: "file:/some/directory/application.crt"
+                private-key: "file:/some/directory/application.key"
+----
+
+A file watcher is then watching the files and if they change, the SSL bundle will be reloaded.
+This in turn triggers a reload in the consuming component, e.g. Tomcat rotates the certificates in the SSL enabled connectors.
+
+You can configure the quiet period (to make sure that there are no more changes) of the file watcher with the configprop:spring.ssl.bundle.watch.file.quiet-period[] property.
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/task-execution-and-scheduling.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/task-execution-and-scheduling.adoc
index 71c66a419add..547be8751408 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/task-execution-and-scheduling.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/task-execution-and-scheduling.adoc
@@ -1,16 +1,25 @@
 [[features.task-execution-and-scheduling]]
 == Task Execution and Scheduling
-In the absence of an `Executor` bean in the context, Spring Boot auto-configures a `ThreadPoolTaskExecutor` with sensible defaults that can be automatically associated to asynchronous task execution (`@EnableAsync`) and Spring MVC asynchronous request processing.
+In the absence of an `Executor` bean in the context, Spring Boot auto-configures an `AsyncTaskExecutor`.
+When virtual threads are enabled (using Java 21+ and configprop:spring.threads.virtual.enabled[] set to `true`) this will be a `SimpleAsyncTaskExecutor` that uses virtual threads.
+Otherwise, it will be a `ThreadPoolTaskExecutor` with sensible defaults.
+In either case, the auto-configured executor will be automatically used for:
+
+- asynchronous task execution (`@EnableAsync`)
+- Spring for GraphQL's asynchronous handling of `Callable` return values from controller methods
+- Spring MVC's asynchronous request processing
+- Spring WebFlux's blocking execution support
 
 [TIP]
 ====
-If you have defined a custom `Executor` in the context, regular task execution (that is `@EnableAsync`) will use it transparently but the Spring MVC support will not be configured as it requires an `AsyncTaskExecutor` implementation (named `applicationTaskExecutor`).
-Depending on your target arrangement, you could change your `Executor` into a `ThreadPoolTaskExecutor` or define both a `ThreadPoolTaskExecutor` and an `AsyncConfigurer` wrapping your custom `Executor`.
+If you have defined a custom `Executor` in the context, both regular task execution (that is `@EnableAsync`) and Spring for GraphQL will use it.
+However, the Spring MVC and Spring WebFlux support will only use it if it is an `AsyncTaskExecutor` implementation (named `applicationTaskExecutor`).
+Depending on your target arrangement, you could change your `Executor` into an `AsyncTaskExecutor` or define both an `AsyncTaskExecutor` and an `AsyncConfigurer` wrapping your custom `Executor`.
 
-The auto-configured `TaskExecutorBuilder` allows you to easily create instances that reproduce what the auto-configuration does by default.
+The auto-configured `ThreadPoolTaskExecutorBuilder` allows you to easily create instances that reproduce what the auto-configuration does by default.
 ====
 
-The thread pool uses 8 core threads that can grow and shrink according to the load.
+When a `ThreadPoolTaskExecutor` is auto-configured, the thread pool uses 8 core threads that can grow and shrink according to the load.
 Those default settings can be fine-tuned using the `spring.task.execution` namespace, as shown in the following example:
 
 [source,yaml,indent=0,subs="verbatim",configprops,configblocks]
@@ -27,8 +36,11 @@ Those default settings can be fine-tuned using the `spring.task.execution` names
 This changes the thread pool to use a bounded queue so that when the queue is full (100 tasks), the thread pool increases to maximum 16 threads.
 Shrinking of the pool is more aggressive as threads are reclaimed when they are idle for 10 seconds (rather than 60 seconds by default).
 
-A `ThreadPoolTaskScheduler` can also be auto-configured if need to be associated to scheduled task execution (using `@EnableScheduling` for instance).
-The thread pool uses one thread by default and its settings can be fine-tuned using the `spring.task.scheduling` namespace, as shown in the following example:
+A scheduler can also be auto-configured if it needs to be associated with scheduled task execution (using `@EnableScheduling` for instance).
+When virtual threads are enabled (using Java 21+ and configprop:spring.threads.virtual.enabled[] set to `true`) this will be a `SimpleAsyncTaskScheduler` that uses virtual threads.
+Otherwise, it will be a `ThreadPoolTaskScheduler` with sensible defaults.
+
+The `ThreadPoolTaskScheduler` uses one thread by default and its settings can be fine-tuned using the `spring.task.scheduling` namespace, as shown in the following example:
 
 [source,yaml,indent=0,subs="verbatim",configprops,configblocks]
 ----
@@ -40,4 +52,5 @@ The thread pool uses one thread by default and its settings can be fine-tuned us
 	        size: 2
 ----
 
-Both a `TaskExecutorBuilder` bean and a `TaskSchedulerBuilder` bean are made available in the context if a custom executor or scheduler needs to be created.
+A `ThreadPoolTaskExecutorBuilder` bean, a `SimpleAsyncTaskExecutorBuilder` bean, a `ThreadPoolTaskSchedulerBuilder` bean and a `SimpleAsyncTaskSchedulerBuilder` are made available in the context if a custom executor or scheduler needs to be created.
+The `SimpleAsyncTaskExecutorBuilder` and `SimpleAsyncTaskSchedulerBuilder` beans are auto-configured to use virtual threads if they are enabled (using Java 21+ and configprop:spring.threads.virtual.enabled[] set to `true`).
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testcontainers.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testcontainers.adoc
new file mode 100644
index 000000000000..a7c6b751ee88
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testcontainers.adoc
@@ -0,0 +1,93 @@
+[[features.testcontainers]]
+== Testcontainers Support
+As well as <<features#features.testing.testcontainers, using Testcontainers for integration testing>>, it's also possible to use them at development time.
+The next sections will provide more details about that.
+
+
+
+[[features.testcontainers.at-development-time]]
+=== Using Testcontainers at Development Time
+This approach allows developers to quickly start containers for the services that the application depends on, removing the need to manually provision things like database servers.
+Using Testcontainers in this way provides functionality similar to Docker Compose, except that your container configuration is in Java rather than YAML.
+
+To use Testcontainers at development time you need to launch your application using your "`test`" classpath rather than "`main`".
+This will allow you to access all declared test dependencies and give you a natural place to write your test configuration.
+
+To create a test launchable version of your application you should create an "`Application`" class in the `src/test` directory.
+For example, if your main application is in `src/main/java/com/example/MyApplication.java`, you should create `src/test/java/com/example/TestMyApplication.java`
+
+The `TestMyApplication` class can use the `SpringApplication.from(...)` method to launch the real application:
+
+include::code:launch/TestMyApplication[]
+
+You'll also need to define the `Container` instances that you want to start along with your application.
+To do this, you need to make sure that the `spring-boot-testcontainers` module has been added as a `test` dependency.
+Once that has been done, you can create a `@TestConfiguration` class that declares `@Bean` methods for the containers you want to start.
+
+You can also annotate your `@Bean` methods with `@ServiceConnection` in order to create `ConnectionDetails` beans.
+See <<features#features.testing.testcontainers.service-connections, the service connections>> section for details of the supported technologies.
+
+A typical Testcontainers configuration would look like this:
+
+include::code:test/MyContainersConfiguration[]
+
+NOTE: The lifecycle of `Container` beans is automatically managed by Spring Boot.
+Containers will be started and stopped automatically.
+
+TIP: You can use the configprop:spring.testcontainers.beans.startup[] property to change how containers are started.
+By default `sequential` startup is used, but you may also choose `parallel` if you wish to start multiple containers in parallel.
+
+Once you have defined your test configuration, you can use the `with(...)` method to attach it to your test launcher:
+
+include::code:test/TestMyApplication[]
+
+You can now launch `TestMyApplication` as you would any regular Java `main` method application to start your application and the containers that it needs to run.
+
+TIP: You can use the Maven goal `spring-boot:test-run` or the Gradle task `bootTestRun` to do this from the command line.
+
+
+
+[[features.testcontainers.at-development-time.dynamic-properties]]
+==== Contributing Dynamic Properties at Development Time
+If you want to contribute dynamic properties at development time from your `Container` `@Bean` methods, you can do so by injecting a `DynamicPropertyRegistry`.
+This works in a similar way to the <<features#features.testing.testcontainers.dynamic-properties,`@DynamicPropertySource` annotation>> that you can use in your tests.
+It allows you to add properties that will become available once your container has started.
+
+A typical configuration would look like this:
+
+include::code:MyContainersConfiguration[]
+
+NOTE: Using a `@ServiceConnection` is recommended whenever possible, however, dynamic properties can be a useful fallback for technologies that don't yet have `@ServiceConnection` support.
+
+
+
+[[features.testcontainers.at-development-time.importing-container-declarations]]
+==== Importing Testcontainer Declaration Classes
+A common pattern when using Testcontainers is to declare `Container` instances as static fields.
+Often these fields are defined directly on the test class.
+They can also be declared on a parent class or on an interface that the test implements.
+
+For example, the following `MyContainers` interface declares `mongo` and `neo4j` containers:
+
+include::code:MyContainers[]
+
+If you already have containers defined in this way, or you just prefer this style, you can import these declaration classes rather than defining you containers as `@Bean` methods.
+To do so, add the `@ImportTestcontainers` annotation to your test configuration class:
+
+include::code:MyContainersConfiguration[]
+
+TIP: If you don't intend to use the <<features#features.testing.testcontainers.service-connections, service connections feature>> but want to use <<features#features.testing.testcontainers.dynamic-properties, `@DynamicPropertySource`>> instead, remove the `@ServiceConnection` annotation from the `Container` fields.
+You can also add `@DynamicPropertySource` annotated methods to your declaration class.
+
+
+
+[[features.testcontainers.at-development-time.devtools]]
+==== Using DevTools with Testcontainers at Development Time
+When using devtools, you can annotate beans and bean methods with `@RestartScope`.
+Such beans won't be recreated when the devtools restart the application.
+This is especially useful for Testcontainer `Container` beans, as they keep their state despite the application restart.
+
+include::code:MyContainersConfiguration[]
+
+WARNING: If you're using Gradle and want to use this feature, you need to change the configuration of the `spring-boot-devtools` dependency from `developmentOnly` to `testImplementation`.
+With the default scope of `developmentOnly`, the `bootTestRun` task will not pick up changes in your code, as the devtools are not active.
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testing.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testing.adoc
index 436deb91f7dd..d39d902b31bd 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testing.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testing.adoc
@@ -35,12 +35,13 @@ To use the vintage engine, add a dependency on `junit-vintage-engine`, as shown
 The `spring-boot-starter-test` "`Starter`" (in the `test` `scope`) contains the following provided libraries:
 
 * https://junit.org/junit5/[JUnit 5]: The de-facto standard for unit testing Java applications.
-* {spring-framework-docs}/testing.html#integration-testing[Spring Test] & Spring Boot Test: Utilities and integration test support for Spring Boot applications.
+* {spring-framework-docs}/testing/integration.html[Spring Test] & Spring Boot Test: Utilities and integration test support for Spring Boot applications.
 * https://assertj.github.io/doc/[AssertJ]: A fluent assertion library.
 * https://github.com/hamcrest/JavaHamcrest[Hamcrest]: A library of matcher objects (also known as constraints or predicates).
 * https://site.mockito.org/[Mockito]: A Java mocking framework.
 * https://github.com/skyscreamer/JSONassert[JSONassert]: An assertion library for JSON.
 * https://github.com/jayway/JsonPath[JsonPath]: XPath for JSON.
+* https://https://github.com/awaitility/awaitility[Awaitility]: A library for testing asynchronous systems.
 
 We generally find these common libraries to be useful when writing tests.
 If these libraries do not suit your needs, you can add additional test dependencies of your own.
@@ -59,7 +60,7 @@ It is useful to be able to perform integration testing without requiring deploym
 The Spring Framework includes a dedicated test module for such integration testing.
 You can declare a dependency directly to `org.springframework:spring-test` or use the `spring-boot-starter-test` "`Starter`" to pull it in transitively.
 
-If you have not used the `spring-test` module before, you should start by reading the {spring-framework-docs}/testing.html#testing[relevant section] of the Spring Framework reference documentation.
+If you have not used the `spring-test` module before, you should start by reading the {spring-framework-docs}/testing.html[relevant section] of the Spring Framework reference documentation.
 
 
 
@@ -172,14 +173,17 @@ include::code:always/MyApplicationTests[]
 If your application uses component scanning (for example, if you use `@SpringBootApplication` or `@ComponentScan`), you may find top-level configuration classes that you created only for specific tests accidentally get picked up everywhere.
 
 As we <<features#features.testing.spring-boot-applications.detecting-configuration,have seen earlier>>, `@TestConfiguration` can be used on an inner class of a test to customize the primary configuration.
-When placed on a top-level class, `@TestConfiguration` indicates that classes in `src/test/java` should not be picked up by scanning.
-You can then import that class explicitly where it is required, as shown in the following example:
+`@TestConfiguration` can also be used on a top-level class. Doing so indicates that the class should not be picked up by scanning.
+You can then import the class explicitly where it is required, as shown in the following example:
 
 include::code:MyTests[]
 
 NOTE: If you directly use `@ComponentScan` (that is, not through `@SpringBootApplication`) you need to register the `TypeExcludeFilter` with it.
 See {spring-boot-module-api}/context/TypeExcludeFilter.html[the Javadoc] for details.
 
+NOTE: An imported `@TestConfiguration` is processed earlier than an inner-class `@TestConfiguration` and an imported `@TestConfiguration` will be processed before any configuration found through component scanning.
+Generally speaking, this difference in ordering has no noticeable effect but it is something to be aware of if you're relying on bean overriding.
+
 
 
 [[features.testing.spring-boot-applications.using-application-arguments]]
@@ -195,13 +199,13 @@ include::code:MyApplicationArgumentTests[]
 ==== Testing With a Mock Environment
 By default, `@SpringBootTest` does not start the server but instead sets up a mock environment for testing web endpoints.
 
-With Spring MVC, we can query our web endpoints using {spring-framework-docs}/testing.html#spring-mvc-test-framework[`MockMvc`] or `WebTestClient`, as shown in the following example:
+With Spring MVC, we can query our web endpoints using {spring-framework-docs}/testing/spring-mvc-test-framework.html[`MockMvc`] or `WebTestClient`, as shown in the following example:
 
 include::code:MyMockMvcTests[]
 
 TIP: If you want to focus only on the web layer and not start a complete `ApplicationContext`, consider <<features#features.testing.spring-boot-applications.spring-mvc-tests,using `@WebMvcTest` instead>>.
 
-With Spring WebFlux endpoints, you can use {spring-framework-docs}/testing.html#webtestclient-tests[`WebTestClient`] as shown in the following example:
+With Spring WebFlux endpoints, you can use {spring-framework-docs}/testing/webtestclient.html[`WebTestClient`] as shown in the following example:
 
 include::code:MyMockWebTestClientTests[]
 
@@ -223,11 +227,11 @@ If you need to start a full running server, we recommend that you use random por
 If you use `@SpringBootTest(webEnvironment=WebEnvironment.RANDOM_PORT)`, an available port is picked at random each time your test runs.
 
 The `@LocalServerPort` annotation can be used to <<howto#howto.webserver.discover-port,inject the actual port used>> into your test.
-For convenience, tests that need to make REST calls to the started server can additionally `@Autowire` a {spring-framework-docs}/testing.html#webtestclient-tests[`WebTestClient`], which resolves relative links to the running server and comes with a dedicated API for verifying responses, as shown in the following example:
+For convenience, tests that need to make REST calls to the started server can additionally `@Autowire` a {spring-framework-docs}/testing/webtestclient.html[`WebTestClient`], which resolves relative links to the running server and comes with a dedicated API for verifying responses, as shown in the following example:
 
 include::code:MyRandomPortWebTestClientTests[]
 
-TIP: `WebTestClient` can be used against both live servers and <<features#features.testing.spring-boot-applications.with-mock-environment, mock environments>>.
+TIP: `WebTestClient` can also used with a <<features#features.testing.spring-boot-applications.with-mock-environment, mock environment>>, removing the need for a running server, by annotating your test class with `@AutoConfigureWebTestClient`.
 
 This setup requires `spring-webflux` on the classpath.
 If you can not or will not add webflux, Spring Boot also provides a `TestRestTemplate` facility:
@@ -262,9 +266,11 @@ If you need to export metrics to a different backend as part of an integration t
 
 [[features.testing.spring-boot-applications.tracing]]
 ==== Using Tracing
-Regardless of your classpath, tracing is not auto-configured when using `@SpringBootTest`.
+Regardless of your classpath, tracing components which are reporting data are not auto-configured when using `@SpringBootTest`.
+
+If you need those components as part of an integration test, annotate the test with `@AutoConfigureObservability`.
 
-If you need tracing as part of an integration test, annotate it with `@AutoConfigureObservability`.
+If you have created your own reporting components (e.g. a custom `SpanExporter` or `SpanHandler`) and you don't want them to be active in tests, you can use the `@ConditionalOnEnabledTracing` annotation to disable them.
 
 
 
@@ -299,11 +305,6 @@ We recommend using a `@Bean` method to create and configure the mock in this sit
 Additionally, you can use `@SpyBean` to wrap any existing bean with a Mockito `spy`.
 See the {spring-boot-test-module-api}/mock/mockito/SpyBean.html[Javadoc] for full details.
 
-NOTE: CGLib proxies, such as those created for scoped beans, declare the proxied methods as `final`.
-This stops Mockito from functioning correctly as it cannot mock or spy on `final` methods in its default configuration.
-If you want to mock or spy on such a bean, configure Mockito to use its inline mock maker by adding `org.mockito:mockito-inline` to your application's test dependencies.
-This allows Mockito to mock and spy on `final` methods.
-
 NOTE: While Spring's test framework caches application contexts between tests and reuses a context for tests sharing the same configuration, the use of `@MockBean` or `@SpyBean` influences the cache key, which will most likely increase the number of contexts.
 
 TIP: If you are using `@SpyBean` to spy on a bean with `@Cacheable` methods that refer to parameters by name, your application must be compiled with `-parameters`.
@@ -421,7 +422,7 @@ TIP: If you need to register extra components, such as Jackson `Module`, you can
 
 Often, `@WebFluxTest` is limited to a single controller and used in combination with the `@MockBean` annotation to provide mock implementations for required collaborators.
 
-`@WebFluxTest` also auto-configures {spring-framework-docs}/testing.html#webtestclient[`WebTestClient`], which offers a powerful way to quickly test WebFlux controllers without needing to start a full HTTP server.
+`@WebFluxTest` also auto-configures {spring-framework-docs}/testing/webtestclient.html[`WebTestClient`], which offers a powerful way to quickly test WebFlux controllers without needing to start a full HTTP server.
 
 TIP: You can also auto-configure `WebTestClient` in a non-`@WebFluxTest` (such as `@SpringBootTest`) by annotating it with `@AutoConfigureWebTestClient`.
 The following example shows a class that uses both `@WebFluxTest` and a `WebTestClient`:
@@ -479,7 +480,7 @@ There are `GraphQlTester` variants and Spring Boot will auto-configure them depe
 * the `ExecutionGraphQlServiceTester` performs tests on the server side, without a client nor a transport
 * the `HttpGraphQlTester` performs tests with a client that connects to a server, with or without a live server
 
-Spring Boot helps you to test your {spring-graphql-docs}#controllers[Spring GraphQL Controllers] with the `@GraphQlTest` annotation.
+Spring Boot helps you to test your {spring-graphql-docs}/#controllers[Spring GraphQL Controllers] with the `@GraphQlTest` annotation.
 `@GraphQlTest` auto-configures the Spring GraphQL infrastructure, without any transport nor server being involved.
 This limits scanned beans to `@Controller`, `RuntimeWiringConfigurer`, `JsonComponent`, `Converter`, `GenericConverter`, `DataFetcherExceptionResolver`, `Instrumentation` and `GraphQlSourceBuilderCustomizer`.
 Regular `@Component` and `@ConfigurationProperties` beans are not scanned when the `@GraphQlTest` annotation is used.
@@ -561,7 +562,7 @@ Regular `@Component` and `@ConfigurationProperties` beans are not scanned when t
 TIP: A list of the auto-configuration settings that are enabled by `@DataJpaTest` can be <<test-auto-configuration#appendix.test-auto-configuration,found in the appendix>>.
 
 By default, data JPA tests are transactional and roll back at the end of each test.
-See the {spring-framework-docs}/testing.html#testcontext-tx-enabling-transactions[relevant section] in the Spring Framework Reference Documentation for more details.
+See the {spring-framework-docs}/testing/testcontext-framework/tx.html#testcontext-tx-enabling-transactions[relevant section] in the Spring Framework Reference Documentation for more details.
 If that is not what you want, you can disable transaction management for a test or for the whole class as follows:
 
 include::code:MyNonTransactionalTests[]
@@ -593,12 +594,12 @@ Regular `@Component` and `@ConfigurationProperties` beans are not scanned when t
 TIP: A list of the auto-configurations that are enabled by `@JdbcTest` can be <<test-auto-configuration#appendix.test-auto-configuration,found in the appendix>>.
 
 By default, JDBC tests are transactional and roll back at the end of each test.
-See the {spring-framework-docs}/testing.html#testcontext-tx-enabling-transactions[relevant section] in the Spring Framework Reference Documentation for more details.
+See the {spring-framework-docs}/testing/testcontext-framework/tx.html#testcontext-tx-enabling-transactions[relevant section] in the Spring Framework Reference Documentation for more details.
 If that is not what you want, you can disable transaction management for a test or for the whole class, as follows:
 
 include::code:MyTransactionalTests[]
 
-If you prefer your test to run against a real database, you can use the `@AutoConfigureTestDatabase` annotation in the same way as for `DataJpaTest`.
+If you prefer your test to run against a real database, you can use the `@AutoConfigureTestDatabase` annotation in the same way as for `@DataJpaTest`.
 (See "<<features#features.testing.spring-boot-applications.autoconfigured-spring-data-jpa>>".)
 
 
@@ -613,10 +614,26 @@ Only `AbstractJdbcConfiguration` subclasses are scanned when the `@DataJdbcTest`
 TIP: A list of the auto-configurations that are enabled by `@DataJdbcTest` can be <<test-auto-configuration#appendix.test-auto-configuration,found in the appendix>>.
 
 By default, Data JDBC tests are transactional and roll back at the end of each test.
-See the {spring-framework-docs}/testing.html#testcontext-tx-enabling-transactions[relevant section] in the Spring Framework Reference Documentation for more details.
+See the {spring-framework-docs}/testing/testcontext-framework/tx.html#testcontext-tx-enabling-transactions[relevant section] in the Spring Framework Reference Documentation for more details.
 If that is not what you want, you can disable transaction management for a test or for the whole test class as <<features#features.testing.spring-boot-applications.autoconfigured-jdbc,shown in the JDBC example>>.
 
-If you prefer your test to run against a real database, you can use the `@AutoConfigureTestDatabase` annotation in the same way as for `DataJpaTest`.
+If you prefer your test to run against a real database, you can use the `@AutoConfigureTestDatabase` annotation in the same way as for `@DataJpaTest`.
+(See "<<features#features.testing.spring-boot-applications.autoconfigured-spring-data-jpa>>".)
+
+
+
+[[features.testing.spring-boot-applications.autoconfigured-spring-data-r2dbc]]
+==== Auto-configured Data R2DBC Tests
+`@DataR2dbcTest` is similar to `@DataJdbcTest` but is for tests that use Spring Data R2DBC repositories.
+By default, it configures an in-memory embedded database, an `R2dbcEntityTemplate`, and Spring Data R2DBC repositories.
+Regular `@Component` and `@ConfigurationProperties` beans are not scanned when the `@DataR2dbcTest` annotation is used.
+`@EnableConfigurationProperties` can be used to include `@ConfigurationProperties` beans.
+
+TIP: A list of the auto-configurations that are enabled by `@DataR2dbcTest` can be <<test-auto-configuration#appendix.test-auto-configuration,found in the appendix>>.
+
+By default, Data R2DBC tests are not transactional.
+
+If you prefer your test to run against a real database, you can use the `@AutoConfigureTestDatabase` annotation in the same way as for `@DataJpaTest`.
 (See "<<features#features.testing.spring-boot-applications.autoconfigured-spring-data-jpa>>".)
 
 
@@ -673,7 +690,7 @@ The following example shows a typical setup for using Neo4J tests in Spring Boot
 include::code:propagation/MyDataNeo4jTests[]
 
 By default, Data Neo4j tests are transactional and roll back at the end of each test.
-See the {spring-framework-docs}/testing.html#testcontext-tx-enabling-transactions[relevant section] in the Spring Framework Reference Documentation for more details.
+See the {spring-framework-docs}/testing/testcontext-framework/tx.html#testcontext-tx-enabling-transactions[relevant section] in the Spring Framework Reference Documentation for more details.
 If that is not what you want, you can disable transaction management for a test or for the whole class, as follows:
 
 include::code:nopropagation/MyDataNeo4jTests[]
@@ -723,15 +740,21 @@ include::code:server/MyDataLdapTests[]
 [[features.testing.spring-boot-applications.autoconfigured-rest-client]]
 ==== Auto-configured REST Clients
 You can use the `@RestClientTest` annotation to test REST clients.
-By default, it auto-configures Jackson, GSON, and Jsonb support, configures a `RestTemplateBuilder`, and adds support for `MockRestServiceServer`.
+By default, it auto-configures Jackson, GSON, and Jsonb support, configures a `RestTemplateBuilder` and a `RestClient.Builder`, and adds support for `MockRestServiceServer`.
 Regular `@Component` and `@ConfigurationProperties` beans are not scanned when the `@RestClientTest` annotation is used.
 `@EnableConfigurationProperties` can be used to include `@ConfigurationProperties` beans.
 
 TIP: A list of the auto-configuration settings that are enabled by `@RestClientTest` can be <<test-auto-configuration#appendix.test-auto-configuration,found in the appendix>>.
 
-The specific beans that you want to test should be specified by using the `value` or `components` attribute of `@RestClientTest`, as shown in the following example:
+The specific beans that you want to test should be specified by using the `value` or `components` attribute of `@RestClientTest`.
+
+When using a `RestTemplateBuilder` in the beans under test and `RestTemplateBuilder.rootUri(String rootUri)` has been called when building the `RestTemplate`, then the root URI should be omitted from the `MockRestServiceServer` expectations as shown in the following example:
+
+include::code:MyRestTemplateServiceTests[]
+
+When using a `RestClient.Builder` in the beans under test, or when using a `RestTemplateBuilder` without calling `rootUri(String rootURI)`, the full URI must be used in the `MockRestServiceServer` expectations as shown in the following example:
 
-include::code:MyRestClientTests[]
+include::code:MyRestClientServiceTests[]
 
 
 
@@ -946,6 +969,9 @@ The following service connection factories are provided in the `spring-boot-test
 |===
 | Connection Details | Matched on
 
+| `ActiveMQConnectionDetails`
+| Containers named "symptoma/activemq"
+
 | `CassandraConnectionDetails`
 | Containers of type `CassandraContainer`
 
@@ -973,6 +999,15 @@ The following service connection factories are provided in the `spring-boot-test
 | `Neo4jConnectionDetails`
 | Containers of type `Neo4jContainer`
 
+| `OtlpMetricsConnectionDetails`
+| Containers named "otel/opentelemetry-collector-contrib"
+
+| `OtlpTracingConnectionDetails`
+| Containers named "otel/opentelemetry-collector-contrib"
+
+| `PulsarConnectionDetails`
+| Containers of type `PulsarContainer`
+
 | `R2dbcConnectionDetails`
 | Containers of type `MariaDBContainer`, `MSSQLServerContainer`, `MySQLContainer`, `OracleContainer`, or `PostgreSQLContainer`
 
@@ -995,9 +1030,19 @@ If you want to create only a subset of the applicable types, you can use the `ty
 ====
 
 By default `Container.getDockerImageName()` is used to obtain the name used to find connection details.
-If you are using a custom docker image, you can use the `name` attribute of `@ServiceConnection` to override it.
+This works as long as Spring Boot is able to get the instance of the `Container`, which is the case when using a `static` field like in the example above.
+
+If you're using a `@Bean` method, Spring Boot won't call the bean method to get the Docker image name, because this would cause eager initialization issues.
+Instead, the return type of the bean method is used to find out which connection detail should be used.
+This works as long as you're using typed containers, e.g. `Neo4jContainer` or `RabbitMQContainer`.
+This stops working if you're using `GenericContainer`, e.g. with Redis, as shown in the following example:
+
+include::code:MyRedisConfiguration[]
+
+Spring Boot can't tell from `GenericContainer` which container image is used, so the `name` attribute from `@ServiceConnection` must be used to provide that hint.
 
-For example, if you have a `GenericContainer` using a Docker image of `registry.mycompany.com/mirror/myredis`, you'd use `@ServiceConnection(name="redis")` to ensure `RedisConnectionDetails` are created.
+You can also can use the `name` attribute of `@ServiceConnection` to override which connection detail will be used, for example when using custom images.
+If you are using the Docker image `registry.mycompany.com/mirror/myredis`, you'd use `@ServiceConnection(name="redis")` to ensure `RedisConnectionDetails` are created.
 
 
 
@@ -1012,91 +1057,6 @@ The above configuration allows Neo4j-related beans in the application to communi
 
 
 
-[[features.testing.testcontainers.at-development-time]]
-==== Using Testcontainers at Development Time
-As well as using Testcontainers for integration testing, it's also possible to use them at development time.
-This approach allows developers to quickly start containers for the services that the application depends on, removing the need to manually provision things like database servers.
-Using Testcontainers in this way provides functionality similar to Docker Compose, except that your container configuration is in Java rather than YAML.
-
-To use Testcontainers at development time you need to launch your application using your "`test`" classpath rather than "`main`".
-This will allow you to access all declared test dependencies and give you a natural place to write your test configuration.
-
-To create a test launchable version of your application you should create an "`Application`" class in the `src/test` directory.
-For example, if your main application is in `src/main/java/com/example/MyApplication.java`, you should create `src/test/java/com/example/TestMyApplication.java`
-
-The `TestMyApplication` class can use the `SpringApplication.from(...)` method to launch the real application:
-
-include::code:launch/TestMyApplication[]
-
-You'll also need to define the `Container` instances that you want to start along with your application.
-To do this, you need to make sure that the `spring-boot-testcontainers` module has been added as a `test` dependency.
-Once that has been done, you can create a `@TestConfiguration` class that declares `@Bean` methods for the containers you want to start.
-
-You can also annotate your `@Bean` methods with `@ServiceConnection` in order to create `ConnectionDetails` beans.
-See <<features#features.testing.testcontainers.service-connections, the service connections>> section above for details of the supported technologies.
-
-A typical Testcontainers configuration would look like this:
-
-include::code:test/MyContainersConfiguration[]
-
-NOTE: The lifecycle of `Container` beans is automatically managed by Spring Boot.
-Containers will be started and stopped automatically.
-
-Once you have defined your test configuration, you can use the `with(...)` method to attach it to your test launcher:
-
-include::code:test/TestMyApplication[]
-
-You can now launch `TestMyApplication` as you would any regular Java `main` method application to start your application and the containers that it needs to run.
-
-TIP: You can use the Maven goal `spring-boot:test-run` or the Gradle task `bootTestRun` to do this from the command line.
-
-[[features.testing.testcontainers.at-development-time.dynamic-properties]]
-===== Contributing Dynamic Properties at Development Time
-If you want to contribute dynamic properties at development time from your `Container` `@Bean` methods, you can do so by injecting a `DynamicPropertyRegistry`.
-This works in a similar way to the <<features#features.testing.testcontainers.dynamic-properties,`@DynamicPropertySource` annotation>> that you can use in your tests.
-It allows you to add properties that will become available once your container has started.
-
-A typical configuration would look like this:
-
-include::code:MyContainersConfiguration[]
-
-NOTE: Using a `@ServiceConnection` is recommended whenever possible, however, dynamic properties can be a useful fallback for technologies that don't yet have `@ServiceConnection` support.
-
-
-
-[[features.testing.testcontainers.at-development-time.importing-container-declarations]]
-===== Importing Testcontainer Declaration Classes
-A common pattern when using Testcontainers is to declare `Container` instances as static fields.
-Often these fields are defined directly on the test class.
-They can also be declared on a parent class or on an interface that the test implements.
-
-For example, the following `MyContainers` interface declares `mongo` and `neo4j` containers:
-
-include::code:MyContainers[]
-
-If you already have containers defined in this way, or you just prefer this style, you can import these declaration classes rather than defining you containers as `@Bean` methods.
-To do so, add the `@ImportTestcontainers` annotation to your test configuration class:
-
-include::code:MyContainersConfiguration[]
-
-TIP: You can use the `@ServiceConnection` annotation on `Container` fields to establish service connections.
-You can also add <<features#features.testing.testcontainers.dynamic-properties,`@DynamicPropertySource` annotated methods>> to your declaration class.
-
-
-
-[[features.testing.testcontainers.at-development-time.devtools]]
-===== Using DevTools with Testcontainers at Development Time
-When using devtools, you can annotate beans and bean methods with `@RestartScope`.
-Such beans won't be recreated when the devtools restart the application.
-This is especially useful for Testcontainer `Container` beans, as they keep their state despite the application restart.
-
-include::code:MyContainersConfiguration[]
-
-WARNING: If you're using Gradle and want to use this feature, you need to change the configuration of the `spring-boot-devtools` dependency from `developmentOnly` to `testImplementation`.
-With the default scope of `developmentOnly`, the `bootTestRun` task will not pick up changes in your code, as the devtools are not active.
-
-
-
 [[features.testing.utilities]]
 === Test Utilities
 A few test utility classes that are generally useful when testing your application are packaged as part of `spring-boot`.
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/getting-started/first-application.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/getting-started/first-application.adoc
index 6abd497b49b7..3556aed2c0c7 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/getting-started/first-application.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/getting-started/first-application.adoc
@@ -250,6 +250,7 @@ If you run `gradle dependencies` again, you see that there are now a number of a
 To finish our application, we need to create a single Java file.
 By default, Maven and Gradle compile sources from `src/main/java`, so you need to create that directory structure and then add a file named `src/main/java/MyApplication.java` to contain the following code:
 
+[chomp_package_replacement=com.example]
 include::code:MyApplication[]
 
 Although there is not much code here, quite a lot is going on.
@@ -269,7 +270,7 @@ It tells Spring that any HTTP request with the `/` path should be mapped to the
 The `@RestController` annotation tells Spring to render the resulting string directly back to the caller.
 
 TIP: The `@RestController` and `@RequestMapping` annotations are Spring MVC annotations (they are not specific to Spring Boot).
-See the {spring-framework-docs}/web.html#mvc[MVC section] in the Spring Reference Documentation for more details.
+See the {spring-framework-docs}/web/webmvc.html[MVC section] in the Spring Reference Documentation for more details.
 
 
 
@@ -380,7 +381,7 @@ To gracefully exit the application, press `ctrl-c`.
 [[getting-started.first-application.executable-jar]]
 === Creating an Executable Jar
 We finish our example by creating a completely self-contained executable jar file that we could run in production.
-Executable jars (sometimes called "`fat jars`") are archives containing your compiled classes along with all of the jar dependencies that your code needs to run.
+Executable jars (sometimes called "`uber jars`" or "`fat jars`") are archives containing your compiled classes along with all of the jar dependencies that your code needs to run.
 
 .Executable jars and Java
 ****
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/getting-started/system-requirements.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/getting-started/system-requirements.adoc
index a180e0efb574..fb7208597a61 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/getting-started/system-requirements.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/getting-started/system-requirements.adoc
@@ -1,6 +1,6 @@
 [[getting-started.system-requirements]]
 == System Requirements
-Spring Boot {spring-boot-version} requires https://www.java.com[Java 17] and is compatible up to and including Java 20.
+Spring Boot {spring-boot-version} requires https://www.java.com[Java 17] and is compatible up to and including Java 21.
 {spring-framework-docs}/[Spring Framework {spring-framework-version}] or above is also required.
 
 Explicit build support is provided for the following build tools:
@@ -27,8 +27,8 @@ Spring Boot supports the following embedded servlet containers:
 | Tomcat 10.1
 | 6.0
 
-| Jetty 11.0
-| 5.0
+| Jetty 12.0
+| 6.0
 
 | Undertow 2.3
 | 6.0
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/actuator.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/actuator.adoc
index 28a8638948fc..7c0c993eb793 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/actuator.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/actuator.adoc
@@ -34,38 +34,8 @@ See also the section on "`<<web#web.servlet.spring-mvc.error-handling, Error Han
 
 
 
-[[howto.actuator.sanitize-sensitive-values]]
-=== Sanitize Sensitive Values
-Information returned by the `/env`, `/configprops` and `/quartz` endpoints can be somewhat sensitive.
-All values are sanitized by default (that is replaced by `+******+`).
-Viewing original values in the unsanitized form can be configured per endpoint using the `showValues` property for that endpoint.
-This property can be configured to have the following values:
-
-- `ALWAYS` - all values are shown in their unsanitized form to all users
-- `NEVER`  - all values are always sanitized (that is replaced by `+******+`)
-- `WHEN_AUTHORIZED` - all values are shown in their unsanitized form to authorized users
-
-For HTTP endpoints, a user is considered to be authorized if they have authenticated and have the roles configured by the endpoint's roles property.
-By default, any authenticated user is authorized.
-For JMX endpoints, all users are always authorized.
-
-[source,yaml,indent=0,subs="verbatim",configprops,configblocks]
-----
-	management:
-	  endpoint:
-	    env:
-	      show-values: WHEN_AUTHORIZED
-	      roles: "admin"
-----
-
-The configuration above enables the ability for all users with the `admin` role to view all values in their original form from the `/env` endpoint.
-
-NOTE: When `show-values` is set to `ALWAYS` or `WHEN_AUTHORIZED` any sanitization applied by a `<<howto#howto.actuator.sanitize-sensitive-values.customizing-sanitization, SanitizingFunction>>` will still be applied.
-
-
-
-[[howto.actuator.sanitize-sensitive-values.customizing-sanitization]]
-==== Customizing Sanitization
+[[howto.actuator.customizing-sanitization]]
+=== Customizing Sanitization
 To take control over the sanitization, define a `SanitizingFunction` bean.
 The `SanitizableData` with which the function is called provides access to the key and value as well as the `PropertySource` from which they came.
 This allows you to, for example, sanitize every value that comes from a particular property source.
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/application.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/application.adoc
index 638864778836..26b1da2c2e23 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/application.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/application.adoc
@@ -61,7 +61,6 @@ Spring Boot loads a number of such customizations for use internally from `META-
 There is more than one way to register additional customizations:
 
 * Programmatically, per application, by calling the `addListeners` and `addInitializers` methods on `SpringApplication` before you run it.
-* Declaratively, per application, by setting the `context.initializer.classes` or `context.listener.classes` properties.
 * Declaratively, for all applications, by adding a `META-INF/spring.factories` and packaging a jar file that the applications all use as a library.
 
 The `SpringApplication` sends some special `ApplicationEvents` to the listeners (some even before the context is created) and then registers the listeners for events published by the `ApplicationContext` as well.
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/batch.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/batch.adoc
index f2113a069745..2df1dbbf8516 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/batch.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/batch.adoc
@@ -55,8 +55,18 @@ This provides only one argument to the batch job: `someParameter=someValue`.
 
 
 
+[[howto.batch.restarting-a-failed-job]]
+=== Restarting a stopped or failed Job
+To restart a failed `Job`, all parameters (identifying and non-identifying) must be re-specified on the command line.
+Non-identifying parameters are *not* copied from the previous execution.
+This allows them to be modified or removed.
+
+NOTE: When you're using a custom `JobParametersIncrementer`, you have to gather all parameters managed by the incrementer to restart a failed execution.
+
+
+
 [[howto.batch.storing-job-repository]]
 === Storing the Job Repository
 Spring Batch requires a data store for the `Job` repository.
 If you use Spring Boot, you must use an actual database.
-Note that it can be an in-memory database, see {spring-batch-docs}job.html#configuringJobRepository[Configuring a Job Repository].
+Note that it can be an in-memory database, see {spring-batch-docs}/job.html#configuringJobRepository[Configuring a Job Repository].
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/build.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/build.adoc
index 97c24447a62d..cfae774a9500 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/build.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/build.adoc
@@ -290,7 +290,7 @@ The following example shows how to build an executable archive with Ant:
 			</mappedresources>
 			<zipfileset src="${lib.dir}/loader/spring-boot-loader-jar-${spring-boot.version}.jar" />
 			<manifest>
-				<attribute name="Main-Class" value="org.springframework.boot.loader.JarLauncher" />
+				<attribute name="Main-Class" value="org.springframework.boot.loader.launch.JarLauncher" />
 				<attribute name="Start-Class" value="${start-class}" />
 			</manifest>
 		</jar>
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/data-access.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/data-access.adoc
index 99c2c8ce3bb6..5c5894d83724 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/data-access.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/data-access.adoc
@@ -150,14 +150,14 @@ Note that each `configuration` sub namespace provides advanced settings based on
 [[howto.data-access.spring-data-repositories]]
 === Use Spring Data Repositories
 Spring Data can create implementations of `@Repository` interfaces of various flavors.
-Spring Boot handles all of that for you, as long as those `@Repositories` are included in the same package (or a sub-package) of your `@EnableAutoConfiguration` class.
+Spring Boot handles all of that for you, as long as those `@Repository` annotations are included in one of the <<using#using.auto-configuration.packages,auto-configuration packages>>, typically the package (or a sub-package) of your main application class that is annotated with `@SpringBootApplication` or `@EnableAutoConfiguration`.
 
 For many applications, all you need is to put the right Spring Data dependencies on your classpath.
 There is a `spring-boot-starter-data-jpa` for JPA, `spring-boot-starter-data-mongodb` for Mongodb, and various other starters for supported technologies.
 To get started, create some repository interfaces to handle your `@Entity` objects.
 
-Spring Boot tries to guess the location of your `@Repository` definitions, based on the `@EnableAutoConfiguration` it finds.
-To get more control, use the `@EnableJpaRepositories` annotation (from Spring Data JPA).
+Spring Boot determines the location of your `@Repository` definitions by scanning the <<using#using.auto-configuration.packages,auto-configuration packages>>.
+For more control, use the `@Enable
Repositories` annotations from Spring Data.
 
 For more about Spring Data, see the {spring-data}[Spring Data project page].
 
@@ -165,8 +165,8 @@ For more about Spring Data, see the {spring-data}[Spring Data project page].
 
 [[howto.data-access.separate-entity-definitions-from-spring-configuration]]
 === Separate @Entity Definitions from Spring Configuration
-Spring Boot tries to guess the location of your `@Entity` definitions, based on the `@EnableAutoConfiguration` it finds.
-To get more control, you can use the `@EntityScan` annotation, as shown in the following example:
+Spring Boot determines the location of your `@Entity` definitions by scanning the <<using#using.auto-configuration.packages,auto-configuration packages>>.
+For more control, use the `@EntityScan` annotation, as shown in the following example:
 
 include::code:MyApplication[]
 
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/data-initialization.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/data-initialization.adoc
index 172550e7358d..cf7a4a90cee5 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/data-initialization.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/data-initialization.adoc
@@ -38,11 +38,17 @@ It is a Hibernate feature (and has nothing to do with Spring).
 
 [[howto.data-initialization.using-basic-sql-scripts]]
 === Initialize a Database Using Basic SQL Scripts
-Spring Boot can automatically create the schema (DDL scripts) of your JDBC `DataSource` or R2DBC `ConnectionFactory` and initialize it (DML scripts).
-It loads SQL from the standard root classpath locations: `schema.sql` and `data.sql`, respectively.
-In addition, Spring Boot processes the `schema-$\{platform}.sql` and `data-$\{platform}.sql` files (if present), where `platform` is the value of configprop:spring.sql.init.platform[].
+Spring Boot can automatically create the schema (DDL scripts) of your JDBC `DataSource` or R2DBC `ConnectionFactory` and initialize its data (DML scripts).
+
+By default, it loads schema scripts from `optional:classpath*:schema.sql` and data scripts from `optional:classpath*:data.sql`.
+The locations of these schema and data scripts can customized using configprop:spring.sql.init.schema-locations[] and configprop:spring.sql.init.data-locations[] respectively.
+The `optional:` prefix means that the application will start when the files do not exist.
+To have the application fail to start when the files are absent, remove the `optional:` prefix.
+
+In addition, Spring Boot processes the `optional:classpath*:schema-$\{platform}.sql` and `optional:classpath*:data-$\{platform}.sql` files (if present), where `$\{platform}` is the value of configprop:spring.sql.init.platform[].
 This allows you to switch to database-specific scripts if necessary.
 For example, you might choose to set it to the vendor name of the database (`hsqldb`, `h2`, `oracle`, `mysql`, `postgresql`, and so on).
+
 By default, SQL database initialization is only performed when using an embedded in-memory database.
 To always initialize an SQL database, irrespective of its type, set configprop:spring.sql.init.mode[] to `always`.
 Similarly, to disable initialization, set configprop:spring.sql.init.mode[] to `never`.
@@ -56,9 +62,14 @@ While we do not recommend using multiple data source initialization technologies
 This will defer data source initialization until after any `EntityManagerFactory` beans have been created and initialized.
 `schema.sql` can then be used to make additions to any schema creation performed by Hibernate and `data.sql` can be used to populate it.
 
+NOTE: The initialization scripts support `--` for single line comments and `/++*++ ++*++/` for block comments.
+Other comment formats are not supported.
+
 If you are using a <<howto#howto.data-initialization.migration-tool,Higher-level Database Migration Tool>>, like Flyway or Liquibase, you should use them alone to create and initialize the schema.
 Using the basic `schema.sql` and `data.sql` scripts alongside Flyway or Liquibase is not recommended and support will be removed in a future release.
 
+If you need to initialize test data using a higher-level database migration tool, please see the sections about <<howto#howto.data-initialization.migration-tool.flyway-tests, Flyway>> and <<howto#howto.data-initialization.migration-tool.liquibase-tests, Liquibase>>.
+
 
 
 [[howto.data-initialization.batch]]
@@ -179,6 +190,55 @@ See {spring-boot-autoconfigure-module-code}/liquibase/LiquibaseProperties.java[`
 
 
 
+[[howto.data-initialization.migration-tool.flyway-tests]]
+==== Use Flyway for test-only migrations
+If you want to create Flyway migrations which populate your test database, place them in `src/test/resources/db/migration`.
+A file named, for example, `src/test/resources/db/migration/V9999__test-data.sql` will be executed after your production migrations and only if you're running the tests.
+You can use this file to create the needed test data.
+This file will not be packaged in your uber jar or your container.
+
+
+
+[[howto.data-initialization.migration-tool.liquibase-tests]]
+==== Use Liquibase for test-only migrations
+If you want to create Liquibase migrations which populate your test database, you have to create a test changelog which also includes the production changelog.
+
+First, you need to configure Liquibase to use a different changelog when running the tests.
+One way to do this is to create a Spring Boot `test` profile and put the Liquibase properties in there.
+For that, create a file named `src/test/resources/application-test.properties` and put the following property in there:
+
+[source,yaml,indent=0,subs="verbatim",configprops,configblocks]
+----
+  spring:
+    liquibase:
+      change-log: "classpath:/db/changelog/db.changelog-test.yaml"
+----
+
+This configures Liquibase to use a different changelog when running in the `test` profile.
+
+Now create the changelog file at `src/test/resources/db/changelog/db.changelog-test.yaml`:
+
+[source,yaml,indent=0,subs="verbatim"]
+----
+databaseChangeLog:
+  - include:
+      file: classpath:/db/changelog/db.changelog-master.yaml
+  - changeSet:
+      runOrder: "last"
+      id: "test"
+      changes:
+        # Insert your changes here
+----
+
+This changelog will be used when the tests are run and it will not be packaged in your uber jar or your container.
+It includes the production changelog and then declares a new changeset, whose `runOrder: last` setting specifies that it runs after all the production changesets have been run.
+You can now use for example the https://docs.liquibase.com/change-types/insert.html[insert changeset] to insert data or the https://docs.liquibase.com/change-types/sql.html[sql changeset] to execute SQL directly.
+
+The last thing to do is to configure Spring Boot to activate the `test` profile when running tests.
+To do this, you can add the `@ActiveProfiles("test")` annotation to your `@SpringBootTest` annotated test classes.
+
+
+
 [[howto.data-initialization.dependencies]]
 === Depend Upon an Initialized Database
 Database initialization is performed while the application is starting up as part of application context refresh.
@@ -211,6 +271,7 @@ Spring Boot will automatically detect beans of the following types that depends
 - `AbstractEntityManagerFactoryBean` (unless configprop:spring.jpa.defer-datasource-initialization[] is set to `true`)
 - `DSLContext` (jOOQ)
 - `EntityManagerFactory` (unless configprop:spring.jpa.defer-datasource-initialization[] is set to `true`)
+- `JdbcClient`
 - `JdbcOperations`
 - `NamedParameterJdbcOperations`
 
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/docker-compose.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/docker-compose.adoc
index 7d18533974cf..6d85a95109fc 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/docker-compose.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/docker-compose.adoc
@@ -26,3 +26,13 @@ services:
 ----
 
 With this Docker Compose file in place, the JDBC URL used is `jdbc:postgresql://127.0.0.1:5432/mydb?ssl=true&sslmode=require`.
+
+
+
+[[howto.docker-compose.sharing-services]]
+=== Sharing services between multiple applications
+
+If you want to share services between multiple applications, create the `compose.yaml` file in one of the applications and then use the configuration property configprop:spring.docker.compose.file[] in the other applications to reference the `compose.yaml` file.
+You should also set configprop:spring.docker.compose.lifecycle-management[] to `start-only`, as it defaults to `start-and-stop` and stopping one application would shut down the shared services for the other still running applications, too.
+Setting it to `start-only` won't stop the shared services on application stop, but a caveat is that if you shut down all applications, the services stay running.
+You can stop the services manually by running `docker compose stop` on the command line in the directory which contains the `compose.yaml` file.
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/spring-mvc.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/spring-mvc.adoc
index 038d07338e52..1a137f05a2a6 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/spring-mvc.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/spring-mvc.adoc
@@ -59,16 +59,25 @@ The `ObjectMapper` (or `XmlMapper` for Jackson XML converter) instance (created
 * `MapperFeature.DEFAULT_VIEW_INCLUSION` is disabled
 * `DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES` is disabled
 * `SerializationFeature.WRITE_DATES_AS_TIMESTAMPS` is disabled
+* `SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS` is disabled
 
 Spring Boot also has some features to make it easier to customize this behavior.
 
 You can configure the `ObjectMapper` and `XmlMapper` instances by using the environment.
 Jackson provides an extensive suite of on/off features that can be used to configure various aspects of its processing.
-These features are described in six enums (in Jackson) that map onto properties in the environment:
+These features are described in several enums (in Jackson) that map onto properties in the environment:
 
 |===
 | Enum | Property | Values
 
+| `com.fasterxml.jackson.databind.cfg.EnumFeature`
+| `spring.jackson.datatype.enum.<feature_name>`
+| `true`, `false`
+
+| `com.fasterxml.jackson.databind.cfg.JsonNodeFeature`
+| `spring.jackson.datatype.json-node.<feature_name>`
+| `true`, `false`
+
 | `com.fasterxml.jackson.databind.DeserializationFeature`
 | `spring.jackson.deserialization.<feature_name>`
 | `true`, `false`
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/webserver.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/webserver.adoc
index ecee2031edf9..4424f4bc9323 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/webserver.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/webserver.adoc
@@ -20,9 +20,6 @@ The following Maven example shows how to exclude Tomcat and include Jetty for Sp
 
 [source,xml,indent=0,subs="verbatim"]
 ----
-	<properties>
-		<servlet-api.version>3.1.0</servlet-api.version>
-	</properties>
 	<dependency>
 		<groupId>org.springframework.boot</groupId>
 		<artifactId>spring-boot-starter-web</artifactId>
@@ -41,46 +38,6 @@ The following Maven example shows how to exclude Tomcat and include Jetty for Sp
 	</dependency>
 ----
 
-NOTE: The version of the servlet API has been overridden as, unlike Tomcat 9 and Undertow 2, Jetty 9.4 does not support servlet 4.0.
-
-If you wish to use Jetty 10, which does support servlet 4.0, you can do so as shown in the following example:
-
-[source,xml,indent=0,subs="verbatim"]
-----
-	<properties>
-		<jetty.version>10.0.8</jetty.version>
-	</properties>
-	<dependency>
-		<groupId>org.springframework.boot</groupId>
-		<artifactId>spring-boot-starter-web</artifactId>
-		<exclusions>
-			<!-- Exclude the Tomcat dependency -->
-			<exclusion>
-				<groupId>org.springframework.boot</groupId>
-				<artifactId>spring-boot-starter-tomcat</artifactId>
-			</exclusion>
-		</exclusions>
-	</dependency>
-	<!-- Use Jetty instead -->
-	<dependency>
-		<groupId>org.springframework.boot</groupId>
-		<artifactId>spring-boot-starter-jetty</artifactId>
-		<exclusions>
-			<!-- Exclude the Jetty-9 specific dependencies -->
-			<exclusion>
-				<groupId>org.eclipse.jetty.websocket</groupId>
-				<artifactId>websocket-server</artifactId>
-			</exclusion>
-			<exclusion>
-				<groupId>org.eclipse.jetty.websocket</groupId>
-				<artifactId>javax-websocket-server-impl</artifactId>
-			</exclusion>
-		</exclusions>
-	</dependency>
-----
-
-Note that along with excluding the Tomcat starter, a couple of Jetty9-specific dependencies also need to be excluded.
-
 The following Gradle example configures the necessary dependencies and a {gradle-docs}/resolution_rules.html#sec:module_replacement[module replacement] to use Undertow in place of Reactor Netty for Spring WebFlux:
 
 [source,gradle,indent=0,subs="verbatim"]
@@ -195,6 +152,26 @@ The following example shows setting SSL properties using a Java KeyStore file:
 	    key-password: "another-secret"
 ----
 
+Using configuration such as the preceding example means the application no longer supports a plain HTTP connector at port 8080.
+Spring Boot does not support the configuration of both an HTTP connector and an HTTPS connector through `application.properties`.
+If you want to have both, you need to configure one of them programmatically.
+We recommend using `application.properties` to configure HTTPS, as the HTTP connector is the easier of the two to configure programmatically.
+
+
+
+[[howto.webserver.configure-ssl.pem-files]]
+==== Using PEM-encoded files
+You can use PEM-encoded files instead of Java KeyStore files.
+You should use PKCS#8 key files wherever possible.
+PEM-encoded PKCS#8 key files start with a `-----BEGIN PRIVATE KEY-----` or `-----BEGIN ENCRYPTED PRIVATE KEY-----` header.
+
+If you have files in other formats, e.g., PKCS#1 (`-----BEGIN RSA PRIVATE KEY-----`) or SEC 1 (`-----BEGIN EC PRIVATE KEY-----`), you can convert them to PKCS#8 using OpenSSL:
+
+[source,shell,indent=0,subs="verbatim,attributes"]
+----
+openssl pkcs8 -topk8 -nocrypt -in <input file> -out <output file>
+----
+
 The following example shows setting SSL properties using PEM-encoded certificate and private key files:
 
 [source,yaml,indent=0,subs="verbatim",configprops,configblocks]
@@ -219,11 +196,6 @@ Alternatively, the SSL trust material can be configured in an <<features#feature
 
 See {spring-boot-module-code}/web/server/Ssl.java[`Ssl`] for details of all of the supported properties.
 
-Using configuration such as the preceding example means the application no longer supports a plain HTTP connector at port 8080.
-Spring Boot does not support the configuration of both an HTTP connector and an HTTPS connector through `application.properties`.
-If you want to have both, you need to configure one of them programmatically.
-We recommend using `application.properties` to configure HTTPS, as the HTTP connector is the easier of the two to configure programmatically.
-
 
 
 [[howto.webserver.configure-http2]]
@@ -383,7 +355,7 @@ For instance, the following settings log access on Tomcat with a {tomcat-docs}/c
 	    basedir: "my-tomcat"
 	    accesslog:
 	      enabled: true
-	      pattern: "%t %a %r %s (%D ms)"
+	      pattern: "%t %a %r %s (%D microseconds)"
 ----
 
 NOTE: The default location for logs is a `logs` directory relative to the Tomcat base directory.
@@ -398,7 +370,7 @@ Access logging for Undertow can be configured in a similar fashion, as shown in
 	  undertow:
 	    accesslog:
 	      enabled: true
-	      pattern: "%t %a %r %s (%D ms)"
+	      pattern: "%t %a %r %s (%D milliseconds)"
 	    options:
 	      server:
 	        record-request-start-time: true
@@ -437,13 +409,13 @@ There are also non-standard headers, like `X-Forwarded-Host`, `X-Forwarded-Port`
 If the proxy adds the commonly used `X-Forwarded-For` and `X-Forwarded-Proto` headers, setting `server.forward-headers-strategy` to `NATIVE` is enough to support those.
 With this option, the Web servers themselves natively support this feature; you can check their specific documentation to learn about specific behavior.
 
-If this is not enough, Spring Framework provides a {spring-framework-docs}/web.html#filters-forwarded-headers[ForwardedHeaderFilter].
-You can register it as a servlet filter in your application by setting `server.forward-headers-strategy` is set to `FRAMEWORK`.
+If this is not enough, Spring Framework provides a {spring-framework-docs}/web/webmvc/filters.html#filters-forwarded-headers[ForwardedHeaderFilter] for the servlet stack and a {spring-framework-docs}/web/webflux/reactive-spring.html#webflux-forwarded-headers[ForwardedHeaderTransformer] for the reactive stack.
+You can use them in your application by setting configprop:server.forward-headers-strategy[] to `FRAMEWORK`.
 
 TIP: If you are using Tomcat and terminating SSL at the proxy, configprop:server.tomcat.redirect-context-root[] should be set to `false`.
 This allows the `X-Forwarded-Proto` header to be honored before any redirects are performed.
 
-NOTE: If your application runs in Cloud Foundry or Heroku, the configprop:server.forward-headers-strategy[] property defaults to `NATIVE`.
+NOTE: If your application runs in Cloud Foundry, Heroku or Kubernetes, the configprop:server.forward-headers-strategy[] property defaults to `NATIVE`.
 In all other instances, it defaults to `NONE`.
 
 
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/index.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/index.adoc
index 3d6604e85b76..4966c197d1b7 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/index.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/index.adoc
@@ -19,7 +19,7 @@ The reference documentation consists of the following sections:
 <<web#web,Web>> :: Servlet Web, Reactive Web, Embedded Container Support, Graceful Shutdown, and more.
 <<data#data,Data>> :: SQL and NOSQL data access.
 <<io#io,IO>> :: Caching, Quartz Scheduler, REST clients, Sending email, Spring Web Services, and more.
-<<messaging#messaging,Messaging>> :: JMS, AMQP, Apache Kafka, RSocket, WebSocket, and Spring Integration.
+<<messaging#messaging,Messaging>> :: JMS, AMQP, Apache Kafka, Apache Pulsar, RSocket, WebSocket, and Spring Integration.
 <<container-images#container-images,Container Images>> :: Efficient container images and Building container images with Dockerfiles and Cloud Native Buildpacks.
 <<actuator#actuator,Production-ready Features>> :: Monitoring, Metrics, Auditing, and more.
 <<deployment#deployment,Deploying Spring Boot Applications>> :: Deploying to the Cloud, and Installing as a Unix application.
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/io/caching.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/io/caching.adoc
index 33b370aa5c91..fcbf3fc7bc1f 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/io/caching.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/io/caching.adoc
@@ -5,7 +5,7 @@ At its core, the abstraction applies caching to methods, thus reducing the numbe
 The caching logic is applied transparently, without any interference to the invoker.
 Spring Boot auto-configures the cache infrastructure as long as caching support is enabled by using the `@EnableCaching` annotation.
 
-NOTE: Check the {spring-framework-docs}/integration.html#cache[relevant section] of the Spring Framework reference for more details.
+NOTE: Check the {spring-framework-docs}/integration/cache.html[relevant section] of the Spring Framework reference for more details.
 
 In a nutshell, to add caching to an operation of your service add the relevant annotation to its method, as shown in the following example:
 
@@ -26,7 +26,7 @@ When you have made up your mind about the cache provider to use, please make sur
 Nearly all providers require you to explicitly configure every cache that you use in the application.
 Some offer a way to customize the default caches defined by the configprop:spring.cache.cache-names[] property.
 
-TIP: It is also possible to transparently {spring-framework-docs}/integration.html#cache-annotations-put[update] or {spring-framework-docs}/integration.html#cache-annotations-evict[evict] data from the cache.
+TIP: It is also possible to transparently {spring-framework-docs}/integration/cache/annotations.html#cache-annotations-put[update] or {spring-framework-docs}/integration/cache/annotations.html#cache-annotations-evict[evict] data from the cache.
 
 
 
@@ -48,8 +48,8 @@ If you have not defined a bean of type `CacheManager` or a `CacheResolver` named
 
 Additionally, {spring-boot-for-apache-geode}[Spring Boot for Apache Geode] provides {spring-boot-for-apache-geode-docs}#geode-caching-provider[auto-configuration for using Apache Geode as a cache provider].
 
-TIP: It is also possible to _force_ a particular cache provider by setting the configprop:spring.cache.type[] property.
-Use this property if you need to <<io#io.caching.provider.none,disable caching altogether>> in certain environments (such as tests).
+TIP: If the `CacheManager` is auto-configured by Spring Boot, it is possible to _force_ a particular cache provider by setting the configprop:spring.cache.type[] property.
+Use this property if you need to <<io#io.caching.provider.none,use no-op caches>> in certain environments (such as tests).
 
 TIP: Use the `spring-boot-starter-cache` "`Starter`" to quickly add basic caching dependencies.
 The starter brings in `spring-context-support`.
@@ -254,7 +254,10 @@ This is similar to the way the "real" cache providers behave if you use an undec
 [[io.caching.provider.none]]
 ==== None
 When `@EnableCaching` is present in your configuration, a suitable cache configuration is expected as well.
-If you need to disable caching altogether in certain environments, force the cache type to `none` to use a no-op implementation, as shown in the following example:
+If you have a custom `CacheManager`, consider defining it in a separate `@Configuration` class so that you can override it if necessary.
+None uses a no-op implementation that is useful in tests, and slice tests use that by default via `@AutoConfigureCache`.
+
+If you need to use a no-op cache rather than the auto-configured cache manager in a certain environment, set the cache type to `none`, as shown in the following example:
 
 [source,yaml,indent=0,subs="verbatim",configprops,configblocks]
 ----
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/io/email.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/io/email.adoc
index 24df7f09525d..0646df468f43 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/io/email.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/io/email.adoc
@@ -2,7 +2,7 @@
 == Sending Email
 The Spring Framework provides an abstraction for sending email by using the `JavaMailSender` interface, and Spring Boot provides auto-configuration for it as well as a starter module.
 
-TIP: See the {spring-framework-docs}/integration.html#mail[reference documentation] for a detailed explanation of how you can use `JavaMailSender`.
+TIP: See the {spring-framework-docs}/integration/email.html[reference documentation] for a detailed explanation of how you can use `JavaMailSender`.
 
 If `spring.mail.host` and the relevant libraries (as defined by `spring-boot-starter-mail`) are available, a default `JavaMailSender` is created if none exists.
 The sender can be further customized by configuration items from the `spring.mail` namespace.
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/io/rest-client.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/io/rest-client.adoc
index 0d46c8c21e15..6ec0da3ca536 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/io/rest-client.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/io/rest-client.adoc
@@ -1,79 +1,23 @@
 [[io.rest-client]]
 == Calling REST Services
-If your application calls remote REST services, Spring Boot makes that very convenient using a `RestTemplate` or a `WebClient`.
-
-[[io.rest-client.resttemplate]]
-=== RestTemplate
-If you need to call remote REST services from your application, you can use the Spring Framework's {spring-framework-api}/web/client/RestTemplate.html[`RestTemplate`] class.
-Since `RestTemplate` instances often need to be customized before being used, Spring Boot does not provide any single auto-configured `RestTemplate` bean.
-It does, however, auto-configure a `RestTemplateBuilder`, which can be used to create `RestTemplate` instances when needed.
-The auto-configured `RestTemplateBuilder` ensures that sensible `HttpMessageConverters` are applied to `RestTemplate` instances.
-
-The following code shows a typical example:
-
-include::code:MyService[]
-
-`RestTemplateBuilder` includes a number of useful methods that can be used to quickly configure a `RestTemplate`.
-For example, to add BASIC authentication support, you can use `builder.basicAuthentication("user", "password").build()`.
-
-
-
-[[io.rest-client.resttemplate.http-client]]
-==== RestTemplate HTTP Client
-Spring Boot will auto-detect which HTTP client to use with `RestTemplate` depending on the libraries available on the application classpath.
-In order of preference, the following clients are supported:
-
-. Apache HttpClient
-. OkHttp
-. Simple JDK client (`HttpURLConnection`)
-
-If multiple clients are available on the classpath, the most preferred client will be used.
-
-
-
-[[io.rest-client.resttemplate.customization]]
-==== RestTemplate Customization
-There are three main approaches to `RestTemplate` customization, depending on how broadly you want the customizations to apply.
-
-To make the scope of any customizations as narrow as possible, inject the auto-configured `RestTemplateBuilder` and then call its methods as required.
-Each method call returns a new `RestTemplateBuilder` instance, so the customizations only affect this use of the builder.
-
-To make an application-wide, additive customization, use a `RestTemplateCustomizer` bean.
-All such beans are automatically registered with the auto-configured `RestTemplateBuilder` and are applied to any templates that are built with it.
-
-The following example shows a customizer that configures the use of a proxy for all hosts except `192.168.0.5`:
-
-include::code:MyRestTemplateCustomizer[]
-
-Finally, you can define your own `RestTemplateBuilder` bean.
-Doing so will replace the auto-configured builder.
-If you want any `RestTemplateCustomizer` beans to be applied to your custom builder, as the auto-configuration would have done, configure it using a `RestTemplateBuilderConfigurer`.
-The following example exposes a `RestTemplateBuilder` that matches what Spring Boot's auto-configuration would have done, except that custom connect and read timeouts are also specified:
-
-include::code:MyRestTemplateBuilderConfiguration[]
-
-The most extreme (and rarely used) option is to create your own `RestTemplateBuilder` bean without using a configurer.
-In addition to replacing the auto-configured builder, this also prevents any `RestTemplateCustomizer` beans from being used.
-
-
-
-[[io.rest-client.resttemplate.ssl]]
-==== RestTemplate SSL Support
-If you need custom SSL configuration on the `RestTemplate`, you can apply an <<features#features.ssl.bundles,SSL bundle>> to the `RestTemplateBuilder` as shown in this example:
-
-include::code:MyService[]
+Spring Boot provides various convenient ways to call remote REST services.
+If you are developing a non-blocking reactive application and you're using Spring WebFlux, then you can use `WebClient`.
+If you prefer blocking APIs then you can use `RestClient` or `RestTemplate`.
 
 
 
 [[io.rest-client.webclient]]
 === WebClient
-If you have Spring WebFlux on your classpath, you can also choose to use `WebClient` to call remote REST services.
-Compared to `RestTemplate`, this client has a more functional feel and is fully reactive.
-You can learn more about the `WebClient` in the dedicated {spring-framework-docs}/web-reactive.html#webflux-client[section in the Spring Framework docs].
+If you have Spring WebFlux on your classpath we recommend that you use `WebClient` to call remote REST services.
+The `WebClient` interface provides a functional style API and is fully reactive.
+You can learn more about the `WebClient` in the dedicated {spring-framework-docs}/web/webflux-webclient.html[section in the Spring Framework docs].
 
-Spring Boot creates and pre-configures a `WebClient.Builder` for you.
+TIP: If you are not writing a reactive Spring WebFlux application you can use the <<io#io.rest-client.restclient,`RestClient`>> instead of a `WebClient`.
+This provides a similar functional API, but is blocking rather than reactive.
+
+Spring Boot creates and pre-configures a prototype `WebClient.Builder` bean for you.
 It is strongly advised to inject it in your components and use it to create `WebClient` instances.
-Spring Boot is configuring that builder to share HTTP resources, reflect codecs setup in the same fashion as the server ones (see <<web#web.reactive.webflux.httpcodecs,WebFlux HTTP codecs auto-configuration>>), and more.
+Spring Boot is configuring that builder to share HTTP resources and reflect codecs setup in the same fashion as the server ones (see <<web#web.reactive.webflux.httpcodecs,WebFlux HTTP codecs auto-configuration>>), and more.
 
 The following code shows a typical example:
 
@@ -101,7 +45,7 @@ Developers can override the resource configuration for Jetty and Reactor Netty b
 
 If you wish to override that choice for the client, you can define your own `ClientHttpConnector` bean and have full control over the client configuration.
 
-You can learn more about the {spring-framework-docs}/web-reactive.html#webflux-client-builder[`WebClient` configuration options in the Spring Framework reference documentation].
+You can learn more about the {spring-framework-docs}/web/webflux-webclient/client-builder.html[`WebClient` configuration options in the Spring Framework reference documentation].
 
 
 
@@ -130,3 +74,115 @@ The following code shows a typical example:
 
 include::code:MyService[]
 
+
+
+[[io.rest-client.restclient]]
+=== RestClient
+If you are not using Spring WebFlux or Project Reactor in your application we recommend that you use `RestClient` to call remote REST services.
+
+The `RestClient` interface provides a functional style blocking API.
+
+Spring Boot creates and pre-configures a prototype `RestClient.Builder` bean for you.
+It is strongly advised to inject it in your components and use it to create `RestClient` instances.
+Spring Boot is configuring that builder with `HttpMessageConverters` and an appropriate `ClientHttpRequestFactory`.
+
+The following code shows a typical example:
+
+include::code:MyService[]
+
+
+
+[[io.rest-client.restclient.customization]]
+==== RestClient Customization
+There are three main approaches to `RestClient` customization, depending on how broadly you want the customizations to apply.
+
+To make the scope of any customizations as narrow as possible, inject the auto-configured `RestClient.Builder` and then call its methods as required.
+`RestClient.Builder` instances are stateful: Any change on the builder is reflected in all clients subsequently created with it.
+If you want to create several clients with the same builder, you can also consider cloning the builder with `RestClient.Builder other = builder.clone();`.
+
+To make an application-wide, additive customization to all `RestClient.Builder` instances, you can declare `RestClientCustomizer` beans and change the `RestClient.Builder` locally at the point of injection.
+
+Finally, you can fall back to the original API and use `RestClient.create()`.
+In that case, no auto-configuration or `RestClientCustomizer` is applied.
+
+
+
+[[io.rest-client.restclient.ssl]]
+==== RestClient SSL Support
+If you need custom SSL configuration on the `ClientHttpRequestFactory` used by the `RestClient`, you can inject a `RestClientSsl` instance that can be used with the builder's `apply` method.
+
+The `RestClientSsl` interface provides access to any <<features#features.ssl.bundles,SSL bundles>> that you have defined in your `application.properties` or `application.yaml` file.
+
+The following code shows a typical example:
+
+include::code:MyService[]
+
+If you need to apply other customization in addition to an SSL bundle, you can use the `ClientHttpRequestFactorySettings` class with `ClientHttpRequestFactories`:
+
+include::code:settings/MyService[]
+
+
+
+[[io.rest-client.resttemplate]]
+=== RestTemplate
+Spring Framework's {spring-framework-api}/web/client/RestTemplate.html[`RestTemplate`] class predates `RestClient` and is the classic way that many applications use to call remote REST services.
+You might choose to use `RestTemplate` when you have existing code that you don't want to migrate to `RestClient`, or because you're already familiar with the `RestTemplate` API.
+
+Since `RestTemplate` instances often need to be customized before being used, Spring Boot does not provide any single auto-configured `RestTemplate` bean.
+It does, however, auto-configure a `RestTemplateBuilder`, which can be used to create `RestTemplate` instances when needed.
+The auto-configured `RestTemplateBuilder` ensures that sensible `HttpMessageConverters` and an appropriate `ClientHttpRequestFactory` are applied to `RestTemplate` instances.
+
+The following code shows a typical example:
+
+include::code:MyService[]
+
+`RestTemplateBuilder` includes a number of useful methods that can be used to quickly configure a `RestTemplate`.
+For example, to add BASIC authentication support, you can use `builder.basicAuthentication("user", "password").build()`.
+
+
+
+[[io.rest-client.resttemplate.customization]]
+==== RestTemplate Customization
+There are three main approaches to `RestTemplate` customization, depending on how broadly you want the customizations to apply.
+
+To make the scope of any customizations as narrow as possible, inject the auto-configured `RestTemplateBuilder` and then call its methods as required.
+Each method call returns a new `RestTemplateBuilder` instance, so the customizations only affect this use of the builder.
+
+To make an application-wide, additive customization, use a `RestTemplateCustomizer` bean.
+All such beans are automatically registered with the auto-configured `RestTemplateBuilder` and are applied to any templates that are built with it.
+
+The following example shows a customizer that configures the use of a proxy for all hosts except `192.168.0.5`:
+
+include::code:MyRestTemplateCustomizer[]
+
+Finally, you can define your own `RestTemplateBuilder` bean.
+Doing so will replace the auto-configured builder.
+If you want any `RestTemplateCustomizer` beans to be applied to your custom builder, as the auto-configuration would have done, configure it using a `RestTemplateBuilderConfigurer`.
+The following example exposes a `RestTemplateBuilder` that matches what Spring Boot's auto-configuration would have done, except that custom connect and read timeouts are also specified:
+
+include::code:MyRestTemplateBuilderConfiguration[]
+
+The most extreme (and rarely used) option is to create your own `RestTemplateBuilder` bean without using a configurer.
+In addition to replacing the auto-configured builder, this also prevents any `RestTemplateCustomizer` beans from being used.
+
+
+
+[[io.rest-client.resttemplate.ssl]]
+==== RestTemplate SSL Support
+If you need custom SSL configuration on the `RestTemplate`, you can apply an <<features#features.ssl.bundles,SSL bundle>> to the `RestTemplateBuilder` as shown in this example:
+
+include::code:MyService[]
+
+
+
+[[io.rest-client.clienthttprequestfactory]]
+=== HTTP Client Detection for RestClient and RestTemplate
+Spring Boot will auto-detect which HTTP client to use with `RestClient` and `RestTemplate` depending on the libraries available on the application classpath.
+In order of preference, the following clients are supported:
+
+. Apache HttpClient
+. Jetty HttpClient
+. OkHttp (deprecated)
+. Simple JDK client (`HttpURLConnection`)
+
+If multiple clients are available on the classpath, the most preferred client will be used.
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging.adoc
index 8b6a5ec6e626..12aca393d1a6 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging.adoc
@@ -6,7 +6,7 @@ The Spring Framework provides extensive support for integrating with messaging s
 Spring AMQP provides a similar feature set for the Advanced Message Queuing Protocol.
 Spring Boot also provides auto-configuration options for `RabbitTemplate` and RabbitMQ.
 Spring WebSocket natively includes support for STOMP messaging, and Spring Boot has support for that through starters and a small amount of auto-configuration.
-Spring Boot also has support for Apache Kafka.
+Spring Boot also has support for Apache Kafka and Apache Pulsar.
 
 
 include::messaging/jms.adoc[]
@@ -15,6 +15,8 @@ include::messaging/amqp.adoc[]
 
 include::messaging/kafka.adoc[]
 
+include::messaging/pulsar.adoc[]
+
 include::messaging/rsocket.adoc[]
 
 include::messaging/spring-integration.adoc[]
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/jms.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/jms.adoc
index 137bd648849f..d0a22ebe440c 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/jms.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/jms.adoc
@@ -2,19 +2,19 @@
 == JMS
 The `jakarta.jms.ConnectionFactory` interface provides a standard method of creating a `jakarta.jms.Connection` for interacting with a JMS broker.
 Although Spring needs a `ConnectionFactory` to work with JMS, you generally need not use it directly yourself and can instead rely on higher level messaging abstractions.
-(See the {spring-framework-docs}/integration.html#jms[relevant section] of the Spring Framework reference documentation for details.)
+(See the {spring-framework-docs}/integration/jms.html[relevant section] of the Spring Framework reference documentation for details.)
 Spring Boot also auto-configures the necessary infrastructure to send and receive messages.
 
 
 
 [[messaging.jms.activemq]]
-=== ActiveMQ Support
-When https://activemq.apache.org/[ActiveMQ] is available on the classpath, Spring Boot can configure a `ConnectionFactory`.
+=== ActiveMQ "Classic" Support
+When https://activemq.apache.org/components/classic[ActiveMQ "Classic"] is available on the classpath, Spring Boot can configure a `ConnectionFactory`.
 
-NOTE: If you use `spring-boot-starter-activemq`, the necessary dependencies to connect to an ActiveMQ instance are provided, as is the Spring infrastructure to integrate with JMS.
+NOTE: If you use `spring-boot-starter-activemq`, the necessary dependencies to connect to an ActiveMQ "Classic" instance are provided, as is the Spring infrastructure to integrate with JMS.
 
-ActiveMQ configuration is controlled by external configuration properties in `+spring.activemq.*+`.
-By default, ActiveMQ is auto-configured to use the https://activemq.apache.org/tcp-transport-reference[TCP transport], connecting by default to `tcp://localhost:61616`. The following example shows how to change the default broker URL:
+ActiveMQ "Classic" configuration is controlled by external configuration properties in `+spring.activemq.*+`.
+By default, ActiveMQ "Classic" is auto-configured to use the https://activemq.apache.org/tcp-transport-reference[TCP transport], connecting by default to `tcp://localhost:61616`. The following example shows how to change the default broker URL:
 
 [source,yaml,indent=0,configprops,configblocks]
 ----
@@ -49,7 +49,7 @@ If you'd rather use native pooling, you can do so by adding a dependency to `org
 TIP: See {spring-boot-autoconfigure-module-code}/jms/activemq/ActiveMQProperties.java[`ActiveMQProperties`] for more of the supported options.
 You can also register an arbitrary number of beans that implement `ActiveMQConnectionFactoryCustomizer` for more advanced customizations.
 
-By default, ActiveMQ creates a destination if it does not yet exist so that destinations are resolved against their provided names.
+By default, ActiveMQ "Classic" creates a destination if it does not yet exist so that destinations are resolved against their provided names.
 
 
 
@@ -102,7 +102,7 @@ If you'd rather use native pooling, you can do so by adding a dependency on `org
 
 See {spring-boot-autoconfigure-module-code}/jms/artemis/ArtemisProperties.java[`ArtemisProperties`] for more supported options.
 
-No JNDI lookup is involved, and destinations are resolved against their names, using either the `name` attribute in the Artemis configuration or the names provided through configuration.
+No JNDI lookup is involved, and destinations are resolved against their names, using either the `name` attribute in the ActiveMQ Artemis configuration or the names provided through configuration.
 
 
 
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/kafka.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/kafka.adoc
index 9956d84f5f8e..314b0e3a0a93 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/kafka.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/kafka.adoc
@@ -142,7 +142,7 @@ IMPORTANT: Properties set in this way override any configuration item that Sprin
 === Testing with Embedded Kafka
 Spring for Apache Kafka provides a convenient way to test projects with an embedded Apache Kafka broker.
 To use this feature, annotate a test class with `@EmbeddedKafka` from the `spring-kafka-test` module.
-For more information, please see the Spring for Apache Kafka {spring-kafka-docs}#embedded-kafka-annotation[reference manual].
+For more information, please see the Spring for Apache Kafka {spring-kafka-docs}testing.html#ekb[reference manual].
 
 To make Spring Boot auto-configuration work with the aforementioned embedded Apache Kafka broker, you need to remap a system property for embedded broker addresses (populated by the `EmbeddedKafkaBroker`) into the Spring Boot configuration property for Apache Kafka.
 There are several ways to do that:
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/pulsar.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/pulsar.adoc
new file mode 100644
index 000000000000..4a4d435bb5f4
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/pulsar.adoc
@@ -0,0 +1,208 @@
+[[messaging.pulsar]]
+== Apache Pulsar Support
+https://pulsar.apache.org/[Apache Pulsar] is supported by providing auto-configuration of the {spring-pulsar-docs}[Spring for Apache Pulsar] project.
+
+Spring Boot will auto-configure and register the classic (imperative) Spring for Apache Pulsar components when `org.springframework.pulsar:spring-pulsar` is on the classpath.
+It will do the same for the reactive components when `org.springframework.pulsar:spring-pulsar-reactive` is on the classpath.
+
+There are `spring-boot-starter-pulsar` and `spring-boot-starter-pulsar-reactive` "`Starters`" for conveniently collecting the dependencies for imperative and reactive use, respectively.
+
+
+
+[[messaging.pulsar.connecting]]
+=== Connecting to Pulsar
+When you use the Pulsar starter, Spring Boot will auto-configure and register a `PulsarClient` bean.
+
+By default, the application tries to connect to a local Pulsar instance at `pulsar://localhost:6650`.
+This can be adjusted by setting the configprop:spring.pulsar.client.service-url[] property to a different value.
+
+NOTE: The value must be a valid https://pulsar.apache.org/docs/client-libraries-java/#connection-urls[Pulsar Protocol] URL
+
+You can configure the client by specifying any of the `spring.pulsar.client.*` prefixed application properties.
+
+If you need more control over the configuration, consider registering one or more `PulsarClientBuilderCustomizer` beans.
+
+
+
+[[messaging.pulsar.connecting.auth]]
+==== Authentication
+To connect to a Pulsar cluster that requires authentication, you need to specify which authentication plugin to use by setting the `pluginClassName` and any parameters required by the plugin.
+You can set the parameters as a map of parameter names to parameter values.
+The following example shows how to configure the `AuthenticationOAuth2` plugin.
+
+[source,yaml,indent=0,subs="verbatim",configprops,configblocks]
+----
+spring:
+  pulsar:
+    client:
+      authentication:
+        plugin-class-name: org.apache.pulsar.client.impl.auth.oauth2.AuthenticationOAuth2
+        param:
+          issuerUrl: https://auth.server.cloud/
+          privateKey: file:///Users/some-key.json
+          audience: urn:sn:acme:dev:my-instance
+----
+
+[NOTE]
+====
+You need to ensure that names defined under `+spring.pulsar.client.authentication.param.*+` exactly match those expected by your auth plugin (which is typically camel cased).
+Spring Boot will not attempt any kind of relaxed binding for these entries.
+
+For example, if you want to configure the issuer url for the `AuthenticationOAuth2` auth plugin you must use `+spring.pulsar.client.authentication.param.issuerUrl+`.
+If you use other forms, such as `issuerurl` or `issuer-url`, the setting will not be applied to the plugin.
+====
+
+
+
+[[messaging.pulsar.connecting.ssl]]
+==== SSL
+By default, Pulsar clients communicate with Pulsar services in plain text.
+You can follow {spring-pulsar-docs}reference/pulsar.html#tls-encryption[these steps] in the Spring for Apache Pulsar reference documentation to enable TLS encryption.
+
+For complete details on the client and authentication see the Spring for Apache Pulsar {spring-pulsar-docs}reference/pulsar.html#pulsar-client[reference documentation].
+
+
+
+[[messaging.pulsar.connecting-reactive]]
+=== Connecting to Pulsar Reactively
+When the Reactive auto-configuration is activated, Spring Boot will auto-configure and register a `ReactivePulsarClient` bean.
+
+The `ReactivePulsarClient` adapts an instance of the previously described `PulsarClient`.
+Therefore, follow the previous section to configure the `PulsarClient` used by the `ReactivePulsarClient`.
+
+
+
+[[messaging.pulsar.admin]]
+=== Connecting to Pulsar Administration
+Spring for Apache Pulsar's `PulsarAdministration` client is also auto-configured.
+
+By default, the application tries to connect to a local Pulsar instance at `\http://localhost:8080`.
+This can be adjusted by setting the configprop:spring.pulsar.admin.service-url[] property to a different value in the form `(http|https)://<host>:<port>`.
+
+If you need more control over the configuration, consider registering one or more `PulsarAdminBuilderCustomizer` beans.
+
+
+[[messaging.pulsar.admin.auth]]
+==== Authentication
+When accessing a Pulsar cluster that requires authentication, the admin client requires the same security configuration as the regular Pulsar client.
+You can use the aforementioned <<messaging.pulsar.connecting.auth,authentication configuration>> by replacing `spring.pulsar.client.authentication` with `spring.pulsar.admin.authentication`.
+
+TIP: To create a topic on startup, add a bean of type `PulsarTopic`.
+If the topic already exists, the bean is ignored.
+
+
+
+[[messaging.pulsar.sending]]
+=== Sending a Message
+Spring's `PulsarTemplate` is auto-configured, and you can use it to send messages, as shown in the following example:
+
+include::code:MyBean[]
+
+The `PulsarTemplate` relies on a `PulsarProducerFactory` to create the underlying Pulsar producer.
+Spring Boot auto-configuration also provides this producer factory, which by default, caches the producers that it creates.
+You can configure the producer factory and cache settings by specifying any of the `spring.pulsar.producer.\*` and `spring.pulsar.producer.cache.*` prefixed application properties.
+
+If you need more control over the producer factory configuration, consider registering one or more `ProducerBuilderCustomizer` beans.
+These customizers are applied to all created producers.
+You can also pass in a `ProducerBuilderCustomizer` when sending a message to only affect the current producer.
+
+If you need more control over the message being sent, you can pass in a `TypedMessageBuilderCustomizer` when sending a message.
+
+
+
+[[messaging.pulsar.sending-reactive]]
+=== Sending a Message Reactively
+When the Reactive auto-configuration is activated, Spring's `ReactivePulsarTemplate` is auto-configured, and you can use it to send messages, as shown in the following example:
+
+include::code:MyBean[]
+
+The `ReactivePulsarTemplate` relies on a `ReactivePulsarSenderFactory` to actually create the underlying sender.
+Spring Boot auto-configuration also provides this sender factory, which by default, caches the producers that it creates.
+You can configure the sender factory and cache settings by specifying any of the `spring.pulsar.producer.\*` and `spring.pulsar.producer.cache.*` prefixed application properties.
+
+If you need more control over the sender factory configuration, consider registering one or more `ReactiveMessageSenderBuilderCustomizer` beans.
+These customizers are applied to all created senders.
+You can also pass in a `ReactiveMessageSenderBuilderCustomizer` when sending a message to only affect the current sender.
+
+If you need more control over the message being sent, you can pass in a `MessageSpecBuilderCustomizer` when sending a message.
+
+
+
+[[messaging.pulsar.receiving]]
+=== Receiving a Message
+When the Apache Pulsar infrastructure is present, any bean can be annotated with `@PulsarListener` to create a listener endpoint.
+The following component creates a listener endpoint on the `someTopic` topic:
+
+include::code:MyBean[]
+
+Spring Boot auto-configuration provides all the components necessary for `PulsarListener`, such as the `PulsarListenerContainerFactory` and the consumer factory it uses to construct the underlying Pulsar consumers.
+You can configure these components by specifying any of the `spring.pulsar.listener.\*` and `spring.pulsar.consumer.*` prefixed application properties.
+
+If you need more control over the consumer factory configuration, consider registering one or more `ConsumerBuilderCustomizer` beans.
+These customizers are applied to all consumers created by the factory, and therefore all `@PulsarListener` instances.
+You can also customize a single listener by setting the `consumerCustomizer` attribute of the `@PulsarListener` annotation.
+
+
+
+[[messaging.pulsar.receiving-reactive]]
+=== Receiving a Message Reactively
+When the Apache Pulsar infrastructure is present and the Reactive auto-configuration is activated, any bean can be annotated with `@ReactivePulsarListener` to create a reactive listener endpoint.
+The following component creates a reactive listener endpoint on the `someTopic` topic:
+
+include::code:MyBean[]
+
+Spring Boot auto-configuration provides all the components necessary for `ReactivePulsarListener`, such as the `ReactivePulsarListenerContainerFactory` and the consumer factory it uses to construct the underlying reactive Pulsar consumers.
+You can configure these components by specifying any of the `spring.pulsar.listener.*` and `spring.pulsar.consumer.*` prefixed application properties.
+
+If you need more control over the consumer factory configuration, consider registering one or more `ReactiveMessageConsumerBuilderCustomizer` beans.
+These customizers are applied to all consumers created by the factory, and therefore all `@ReactivePulsarListener` instances.
+You can also customize a single listener by setting the `consumerCustomizer` attribute of the `@ReactivePulsarListener` annotation.
+
+
+
+[[messaging.pulsar.reading]]
+=== Reading a Message
+The Pulsar reader interface enables applications to manually manage cursors.
+When you use a reader to connect to a topic you need to specify which message the reader begins reading from when it connects to a topic.
+
+When the Apache Pulsar infrastructure is present, any bean can be annotated with `@PulsarReader` to consume messages using a reader.
+The following component creates a reader endpoint that starts reading messages from the beginning of the `someTopic` topic:
+
+include::code:MyBean[]
+
+The `@PulsarReader` relies on a `PulsarReaderFactory` to create the underlying Pulsar reader.
+Spring Boot auto-configuration provides this reader factory which can be customized by setting any of the `spring.pulsar.reader.*` prefixed application properties.
+
+If you need more control over the reader factory configuration, consider registering one or more `ReaderBuilderCustomizer` beans.
+These customizers are applied to all readers created by the factory, and therefore all `@PulsarReader` instances.
+You can also customize a single listener by setting the `readerCustomizer` attribute of the `@PulsarReader` annotation.
+
+
+
+[[messaging.pulsar.reading-reactive]]
+=== Reading a Message Reactively
+When the Apache Pulsar infrastructure is present and the Reactive auto-configuration is activated, Spring's `ReactivePulsarReaderFactory` is provided, and you can use it to create a reader in order to read messages in a reactive fashion.
+The following component creates a reader using the provided factory and reads a single message from 5 minutes ago from the `someTopic` topic:
+
+include::code:MyBean[]
+
+Spring Boot auto-configuration provides this reader factory which can be customized by setting any of the `spring.pulsar.reader.*` prefixed application properties.
+
+If you need more control over the reader factory configuration, consider passing in one or more `ReactiveMessageReaderBuilderCustomizer` instances when using the factory to create a reader.
+
+If you need more control over the reader factory configuration, consider registering one or more `ReactiveMessageReaderBuilderCustomizer` beans.
+These customizers are applied to all created readers.
+You can also pass one or more `ReactiveMessageReaderBuilderCustomizer` when creating a reader to only apply the customizations to the created reader.
+
+TIP: For more details on any of the above components and to discover other available features, see the Spring for Apache Pulsar {spring-pulsar-docs}[reference documentation].
+
+
+
+[[messaging.pulsar.additional-properties]]
+=== Additional Pulsar Properties
+The properties supported by auto-configuration are shown in the <<application-properties#appendix.application-properties.integration, "`Integration Properties`">> section of the Appendix.
+Note that, for the most part, these properties (hyphenated or camelCase) map directly to the Apache Pulsar configuration properties.
+See the Apache Pulsar documentation for details.
+
+Only a subset of the properties supported by Pulsar are available directly through the `PulsarProperties` class.
+If you wish to tune the auto-configured components with additional properties that are not directly supported, you can use the customizer supported by each aforementioned component.
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/rsocket.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/rsocket.adoc
index e29a3e827025..a3a9c76c6062 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/rsocket.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/rsocket.adoc
@@ -5,7 +5,7 @@ It enables symmetric interaction models through async message passing over a sin
 
 
 The `spring-messaging` module of the Spring Framework provides support for RSocket requesters and responders, both on the client and on the server side.
-See the {spring-framework-docs}/web-reactive.html#rsocket-spring[RSocket section] of the Spring Framework reference for more details, including an overview of the RSocket protocol.
+See the {spring-framework-docs}/rsocket.html#rsocket-spring[RSocket section] of the Spring Framework reference for more details, including an overview of the RSocket protocol.
 
 
 
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/websockets.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/websockets.adoc
index 590fc600f206..4a3e0f23d59c 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/websockets.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/websockets.adoc
@@ -3,9 +3,9 @@
 Spring Boot provides WebSockets auto-configuration for embedded Tomcat, Jetty, and Undertow.
 If you deploy a war file to a standalone container, Spring Boot assumes that the container is responsible for the configuration of its WebSocket support.
 
-Spring Framework provides {spring-framework-docs}/web.html#websocket[rich WebSocket support] for MVC web applications that can be easily accessed through the `spring-boot-starter-websocket` module.
+Spring Framework provides {spring-framework-docs}/web/websocket.html[rich WebSocket support] for MVC web applications that can be easily accessed through the `spring-boot-starter-websocket` module.
 
-WebSocket support is also available for {spring-framework-docs}/web-reactive.html#webflux-websocket[reactive web applications] and requires to include the WebSocket API alongside `spring-boot-starter-webflux`:
+WebSocket support is also available for {spring-framework-docs}/web/webflux-websocket.html[reactive web applications] and requires to include the WebSocket API alongside `spring-boot-starter-webflux`:
 
 [source,xml,indent=0,subs="verbatim"]
 ----
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/native-image/advanced-topics.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/native-image/advanced-topics.adoc
index 96ac3ef66c60..e1735662d94d 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/native-image/advanced-topics.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/native-image/advanced-topics.adoc
@@ -62,7 +62,7 @@ Assuming an AOT processed Spring Boot executable jar built as `myproject-0.0.1-S
 
 [source,shell,indent=0,subs="verbatim"]
 ----
-	$ pack build --builder paketobuildpacks/builder:tiny \
+	$ pack build --builder paketobuildpacks/builder-jammy-tiny \
 	    --path target/myproject-0.0.1-SNAPSHOT.jar \
 	    --env 'BP_NATIVE_IMAGE=true' \
 	    my-application:0.0.1-SNAPSHOT
@@ -157,9 +157,9 @@ include::code:MyRuntimeHints[]
 
 You can then use `@ImportRuntimeHints` on any `@Configuration` class (for example your `@SpringBootApplication` annotated application class) to activate those hints.
 
-If you have classes which need binding (mostly needed when serializing or deserializing JSON), you can use {spring-framework-docs}/core.html#aot-hints-register-reflection-for-binding[`@RegisterReflectionForBinding`] on any bean.
+If you have classes which need binding (mostly needed when serializing or deserializing JSON), you can use {spring-framework-docs}/core/aot.html#aot.hints.register-reflection-for-binding[`@RegisterReflectionForBinding`] on any bean.
 Most of the hints are automatically inferred, for example when accepting or returning data from a `@RestController` method.
-But when you work with `WebClient` or `RestTemplate` directly, you might need to use `@RegisterReflectionForBinding`.
+But when you work with `WebClient`, `RestClient` or `RestTemplate` directly, you might need to use `@RegisterReflectionForBinding`.
 
 [[native-image.advanced.custom-hints.testing]]
 ==== Testing custom hints
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/native-image/developing-your-first-application.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/native-image/developing-your-first-application.adoc
index 9a1f469bb9a4..fe5a0d568380 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/native-image/developing-your-first-application.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/native-image/developing-your-first-application.adoc
@@ -32,8 +32,8 @@ This means you can just type a single command and quickly get a sensible image i
 The resulting image doesn't contain a JVM, instead the native image is compiled statically.
 This leads to smaller images.
 
-NOTE: The builder used for the images is `paketobuildpacks/builder:tiny`.
-It has small footprint and reduced attack surface, but you can also use `paketobuildpacks/builder:base` or `paketobuildpacks/builder:full` to have more tools available in the image if required.
+NOTE: The builder used for the images is `paketobuildpacks/builder-jammy-tiny:latest`.
+It has small footprint and reduced attack surface, but you can also use `paketobuildpacks/builder-jammy-base:latest` or `paketobuildpacks/builder-jammy-full:latest` to have more tools available in the image if required.
 
 
 
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/native-image/introducing-graalvm-native-images.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/native-image/introducing-graalvm-native-images.adoc
index 31c4673f695e..bacc73c71301 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/native-image/introducing-graalvm-native-images.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/native-image/introducing-graalvm-native-images.adoc
@@ -27,6 +27,9 @@ The main differences are:
 * There is no lazy class loading, everything shipped in the executables will be loaded in memory on startup.
 * There are some limitations around some aspects of Java applications that are not fully supported.
 
+On top of those differences, Spring uses a process called <<native-image#native-image.introducing-graalvm-native-images.understanding-aot-processing, Spring Ahead-of-Time processing>>, which imposes further limitations.
+Please make sure to read at least the beginning of the next section to learn about those.
+
 TIP: The {graal-native-image-docs}/metadata/Compatibility/[Native Image Compatibility Guide] section of the GraalVM reference documentation provides more details about GraalVM limitations.
 
 
@@ -39,9 +42,8 @@ In fact, the concept of Spring Boot auto-configuration depends heavily on reacti
 Although it would be possible to tell GraalVM about these dynamic aspects of the application, doing so would undo most of the benefit of static analysis.
 So instead, when using Spring Boot to create native images, a closed-world is assumed and the dynamic aspects of the application are restricted.
 
-A closed-world assumption implies the following restrictions:
+A closed-world assumption implies, besides <<native-image#native-image.introducing-graalvm-native-images.key-differences-with-jvm-deployments, the limitations created by GraalVM itself>>, the following restrictions:
 
-* The classpath is fixed and fully defined at build time
 * The beans defined in your application cannot change at runtime, meaning:
 - The Spring `@Profile` annotation and profile-specific configuration <<howto#howto.aot.conditions,have limitations>>.
 - Properties that change if a bean is created are not supported (for example, `@ConditionalOnProperty` and `.enable` properties).
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/using/auto-configuration.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/using/auto-configuration.adoc
index c11c9b95045f..b3c7375f2364 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/using/auto-configuration.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/using/auto-configuration.adoc
@@ -35,3 +35,10 @@ TIP: You can define exclusions both at the annotation level and by using the pro
 
 NOTE: Even though auto-configuration classes are `public`, the only aspect of the class that is considered public API is the name of the class which can be used for disabling the auto-configuration.
 The actual contents of those classes, such as nested configuration classes or bean methods are for internal use only and we do not recommend using those directly.
+
+
+[[using.auto-configuration.packages]]
+=== Auto-configuration Packages
+Auto-configuration packages are the packages that various auto-configured features look in by default when scanning for things such as entities and Spring Data repositories.
+The `@EnableAutoConfiguration` annotation (either directly or through its presence on `@SpringBootApplication`) determines the default auto-configuration package.
+Additional packages can be configured using the `@AutoConfigurationPackage` annotation.
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/using/devtools.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/using/devtools.adoc
index f4122a2cf4e4..4f480d402ae9 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/using/devtools.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/using/devtools.adoc
@@ -269,7 +269,8 @@ If you find such a problem, you need to request a fix with the original authors.
 [[using.devtools.livereload]]
 === LiveReload
 The `spring-boot-devtools` module includes an embedded LiveReload server that can be used to trigger a browser refresh when a resource is changed.
-LiveReload browser extensions are freely available for Chrome, Firefox and Safari from http://livereload.com/extensions/[livereload.com].
+LiveReload browser extensions are freely available for Chrome, Firefox and Safari.
+You can find these extensions by searching 'LiveReload' in the marketplace or store of your chosen browser.
 
 If you do not want to start the LiveReload server when your application runs, you can set the configprop:spring.devtools.livereload.enabled[] property to `false`.
 
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/using/structuring-your-code.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/using/structuring-your-code.adoc
index 896651f20550..406f22260865 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/using/structuring-your-code.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/using/structuring-your-code.adoc
@@ -3,6 +3,8 @@
 Spring Boot does not require any specific code layout to work.
 However, there are some best practices that help.
 
+TIP: If you wish to enforce a structure based on domains, take a look at https://spring.io/projects/spring-modulith#overview[Spring Modulith].
+
 
 
 [[using.structuring-your-code.using-the-default-package]]
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/reactive.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/reactive.adoc
index 51c1d928c492..82292dbe64aa 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/reactive.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/reactive.adoc
@@ -12,13 +12,15 @@ The annotation-based one is quite close to the Spring MVC model, as shown in the
 
 include::code:MyRestController[]
 
+WebFlux is part of the Spring Framework and detailed information is available in its {spring-framework-docs}/web/webflux.html[reference documentation].
+
 "`WebFlux.fn`", the functional variant, separates the routing configuration from the actual handling of the requests, as shown in the following example:
 
 include::code:MyRoutingConfiguration[]
 
 include::code:MyUserHandler[]
 
-WebFlux is part of the Spring Framework and detailed information is available in its {spring-framework-docs}/web-reactive.html#webflux-fn[reference documentation].
+"`WebFlux.fn`" is part of the Spring Framework and detailed information is available in its {spring-framework-docs}/web/webflux-functional.html[reference documentation].
 
 TIP: You can define as many `RouterFunction` beans as you like to modularize the definition of the router.
 Beans can be ordered if you need to apply a precedence.
@@ -40,12 +42,34 @@ The auto-configuration adds the following features on top of Spring's defaults:
 * Configuring codecs for `HttpMessageReader` and `HttpMessageWriter` instances (described <<web#web.reactive.webflux.httpcodecs,later in this document>>).
 * Support for serving static resources, including support for WebJars (described <<web#web.servlet.spring-mvc.static-content,later in this document>>).
 
-If you want to keep Spring Boot WebFlux features and you want to add additional {spring-framework-docs}/web-reactive.html#webflux-config[WebFlux configuration], you can add your own `@Configuration` class of type `WebFluxConfigurer` but *without* `@EnableWebFlux`.
+If you want to keep Spring Boot WebFlux features and you want to add additional {spring-framework-docs}/web/webflux/config.html[WebFlux configuration], you can add your own `@Configuration` class of type `WebFluxConfigurer` but *without* `@EnableWebFlux`.
 
 If you want to take complete control of Spring WebFlux, you can add your own `@Configuration` annotated with `@EnableWebFlux`.
 
 
 
+[[web.reactive.webflux.conversion-service]]
+==== Spring WebFlux Conversion Service
+If you want to customize the `ConversionService` used by Spring WebFlux, you can provide a `WebFluxConfigurer` bean with an `addFormatters` method.
+
+Conversion can also be customized using the `spring.webflux.format.*` configuration properties.
+When not configured, the following defaults are used:
+
+|===
+|Property |`DateTimeFormatter`
+
+|configprop:spring.webflux.format.date[]
+|`ofLocalizedDate(FormatStyle.SHORT)`
+
+|configprop:spring.webflux.format.time[]
+|`ofLocalizedTime(FormatStyle.SHORT)`
+
+|configprop:spring.webflux.format.date-time[]
+|`ofLocalizedDateTime(FormatStyle.SHORT)`
+|===
+
+
+
 [[web.reactive.webflux.httpcodecs]]
 ==== HTTP Codecs with HttpMessageReaders and HttpMessageWriters
 Spring WebFlux uses the `HttpMessageReader` and `HttpMessageWriter` interfaces to convert HTTP requests and responses.
@@ -98,6 +122,21 @@ It first looks for an `index.html` file in the configured static content locatio
 If one is not found, it then looks for an `index` template.
 If either is found, it is automatically used as the welcome page of the application.
 
+This only acts as a fallback for actual index routes defined by the application.
+The ordering is defined by the order of `HandlerMapping` beans which is by default the following:
+
+[cols="1,1"]
+|===
+|`RouterFunctionMapping`
+|Endpoints declared with `RouterFunction` beans
+
+|`RequestMappingHandlerMapping`
+|Endpoints declared in `@Controller` beans
+
+|`RouterFunctionMapping` for the Welcome Page
+|The welcome page support
+|===
+
 
 
 [[web.reactive.webflux.template-engines]]
@@ -123,7 +162,7 @@ For machine clients, it produces a JSON response with details of the error, the
 For browser clients, there is a "`whitelabel`" error handler that renders the same data in HTML format.
 You can also provide your own HTML templates to display errors (see the <<web#web.reactive.webflux.error-handling.error-pages,next section>>).
 
-Before customizing error handling in Spring Boot directly, you can leverage the {spring-framework-docs}/web-reactive.html#webflux-ann-rest-exceptions[RFC 7807 Problem Details] support in Spring WebFlux.
+Before customizing error handling in Spring Boot directly, you can leverage the {spring-framework-docs}/web/webflux/ann-rest-exceptions.html[RFC 7807 Problem Details] support in Spring WebFlux.
 Spring WebFlux can produce custom error messages with the `application/problem+json` media type, like:
 
 [source,json,indent=0,subs="verbatim"]
@@ -208,9 +247,6 @@ When it does so, the orders shown in the following table will be used:
 |===
 | Web Filter | Order
 
-| `ServerHttpObservationFilter` (Micrometer Observability)
-| `Ordered.HIGHEST_PRECEDENCE + 1`
-
 | `WebFilterChainProxy` (Spring Security)
 | `-100`
 
@@ -228,6 +264,52 @@ By default, the embedded server listens for HTTP requests on port 8080.
 
 
 
+[[web.reactive.reactive-server.customizing]]
+==== Customizing Reactive Servers
+Common reactive web server settings can be configured by using Spring `Environment` properties.
+Usually, you would define the properties in your `application.properties` or `application.yaml` file.
+
+Common server settings include:
+
+* Network settings: Listen port for incoming HTTP requests (`server.port`), interface address to bind to (`server.address`), and so on.
+* Error management: Location of the error page (`server.error.path`) and so on.
+* <<howto#howto.webserver.configure-ssl,SSL>>
+* <<howto#howto.webserver.enable-response-compression,HTTP compression>>
+
+Spring Boot tries as much as possible to expose common settings, but this is not always possible.
+For those cases, dedicated namespaces such as `server.netty.*` offer server-specific customizations.
+
+TIP: See the {spring-boot-autoconfigure-module-code}/web/ServerProperties.java[`ServerProperties`] class for a complete list.
+
+
+
+[[web.reactive.reactive-server.customizing.programmatic]]
+===== Programmatic Customization
+If you need to programmatically configure your reactive web server, you can register a Spring bean that implements the `WebServerFactoryCustomizer` interface.
+`WebServerFactoryCustomizer` provides access to the `ConfigurableReactiveWebServerFactory`, which includes numerous customization setter methods.
+The following example shows programmatically setting the port:
+
+include::code:MyWebServerFactoryCustomizer[]
+
+`JettyReactiveWebServerFactory`, `NettyReactiveWebServerFactory`, `TomcatReactiveWebServerFactory`, and `UndertowReactiveWebServerFactory` are dedicated variants of `ConfigurableReactiveWebServerFactory` that have additional customization setter methods for Jetty, Reactor Netty, Tomcat, and Undertow respectively.
+The following example shows how to customize `NettyReactiveWebServerFactory` that provides access to Reactor Netty-specific configuration options:
+
+include::code:MyNettyWebServerFactoryCustomizer[]
+
+
+
+[[web.reactive.reactive-server.customizing.direct]]
+===== Customizing ConfigurableReactiveWebServerFactory Directly
+For more advanced use cases that require you to extend from `ReactiveWebServerFactory`, you can expose a bean of such type yourself.
+
+Setters are provided for many configuration options.
+Several protected method "`hooks`" are also provided should you need to do something more exotic.
+See the {spring-boot-module-api}/web/reactive/server/ConfigurableReactiveWebServerFactory.html[source code documentation] for details.
+
+NOTE: Auto-configured customizers are still applied on your custom factory, so use that option carefully.
+
+
+
 [[web.reactive.reactive-server-resources-configuration]]
 === Reactive Server Resources Configuration
 When auto-configuring a Reactor Netty or Jetty server, Spring Boot will create specific beans that will provide HTTP resources to the server instance: `ReactorResourceFactory` or `JettyResourceFactory`.
@@ -240,3 +322,5 @@ By default, those resources will be also shared with the Reactor Netty and Jetty
 Developers can override the resource configuration for Jetty and Reactor Netty by providing a custom `ReactorResourceFactory` or `JettyResourceFactory` bean - this will be applied to both clients and servers.
 
 You can learn more about the resource configuration on the client side in the <<io#io.rest-client.webclient.runtime, WebClient Runtime section>>.
+
+
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/servlet.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/servlet.adoc
index c9bde9df809b..19503eab0796 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/servlet.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/servlet.adoc
@@ -6,7 +6,7 @@ If you want to build servlet-based web applications, you can take advantage of S
 
 [[web.servlet.spring-mvc]]
 === The "`Spring Web MVC Framework`"
-The {spring-framework-docs}/web.html#mvc[Spring Web MVC framework] (often referred to as "`Spring MVC`") is a rich "`model view controller`" web framework.
+The {spring-framework-docs}/web/webmvc.html[Spring Web MVC framework] (often referred to as "`Spring MVC`") is a rich "`model view controller`" web framework.
 Spring MVC lets you create special `@Controller` or `@RestController` beans to handle incoming HTTP requests.
 Methods in your controller are mapped to HTTP by using `@RequestMapping` annotations.
 
@@ -20,7 +20,7 @@ include::code:MyRoutingConfiguration[]
 
 include::code:MyUserHandler[]
 
-Spring MVC is part of the core Spring Framework, and detailed information is available in the  {spring-framework-docs}/web.html#mvc[reference documentation].
+Spring MVC is part of the core Spring Framework, and detailed information is available in the  {spring-framework-docs}/web/webmvc.html[reference documentation].
 There are also several guides that cover Spring MVC available at https://spring.io/guides.
 
 TIP: You can define as many `RouterFunction` beans as you like to modularize the definition of the router.
@@ -31,8 +31,8 @@ Beans can be ordered if you need to apply a precedence.
 [[web.servlet.spring-mvc.auto-configuration]]
 ==== Spring MVC Auto-configuration
 Spring Boot provides auto-configuration for Spring MVC that works well with most applications.
-
-The auto-configuration adds the following features on top of Spring's defaults:
+It replaces the need for `@EnableWebMvc` and the two cannot be used together.
+In addition to Spring MVC's defaults, the auto-configuration provides the following features:
 
 * Inclusion of `ContentNegotiatingViewResolver` and `BeanNameViewResolver` beans.
 * Support for serving static resources, including support for WebJars (covered <<features#web.servlet.spring-mvc.static-content,later in this document>>).
@@ -42,20 +42,40 @@ The auto-configuration adds the following features on top of Spring's defaults:
 * Static `index.html` support.
 * Automatic use of a `ConfigurableWebBindingInitializer` bean (covered <<features#web.servlet.spring-mvc.binding-initializer,later in this document>>).
 
-If you want to keep those Spring Boot MVC customizations and make more {spring-framework-docs}/web.html#mvc[MVC customizations] (interceptors, formatters, view controllers, and other features), you can add your own `@Configuration` class of type `WebMvcConfigurer` but *without* `@EnableWebMvc`.
+If you want to keep those Spring Boot MVC customizations and make more {spring-framework-docs}/web/webmvc.html[MVC customizations] (interceptors, formatters, view controllers, and other features), you can add your own `@Configuration` class of type `WebMvcConfigurer` but *without* `@EnableWebMvc`.
 
 If you want to provide custom instances of `RequestMappingHandlerMapping`, `RequestMappingHandlerAdapter`, or `ExceptionHandlerExceptionResolver`, and still keep the Spring Boot MVC customizations, you can declare a bean of type `WebMvcRegistrations` and use it to provide custom instances of those components.
+The custom instances will be subject to further initialization and configuration by Spring MVC.
+To participate in, and if desired, override that subsequent processing, a `WebMvcConfigurer` should be used.
 
-If you want to take complete control of Spring MVC, you can add your own `@Configuration` annotated with `@EnableWebMvc`, or alternatively add your own `@Configuration`-annotated `DelegatingWebMvcConfiguration` as described in the Javadoc of `@EnableWebMvc`.
+If you do not want to use the auto-configuration and want to take complete control of Spring MVC, add your own `@Configuration` annotated with `@EnableWebMvc`.
+Alternatively, add your own `@Configuration`-annotated `DelegatingWebMvcConfiguration` as described in the Javadoc of `@EnableWebMvc`.
 
-[NOTE]
-====
+
+
+[[web.servlet.spring-mvc.conversion-service]]
+==== Spring MVC Conversion Service
 Spring MVC uses a different `ConversionService` to the one used to convert values from your `application.properties` or `application.yaml` file.
 It means that `Period`, `Duration` and `DataSize` converters are not available and that `@DurationUnit` and `@DataSizeUnit` annotations will be ignored.
 
 If you want to customize the `ConversionService` used by Spring MVC, you can provide a `WebMvcConfigurer` bean with an `addFormatters` method.
 From this method you can register any converter that you like, or you can delegate to the static methods available on `ApplicationConversionService`.
-====
+
+Conversion can also be customized using the `spring.mvc.format.*` configuration properties.
+When not configured, the following defaults are used:
+
+|===
+|Property |`DateTimeFormatter`
+
+|configprop:spring.mvc.format.date[]
+|`ofLocalizedDate(FormatStyle.SHORT)`
+
+|configprop:spring.mvc.format.time[]
+|`ofLocalizedTime(FormatStyle.SHORT)`
+
+|configprop:spring.mvc.format.date-time[]
+|`ofLocalizedDateTime(FormatStyle.SHORT)`
+|===
 
 
 
@@ -166,7 +186,7 @@ See {spring-boot-autoconfigure-module-code}/web/WebProperties.java[`WebPropertie
 
 [TIP]
 ====
-This feature has been thoroughly described in a dedicated https://spring.io/blog/2014/07/24/spring-framework-4-1-handling-static-web-resources[blog post] and in Spring Framework's {spring-framework-docs}/web.html#mvc-config-static-resources[reference documentation].
+This feature has been thoroughly described in a dedicated https://spring.io/blog/2014/07/24/spring-framework-4-1-handling-static-web-resources[blog post] and in Spring Framework's {spring-framework-docs}/web/webmvc/mvc-config/static-resources.html[reference documentation].
 ====
 
 
@@ -178,6 +198,20 @@ It first looks for an `index.html` file in the configured static content locatio
 If one is not found, it then looks for an `index` template.
 If either is found, it is automatically used as the welcome page of the application.
 
+This only acts as a fallback for actual index routes defined by the application.
+The ordering is defined by the order of `HandlerMapping` beans which is by default the following:
+
+[cols="1,1"]
+|===
+|`RouterFunctionMapping`
+|Endpoints declared with `RouterFunction` beans
+
+|`RequestMappingHandlerMapping`
+|Endpoints declared in `@Controller` beans
+
+|`WelcomePageHandlerMapping`
+|The welcome page support
+|===
 
 
 [[web.servlet.spring-mvc.favicon]]
@@ -192,7 +226,7 @@ If such a file is present, it is automatically used as the favicon of the applic
 Spring MVC can map incoming HTTP requests to handlers by looking at the request path and matching it to the mappings defined in your application (for example, `@GetMapping` annotations on Controller methods).
 
 Spring Boot chooses to disable suffix pattern matching by default, which means that requests like `"GET /projects/spring-boot.json"` will not be matched to `@GetMapping("/projects/spring-boot")` mappings.
-This is considered as a {spring-framework-docs}/web.html#mvc-ann-requestmapping-suffix-pattern-match[best practice for Spring MVC applications].
+This is considered as a {spring-framework-docs}/web/webmvc/mvc-controller/ann-requestmapping.html#mvc-ann-requestmapping-suffix-pattern-match[best practice for Spring MVC applications].
 This feature was mainly useful in the past for HTTP clients which did not send proper "Accept" request headers; we needed to make sure to send the correct Content Type to the client.
 Nowadays, Content Negotiation is much more reliable.
 
@@ -229,26 +263,22 @@ Most standard media types are supported out-of-the-box, but you can also define
 	        markdown: "text/markdown"
 ----
 
+As of Spring Framework 5.3, Spring MVC supports two strategies for matching request paths to controllers.
+By default, Spring Boot uses the `PathPatternParser` strategy.
+`PathPatternParser` is an https://spring.io/blog/2020/06/30/url-matching-with-pathpattern-in-spring-mvc[optimized implementation] but comes with some restrictions compared to the `AntPathMatcher` strategy.
+`PathPatternParser` restricts usage of {spring-framework-docs}/web/webmvc/mvc-controller/ann-requestmapping.html#mvc-ann-requestmapping-uri-templates[some path pattern variants].
+It is also incompatible with configuring the `DispatcherServlet` with a path prefix (configprop:spring.mvc.servlet.path[]).
 
-
-As of Spring Framework 5.3, Spring MVC supports several implementation strategies for matching request paths to Controller handlers.
-It was previously only supporting the `AntPathMatcher` strategy, but it now also offers `PathPatternParser`.
-Spring Boot now provides a configuration property to choose and opt in the new strategy:
+The strategy can be configured using the configprop:spring.mvc.pathmatch.matching-strategy[] configuration property, as shown in the following example:
 
 [source,yaml,indent=0,subs="verbatim",configprops,configblocks]
 ----
 	spring:
 	  mvc:
 	    pathmatch:
-	      matching-strategy: "path-pattern-parser"
+	      matching-strategy: "ant-path-matcher"
 ----
 
-For more details on why you should consider this new implementation, see the
-https://spring.io/blog/2020/06/30/url-matching-with-pathpattern-in-spring-mvc[dedicated blog post].
-
-NOTE: `PathPatternParser` is an optimized implementation but restricts usage of {spring-framework-docs}/web.html#mvc-ann-requestmapping-uri-templates[some path patterns variants].
-It is incompatible with suffix pattern matching or mapping the `DispatcherServlet` with a servlet prefix (configprop:spring.mvc.servlet.path[]).
-
 By default, Spring MVC will send a 404 Not Found error response if a handler is not found for a request.
 To have a `NoHandlerFoundException` thrown instead, set configprop:spring.mvc.throw-exception-if-no-handler-found to `true`.
 Note that, by default, the <<web#web.servlet.spring-mvc.static-content, serving of static content>> is mapped to `+/**+` and will, therefore, provide a handler for all requests.
@@ -303,7 +333,7 @@ TIP: The `BasicErrorController` can be used as a base class for a custom `ErrorC
 This is particularly useful if you want to add a handler for a new content type (the default is to handle `text/html` specifically and provide a fallback for everything else).
 To do so, extend `BasicErrorController`, add a public method with a `@RequestMapping` that has a `produces` attribute, and create a bean of your new type.
 
-As of Spring Framework 6.0, {spring-framework-docs}/web.html#mvc-ann-rest-exceptions[RFC 7807 Problem Details] is supported.
+As of Spring Framework 6.0, {spring-framework-docs}/web/webmvc/mvc-ann-rest-exceptions.html[RFC 7807 Problem Details] is supported.
 Spring MVC can produce custom error messages with the `application/problem+json` media type, like:
 
 [source,json,indent=0,subs="verbatim"]
@@ -372,7 +402,7 @@ For more complex mappings, you can also add beans that implement the `ErrorViewR
 
 include::code:MyErrorViewResolver[]
 
-You can also use regular Spring MVC features such as {spring-framework-docs}/web.html#mvc-exceptionhandlers[`@ExceptionHandler` methods] and {spring-framework-docs}/web.html#mvc-ann-controller-advice[`@ControllerAdvice`].
+You can also use regular Spring MVC features such as {spring-framework-docs}/web/webmvc/mvc-servlet/exceptionhandlers.html[`@ExceptionHandler` methods] and {spring-framework-docs}/web/webmvc/mvc-controller/ann-advice.html[`@ControllerAdvice`].
 The `ErrorController` then picks up any unhandled exceptions.
 
 
@@ -408,9 +438,9 @@ You should disable this behavior by setting `com.ibm.ws.webcontainer.invokeFlush
 ==== CORS Support
 https://en.wikipedia.org/wiki/Cross-origin_resource_sharing[Cross-origin resource sharing] (CORS) is a https://www.w3.org/TR/cors/[W3C specification] implemented by https://caniuse.com/#feat=cors[most browsers] that lets you specify in a flexible way what kind of cross-domain requests are authorized, instead of using some less secure and less powerful approaches such as IFRAME or JSONP.
 
-As of version 4.2, Spring MVC {spring-framework-docs}/web.html#mvc-cors[supports CORS].
-Using {spring-framework-docs}/web.html#mvc-cors-controller[controller method CORS configuration] with {spring-framework-api}/web/bind/annotation/CrossOrigin.html[`@CrossOrigin`] annotations in your Spring Boot application does not require any specific configuration.
-{spring-framework-docs}/web.html#mvc-cors-global[Global CORS configuration] can be defined by registering a `WebMvcConfigurer` bean with a customized `addCorsMappings(CorsRegistry)` method, as shown in the following example:
+As of version 4.2, Spring MVC {spring-framework-docs}/web/webmvc-cors.html[supports CORS].
+Using {spring-framework-docs}/web/webmvc-cors.html#mvc-cors-controller[controller method CORS configuration] with {spring-framework-api}/web/bind/annotation/CrossOrigin.html[`@CrossOrigin`] annotations in your Spring Boot application does not require any specific configuration.
+{spring-framework-docs}/web/webmvc-cors.html#mvc-cors-global[Global CORS configuration] can be defined by registering a `WebMvcConfigurer` bean with a customized `addCorsMappings(CorsRegistry)` method, as shown in the following example:
 
 include::code:MyCorsConfiguration[]
 
@@ -545,7 +575,7 @@ Usually, you would define the properties in your `application.properties` or `ap
 
 Common server settings include:
 
-* Network settings: Listen port for incoming HTTP requests (`server.port`), interface address to bind to `server.address`, and so on.
+* Network settings: Listen port for incoming HTTP requests (`server.port`), interface address to bind to (`server.address`), and so on.
 * Session settings: Whether the session is persistent (`server.servlet.session.persistent`), session timeout (`server.servlet.session.timeout`), location of session data (`server.servlet.session.store-dir`), and session-cookie configuration (`server.servlet.session.cookie.*`).
 * Error management: Location of the error page (`server.error.path`) and so on.
 * <<howto#howto.webserver.configure-ssl,SSL>>
@@ -589,6 +619,28 @@ include::code:MySameSiteConfiguration[]
 
 
 
+[[web.servlet.embedded-container.customizing.encoding]]
+===== Character Encoding
+The character encoding behavior of the embedded servlet container for request and response handling can be configured using the `server.servlet.encoding.*` configuration properties.
+
+When a request's `Accept-Language` header indicates a locale for the request it will be automatically mapped to a charset by the servlet container.
+Each container provides default locale to charset mappings and you should verify that they meet your application's needs.
+When they do not, use the configprop:server.servlet.encoding.mapping[] configuration property to customize the mappings, as shown in the following example:
+
+[source,yaml,indent=0,subs="verbatim",configprops,configblocks]
+----
+	server:
+	  servlet:
+	    encoding:
+	      mapping:
+	        ko: "UTF-8"
+----
+
+In the preceding example, the `ko` (Korean) locale has been mapped to `UTF-8`.
+This is equivalent to a `<locale-encoding-mapping-list>` entry in a `web.xml` file of a traditional war deployment.
+
+
+
 [[web.servlet.embedded-container.customizing.programmatic]]
 ===== Programmatic Customization
 If you need to programmatically configure your embedded servlet container, you can register a Spring bean that implements the `WebServerFactoryCustomizer` interface.
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-graphql.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-graphql.adoc
index bde2f63e8ec4..656edbeaadfa 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-graphql.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-graphql.adoc
@@ -54,9 +54,9 @@ If you wish to not expose information about the schema, you can disable introspe
 === GraphQL RuntimeWiring
 The GraphQL Java `RuntimeWiring.Builder` can be used to register custom scalar types, directives, type resolvers, `DataFetcher`, and more.
 You can declare `RuntimeWiringConfigurer` beans in your Spring config to get access to the `RuntimeWiring.Builder`.
-Spring Boot detects such beans and adds them to the {spring-graphql-docs}#execution-graphqlsource[GraphQlSource builder].
+Spring Boot detects such beans and adds them to the {spring-graphql-docs}/#execution-graphqlsource[GraphQlSource builder].
 
-Typically, however, applications will not implement `DataFetcher` directly and will instead create {spring-graphql-docs}#controllers[annotated controllers].
+Typically, however, applications will not implement `DataFetcher` directly and will instead create {spring-graphql-docs}/#controllers[annotated controllers].
 Spring Boot will automatically detect `@Controller` classes with annotated handler methods and register those as ``DataFetcher``s.
 Here's a sample implementation for our greeting query with a `@Controller` class:
 
@@ -67,7 +67,7 @@ include::code:GreetingController[]
 [[web.graphql.data-query]]
 === Querydsl and QueryByExample Repositories Support
 Spring Data offers support for both Querydsl and QueryByExample repositories.
-Spring GraphQL can {spring-graphql-docs}#data[configure Querydsl and QueryByExample repositories as `DataFetcher`].
+Spring GraphQL can {spring-graphql-docs}/#data[configure Querydsl and QueryByExample repositories as `DataFetcher`].
 
 Spring Data repositories annotated with `@GraphQlRepository` and extending one of:
 
@@ -98,12 +98,12 @@ The GraphQL WebSocket endpoint is off by default. To enable it:
 * For a WebFlux application, no additional dependency is required
 * For both, the configprop:spring.graphql.websocket.path[] application property must be set
 
-Spring GraphQL provides a {spring-graphql-docs}#web-interception[Web Interception] model.
+Spring GraphQL provides a {spring-graphql-docs}/#web-interception[Web Interception] model.
 This is quite useful for retrieving information from an HTTP request header and set it in the GraphQL context or fetching information from the same context and writing it to a response header.
 With Spring Boot, you can declare a `WebInterceptor` bean to have it registered with the web transport.
 
 
-{spring-framework-docs}/web.html#mvc-cors[Spring MVC] and {spring-framework-docs}/web-reactive.html#webflux-cors[Spring WebFlux] support CORS (Cross-Origin Resource Sharing) requests.
+{spring-framework-docs}/web/webmvc-cors.html[Spring MVC] and {spring-framework-docs}/web/webflux-cors.html[Spring WebFlux] support CORS (Cross-Origin Resource Sharing) requests.
 CORS is a critical part of the web config for GraphQL applications that are accessed from browsers using different domains.
 
 Spring Boot supports many configuration properties under the `spring.graphql.cors.*` namespace; here's a short configuration sample:
@@ -138,7 +138,7 @@ include::code:RSocketGraphQlClientExample[tag=request]
 [[web.graphql.exception-handling]]
 === Exception Handling
 Spring GraphQL enables applications to register one or more Spring `DataFetcherExceptionResolver` components that are invoked sequentially.
-The Exception must be resolved to a list of `graphql.GraphQLError` objects, see {spring-graphql-docs}#execution-exceptions[Spring GraphQL exception handling documentation].
+The Exception must be resolved to a list of `graphql.GraphQLError` objects, see {spring-graphql-docs}/#execution-exceptions[Spring GraphQL exception handling documentation].
 Spring Boot will automatically detect `DataFetcherExceptionResolver` beans and register them with the `GraphQlSource.Builder`.
 
 
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-hateoas.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-hateoas.adoc
index 72414c8d4361..0731b7067dbe 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-hateoas.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-hateoas.adoc
@@ -9,3 +9,6 @@ Note that doing so disables the `ObjectMapper` customization described earlier.
 
 WARNING: `spring-boot-starter-hateoas` is specific to Spring MVC and should not be combined with Spring WebFlux.
 In order to use Spring HATEOAS with Spring WebFlux, you can add a direct dependency on `org.springframework.hateoas:spring-hateoas` along with `spring-boot-starter-webflux`.
+
+By default, requests that accept `application/json` will receive an `application/hal+json` response.
+To disable this behavior set configprop:spring.hateoas.use-hal-as-default-json-media-type[] to `false` and define a `HypermediaMappingInformation` or `HalConfiguration` to configure Spring HATEOAS to meet the needs of your application and its clients.
diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-security.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-security.adoc
index 2fc187ddba33..b893db247226 100644
--- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-security.adoc
+++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-security.adoc
@@ -34,10 +34,18 @@ You can provide a different `AuthenticationEventPublisher` by adding a bean for
 === MVC Security
 The default security configuration is implemented in `SecurityAutoConfiguration` and `UserDetailsServiceAutoConfiguration`.
 `SecurityAutoConfiguration` imports `SpringBootWebSecurityConfiguration` for web security and `UserDetailsServiceAutoConfiguration` configures authentication, which is also relevant in non-web applications.
-To switch off the default web application security configuration completely or to combine multiple Spring Security components such as OAuth2 Client and Resource Server, add a bean of type `SecurityFilterChain` (doing so does not disable the `UserDetailsService` configuration or Actuator's security).
 
+To switch off the default web application security configuration completely or to combine multiple Spring Security components such as OAuth2 Client and Resource Server, add a bean of type `SecurityFilterChain` (doing so does not disable the `UserDetailsService` configuration or Actuator's security).
 To also switch off the `UserDetailsService` configuration, you can add a bean of type `UserDetailsService`, `AuthenticationProvider`, or `AuthenticationManager`.
 
+The auto-configuration of a `UserDetailsService` will also back off any of the following Spring Security modules is on the classpath:
+
+- `spring-security-oauth2-client`
+- `spring-security-oauth2-resource-server`
+- `spring-security-saml2-service-provider`
+
+To use `UserDetailsService` in addition to one or more of these dependencies, define your own `InMemoryUserDetailsManager` bean.
+
 Access rules can be overridden by adding a custom `SecurityFilterChain` bean.
 Spring Boot provides convenience methods that can be used to override access rules for actuator endpoints and static resources.
 `EndpointRequest` can be used to create a `RequestMatcher` that is based on the configprop:management.endpoints.web.base-path[] property.
@@ -50,10 +58,17 @@ Spring Boot provides convenience methods that can be used to override access rul
 Similar to Spring MVC applications, you can secure your WebFlux applications by adding the `spring-boot-starter-security` dependency.
 The default security configuration is implemented in `ReactiveSecurityAutoConfiguration` and `UserDetailsServiceAutoConfiguration`.
 `ReactiveSecurityAutoConfiguration` imports `WebFluxSecurityConfiguration` for web security and `UserDetailsServiceAutoConfiguration` configures authentication, which is also relevant in non-web applications.
-To switch off the default web application security configuration completely, you can add a bean of type `WebFilterChainProxy` (doing so does not disable the `UserDetailsService` configuration or Actuator's security).
 
+To switch off the default web application security configuration completely, you can add a bean of type `WebFilterChainProxy` (doing so does not disable the `UserDetailsService` configuration or Actuator's security).
 To also switch off the `UserDetailsService` configuration, you can add a bean of type `ReactiveUserDetailsService` or `ReactiveAuthenticationManager`.
 
+The auto-configuration will also back off when any of the following Spring Security modules is on the classpath:
+
+- `spring-security-oauth2-client`
+- `spring-security-oauth2-resource-server`
+
+To use `ReactiveUserDetailsService` in addition to one or more of these dependencies, define your own `MapReactiveUserDetailsService` bean.
+
 Access rules and the use of multiple Spring Security components such as OAuth 2 Client and Resource Server can be configured by adding a custom `SecurityWebFilterChain` bean.
 Spring Boot provides convenience methods that can be used to override access rules for actuator endpoints and static resources.
 `EndpointRequest` can be used to create a `ServerWebExchangeMatcher` that is based on the configprop:management.endpoints.web.base-path[] property.
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/actuator/cloudfoundry/customcontextpath/MyReactiveCloudFoundryConfiguration.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/actuator/cloudfoundry/customcontextpath/MyReactiveCloudFoundryConfiguration.java
new file mode 100644
index 000000000000..c4712866c75d
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/actuator/cloudfoundry/customcontextpath/MyReactiveCloudFoundryConfiguration.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.actuator.cloudfoundry.customcontextpath;
+
+import java.util.Map;
+
+import reactor.core.publisher.Mono;
+
+import org.springframework.boot.autoconfigure.web.reactive.WebFluxProperties;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.server.reactive.ContextPathCompositeHandler;
+import org.springframework.http.server.reactive.HttpHandler;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.http.server.reactive.ServerHttpResponse;
+import org.springframework.web.server.adapter.WebHttpHandlerBuilder;
+
+@Configuration(proxyBeanMethods = false)
+@EnableConfigurationProperties(WebFluxProperties.class)
+public class MyReactiveCloudFoundryConfiguration {
+
+	@Bean
+	public HttpHandler httpHandler(ApplicationContext applicationContext, WebFluxProperties properties) {
+		HttpHandler httpHandler = WebHttpHandlerBuilder.applicationContext(applicationContext).build();
+		return new CloudFoundryHttpHandler(properties.getBasePath(), httpHandler);
+	}
+
+	private static final class CloudFoundryHttpHandler implements HttpHandler {
+
+		private final HttpHandler delegate;
+
+		private final ContextPathCompositeHandler contextPathDelegate;
+
+		private CloudFoundryHttpHandler(String basePath, HttpHandler delegate) {
+			this.delegate = delegate;
+			this.contextPathDelegate = new ContextPathCompositeHandler(Map.of(basePath, delegate));
+		}
+
+		@Override
+		public Mono<Void> handle(ServerHttpRequest request, ServerHttpResponse response) {
+			// Remove underlying context path first (e.g. Servlet container)
+			String path = request.getPath().pathWithinApplication().value();
+			if (path.startsWith("/cloudfoundryapplication")) {
+				return this.delegate.handle(request, response);
+			}
+			else {
+				return this.contextPathDelegate.handle(request, response);
+			}
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/actuator/observability/preventingobservations/MyObservationPredicate.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/actuator/observability/preventingobservations/MyObservationPredicate.java
new file mode 100644
index 000000000000..f065c8e2ca83
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/actuator/observability/preventingobservations/MyObservationPredicate.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.actuator.observability.preventingobservations;
+
+import io.micrometer.observation.Observation.Context;
+import io.micrometer.observation.ObservationPredicate;
+
+import org.springframework.stereotype.Component;
+
+@Component
+class MyObservationPredicate implements ObservationPredicate {
+
+	@Override
+	public boolean test(String name, Context context) {
+		return !name.contains("denied");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/data/sql/jdbcclient/MyBean.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/data/sql/jdbcclient/MyBean.java
new file mode 100644
index 000000000000..4441070de213
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/data/sql/jdbcclient/MyBean.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.data.sql.jdbcclient;
+
+import org.springframework.jdbc.core.simple.JdbcClient;
+import org.springframework.stereotype.Component;
+
+@Component
+public class MyBean {
+
+	private final JdbcClient jdbcClient;
+
+	public MyBean(JdbcClient jdbcClient) {
+		this.jdbcClient = jdbcClient;
+	}
+
+	public void doSomething() {
+		/* @chomp:line this.jdbcClient ... */ this.jdbcClient.sql("delete from customer").update();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/logexample/MyApplication.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/logexample/MyApplication.java
new file mode 100644
index 000000000000..c588089d8d2a
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/logexample/MyApplication.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.features.logexample;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * Sample application used to collect logs for the reference doc.
+ *
+ * @author Stephane Nicoll
+ */
+@SpringBootApplication
+public class MyApplication {
+
+	public static void main(String[] args) {
+		SpringApplication.run(MyApplication.class, args);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/springapplication/applicationavailability/managing/MyReadinessStateExporter.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/springapplication/applicationavailability/managing/MyReadinessStateExporter.java
index cfec6d397009..64c09bcae3ca 100644
--- a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/springapplication/applicationavailability/managing/MyReadinessStateExporter.java
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/springapplication/applicationavailability/managing/MyReadinessStateExporter.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -27,12 +27,12 @@ public class MyReadinessStateExporter {
 	@EventListener
 	public void onStateChange(AvailabilityChangeEvent<ReadinessState> event) {
 		switch (event.getState()) {
-			case ACCEPTING_TRAFFIC:
+			case ACCEPTING_TRAFFIC -> {
 				// create file /tmp/healthy
-				break;
-			case REFUSING_TRAFFIC:
+			}
+			case REFUSING_TRAFFIC -> {
 				// remove file /tmp/healthy
-				break;
+			}
 		}
 	}
 
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/devtools/MyContainersConfiguration.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/devtools/MyContainersConfiguration.java
new file mode 100644
index 000000000000..541cdae30a86
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/devtools/MyContainersConfiguration.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.features.testcontainers.atdevelopmenttime.devtools;
+
+import org.testcontainers.containers.MongoDBContainer;
+
+import org.springframework.boot.devtools.restart.RestartScope;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.context.annotation.Bean;
+
+@TestConfiguration(proxyBeanMethods = false)
+public class MyContainersConfiguration {
+
+	@Bean
+	@RestartScope
+	@ServiceConnection
+	public MongoDBContainer mongoDbContainer() {
+		return new MongoDBContainer("mongo:5.0");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/dynamicproperties/MyContainersConfiguration.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/dynamicproperties/MyContainersConfiguration.java
new file mode 100644
index 000000000000..143a28f6a843
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/dynamicproperties/MyContainersConfiguration.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.features.testcontainers.atdevelopmenttime.dynamicproperties;
+
+import org.testcontainers.containers.MongoDBContainer;
+
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.test.context.DynamicPropertyRegistry;
+
+@TestConfiguration(proxyBeanMethods = false)
+public class MyContainersConfiguration {
+
+	@Bean
+	public MongoDBContainer mongoDbContainer(DynamicPropertyRegistry properties) {
+		MongoDBContainer container = new MongoDBContainer("mongo:5.0");
+		properties.add("spring.data.mongodb.host", container::getHost);
+		properties.add("spring.data.mongodb.port", container::getFirstMappedPort);
+		return container;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainers.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainers.java
similarity index 80%
rename from spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainers.java
rename to spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainers.java
index 8ae02897c37a..5afd1d6b9d0b 100644
--- a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainers.java
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainers.java
@@ -14,18 +14,22 @@
  * limitations under the License.
  */
 
-package org.springframework.boot.docs.features.testing.testcontainers.atdevelopmenttime.importingcontainerdeclarations;
+package org.springframework.boot.docs.features.testcontainers.atdevelopmenttime.importingcontainerdeclarations;
 
 import org.testcontainers.containers.MongoDBContainer;
 import org.testcontainers.containers.Neo4jContainer;
 import org.testcontainers.junit.jupiter.Container;
 
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+
 public interface MyContainers {
 
 	@Container
+	@ServiceConnection
 	MongoDBContainer mongoContainer = new MongoDBContainer("mongo:5.0");
 
 	@Container
+	@ServiceConnection
 	Neo4jContainer<?> neo4jContainer = new Neo4jContainer<>("neo4j:5");
 
 }
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainersConfiguration.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainersConfiguration.java
new file mode 100644
index 000000000000..1e6efc47b1b2
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainersConfiguration.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.features.testcontainers.atdevelopmenttime.importingcontainerdeclarations;
+
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.testcontainers.context.ImportTestcontainers;
+
+@TestConfiguration(proxyBeanMethods = false)
+@ImportTestcontainers(MyContainers.class)
+public class MyContainersConfiguration {
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/launch/MyApplication.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/launch/MyApplication.java
new file mode 100644
index 000000000000..1c44bb1a0270
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/launch/MyApplication.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.features.testcontainers.atdevelopmenttime.launch;
+
+public class MyApplication {
+
+	public static void main(String[] args) {
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/launch/TestMyApplication.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/launch/TestMyApplication.java
new file mode 100644
index 000000000000..54ca0ebee36e
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/launch/TestMyApplication.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.features.testcontainers.atdevelopmenttime.launch;
+
+import org.springframework.boot.SpringApplication;
+
+public class TestMyApplication {
+
+	public static void main(String[] args) {
+		SpringApplication.from(MyApplication::main).run(args);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/test/MyApplication.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/test/MyApplication.java
new file mode 100644
index 000000000000..08c049ab5044
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/test/MyApplication.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.features.testcontainers.atdevelopmenttime.test;
+
+public class MyApplication {
+
+	public static void main(String[] args) {
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/test/MyContainersConfiguration.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/test/MyContainersConfiguration.java
new file mode 100644
index 000000000000..11a9e78fb474
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/test/MyContainersConfiguration.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.features.testcontainers.atdevelopmenttime.test;
+
+import org.testcontainers.containers.Neo4jContainer;
+
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.context.annotation.Bean;
+
+@TestConfiguration(proxyBeanMethods = false)
+public class MyContainersConfiguration {
+
+	@Bean
+	@ServiceConnection
+	public Neo4jContainer<?> neo4jContainer() {
+		return new Neo4jContainer<>("neo4j:5");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/test/TestMyApplication.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/test/TestMyApplication.java
new file mode 100644
index 000000000000..199e4cf98e82
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/test/TestMyApplication.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.features.testcontainers.atdevelopmenttime.test;
+
+import org.springframework.boot.SpringApplication;
+
+public class TestMyApplication {
+
+	public static void main(String[] args) {
+		SpringApplication.from(MyApplication::main).with(MyContainersConfiguration.class).run(args);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientServiceTests.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientServiceTests.java
new file mode 100644
index 000000000000..8d8437fc8f5b
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientServiceTests.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.features.testing.springbootapplications.autoconfiguredrestclient;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.client.MockRestServiceServer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+@RestClientTest(RemoteVehicleDetailsService.class)
+class MyRestClientServiceTests {
+
+	@Autowired
+	private RemoteVehicleDetailsService service;
+
+	@Autowired
+	private MockRestServiceServer server;
+
+	@Test
+	void getVehicleDetailsWhenResultIsSuccessShouldReturnDetails() {
+		this.server.expect(requestTo("https://example.com/greet/details"))
+			.andRespond(withSuccess("hello", MediaType.TEXT_PLAIN));
+		String greeting = this.service.callRestService();
+		assertThat(greeting).isEqualTo("hello");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientTests.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientTests.java
deleted file mode 100644
index 90a1a82c2e4e..000000000000
--- a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientTests.java
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.docs.features.testing.springbootapplications.autoconfiguredrestclient;
-
-import org.junit.jupiter.api.Test;
-
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
-import org.springframework.http.MediaType;
-import org.springframework.test.web.client.MockRestServiceServer;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
-import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
-
-@RestClientTest(RemoteVehicleDetailsService.class)
-class MyRestClientTests {
-
-	@Autowired
-	private RemoteVehicleDetailsService service;
-
-	@Autowired
-	private MockRestServiceServer server;
-
-	@Test
-	void getVehicleDetailsWhenResultIsSuccessShouldReturnDetails() {
-		this.server.expect(requestTo("/greet/details")).andRespond(withSuccess("hello", MediaType.TEXT_PLAIN));
-		String greeting = this.service.callRestService();
-		assertThat(greeting).isEqualTo("hello");
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestTemplateServiceTests.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestTemplateServiceTests.java
new file mode 100644
index 000000000000..fdf67b9621b3
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestTemplateServiceTests.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.features.testing.springbootapplications.autoconfiguredrestclient;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.client.MockRestServiceServer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+@RestClientTest(RemoteVehicleDetailsService.class)
+class MyRestTemplateServiceTests {
+
+	@Autowired
+	private RemoteVehicleDetailsService service;
+
+	@Autowired
+	private MockRestServiceServer server;
+
+	@Test
+	void getVehicleDetailsWhenResultIsSuccessShouldReturnDetails() {
+		this.server.expect(requestTo("/greet/details")).andRespond(withSuccess("hello", MediaType.TEXT_PLAIN));
+		String greeting = this.service.callRestService();
+		assertThat(greeting).isEqualTo("hello");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/devtools/MyContainersConfiguration.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/devtools/MyContainersConfiguration.java
deleted file mode 100644
index b03c52b4140e..000000000000
--- a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/devtools/MyContainersConfiguration.java
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.docs.features.testing.testcontainers.atdevelopmenttime.devtools;
-
-import org.testcontainers.containers.MongoDBContainer;
-
-import org.springframework.boot.devtools.restart.RestartScope;
-import org.springframework.boot.test.context.TestConfiguration;
-import org.springframework.context.annotation.Bean;
-
-@TestConfiguration(proxyBeanMethods = false)
-public class MyContainersConfiguration {
-
-	@Bean
-	@RestartScope
-	public MongoDBContainer mongoDbContainer() {
-		return new MongoDBContainer("mongo:5.0");
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/dynamicproperties/MyContainersConfiguration.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/dynamicproperties/MyContainersConfiguration.java
deleted file mode 100644
index fb839227f026..000000000000
--- a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/dynamicproperties/MyContainersConfiguration.java
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.docs.features.testing.testcontainers.atdevelopmenttime.dynamicproperties;
-
-import org.testcontainers.containers.MongoDBContainer;
-
-import org.springframework.boot.test.context.TestConfiguration;
-import org.springframework.context.annotation.Bean;
-import org.springframework.test.context.DynamicPropertyRegistry;
-
-@TestConfiguration(proxyBeanMethods = false)
-public class MyContainersConfiguration {
-
-	@Bean
-	public MongoDBContainer mongoDbContainer(DynamicPropertyRegistry properties) {
-		MongoDBContainer container = new MongoDBContainer("mongo:5.0");
-		properties.add("spring.data.mongodb.host", container::getHost);
-		properties.add("spring.data.mongodb.port", container::getFirstMappedPort);
-		return container;
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainersConfiguration.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainersConfiguration.java
deleted file mode 100644
index 00b4078610a9..000000000000
--- a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainersConfiguration.java
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.docs.features.testing.testcontainers.atdevelopmenttime.importingcontainerdeclarations;
-
-import org.springframework.boot.test.context.TestConfiguration;
-import org.springframework.boot.testcontainers.context.ImportTestcontainers;
-
-@TestConfiguration(proxyBeanMethods = false)
-@ImportTestcontainers(MyContainers.class)
-public class MyContainersConfiguration {
-
-}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/launch/MyApplication.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/launch/MyApplication.java
deleted file mode 100644
index f93a2601abbc..000000000000
--- a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/launch/MyApplication.java
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.docs.features.testing.testcontainers.atdevelopmenttime.launch;
-
-public class MyApplication {
-
-	public static void main(String[] args) {
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/launch/TestMyApplication.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/launch/TestMyApplication.java
deleted file mode 100644
index 782d5e6754b3..000000000000
--- a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/launch/TestMyApplication.java
+++ /dev/null
@@ -1,27 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.docs.features.testing.testcontainers.atdevelopmenttime.launch;
-
-import org.springframework.boot.SpringApplication;
-
-public class TestMyApplication {
-
-	public static void main(String[] args) {
-		SpringApplication.from(MyApplication::main).run(args);
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/test/MyApplication.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/test/MyApplication.java
deleted file mode 100644
index e8339f1dae02..000000000000
--- a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/test/MyApplication.java
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.docs.features.testing.testcontainers.atdevelopmenttime.test;
-
-public class MyApplication {
-
-	public static void main(String[] args) {
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/test/MyContainersConfiguration.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/test/MyContainersConfiguration.java
deleted file mode 100644
index df8852b44762..000000000000
--- a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/test/MyContainersConfiguration.java
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.docs.features.testing.testcontainers.atdevelopmenttime.test;
-
-import org.testcontainers.containers.Neo4jContainer;
-
-import org.springframework.boot.test.context.TestConfiguration;
-import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
-import org.springframework.context.annotation.Bean;
-
-@TestConfiguration(proxyBeanMethods = false)
-public class MyContainersConfiguration {
-
-	@Bean
-	@ServiceConnection
-	public Neo4jContainer<?> neo4jContainer() {
-		return new Neo4jContainer<>("neo4j:5");
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/test/TestMyApplication.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/test/TestMyApplication.java
deleted file mode 100644
index 9253edcdcb56..000000000000
--- a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/test/TestMyApplication.java
+++ /dev/null
@@ -1,27 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.docs.features.testing.testcontainers.atdevelopmenttime.test;
-
-import org.springframework.boot.SpringApplication;
-
-public class TestMyApplication {
-
-	public static void main(String[] args) {
-		SpringApplication.from(MyApplication::main).with(MyContainersConfiguration.class).run(args);
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/serviceconnections/MyRedisConfiguration.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/serviceconnections/MyRedisConfiguration.java
new file mode 100644
index 000000000000..38b2640557f6
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/testcontainers/serviceconnections/MyRedisConfiguration.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.features.testing.testcontainers.serviceconnections;
+
+import org.testcontainers.containers.GenericContainer;
+
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.context.annotation.Bean;
+
+@TestConfiguration(proxyBeanMethods = false)
+public class MyRedisConfiguration {
+
+	@Bean
+	@ServiceConnection(name = "redis")
+	public GenericContainer<?> redisContainer() {
+		return new GenericContainer<>("redis:7");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/howto/httpclients/webclientreactornettycustomization/MyReactorNettyClientConfiguration.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/howto/httpclients/webclientreactornettycustomization/MyReactorNettyClientConfiguration.java
index 8b67fb450e23..b47d7dd48408 100644
--- a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/howto/httpclients/webclientreactornettycustomization/MyReactorNettyClientConfiguration.java
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/howto/httpclients/webclientreactornettycustomization/MyReactorNettyClientConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -22,9 +22,9 @@
 
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.http.client.ReactorResourceFactory;
 import org.springframework.http.client.reactive.ClientHttpConnector;
 import org.springframework.http.client.reactive.ReactorClientHttpConnector;
-import org.springframework.http.client.reactive.ReactorResourceFactory;
 
 @Configuration(proxyBeanMethods = false)
 public class MyReactorNettyClientConfiguration {
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/Details.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/Details.java
new file mode 100644
index 000000000000..28c038969b28
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/Details.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.io.restclient.restclient;
+
+public class Details {
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/MyService.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/MyService.java
new file mode 100644
index 000000000000..98bd2049406c
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/MyService.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.io.restclient.restclient;
+
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestClient;
+
+@Service
+public class MyService {
+
+	private final RestClient restClient;
+
+	public MyService(RestClient.Builder restClientBuilder) {
+		this.restClient = restClientBuilder.baseUrl("https://example.org").build();
+	}
+
+	public Details someRestCall(String name) {
+		return this.restClient.get().uri("/{name}/details", name).retrieve().body(Details.class);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/Details.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/Details.java
new file mode 100644
index 000000000000..eb853cba5e36
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/Details.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.io.restclient.restclient.ssl;
+
+public class Details {
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/MyService.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/MyService.java
new file mode 100644
index 000000000000..0fa7fa50cbba
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/MyService.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.io.restclient.restclient.ssl;
+
+import org.springframework.boot.autoconfigure.web.client.RestClientSsl;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestClient;
+
+@Service
+public class MyService {
+
+	private final RestClient restClient;
+
+	public MyService(RestClient.Builder restClientBuilder, RestClientSsl ssl) {
+		this.restClient = restClientBuilder.baseUrl("https://example.org").apply(ssl.fromBundle("mybundle")).build();
+	}
+
+	public Details someRestCall(String name) {
+		return this.restClient.get().uri("/{name}/details", name).retrieve().body(Details.class);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/Details.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/Details.java
new file mode 100644
index 000000000000..1b0bbdb7533b
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/Details.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.io.restclient.restclient.ssl.settings;
+
+public class Details {
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/MyService.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/MyService.java
new file mode 100644
index 000000000000..8fef86df53e3
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/MyService.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.io.restclient.restclient.ssl.settings;
+
+import java.time.Duration;
+
+import org.springframework.boot.ssl.SslBundles;
+import org.springframework.boot.web.client.ClientHttpRequestFactories;
+import org.springframework.boot.web.client.ClientHttpRequestFactorySettings;
+import org.springframework.http.client.ClientHttpRequestFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestClient;
+
+@Service
+public class MyService {
+
+	private final RestClient restClient;
+
+	public MyService(RestClient.Builder restClientBuilder, SslBundles sslBundles) {
+		ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS
+			.withReadTimeout(Duration.ofMinutes(2))
+			.withSslBundle(sslBundles.getBundle("mybundle"));
+		ClientHttpRequestFactory requestFactory = ClientHttpRequestFactories.get(settings);
+		this.restClient = restClientBuilder.baseUrl("https://example.org").requestFactory(requestFactory).build();
+	}
+
+	public Details someRestCall(String name) {
+		return this.restClient.get().uri("/{name}/details", name).retrieve().body(Details.class);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/reading/MyBean.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/reading/MyBean.java
new file mode 100644
index 000000000000..f13cf6ec5451
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/reading/MyBean.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2023-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.messaging.pulsar.reading;
+
+import org.springframework.pulsar.annotation.PulsarReader;
+import org.springframework.stereotype.Component;
+
+@Component
+public class MyBean {
+
+	@PulsarReader(topics = "someTopic", startMessageId = "earliest")
+	public void processMessage(String content) {
+		// ...
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/readingreactive/MyBean.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/readingreactive/MyBean.java
new file mode 100644
index 000000000000..c42145288d55
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/readingreactive/MyBean.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2023-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.messaging.pulsar.readingreactive;
+
+import java.time.Instant;
+import java.util.List;
+
+import org.apache.pulsar.client.api.Message;
+import org.apache.pulsar.client.api.Schema;
+import org.apache.pulsar.reactive.client.api.StartAtSpec;
+import reactor.core.publisher.Mono;
+
+import org.springframework.pulsar.reactive.core.ReactiveMessageReaderBuilderCustomizer;
+import org.springframework.pulsar.reactive.core.ReactivePulsarReaderFactory;
+import org.springframework.stereotype.Component;
+
+@Component
+public class MyBean {
+
+	private final ReactivePulsarReaderFactory<String> pulsarReaderFactory;
+
+	public MyBean(ReactivePulsarReaderFactory<String> pulsarReaderFactory) {
+		this.pulsarReaderFactory = pulsarReaderFactory;
+	}
+
+	public void someMethod() {
+		ReactiveMessageReaderBuilderCustomizer<String> readerBuilderCustomizer = (readerBuilder) -> readerBuilder
+			.topic("someTopic")
+			.startAtSpec(StartAtSpec.ofInstant(Instant.now().minusSeconds(5)));
+		Mono<Message<String>> message = this.pulsarReaderFactory
+			.createReader(Schema.STRING, List.of(readerBuilderCustomizer))
+			.readOne();
+		// ...
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/receiving/MyBean.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/receiving/MyBean.java
new file mode 100644
index 000000000000..103e4ac8d65a
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/receiving/MyBean.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2023-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.messaging.pulsar.receiving;
+
+import org.springframework.pulsar.annotation.PulsarListener;
+import org.springframework.stereotype.Component;
+
+@Component
+public class MyBean {
+
+	@PulsarListener(topics = "someTopic")
+	public void processMessage(String content) {
+		// ...
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/receivingreactive/MyBean.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/receivingreactive/MyBean.java
new file mode 100644
index 000000000000..3dd9e8ffba98
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/receivingreactive/MyBean.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2023-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.messaging.pulsar.receivingreactive;
+
+import reactor.core.publisher.Mono;
+
+import org.springframework.pulsar.reactive.config.annotation.ReactivePulsarListener;
+import org.springframework.stereotype.Component;
+
+@Component
+public class MyBean {
+
+	@ReactivePulsarListener(topics = "someTopic")
+	public Mono<Void> processMessage(String content) {
+		// ...
+		return Mono.empty();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/sending/MyBean.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/sending/MyBean.java
new file mode 100644
index 000000000000..7b6610b03e92
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/sending/MyBean.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2023-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.messaging.pulsar.sending;
+
+import org.apache.pulsar.client.api.PulsarClientException;
+
+import org.springframework.pulsar.core.PulsarTemplate;
+import org.springframework.stereotype.Component;
+
+@Component
+public class MyBean {
+
+	private final PulsarTemplate<String> pulsarTemplate;
+
+	public MyBean(PulsarTemplate<String> pulsarTemplate) {
+		this.pulsarTemplate = pulsarTemplate;
+	}
+
+	public void someMethod() throws PulsarClientException {
+		this.pulsarTemplate.send("someTopic", "Hello");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/sendingreactive/MyBean.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/sendingreactive/MyBean.java
new file mode 100644
index 000000000000..1784f4ea8059
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/sendingreactive/MyBean.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2023-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.messaging.pulsar.sendingreactive;
+
+import org.springframework.pulsar.reactive.core.ReactivePulsarTemplate;
+import org.springframework.stereotype.Component;
+
+@Component
+public class MyBean {
+
+	private final ReactivePulsarTemplate<String> pulsarTemplate;
+
+	public MyBean(ReactivePulsarTemplate<String> pulsarTemplate) {
+		this.pulsarTemplate = pulsarTemplate;
+	}
+
+	public void someMethod() {
+		this.pulsarTemplate.send("someTopic", "Hello").subscribe();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/reactive/reactiveserver/customizing/programmatic/MyNettyWebServerFactoryCustomizer.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/reactive/reactiveserver/customizing/programmatic/MyNettyWebServerFactoryCustomizer.java
new file mode 100644
index 000000000000..71856eaf64e1
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/reactive/reactiveserver/customizing/programmatic/MyNettyWebServerFactoryCustomizer.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.web.reactive.reactiveserver.customizing.programmatic;
+
+import java.time.Duration;
+
+import org.springframework.boot.web.embedded.netty.NettyReactiveWebServerFactory;
+import org.springframework.boot.web.server.WebServerFactoryCustomizer;
+import org.springframework.stereotype.Component;
+
+@Component
+public class MyNettyWebServerFactoryCustomizer implements WebServerFactoryCustomizer<NettyReactiveWebServerFactory> {
+
+	@Override
+	public void customize(NettyReactiveWebServerFactory factory) {
+		factory.addServerCustomizers((server) -> server.idleTimeout(Duration.ofSeconds(20)));
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/reactive/reactiveserver/customizing/programmatic/MyWebServerFactoryCustomizer.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/reactive/reactiveserver/customizing/programmatic/MyWebServerFactoryCustomizer.java
new file mode 100644
index 000000000000..da9d692489b3
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/reactive/reactiveserver/customizing/programmatic/MyWebServerFactoryCustomizer.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.web.reactive.reactiveserver.customizing.programmatic;
+
+import org.springframework.boot.web.reactive.server.ConfigurableReactiveWebServerFactory;
+import org.springframework.boot.web.server.WebServerFactoryCustomizer;
+import org.springframework.stereotype.Component;
+
+@Component
+public class MyWebServerFactoryCustomizer implements WebServerFactoryCustomizer<ConfigurableReactiveWebServerFactory> {
+
+	@Override
+	public void customize(ConfigurableReactiveWebServerFactory server) {
+		server.setPort(9000);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/reactive/webflux/errorhandling/MyErrorWebExceptionHandler.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/reactive/webflux/errorhandling/MyErrorWebExceptionHandler.java
index 586ca2cff56a..b2cb34518b8f 100644
--- a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/reactive/webflux/errorhandling/MyErrorWebExceptionHandler.java
+++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/reactive/webflux/errorhandling/MyErrorWebExceptionHandler.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -18,12 +18,13 @@
 
 import reactor.core.publisher.Mono;
 
-import org.springframework.boot.autoconfigure.web.WebProperties.Resources;
+import org.springframework.boot.autoconfigure.web.WebProperties;
 import org.springframework.boot.autoconfigure.web.reactive.error.AbstractErrorWebExceptionHandler;
 import org.springframework.boot.web.reactive.error.ErrorAttributes;
 import org.springframework.context.ApplicationContext;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.MediaType;
+import org.springframework.http.codec.ServerCodecConfigurer;
 import org.springframework.stereotype.Component;
 import org.springframework.web.reactive.function.server.RouterFunction;
 import org.springframework.web.reactive.function.server.RouterFunctions;
@@ -34,9 +35,11 @@
 @Component
 public class MyErrorWebExceptionHandler extends AbstractErrorWebExceptionHandler {
 
-	public MyErrorWebExceptionHandler(ErrorAttributes errorAttributes, Resources resources,
-			ApplicationContext applicationContext) {
-		super(errorAttributes, resources, applicationContext);
+	public MyErrorWebExceptionHandler(ErrorAttributes errorAttributes, WebProperties webProperties,
+			ApplicationContext applicationContext, ServerCodecConfigurer serverCodecConfigurer) {
+		super(errorAttributes, webProperties.getResources(), applicationContext);
+		setMessageReaders(serverCodecConfigurer.getReaders());
+		setMessageWriters(serverCodecConfigurer.getWriters());
 	}
 
 	@Override
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/actuator/cloudfoundry/customcontextpath/MyReactiveCloudFoundryConfiguration.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/actuator/cloudfoundry/customcontextpath/MyReactiveCloudFoundryConfiguration.kt
new file mode 100644
index 000000000000..71e748ef4a76
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/actuator/cloudfoundry/customcontextpath/MyReactiveCloudFoundryConfiguration.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.boot.docs.actuator.cloudfoundry.customcontextpath
+
+import org.springframework.boot.autoconfigure.web.reactive.WebFluxProperties
+import org.springframework.boot.context.properties.EnableConfigurationProperties
+import org.springframework.context.ApplicationContext
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.http.server.reactive.ContextPathCompositeHandler
+import org.springframework.http.server.reactive.HttpHandler
+import org.springframework.http.server.reactive.ServerHttpRequest
+import org.springframework.http.server.reactive.ServerHttpResponse
+import org.springframework.web.server.adapter.WebHttpHandlerBuilder
+import reactor.core.publisher.Mono
+
+@Configuration(proxyBeanMethods = false)
+@EnableConfigurationProperties(WebFluxProperties::class)
+class MyReactiveCloudFoundryConfiguration {
+
+	@Bean
+	fun httpHandler(applicationContext: ApplicationContext, properties: WebFluxProperties): HttpHandler {
+		val httpHandler = WebHttpHandlerBuilder.applicationContext(applicationContext).build()
+		return CloudFoundryHttpHandler(properties.basePath, httpHandler)
+	}
+
+	private class CloudFoundryHttpHandler(basePath: String, private val delegate: HttpHandler) : HttpHandler {
+		private val contextPathDelegate = ContextPathCompositeHandler(mapOf(basePath to delegate))
+
+		override fun handle(request: ServerHttpRequest, response: ServerHttpResponse): Mono<Void> {
+			// Remove underlying context path first (e.g. Servlet container)
+			val path = request.path.pathWithinApplication().value()
+			return if (path.startsWith("/cloudfoundryapplication")) {
+				delegate.handle(request, response)
+			} else {
+				contextPathDelegate.handle(request, response)
+			}
+		}
+	}
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/nosql/elasticsearch/connectingusingspringdata/MyBean.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/nosql/elasticsearch/connectingusingspringdata/MyBean.kt
index 95442da426a2..001ee654ce00 100644
--- a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/nosql/elasticsearch/connectingusingspringdata/MyBean.kt
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/nosql/elasticsearch/connectingusingspringdata/MyBean.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -19,8 +19,7 @@ package org.springframework.boot.docs.data.nosql.elasticsearch.connectingusingsp
 import org.springframework.stereotype.Component
 
 @Component
-@Suppress("DEPRECATION")
-class MyBean(private val template: org.springframework.data.elasticsearch.client.erhlc.ElasticsearchRestTemplate ) {
+class MyBean(private val template: org.springframework.data.elasticsearch.client.elc.ElasticsearchTemplate ) {
 
 	// @fold:on // ...
 	fun someMethod(id: String): Boolean {
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/sql/jdbcclient/MyBean.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/sql/jdbcclient/MyBean.kt
new file mode 100644
index 000000000000..02cd7243bd7d
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/sql/jdbcclient/MyBean.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.data.sql.jdbcclient
+
+import org.springframework.jdbc.core.simple.JdbcClient
+import org.springframework.stereotype.Component
+
+@Component
+class MyBean(private val jdbcClient: JdbcClient) {
+
+	fun doSomething() {
+		jdbcClient.sql("delete from customer").update()
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/devtools/MyContainersConfiguration.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/devtools/MyContainersConfiguration.kt
new file mode 100644
index 000000000000..6e6a6b0f45f2
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/devtools/MyContainersConfiguration.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.boot.docs.features.testcontainers.atdevelopmenttime.devtools
+
+import org.springframework.boot.devtools.restart.RestartScope
+import org.springframework.boot.test.context.TestConfiguration
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.context.annotation.Bean
+import org.testcontainers.containers.MongoDBContainer
+
+@TestConfiguration(proxyBeanMethods = false)
+class MyContainersConfiguration {
+
+	@Bean
+	@RestartScope
+	@ServiceConnection
+	fun monogDbContainer(): MongoDBContainer {
+		return MongoDBContainer("mongo:5.0")
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/dynamicproperties/MyContainersConfiguration.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/dynamicproperties/MyContainersConfiguration.kt
new file mode 100644
index 000000000000..b77436639cf5
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/dynamicproperties/MyContainersConfiguration.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.features.testcontainers.atdevelopmenttime.dynamicproperties
+
+import org.springframework.boot.test.context.TestConfiguration
+import org.springframework.context.annotation.Bean;
+import org.springframework.test.context.DynamicPropertyRegistry
+import org.testcontainers.containers.MongoDBContainer
+
+@TestConfiguration(proxyBeanMethods = false)
+class MyContainersConfiguration {
+
+	@Bean
+	fun monogDbContainer(properties: DynamicPropertyRegistry): MongoDBContainer {
+		var container = MongoDBContainer("mongo:5.0")
+		properties.add("spring.data.mongodb.host", container::getHost);
+		properties.add("spring.data.mongodb.port", container::getFirstMappedPort);
+		return container
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainersConfiguration.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainersConfiguration.kt
new file mode 100644
index 000000000000..0d5c7a9c63da
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainersConfiguration.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.features.testcontainers.atdevelopmenttime.importingcontainerdeclarations
+
+import org.springframework.boot.test.context.TestConfiguration
+import org.springframework.boot.testcontainers.context.ImportTestcontainers
+
+@TestConfiguration(proxyBeanMethods = false)
+@ImportTestcontainers(MyContainers::class)
+class MyContainersConfiguration {
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/launch/MyApplication.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/launch/MyApplication.kt
new file mode 100644
index 000000000000..425368d5604a
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/launch/MyApplication.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2012-2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.features.testcontainers.atdevelopmenttime.launch
+
+import org.springframework.boot.autoconfigure.SpringBootApplication
+import org.springframework.boot.docs.features.springapplication.MyApplication
+import org.springframework.boot.runApplication
+
+@SpringBootApplication
+class MyApplication
+
+fun main(args: Array<String>) {
+	runApplication<MyApplication>(*args)
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/launch/TestMyApplication.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/launch/TestMyApplication.kt
new file mode 100644
index 000000000000..40d1e091e166
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/launch/TestMyApplication.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.features.testcontainers.atdevelopmenttime.launch
+
+import org.springframework.boot.fromApplication
+
+fun main(args: Array<String>) {
+	fromApplication<MyApplication>().run(*args)
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/test/MyApplication.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/test/MyApplication.kt
new file mode 100644
index 000000000000..05405fb0063d
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/test/MyApplication.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.features.testcontainers.atdevelopmenttime.test
+
+import org.springframework.boot.autoconfigure.SpringBootApplication
+import org.springframework.boot.docs.features.springapplication.MyApplication
+import org.springframework.boot.runApplication
+
+@SpringBootApplication
+class MyApplication
+
+fun main(args: Array<String>) {
+	runApplication<MyApplication>(*args)
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/test/MyContainersConfiguration.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/test/MyContainersConfiguration.kt
new file mode 100644
index 000000000000..b5d8f291b57c
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/test/MyContainersConfiguration.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.features.testcontainers.atdevelopmenttime.test
+
+import org.springframework.boot.test.context.TestConfiguration
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection
+import org.springframework.context.annotation.Bean
+import org.testcontainers.containers.Neo4jContainer
+
+@TestConfiguration(proxyBeanMethods = false)
+class MyContainersConfiguration {
+
+	@Bean
+	@ServiceConnection
+	fun neo4jContainer(): Neo4jContainer<*> {
+		return Neo4jContainer("neo4j:5")
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/test/TestMyApplication.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/test/TestMyApplication.kt
new file mode 100644
index 000000000000..1f397fba6e4b
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testcontainers/atdevelopmenttime/test/TestMyApplication.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.features.testcontainers.atdevelopmenttime.test
+
+import org.springframework.boot.fromApplication
+import org.springframework.boot.with
+
+fun main(args: Array<String>) {
+	fromApplication<MyApplication>().with(MyContainersConfiguration::class).run(*args)
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientServiceTests.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientServiceTests.kt
new file mode 100644
index 000000000000..d2264757606c
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientServiceTests.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.features.testing.springbootapplications.autoconfiguredrestclient
+
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.boot.test.autoconfigure.web.client.RestClientTest
+import org.springframework.http.MediaType
+import org.springframework.test.web.client.MockRestServiceServer
+import org.springframework.test.web.client.match.MockRestRequestMatchers
+import org.springframework.test.web.client.response.MockRestResponseCreators
+
+@RestClientTest(RemoteVehicleDetailsService::class)
+class MyRestClientServiceTests(
+	@Autowired val service: RemoteVehicleDetailsService,
+	@Autowired val server: MockRestServiceServer) {
+
+	@Test
+	fun getVehicleDetailsWhenResultIsSuccessShouldReturnDetails(): Unit {
+		server.expect(MockRestRequestMatchers.requestTo("https://example.com/greet/details"))
+			.andRespond(MockRestResponseCreators.withSuccess("hello", MediaType.TEXT_PLAIN))
+		val greeting = service.callRestService()
+		assertThat(greeting).isEqualTo("hello")
+	}
+
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientTests.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientTests.kt
deleted file mode 100644
index 3345aa561703..000000000000
--- a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientTests.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.docs.features.testing.springbootapplications.autoconfiguredrestclient
-
-import org.assertj.core.api.Assertions.assertThat
-import org.junit.jupiter.api.Test
-import org.springframework.beans.factory.annotation.Autowired
-import org.springframework.boot.test.autoconfigure.web.client.RestClientTest
-import org.springframework.http.MediaType
-import org.springframework.test.web.client.MockRestServiceServer
-import org.springframework.test.web.client.match.MockRestRequestMatchers
-import org.springframework.test.web.client.response.MockRestResponseCreators
-
-@RestClientTest(RemoteVehicleDetailsService::class)
-class MyRestClientTests(
-	@Autowired val service: RemoteVehicleDetailsService,
-	@Autowired val server: MockRestServiceServer) {
-
-	@Test
-	fun getVehicleDetailsWhenResultIsSuccessShouldReturnDetails(): Unit {
-		server.expect(MockRestRequestMatchers.requestTo("/greet/details"))
-			.andRespond(MockRestResponseCreators.withSuccess("hello", MediaType.TEXT_PLAIN))
-		val greeting = service.callRestService()
-		assertThat(greeting).isEqualTo("hello")
-	}
-
-}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestTemplateServiceTests.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestTemplateServiceTests.kt
new file mode 100644
index 000000000000..03f1a78cc990
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestTemplateServiceTests.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.features.testing.springbootapplications.autoconfiguredrestclient
+
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.boot.test.autoconfigure.web.client.RestClientTest
+import org.springframework.http.MediaType
+import org.springframework.test.web.client.MockRestServiceServer
+import org.springframework.test.web.client.match.MockRestRequestMatchers
+import org.springframework.test.web.client.response.MockRestResponseCreators
+
+@RestClientTest(RemoteVehicleDetailsService::class)
+class MyRestTemplateServiceTests(
+	@Autowired val service: RemoteVehicleDetailsService,
+	@Autowired val server: MockRestServiceServer) {
+
+	@Test
+	fun getVehicleDetailsWhenResultIsSuccessShouldReturnDetails(): Unit {
+		server.expect(MockRestRequestMatchers.requestTo("/greet/details"))
+			.andRespond(MockRestResponseCreators.withSuccess("hello", MediaType.TEXT_PLAIN))
+		val greeting = service.callRestService()
+		assertThat(greeting).isEqualTo("hello")
+	}
+
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/devtools/MyContainersConfiguration.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/devtools/MyContainersConfiguration.kt
deleted file mode 100644
index a5d2f59f48b3..000000000000
--- a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/devtools/MyContainersConfiguration.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.springframework.boot.docs.features.testing.testcontainers.atdevelopmenttime.devtools
-
-import org.springframework.boot.devtools.restart.RestartScope
-import org.springframework.boot.test.context.TestConfiguration
-import org.springframework.context.annotation.Bean
-import org.testcontainers.containers.MongoDBContainer
-
-@TestConfiguration(proxyBeanMethods = false)
-class MyContainersConfiguration {
-
-	@Bean
-	@RestartScope
-	fun monogDbContainer(): MongoDBContainer {
-		return MongoDBContainer("mongo:5.0")
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/dynamicproperties/MyContainersConfiguration.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/dynamicproperties/MyContainersConfiguration.kt
deleted file mode 100644
index c992efeaf96b..000000000000
--- a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/dynamicproperties/MyContainersConfiguration.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.docs.features.testing.testcontainers.atdevelopmenttime.dynamicproperties
-
-import org.springframework.boot.test.context.TestConfiguration
-import org.springframework.context.annotation.Bean;
-import org.springframework.test.context.DynamicPropertyRegistry
-import org.testcontainers.containers.MongoDBContainer
-
-@TestConfiguration(proxyBeanMethods = false)
-class MyContainersConfiguration {
-
-	@Bean
-	fun monogDbContainer(properties: DynamicPropertyRegistry): MongoDBContainer {
-		var container = MongoDBContainer("mongo:5.0")
-		properties.add("spring.data.mongodb.host", container::getHost);
-		properties.add("spring.data.mongodb.port", container::getFirstMappedPort);
-		return container
-	}
-
-}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainersConfiguration.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainersConfiguration.kt
deleted file mode 100644
index 89c5b8ecc29c..000000000000
--- a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/importingcontainerdeclarations/MyContainersConfiguration.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.docs.features.testing.testcontainers.atdevelopmenttime.importingcontainerdeclarations
-
-import org.springframework.boot.test.context.TestConfiguration
-import org.springframework.boot.testcontainers.context.ImportTestcontainers
-
-@TestConfiguration(proxyBeanMethods = false)
-@ImportTestcontainers(MyContainers::class)
-class MyContainersConfiguration {
-
-}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/launch/MyApplication.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/launch/MyApplication.kt
deleted file mode 100644
index 604343d60eac..000000000000
--- a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/launch/MyApplication.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.docs.features.testing.testcontainers.atdevelopmenttime.launch
-
-import org.springframework.boot.autoconfigure.SpringBootApplication
-import org.springframework.boot.docs.features.springapplication.MyApplication
-import org.springframework.boot.runApplication
-
-@SpringBootApplication
-class MyApplication
-
-fun main(args: Array<String>) {
-	runApplication<MyApplication>(*args)
-}
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/launch/TestMyApplication.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/launch/TestMyApplication.kt
deleted file mode 100644
index 3aac403b3060..000000000000
--- a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/launch/TestMyApplication.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.docs.features.testing.testcontainers.atdevelopmenttime.launch
-
-import org.springframework.boot.fromApplication
-
-fun main(args: Array<String>) {
-	fromApplication<MyApplication>().run(*args)
-}
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/test/MyApplication.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/test/MyApplication.kt
deleted file mode 100644
index 6207bfb2ba32..000000000000
--- a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/test/MyApplication.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.docs.features.testing.testcontainers.atdevelopmenttime.test
-
-import org.springframework.boot.autoconfigure.SpringBootApplication
-import org.springframework.boot.docs.features.springapplication.MyApplication
-import org.springframework.boot.runApplication
-
-@SpringBootApplication
-class MyApplication
-
-fun main(args: Array<String>) {
-	runApplication<MyApplication>(*args)
-}
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/test/MyContainersConfiguration.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/test/MyContainersConfiguration.kt
deleted file mode 100644
index 26a3e420ec7b..000000000000
--- a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/test/MyContainersConfiguration.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.docs.features.testing.testcontainers.atdevelopmenttime.test
-
-import org.testcontainers.containers.Neo4jContainer
-
-import org.springframework.boot.test.context.TestConfiguration
-import org.springframework.boot.testcontainers.service.connection.ServiceConnection
-import org.springframework.context.annotation.Bean;
-
-@TestConfiguration(proxyBeanMethods = false)
-class MyContainersConfiguration {
-
-	@Bean
-	@ServiceConnection
-	fun neo4jContainer(): Neo4jContainer<*> {
-		return Neo4jContainer("neo4j:5")
-	}
-
-}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/test/TestMyApplication.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/test/TestMyApplication.kt
deleted file mode 100644
index 8918374d612e..000000000000
--- a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/atdevelopmenttime/test/TestMyApplication.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.docs.features.testing.testcontainers.atdevelopmenttime.test
-
-import org.springframework.boot.fromApplication
-import org.springframework.boot.with
-
-fun main(args: Array<String>) {
-	fromApplication<MyApplication>().with(MyContainersConfiguration::class).run(*args)
-}
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/serviceconnections/MyRedisConfiguration.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/serviceconnections/MyRedisConfiguration.kt
new file mode 100644
index 000000000000..9db09294e2df
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/testcontainers/serviceconnections/MyRedisConfiguration.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.features.testing.testcontainers.serviceconnections
+
+import org.springframework.boot.test.context.TestConfiguration
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection
+import org.springframework.context.annotation.Bean;
+import org.testcontainers.containers.GenericContainer
+
+@TestConfiguration(proxyBeanMethods = false)
+class MyRedisConfiguration {
+
+	@Bean
+	@ServiceConnection(name = "redis")
+	fun redisContainer(): GenericContainer<*> {
+		return GenericContainer("redis:7")
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/howto/actuator/maphealthindicatorstometrics/MyHealthMetricsExportConfiguration.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/howto/actuator/maphealthindicatorstometrics/MyHealthMetricsExportConfiguration.kt
index 86941041b23d..38e8f94f4c07 100644
--- a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/howto/actuator/maphealthindicatorstometrics/MyHealthMetricsExportConfiguration.kt
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/howto/actuator/maphealthindicatorstometrics/MyHealthMetricsExportConfiguration.kt
@@ -32,18 +32,11 @@ class MyHealthMetricsExportConfiguration(registry: MeterRegistry, healthEndpoint
 		}.strongReference(true).register(registry)
 	}
 
-	private fun getStatusCode(health: HealthEndpoint): Int {
-		val status = health.health().status
-		if (Status.UP == status) {
-			return 3
-		}
-		if (Status.OUT_OF_SERVICE == status) {
-			return 2
-		}
-		if (Status.DOWN == status) {
-			return 1
-		}
-		return 0
+	private fun getStatusCode(health: HealthEndpoint) = when (health.health().status) {
+		Status.UP -> 3
+		Status.OUT_OF_SERVICE -> 2
+		Status.DOWN -> 1
+		else -> 0
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/howto/httpclients/webclientreactornettycustomization/MyReactorNettyClientConfiguration.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/howto/httpclients/webclientreactornettycustomization/MyReactorNettyClientConfiguration.kt
index 85c140e3f078..1dac3ab5de0e 100644
--- a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/howto/httpclients/webclientreactornettycustomization/MyReactorNettyClientConfiguration.kt
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/howto/httpclients/webclientreactornettycustomization/MyReactorNettyClientConfiguration.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -22,7 +22,7 @@ import org.springframework.context.annotation.Bean
 import org.springframework.context.annotation.Configuration
 import org.springframework.http.client.reactive.ClientHttpConnector
 import org.springframework.http.client.reactive.ReactorClientHttpConnector
-import org.springframework.http.client.reactive.ReactorResourceFactory
+import org.springframework.http.client.ReactorResourceFactory
 import reactor.netty.http.client.HttpClient
 
 @Configuration(proxyBeanMethods = false)
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/Details.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/Details.kt
new file mode 100644
index 000000000000..219b0a9ffe29
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/Details.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.io.restclient.restclient
+
+class Details
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/MyService.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/MyService.kt
new file mode 100644
index 000000000000..cb1854c03c5e
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/MyService.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2012-2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.io.restclient.restclient
+
+import org.springframework.boot.docs.io.restclient.restclient.ssl.Details
+import org.springframework.stereotype.Service
+import org.springframework.web.client.RestClient
+
+@Service
+class MyService(restClientBuilder: RestClient.Builder) {
+
+	private val restClient: RestClient
+
+	init {
+		restClient = restClientBuilder.baseUrl("https://example.org").build()
+	}
+
+	fun someRestCall(name: String?): Details {
+		return restClient.get().uri("/{name}/details", name)
+				.retrieve().body(Details::class.java)!!
+	}
+
+}
+
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/Details.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/Details.kt
new file mode 100644
index 000000000000..613bbadb3fd7
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/Details.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.io.restclient.restclient.ssl
+
+class Details
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/MyService.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/MyService.kt
new file mode 100644
index 000000000000..220a44252e7f
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/MyService.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.io.restclient.restclient.ssl
+
+import org.springframework.boot.autoconfigure.web.client.RestClientSsl
+import org.springframework.boot.docs.io.restclient.restclient.ssl.settings.Details
+import org.springframework.stereotype.Service
+import org.springframework.web.client.RestClient
+
+@Service
+class MyService(restClientBuilder: RestClient.Builder, ssl: RestClientSsl) {
+
+	private val restClient: RestClient
+
+	init {
+		restClient = restClientBuilder.baseUrl("https://example.org")
+				.apply(ssl.fromBundle("mybundle")).build()
+	}
+
+	fun someRestCall(name: String?): Details {
+		return restClient.get().uri("/{name}/details", name)
+				.retrieve().body(Details::class.java)!!
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/Details.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/Details.kt
new file mode 100644
index 000000000000..3a73e355e1c1
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/Details.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.io.restclient.restclient.ssl.settings
+
+class Details
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/MyService.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/MyService.kt
new file mode 100644
index 000000000000..e153262f8248
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/MyService.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.io.restclient.restclient.ssl.settings
+
+import org.springframework.boot.ssl.SslBundles
+import org.springframework.boot.web.client.ClientHttpRequestFactories
+import org.springframework.boot.web.client.ClientHttpRequestFactorySettings
+import org.springframework.stereotype.Service
+import org.springframework.web.client.RestClient
+import java.time.Duration
+
+@Service
+class MyService(restClientBuilder: RestClient.Builder, sslBundles: SslBundles) {
+
+	private val restClient: RestClient
+
+	init {
+		val settings = ClientHttpRequestFactorySettings.DEFAULTS
+				.withReadTimeout(Duration.ofMinutes(2))
+				.withSslBundle(sslBundles.getBundle("mybundle"))
+		val requestFactory = ClientHttpRequestFactories.get(settings)
+		restClient = restClientBuilder
+				.baseUrl("https://example.org")
+				.requestFactory(requestFactory).build()
+	}
+
+	fun someRestCall(name: String?): Details {
+		return restClient.get().uri("/{name}/details", name).retrieve().body(Details::class.java)!!
+	}
+
+}
+
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/reading/MyBean.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/reading/MyBean.kt
new file mode 100644
index 000000000000..bb2936cc07d5
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/reading/MyBean.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2012-2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.messaging.pulsar.reading
+
+import org.springframework.pulsar.annotation.PulsarReader
+import org.springframework.stereotype.Component
+
+@Suppress("UNUSED_PARAMETER")
+@Component
+class MyBean {
+
+	@PulsarReader(topics = ["someTopic"], startMessageId = "earliest")
+	fun processMessage(content: String?) {
+		// ...
+	}
+
+}
+
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/readingreactive/MyBean.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/readingreactive/MyBean.kt
new file mode 100644
index 000000000000..7651be558113
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/readingreactive/MyBean.kt
@@ -0,0 +1,44 @@
+/*
+* Copyright 2023-2023 the original author or authors.
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*      https://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+package org.springframework.boot.docs.messaging.pulsar.readingreactive
+
+import org.apache.pulsar.client.api.Schema
+import org.apache.pulsar.reactive.client.api.ReactiveMessageReaderBuilder
+import org.apache.pulsar.reactive.client.api.StartAtSpec
+import org.springframework.pulsar.reactive.core.ReactiveMessageReaderBuilderCustomizer
+import org.springframework.pulsar.reactive.core.ReactivePulsarReaderFactory
+import org.springframework.stereotype.Component
+import java.time.Instant
+
+@Suppress("UNUSED_PARAMETER", "UNUSED_VARIABLE")
+@Component
+class MyBean(private val pulsarReaderFactory: ReactivePulsarReaderFactory<String>) {
+
+	fun someMethod() {
+		val readerBuilderCustomizer = ReactiveMessageReaderBuilderCustomizer {
+			readerBuilder: ReactiveMessageReaderBuilder<String> ->
+				readerBuilder
+					.topic("someTopic")
+					.startAtSpec(StartAtSpec.ofInstant(Instant.now().minusSeconds(5)))
+		}
+		val message = pulsarReaderFactory
+				.createReader(Schema.STRING, listOf(readerBuilderCustomizer))
+				.readOne()
+		// ...
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/receiving/MyBean.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/receiving/MyBean.kt
new file mode 100644
index 000000000000..80ee6160ab43
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/receiving/MyBean.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2012-2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.messaging.pulsar.receiving
+
+import org.springframework.pulsar.annotation.PulsarListener
+import org.springframework.stereotype.Component
+
+@Suppress("UNUSED_PARAMETER")
+@Component
+class MyBean {
+
+	@PulsarListener(topics = ["someTopic"])
+	fun processMessage(content: String?) {
+		// ...
+	}
+
+}
+
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/receivingreactive/MyBean.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/receivingreactive/MyBean.kt
new file mode 100644
index 000000000000..6434ff849225
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/receivingreactive/MyBean.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2023-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.boot.docs.messaging.pulsar.receivingreactive
+
+import org.springframework.pulsar.reactive.config.annotation.ReactivePulsarListener
+import org.springframework.stereotype.Component
+import reactor.core.publisher.Mono
+
+@Component
+@Suppress("UNUSED_PARAMETER")
+class MyBean {
+
+	@ReactivePulsarListener(topics = ["someTopic"])
+	fun processMessage(content: String?): Mono<Void> {
+		// ...
+		return Mono.empty()
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/sending/MyBean.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/sending/MyBean.kt
new file mode 100644
index 000000000000..9a94168c6cb1
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/sending/MyBean.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2012-2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.messaging.pulsar.sending
+
+import org.apache.pulsar.client.api.PulsarClientException
+import org.springframework.kafka.core.KafkaTemplate
+import org.springframework.pulsar.core.PulsarTemplate
+import org.springframework.stereotype.Component
+
+@Component
+class MyBean(private val pulsarTemplate: PulsarTemplate<String>) {
+
+	@Throws(PulsarClientException::class)
+	fun someMethod() {
+		pulsarTemplate.send("someTopic", "Hello")
+	}
+
+}
+
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/sendingreactive/MyBean.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/sendingreactive/MyBean.kt
new file mode 100644
index 000000000000..3205912919ec
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/sendingreactive/MyBean.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2023-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.boot.docs.messaging.pulsar.sendingreactive
+
+import org.springframework.pulsar.reactive.core.ReactivePulsarTemplate
+import org.springframework.stereotype.Component
+
+@Component
+class MyBean(private val pulsarTemplate: ReactivePulsarTemplate<String>) {
+
+	fun someMethod() {
+		pulsarTemplate.send("someTopic", "Hello").subscribe()
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/web/reactive/reactiveserver/customizing/programmatic/MyNettyWebServerFactoryCustomizer.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/web/reactive/reactiveserver/customizing/programmatic/MyNettyWebServerFactoryCustomizer.kt
new file mode 100644
index 000000000000..d30381e9fdee
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/web/reactive/reactiveserver/customizing/programmatic/MyNettyWebServerFactoryCustomizer.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.web.reactive.reactiveserver.customizing.programmatic
+
+import org.springframework.boot.web.embedded.netty.NettyReactiveWebServerFactory
+import org.springframework.boot.web.server.WebServerFactoryCustomizer
+import org.springframework.stereotype.Component
+import java.time.Duration
+
+@Component
+class MyNettyWebServerFactoryCustomizer : WebServerFactoryCustomizer<NettyReactiveWebServerFactory> {
+
+	override fun customize(factory: NettyReactiveWebServerFactory) {
+		factory.addServerCustomizers({ server -> server.idleTimeout(Duration.ofSeconds(20)) })
+	}
+
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/web/reactive/reactiveserver/customizing/programmatic/MyWebServerFactoryCustomizer.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/web/reactive/reactiveserver/customizing/programmatic/MyWebServerFactoryCustomizer.kt
new file mode 100644
index 000000000000..82c6b0ec3887
--- /dev/null
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/web/reactive/reactiveserver/customizing/programmatic/MyWebServerFactoryCustomizer.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.docs.web.reactive.reactiveserver.customizing.programmatic
+
+import org.springframework.boot.web.server.WebServerFactoryCustomizer
+import org.springframework.boot.web.reactive.server.ConfigurableReactiveWebServerFactory
+import org.springframework.stereotype.Component
+
+@Component
+class MyWebServerFactoryCustomizer : WebServerFactoryCustomizer<ConfigurableReactiveWebServerFactory> {
+
+	override fun customize(server: ConfigurableReactiveWebServerFactory) {
+		server.setPort(9000)
+	}
+
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/web/reactive/webflux/errorhandling/MyErrorWebExceptionHandler.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/web/reactive/webflux/errorhandling/MyErrorWebExceptionHandler.kt
index d252b81555eb..3ed09fd1e0b9 100644
--- a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/web/reactive/webflux/errorhandling/MyErrorWebExceptionHandler.kt
+++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/web/reactive/webflux/errorhandling/MyErrorWebExceptionHandler.kt
@@ -22,6 +22,7 @@ import org.springframework.boot.web.reactive.error.ErrorAttributes
 import org.springframework.context.ApplicationContext
 import org.springframework.http.HttpStatus
 import org.springframework.http.MediaType
+import org.springframework.http.codec.ServerCodecConfigurer
 import org.springframework.stereotype.Component
 import org.springframework.web.reactive.function.server.RouterFunction
 import org.springframework.web.reactive.function.server.RouterFunctions
@@ -31,8 +32,15 @@ import reactor.core.publisher.Mono
 
 @Suppress("UNUSED_PARAMETER")
 @Component
-class MyErrorWebExceptionHandler(errorAttributes: ErrorAttributes?, resources: WebProperties.Resources?,
-	applicationContext: ApplicationContext?) : AbstractErrorWebExceptionHandler(errorAttributes, resources, applicationContext) {
+class MyErrorWebExceptionHandler(
+		errorAttributes: ErrorAttributes, webProperties: WebProperties,
+		applicationContext: ApplicationContext, serverCodecConfigurer: ServerCodecConfigurer
+) : AbstractErrorWebExceptionHandler(errorAttributes, webProperties.resources, applicationContext) {
+
+	init {
+		setMessageReaders(serverCodecConfigurer.readers)
+		setMessageWriters(serverCodecConfigurer.writers)
+	}
 
 	override fun getRoutingFunction(errorAttributes: ErrorAttributes): RouterFunction<ServerResponse> {
 		return RouterFunctions.route(this::acceptsXml, this::handleErrorAsXml)
@@ -42,7 +50,7 @@ class MyErrorWebExceptionHandler(errorAttributes: ErrorAttributes?, resources: W
 		return request.headers().accept().contains(MediaType.APPLICATION_XML)
 	}
 
-	fun handleErrorAsXml(request: ServerRequest?): Mono<ServerResponse> {
+	fun handleErrorAsXml(request: ServerRequest): Mono<ServerResponse> {
 		val builder = ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR)
 		// ... additional builder calls
 		return builder.build()
diff --git a/spring-boot-project/spring-boot-parent/build.gradle b/spring-boot-project/spring-boot-parent/build.gradle
index de9708b268ce..4484d617520c 100644
--- a/spring-boot-project/spring-boot-parent/build.gradle
+++ b/spring-boot-project/spring-boot-parent/build.gradle
@@ -20,7 +20,7 @@ bom {
 			]
 		}
 	}
-	library("API Guardian", "1.1.0") {
+	library("API Guardian", "1.1.2") {
 		group("org.apiguardian") {
 			modules = [
 				"apiguardian-api"
@@ -30,18 +30,18 @@ bom {
 	library("C3P0", "0.9.5.5") {
 		group("com.mchange") {
 			modules = [
-					"c3p0"
+				"c3p0"
 			]
 		}
 	}
-	library("Commons Compress", "1.21") {
+	library("Commons Compress", "1.23.0") {
 		group("org.apache.commons") {
 			modules = [
 				"commons-compress"
 			]
 		}
 	}
-	library("Commons FileUpload", "1.4") {
+	library("Commons FileUpload", "1.5") {
 		group("commons-fileupload") {
 			modules = [
 				"commons-fileupload"
@@ -55,7 +55,7 @@ bom {
 			]
 		}
 	}
-	library("Janino", "3.1.8") {
+	library("Janino", "3.1.10") {
 		group("org.codehaus.janino") {
 			imports = [
 				"janino"
@@ -73,7 +73,7 @@ bom {
 			]
 		}
 	}
-	library("JNA", "5.7.0") {
+	library("JNA", "5.13.0") {
 		group("net.java.dev.jna") {
 			modules = [
 				"jna-platform"
@@ -87,37 +87,38 @@ bom {
 			]
 		}
 	}
-	library("Maven", "3.6.3") {
+	library("Maven", "${mavenVersion}") {
 		group("org.apache.maven") {
 			modules = [
+				"maven-core",
+				"maven-model-builder",
 				"maven-plugin-api",
-				"maven-resolver-provider",
-				"maven-settings-builder"
+				"maven-resolver-provider"
 			]
 		}
 	}
-	library("Maven Common Artifact Filters", "3.2.0") {
+	library("Maven Common Artifact Filters", "3.3.2") {
 		group("org.apache.maven.shared") {
 			modules = [
 				"maven-common-artifact-filters"
 			]
 		}
 	}
-	library("Maven Invoker", "3.1.0") {
+	library("Maven Invoker", "3.2.0") {
 		group("org.apache.maven.shared") {
 			modules = [
 				"maven-invoker"
 			]
 		}
 	}
-	library("Maven Plugin Tools", "3.6.0") {
+	library("Maven Plugin Tools", "3.9.0") {
 		group("org.apache.maven.plugin-tools") {
 			modules = [
 				"maven-plugin-annotations"
 			]
 		}
 	}
-	library("Maven Resolver", "1.6.3") {
+	library("Maven Resolver", "1.9.14") {
 		group("org.apache.maven.resolver") {
 			modules = [
 				"maven-resolver-api",
@@ -130,14 +131,21 @@ bom {
 			]
 		}
 	}
-	library("Maven Shade Plugin", "3.2.4") {
+	library("Maven Shade Plugin", "3.5.0") {
 		group("org.apache.maven.plugins") {
 			modules = [
 				"maven-shade-plugin"
 			]
 		}
 	}
-	library("MockK", "1.10.6") {
+	library("Micrometer Context Propagation", "1.0.5") {
+		group("io.micrometer") {
+			modules = [
+				"context-propagation"
+			]
+		}
+	}
+	library("MockK", "1.13.5") {
 		group("io.mockk") {
 			modules = [
 				"mockk"
@@ -179,7 +187,7 @@ bom {
 			]
 		}
 	}
-	library("Spock Framework", "2.2-M1-groovy-4.0") {
+	library("Spock Framework", "2.3-groovy-4.0") {
 		group("org.spockframework") {
 			imports = [
 				"spock-bom"
@@ -193,7 +201,7 @@ bom {
 			]
 		}
 	}
-	library("Spring Asciidoctor Extensions", "0.6.1") {
+	library("Spring Asciidoctor Extensions", "0.6.3") {
 		group("io.spring.asciidoctor") {
 			modules = [
 				"spring-asciidoctor-extensions-spring-boot",
diff --git a/spring-boot-project/spring-boot-starters/README.adoc b/spring-boot-project/spring-boot-starters/README.adoc
index ab436cc50ecd..a00d56fb3179 100644
--- a/spring-boot-project/spring-boot-starters/README.adoc
+++ b/spring-boot-project/spring-boot-starters/README.adoc
@@ -88,6 +88,9 @@ do as they were designed before this was clarified.
 | https://elide.io/[Elide]
 | https://github.com/yahoo/elide/tree/master/elide-spring/elide-spring-boot-starter
 
+| https://github.com/flapdoodle-oss/de.flapdoodle.embed.mongo[Embedded MongoDB]
+| https://github.com/flapdoodle-oss/de.flapdoodle.embed.mongo.spring
+
 | ErroREST exception handler
 | https://github.com/mkopylec/errorest-spring-boot-starter
 
@@ -184,6 +187,15 @@ do as they were designed before this was clarified.
 | https://www.optaplanner.org/[OptaPlanner]
 | https://github.com/kiegroup/optaplanner/tree/master/optaplanner-spring-integration/optaplanner-spring-boot-starter
 
+| https://www.oracle.com/cloud/[Oracle Cloud Infrastructure (OCI)]
+| https://github.com/oracle/spring-cloud-oci/tree/main/spring-cloud-oci-starters
+
+| https://spring.coherence.community/3.0.0/refdocs/reference/html/spring-boot.html[Oracle Coherence]
+| https://github.com/coherence-community/coherence-spring/tree/main/coherence-spring-boot-starter
+
+| https://www.oracle.com/database/[Oracle Database]
+| https://github.com/oracle/microservices-datadriven/tree/main/spring/oracle-spring-boot-starters
+
 | https://orika-mapper.github.io/orika-docs/[Orika]
 | https://github.com/akihyro/orika-spring-boot-starter
 
diff --git a/spring-boot-project/spring-boot-starters/spring-boot-starter-actuator/build.gradle b/spring-boot-project/spring-boot-starters/spring-boot-starter-actuator/build.gradle
index 3e2a471593a3..0ff356b4e0d9 100644
--- a/spring-boot-project/spring-boot-starters/spring-boot-starter-actuator/build.gradle
+++ b/spring-boot-project/spring-boot-starters/spring-boot-starter-actuator/build.gradle
@@ -8,5 +8,5 @@ dependencies {
 	api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter"))
 	api(project(":spring-boot-project:spring-boot-actuator-autoconfigure"))
 	api("io.micrometer:micrometer-observation")
-	api("io.micrometer:micrometer-core")
+	api("io.micrometer:micrometer-jakarta9")
 }
diff --git a/spring-boot-project/spring-boot-starters/spring-boot-starter-data-redis-reactive/build.gradle b/spring-boot-project/spring-boot-starters/spring-boot-starter-data-redis-reactive/build.gradle
index d66f98dcfc40..f0c2d2bb4b22 100644
--- a/spring-boot-project/spring-boot-starters/spring-boot-starter-data-redis-reactive/build.gradle
+++ b/spring-boot-project/spring-boot-starters/spring-boot-starter-data-redis-reactive/build.gradle
@@ -5,5 +5,8 @@ plugins {
 description = "Starter for using Redis key-value data store with Spring Data Redis reactive and the Lettuce client"
 
 dependencies {
-	api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-data-redis"))
+	api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter"))
+	api("io.lettuce:lettuce-core")
+	api("io.projectreactor:reactor-core")
+	api("org.springframework.data:spring-data-redis")
 }
diff --git a/spring-boot-project/spring-boot-starters/spring-boot-starter-data-redis/build.gradle b/spring-boot-project/spring-boot-starters/spring-boot-starter-data-redis/build.gradle
index 11f150cd1eec..b76ff7e34fe1 100644
--- a/spring-boot-project/spring-boot-starters/spring-boot-starter-data-redis/build.gradle
+++ b/spring-boot-project/spring-boot-starters/spring-boot-starter-data-redis/build.gradle
@@ -6,6 +6,6 @@ description = "Starter for using Redis key-value data store with Spring Data Red
 
 dependencies {
 	api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter"))
-	api("org.springframework.data:spring-data-redis")
 	api("io.lettuce:lettuce-core")
+	api("org.springframework.data:spring-data-redis")
 }
diff --git a/spring-boot-project/spring-boot-starters/spring-boot-starter-jetty/build.gradle b/spring-boot-project/spring-boot-starters/spring-boot-starter-jetty/build.gradle
index eb83be39310c..3050b1cd5c98 100644
--- a/spring-boot-project/spring-boot-starters/spring-boot-starter-jetty/build.gradle
+++ b/spring-boot-project/spring-boot-starters/spring-boot-starter-jetty/build.gradle
@@ -9,16 +9,8 @@ dependencies {
 	api("jakarta.websocket:jakarta.websocket-api")
 	api("jakarta.websocket:jakarta.websocket-client-api")
 	api("org.apache.tomcat.embed:tomcat-embed-el")
-	api("org.eclipse.jetty:jetty-servlets")
-	api("org.eclipse.jetty:jetty-webapp") {
-		exclude(group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-servlet-api")
-	}
-	api("org.eclipse.jetty.websocket:websocket-jakarta-server") {
-		exclude(group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-servlet-api")
-		exclude(group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-websocket-api")
-	}
-	api("org.eclipse.jetty.websocket:websocket-jetty-server") {
-		exclude group: "org.eclipse.jetty", module: "jetty-jndi"
-		exclude(group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-servlet-api")
-	}
+	api("org.eclipse.jetty.ee10:jetty-ee10-servlets")
+	api("org.eclipse.jetty.ee10:jetty-ee10-webapp")
+	api("org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jakarta-server")
+	api("org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jetty-server")
 }
diff --git a/spring-boot-project/spring-boot-starters/spring-boot-starter-parent/build.gradle b/spring-boot-project/spring-boot-starters/spring-boot-starter-parent/build.gradle
index 2b2028ea2cad..32d88db76916 100644
--- a/spring-boot-project/spring-boot-starters/spring-boot-starter-parent/build.gradle
+++ b/spring-boot-project/spring-boot-starters/spring-boot-starter-parent/build.gradle
@@ -249,7 +249,7 @@ publishing.publications.withType(MavenPublication) {
 									delegate.artifactId('spring-boot-maven-plugin')
 									configuration {
 										image {
-											delegate.builder("paketobuildpacks/builder:tiny");
+											delegate.builder("paketobuildpacks/builder-jammy-tiny:latest");
 											env {
 												delegate.BP_NATIVE_IMAGE("true")
 											}
diff --git a/spring-boot-project/spring-boot-starters/spring-boot-starter-pulsar-reactive/build.gradle b/spring-boot-project/spring-boot-starters/spring-boot-starter-pulsar-reactive/build.gradle
new file mode 100644
index 000000000000..22b23cf2aff3
--- /dev/null
+++ b/spring-boot-project/spring-boot-starters/spring-boot-starter-pulsar-reactive/build.gradle
@@ -0,0 +1,16 @@
+plugins {
+	id "org.springframework.boot.starter"
+}
+
+description = "Starter for using Spring for Apache Pulsar Reactive"
+
+dependencies {
+	api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter"))
+	api("org.springframework.pulsar:spring-pulsar-reactive")
+}
+
+checkRuntimeClasspathForConflicts {
+	ignore { name -> name.startsWith("org/bouncycastle/") ||
+			name.matches("^org\\/apache\\/pulsar\\/.*\\/package-info.class\$") ||
+			name.equals("findbugsExclude.xml") }
+}
diff --git a/spring-boot-project/spring-boot-starters/spring-boot-starter-pulsar/build.gradle b/spring-boot-project/spring-boot-starters/spring-boot-starter-pulsar/build.gradle
new file mode 100644
index 000000000000..87b4c4b6283b
--- /dev/null
+++ b/spring-boot-project/spring-boot-starters/spring-boot-starter-pulsar/build.gradle
@@ -0,0 +1,16 @@
+plugins {
+	id "org.springframework.boot.starter"
+}
+
+description = "Starter for using Spring for Apache Pulsar"
+
+dependencies {
+	api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter"))
+	api("org.springframework.pulsar:spring-pulsar")
+}
+
+checkRuntimeClasspathForConflicts {
+	ignore { name -> name.startsWith("org/bouncycastle/") ||
+				name.matches("^org\\/apache\\/pulsar\\/.*\\/package-info.class\$") ||
+				name.equals("findbugsExclude.xml") }
+}
diff --git a/spring-boot-project/spring-boot-starters/spring-boot-starter-test/build.gradle b/spring-boot-project/spring-boot-starters/spring-boot-starter-test/build.gradle
index f5a2cc091382..e98d71281766 100644
--- a/spring-boot-project/spring-boot-starters/spring-boot-starter-test/build.gradle
+++ b/spring-boot-project/spring-boot-starters/spring-boot-starter-test/build.gradle
@@ -12,6 +12,7 @@ dependencies {
 	api("jakarta.xml.bind:jakarta.xml.bind-api")
 	api("net.minidev:json-smart")
 	api("org.assertj:assertj-core")
+	api("org.awaitility:awaitility")
 	api("org.hamcrest:hamcrest")
 	api("org.junit.jupiter:junit-jupiter")
 	api("org.mockito:mockito-core")
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/SpringBootDependencyInjectionTestExecutionListener.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/SpringBootDependencyInjectionTestExecutionListener.java
deleted file mode 100644
index e3fbb4d61b96..000000000000
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/SpringBootDependencyInjectionTestExecutionListener.java
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.test.autoconfigure;
-
-import org.springframework.boot.autoconfigure.condition.ConditionEvaluationReport;
-import org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportMessage;
-import org.springframework.context.ApplicationContext;
-import org.springframework.context.ConfigurableApplicationContext;
-import org.springframework.test.context.ApplicationContextFailureProcessor;
-import org.springframework.test.context.TestContext;
-import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;
-
-/**
- * Since 3.0.0 this class has been replaced by
- * {@link ConditionReportApplicationContextFailureProcessor} and is not used internally.
- *
- * @author Phillip Webb
- * @since 1.4.1
- * @deprecated since 3.0.0 for removal in 3.2.0 in favor of
- * {@link ApplicationContextFailureProcessor}
- */
-@Deprecated(since = "3.0.0", forRemoval = true)
-public class SpringBootDependencyInjectionTestExecutionListener extends DependencyInjectionTestExecutionListener {
-
-	@Override
-	public void prepareTestInstance(TestContext testContext) throws Exception {
-		try {
-			super.prepareTestInstance(testContext);
-		}
-		catch (Exception ex) {
-			outputConditionEvaluationReport(testContext);
-			throw ex;
-		}
-	}
-
-	private void outputConditionEvaluationReport(TestContext testContext) {
-		try {
-			ApplicationContext context = testContext.getApplicationContext();
-			if (context instanceof ConfigurableApplicationContext configurableContext) {
-				ConditionEvaluationReport report = ConditionEvaluationReport.get(configurableContext.getBeanFactory());
-				System.err.println(new ConditionEvaluationReportMessage(report));
-			}
-		}
-		catch (Exception ex) {
-			// Allow original failure to be reported
-		}
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetrics.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetrics.java
deleted file mode 100644
index 44ec896236d4..000000000000
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetrics.java
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.test.autoconfigure.actuate.metrics;
-
-import java.lang.annotation.Documented;
-import java.lang.annotation.ElementType;
-import java.lang.annotation.Inherited;
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-import java.lang.annotation.Target;
-
-import org.springframework.boot.test.autoconfigure.actuate.observability.AutoConfigureObservability;
-
-/**
- * Annotation that can be applied to a test class to enable auto-configuration for metrics
- * exporters.
- *
- * @author Chris Bono
- * @since 2.4.0
- * @deprecated since 3.0.0 for removal in 3.2.0 in favor of
- * {@link AutoConfigureObservability @AutoConfigureObservability}
- */
-@Target(ElementType.TYPE)
-@Retention(RetentionPolicy.RUNTIME)
-@Documented
-@Inherited
-@Deprecated(since = "3.0.0", forRemoval = true)
-@AutoConfigureObservability(tracing = false)
-public @interface AutoConfigureMetrics {
-
-}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/metrics/package-info.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/metrics/package-info.java
deleted file mode 100644
index 6dfcc180e669..000000000000
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/metrics/package-info.java
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * Copyright 2012-2020 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * Auto-configuration for handling metrics in tests.
- */
-package org.springframework.boot.test.autoconfigure.actuate.metrics;
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/observability/ObservabilityContextCustomizerFactory.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/observability/ObservabilityContextCustomizerFactory.java
index 918777baf1e7..86a003b6b6e0 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/observability/ObservabilityContextCustomizerFactory.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/observability/ObservabilityContextCustomizerFactory.java
@@ -19,39 +19,19 @@
 import java.util.List;
 import java.util.Objects;
 
-import io.micrometer.tracing.Tracer;
-
-import org.springframework.aot.AotDetector;
-import org.springframework.beans.BeansException;
-import org.springframework.beans.factory.BeanFactory;
-import org.springframework.beans.factory.BeanFactoryAware;
-import org.springframework.beans.factory.BeanFactoryUtils;
-import org.springframework.beans.factory.FactoryBean;
-import org.springframework.beans.factory.ListableBeanFactory;
-import org.springframework.beans.factory.config.BeanDefinition;
-import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
-import org.springframework.beans.factory.support.BeanDefinitionRegistry;
-import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor;
-import org.springframework.beans.factory.support.RootBeanDefinition;
 import org.springframework.boot.test.util.TestPropertyValues;
 import org.springframework.context.ConfigurableApplicationContext;
-import org.springframework.context.annotation.ConfigurationClassPostProcessor;
-import org.springframework.core.Ordered;
 import org.springframework.core.env.Environment;
 import org.springframework.test.context.ContextConfigurationAttributes;
 import org.springframework.test.context.ContextCustomizer;
 import org.springframework.test.context.ContextCustomizerFactory;
 import org.springframework.test.context.MergedContextConfiguration;
 import org.springframework.test.context.TestContextAnnotationUtils;
-import org.springframework.util.ClassUtils;
 
 /**
  * {@link ContextCustomizerFactory} that globally disables metrics export and tracing in
  * tests. The behaviour can be controlled with {@link AutoConfigureObservability} on the
  * test class or via the {@value #AUTO_CONFIGURE_PROPERTY} property.
- * <p>
- * Registers {@link Tracer#NOOP} if tracing is disabled, micrometer-tracing is on the
- * classpath, and the user hasn't supplied their own {@link Tracer}.
  *
  * @author Chris Bono
  * @author Moritz Halbritter
@@ -87,7 +67,6 @@ public void customizeContext(ConfigurableApplicationContext context,
 			}
 			if (isTracingDisabled(context.getEnvironment())) {
 				TestPropertyValues.of("management.tracing.enabled=false").applyTo(context);
-				registerNoopTracer(context);
 			}
 		}
 
@@ -105,25 +84,6 @@ private boolean isTracingDisabled(Environment environment) {
 			return !environment.getProperty(AUTO_CONFIGURE_PROPERTY, Boolean.class, false);
 		}
 
-		private void registerNoopTracer(ConfigurableApplicationContext context) {
-			if (AotDetector.useGeneratedArtifacts()) {
-				return;
-			}
-			if (!ClassUtils.isPresent("io.micrometer.tracing.Tracer", context.getClassLoader())) {
-				return;
-			}
-			ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
-			if (beanFactory instanceof BeanDefinitionRegistry registry) {
-				registerNoopTracer(registry);
-			}
-		}
-
-		private void registerNoopTracer(BeanDefinitionRegistry registry) {
-			RootBeanDefinition definition = new RootBeanDefinition(NoopTracerRegistrar.class);
-			definition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE);
-			registry.registerBeanDefinition(NoopTracerRegistrar.class.getName(), definition);
-		}
-
 		@Override
 		public boolean equals(Object o) {
 			if (this == o) {
@@ -143,54 +103,4 @@ public int hashCode() {
 
 	}
 
-	/**
-	 * {@link BeanDefinitionRegistryPostProcessor} that runs after the
-	 * {@link ConfigurationClassPostProcessor} and adds a {@link Tracer} bean definition
-	 * when a {@link Tracer} hasn't already been registered.
-	 */
-	static class NoopTracerRegistrar implements BeanDefinitionRegistryPostProcessor, Ordered, BeanFactoryAware {
-
-		private BeanFactory beanFactory;
-
-		@Override
-		public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
-			this.beanFactory = beanFactory;
-		}
-
-		@Override
-		public int getOrder() {
-			return Ordered.LOWEST_PRECEDENCE;
-		}
-
-		@Override
-		public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException {
-			if (AotDetector.useGeneratedArtifacts()) {
-				return;
-			}
-			if (BeanFactoryUtils.beanNamesForTypeIncludingAncestors((ListableBeanFactory) this.beanFactory,
-					Tracer.class, false, false).length == 0) {
-				registry.registerBeanDefinition("noopTracer", new RootBeanDefinition(NoopTracerFactoryBean.class));
-			}
-		}
-
-		@Override
-		public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
-		}
-
-	}
-
-	static class NoopTracerFactoryBean implements FactoryBean<Tracer> {
-
-		@Override
-		public Tracer getObject() {
-			return Tracer.NOOP;
-		}
-
-		@Override
-		public Class<?> getObjectType() {
-			return Tracer.class;
-		}
-
-	}
-
 }
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/filter/AnnotationCustomizableTypeExcludeFilter.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/filter/AnnotationCustomizableTypeExcludeFilter.java
index 94640ff18f62..807c4a7d9cf8 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/filter/AnnotationCustomizableTypeExcludeFilter.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/filter/AnnotationCustomizableTypeExcludeFilter.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -18,6 +18,8 @@
 
 import java.io.IOException;
 import java.lang.annotation.Annotation;
+import java.util.Arrays;
+import java.util.Objects;
 import java.util.Set;
 
 import org.springframework.beans.factory.BeanClassLoaderAware;
@@ -119,8 +121,7 @@ public boolean equals(Object obj) {
 			return false;
 		}
 		AnnotationCustomizableTypeExcludeFilter other = (AnnotationCustomizableTypeExcludeFilter) obj;
-		boolean result = true;
-		result = result && hasAnnotation() == other.hasAnnotation();
+		boolean result = hasAnnotation() == other.hasAnnotation();
 		for (FilterType filterType : FilterType.values()) {
 			result &= ObjectUtils.nullSafeEquals(getFilters(filterType), other.getFilters(filterType));
 		}
@@ -136,11 +137,11 @@ public int hashCode() {
 		int result = 0;
 		result = prime * result + Boolean.hashCode(hasAnnotation());
 		for (FilterType filterType : FilterType.values()) {
-			result = prime * result + ObjectUtils.nullSafeHashCode(getFilters(filterType));
+			result = prime * result + Arrays.hashCode(getFilters(filterType));
 		}
 		result = prime * result + Boolean.hashCode(isUseDefaultFilters());
-		result = prime * result + ObjectUtils.nullSafeHashCode(getDefaultIncludes());
-		result = prime * result + ObjectUtils.nullSafeHashCode(getComponentIncludes());
+		result = prime * result + Objects.hashCode(getDefaultIncludes());
+		result = prime * result + Objects.hashCode(getComponentIncludes());
 		return result;
 	}
 
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/filter/FilterAnnotations.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/filter/FilterAnnotations.java
index 9dd7ccfbb128..8626043851c8 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/filter/FilterAnnotations.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/filter/FilterAnnotations.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -69,19 +69,20 @@ private List<TypeFilter> createTypeFilters(Filter[] filters) {
 
 	@SuppressWarnings("unchecked")
 	private TypeFilter createTypeFilter(FilterType filterType, Class<?> filterClass) {
-		switch (filterType) {
-			case ANNOTATION:
+		return switch (filterType) {
+			case ANNOTATION -> {
 				Assert.isAssignable(Annotation.class, filterClass,
 						"An error occurred while processing an ANNOTATION type filter: ");
-				return new AnnotationTypeFilter((Class<Annotation>) filterClass);
-			case ASSIGNABLE_TYPE:
-				return new AssignableTypeFilter(filterClass);
-			case CUSTOM:
+				yield new AnnotationTypeFilter((Class<Annotation>) filterClass);
+			}
+			case ASSIGNABLE_TYPE -> new AssignableTypeFilter(filterClass);
+			case CUSTOM -> {
 				Assert.isAssignable(TypeFilter.class, filterClass,
 						"An error occurred while processing a CUSTOM type filter: ");
-				return BeanUtils.instantiateClass(filterClass, TypeFilter.class);
-		}
-		throw new IllegalArgumentException("Filter type not supported with Class value: " + filterType);
+				yield BeanUtils.instantiateClass(filterClass, TypeFilter.class);
+			}
+			default -> throw new IllegalArgumentException("Filter type not supported with Class value: " + filterType);
+		};
 	}
 
 	private TypeFilter createTypeFilter(FilterType filterType, String pattern) {
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabase.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabase.java
index e79b78ec0f43..9bed2317ed5a 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabase.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabase.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -58,7 +58,7 @@
 
 	/**
 	 * The type of connection to be established when {@link #replace() replacing} the
-	 * DataSource. By default will attempt to detect the connection based on the
+	 * DataSource. By default, will attempt to detect the connection based on the
 	 * classpath.
 	 * @return the type of connection to use
 	 */
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/jdbc/TestDatabaseAutoConfiguration.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/jdbc/TestDatabaseAutoConfiguration.java
index b2b63c886a32..c0c28e124cc3 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/jdbc/TestDatabaseAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/jdbc/TestDatabaseAutoConfiguration.java
@@ -163,11 +163,6 @@ public Class<?> getObjectType() {
 			return EmbeddedDatabase.class;
 		}
 
-		@Override
-		public boolean isSingleton() {
-			return true;
-		}
-
 	}
 
 	static class EmbeddedDataSourceFactory {
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/properties/AnnotationsPropertySource.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/properties/AnnotationsPropertySource.java
index 5987729cd8bd..a108e65b8c23 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/properties/AnnotationsPropertySource.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/properties/AnnotationsPropertySource.java
@@ -91,7 +91,7 @@ private void collectProperties(String prefix, SkipPropertyMapping skip, MergedAn
 			return;
 		}
 		Optional<Object> value = annotation.getValue(attribute.getName());
-		if (!value.isPresent()) {
+		if (value.isEmpty()) {
 			return;
 		}
 		if (skip == SkipPropertyMapping.ON_DEFAULT_VALUE) {
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/properties/PropertyMappingContextCustomizer.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/properties/PropertyMappingContextCustomizer.java
index 2dde81b33d70..7598ae4b459f 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/properties/PropertyMappingContextCustomizer.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/properties/PropertyMappingContextCustomizer.java
@@ -98,7 +98,7 @@ private Class<?> getRoot(MergedAnnotation<?> annotation) {
 		private String getAnnotationsDescription(Set<Class<?>> annotations) {
 			StringBuilder result = new StringBuilder();
 			for (Class<?> annotation : annotations) {
-				if (result.length() != 0) {
+				if (!result.isEmpty()) {
 					result.append(", ");
 				}
 				result.append('@').append(ClassUtils.getShortName(annotation));
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServer.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServer.java
index 6e9e8def9672..43e1aac70e07 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServer.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServer.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -25,20 +25,26 @@
 
 import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
 import org.springframework.boot.test.autoconfigure.properties.PropertyMapping;
+import org.springframework.boot.test.web.client.MockServerRestClientCustomizer;
 import org.springframework.boot.test.web.client.MockServerRestTemplateCustomizer;
 import org.springframework.boot.web.client.RestTemplateBuilder;
 import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.web.client.RestClient;
 
 /**
  * Annotation that can be applied to a test class to enable and configure
  * auto-configuration of a single {@link MockRestServiceServer}. Only useful when a single
- * call is made to {@link RestTemplateBuilder}. If multiple
- * {@link org.springframework.web.client.RestTemplate RestTemplates} are in use, inject
+ * call is made to {@link RestTemplateBuilder} or {@link RestClient.Builder}. If multiple
+ * {@link org.springframework.web.client.RestTemplate RestTemplates} or
+ * {@link org.springframework.web.client.RestClient RestClients} are in use, inject a
  * {@link MockServerRestTemplateCustomizer} and use
  * {@link MockServerRestTemplateCustomizer#getServer(org.springframework.web.client.RestTemplate)
- * getServer(RestTemplate)} or bind a {@link MockRestServiceServer} directly.
+ * getServer(RestTemplate)}, or inject a {@link MockServerRestClientCustomizer} and use
+ * {@link MockServerRestClientCustomizer#getServer(org.springframework.web.client.RestClient.Builder)
+ * * getServer(RestClient.Builder)}, or bind a {@link MockRestServiceServer} directly.
  *
  * @author Phillip Webb
+ * @author Scott Frederick
  * @since 1.4.0
  * @see MockServerRestTemplateCustomizer
  */
@@ -51,7 +57,8 @@
 public @interface AutoConfigureMockRestServiceServer {
 
 	/**
-	 * If {@link MockServerRestTemplateCustomizer} should be enabled and
+	 * If {@link MockServerRestTemplateCustomizer} and
+	 * {@link MockServerRestClientCustomizer} should be enabled and
 	 * {@link MockRestServiceServer} beans should be registered. Defaults to {@code true}
 	 * @return if mock support is enabled
 	 */
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/MockRestServiceServerAutoConfiguration.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/MockRestServiceServerAutoConfiguration.java
index aa76319d00a6..6aba6245a020 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/MockRestServiceServerAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/MockRestServiceServerAutoConfiguration.java
@@ -19,10 +19,12 @@
 import java.io.IOException;
 import java.lang.reflect.Constructor;
 import java.time.Duration;
+import java.util.Collection;
 import java.util.Map;
 
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.test.web.client.MockServerRestClientCustomizer;
 import org.springframework.boot.test.web.client.MockServerRestTemplateCustomizer;
 import org.springframework.context.annotation.Bean;
 import org.springframework.http.client.ClientHttpRequest;
@@ -33,12 +35,14 @@
 import org.springframework.test.web.client.RequestMatcher;
 import org.springframework.test.web.client.ResponseActions;
 import org.springframework.util.Assert;
+import org.springframework.web.client.RestClient;
 import org.springframework.web.client.RestTemplate;
 
 /**
  * Auto-configuration for {@link MockRestServiceServer} support.
  *
  * @author Phillip Webb
+ * @author Scott Frederick
  * @since 1.4.0
  * @see AutoConfigureMockRestServiceServer
  */
@@ -52,21 +56,29 @@ public MockServerRestTemplateCustomizer mockServerRestTemplateCustomizer() {
 	}
 
 	@Bean
-	public MockRestServiceServer mockRestServiceServer(MockServerRestTemplateCustomizer customizer) {
+	public MockServerRestClientCustomizer mockServerRestClientCustomizer() {
+		return new MockServerRestClientCustomizer();
+	}
+
+	@Bean
+	public MockRestServiceServer mockRestServiceServer(MockServerRestTemplateCustomizer restTemplateCustomizer,
+			MockServerRestClientCustomizer restClientCustomizer) {
 		try {
-			return createDeferredMockRestServiceServer(customizer);
+			return createDeferredMockRestServiceServer(restTemplateCustomizer, restClientCustomizer);
 		}
 		catch (Exception ex) {
 			throw new IllegalStateException(ex);
 		}
 	}
 
-	private MockRestServiceServer createDeferredMockRestServiceServer(MockServerRestTemplateCustomizer customizer)
-			throws Exception {
+	private MockRestServiceServer createDeferredMockRestServiceServer(
+			MockServerRestTemplateCustomizer restTemplateCustomizer,
+			MockServerRestClientCustomizer restClientCustomizer) throws Exception {
 		Constructor<MockRestServiceServer> constructor = MockRestServiceServer.class
 			.getDeclaredConstructor(RequestExpectationManager.class);
 		constructor.setAccessible(true);
-		return constructor.newInstance(new DeferredRequestExpectationManager(customizer));
+		return constructor
+			.newInstance(new DeferredRequestExpectationManager(restTemplateCustomizer, restClientCustomizer));
 	}
 
 	/**
@@ -77,10 +89,14 @@ private MockRestServiceServer createDeferredMockRestServiceServer(MockServerRest
 	 */
 	private static class DeferredRequestExpectationManager implements RequestExpectationManager {
 
-		private final MockServerRestTemplateCustomizer customizer;
+		private final MockServerRestTemplateCustomizer restTemplateCustomizer;
+
+		private final MockServerRestClientCustomizer restClientCustomizer;
 
-		DeferredRequestExpectationManager(MockServerRestTemplateCustomizer customizer) {
-			this.customizer = customizer;
+		DeferredRequestExpectationManager(MockServerRestTemplateCustomizer restTemplateCustomizer,
+				MockServerRestClientCustomizer restClientCustomizer) {
+			this.restTemplateCustomizer = restTemplateCustomizer;
+			this.restClientCustomizer = restClientCustomizer;
 		}
 
 		@Override
@@ -105,19 +121,34 @@ public void verify(Duration timeout) {
 
 		@Override
 		public void reset() {
-			Map<RestTemplate, RequestExpectationManager> expectationManagers = this.customizer.getExpectationManagers();
+			resetExpectations(this.restTemplateCustomizer.getExpectationManagers().values());
+			resetExpectations(this.restClientCustomizer.getExpectationManagers().values());
+		}
+
+		private void resetExpectations(Collection<RequestExpectationManager> expectationManagers) {
 			if (expectationManagers.size() == 1) {
-				getDelegate().reset();
+				expectationManagers.iterator().next().reset();
 			}
 		}
 
 		private RequestExpectationManager getDelegate() {
-			Map<RestTemplate, RequestExpectationManager> expectationManagers = this.customizer.getExpectationManagers();
-			Assert.state(!expectationManagers.isEmpty(), "Unable to use auto-configured MockRestServiceServer since "
-					+ "MockServerRestTemplateCustomizer has not been bound to a RestTemplate");
-			Assert.state(expectationManagers.size() == 1, "Unable to use auto-configured MockRestServiceServer since "
-					+ "MockServerRestTemplateCustomizer has been bound to more than one RestTemplate");
-			return expectationManagers.values().iterator().next();
+			Map<RestTemplate, RequestExpectationManager> restTemplateExpectationManagers = this.restTemplateCustomizer
+				.getExpectationManagers();
+			Map<RestClient.Builder, RequestExpectationManager> restClientExpectationManagers = this.restClientCustomizer
+				.getExpectationManagers();
+			Assert.state(!(restTemplateExpectationManagers.isEmpty() && restClientExpectationManagers.isEmpty()),
+					"Unable to use auto-configured MockRestServiceServer since "
+							+ "a mock server customizer has not been bound to a RestTemplate or RestClient");
+			if (!restTemplateExpectationManagers.isEmpty()) {
+				Assert.state(restTemplateExpectationManagers.size() == 1,
+						"Unable to use auto-configured MockRestServiceServer since "
+								+ "MockServerRestTemplateCustomizer has been bound to more than one RestTemplate");
+				return restTemplateExpectationManagers.values().iterator().next();
+			}
+			Assert.state(restClientExpectationManagers.size() == 1,
+					"Unable to use auto-configured MockRestServiceServer since "
+							+ "MockServerRestClientCustomizer has been bound to more than one RestClient");
+			return restClientExpectationManagers.values().iterator().next();
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTest.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTest.java
index eba5ed69d003..8a93b1b42226 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTest.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTest.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -38,11 +38,12 @@
 import org.springframework.test.context.BootstrapWith;
 import org.springframework.test.context.junit.jupiter.SpringExtension;
 import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.web.client.RestClient;
 import org.springframework.web.client.RestTemplate;
 
 /**
  * Annotation for a Spring rest client test that focuses <strong>only</strong> on beans
- * that use {@link RestTemplateBuilder}.
+ * that use {@link RestTemplateBuilder} or {@link RestClient.Builder}.
  * <p>
  * Using this annotation will disable full auto-configuration and instead apply only
  * configuration relevant to rest client tests (i.e. Jackson or GSON auto-configuration
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/reactive/AutoConfigureWebTestClient.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/reactive/AutoConfigureWebTestClient.java
index 396a2d6a80dc..c63943a3c086 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/reactive/AutoConfigureWebTestClient.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/reactive/AutoConfigureWebTestClient.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -26,15 +26,18 @@
 
 import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
 import org.springframework.boot.test.autoconfigure.properties.PropertyMapping;
+import org.springframework.context.ApplicationContext;
 import org.springframework.test.web.reactive.server.WebTestClient;
 
 /**
- * Annotation that can be applied to a test class to enable a {@link WebTestClient}. At
- * the moment, only WebFlux applications are supported.
+ * Annotation that can be applied to a test class to enable a {@link WebTestClient} that
+ * is bound directly to the application. Tests do not rely upon an HTTP server and use
+ * mock requests and responses. At the moment, only WebFlux applications are supported.
  *
  * @author Stephane Nicoll
  * @since 2.0.0
  * @see WebTestClientAutoConfiguration
+ * @see WebTestClient#bindToApplicationContext(ApplicationContext)
  */
 @Target({ ElementType.TYPE, ElementType.METHOD })
 @Retention(RetentionPolicy.RUNTIME)
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/MockMvcAutoConfiguration.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/MockMvcAutoConfiguration.java
index 6d429eb4fae3..9e944e4fad3f 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/MockMvcAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/MockMvcAutoConfiguration.java
@@ -131,6 +131,11 @@ private static class MockMvcDispatcherServletCustomizer implements DispatcherSer
 		public void customize(DispatcherServlet dispatcherServlet) {
 			dispatcherServlet.setDispatchOptionsRequest(this.webMvcProperties.isDispatchOptionsRequest());
 			dispatcherServlet.setDispatchTraceRequest(this.webMvcProperties.isDispatchTraceRequest());
+			configureThrowExceptionIfNoHandlerFound(dispatcherServlet);
+		}
+
+		@SuppressWarnings({ "deprecation", "removal" })
+		private void configureThrowExceptionIfNoHandlerFound(DispatcherServlet dispatcherServlet) {
 			dispatcherServlet
 				.setThrowExceptionIfNoHandlerFound(this.webMvcProperties.isThrowExceptionIfNoHandlerFound());
 		}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/SpringBootMockMvcBuilderCustomizer.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/SpringBootMockMvcBuilderCustomizer.java
index 269d3615009a..fa4c8366fa30 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/SpringBootMockMvcBuilderCustomizer.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/SpringBootMockMvcBuilderCustomizer.java
@@ -116,12 +116,8 @@ private void addFilters(ConfigurableMockMvcBuilder<?> builder) {
 	private void addFilter(ConfigurableMockMvcBuilder<?> builder, AbstractFilterRegistrationBean<?> registration) {
 		Filter filter = registration.getFilter();
 		Collection<String> urls = registration.getUrlPatterns();
-		if (urls.isEmpty()) {
-			builder.addFilters(filter);
-		}
-		else {
-			builder.addFilter(filter, StringUtils.toStringArray(urls));
-		}
+		builder.addFilter(filter, registration.getFilterName(), registration.getInitParameters(),
+				registration.determineDispatcherTypes(), StringUtils.toStringArray(urls));
 	}
 
 	public void setAddFilters(boolean addFilters) {
@@ -253,7 +249,7 @@ static DeferredLinesWriter get(ApplicationContext applicationContext) {
 		}
 
 		void clear() {
-			this.lines.get().clear();
+			this.lines.remove();
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/WebDriverContextCustomizer.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/WebDriverContextCustomizer.java
new file mode 100644
index 000000000000..5a7420207dec
--- /dev/null
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/WebDriverContextCustomizer.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.test.autoconfigure.web.servlet;
+
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.test.context.ContextCustomizer;
+import org.springframework.test.context.MergedContextConfiguration;
+
+/**
+ * {@link ContextCustomizer} that registers a {@link WebDriverScope} and configures
+ * appropriate bean definitions to use it.
+ *
+ * @author Phillip Webb
+ * @see WebDriverScope
+ */
+class WebDriverContextCustomizer implements ContextCustomizer {
+
+	@Override
+	public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) {
+		WebDriverScope.registerWith(context);
+	}
+
+	@Override
+	public boolean equals(Object obj) {
+		if (obj == this) {
+			return true;
+		}
+		if (obj == null || obj.getClass() != getClass()) {
+			return false;
+		}
+		return true;
+	}
+
+	@Override
+	public int hashCode() {
+		return getClass().hashCode();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/WebDriverContextCustomizerFactory.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/WebDriverContextCustomizerFactory.java
index c9f836520d61..4e881aea5d8c 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/WebDriverContextCustomizerFactory.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/WebDriverContextCustomizerFactory.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -18,11 +18,9 @@
 
 import java.util.List;
 
-import org.springframework.context.ConfigurableApplicationContext;
 import org.springframework.test.context.ContextConfigurationAttributes;
 import org.springframework.test.context.ContextCustomizer;
 import org.springframework.test.context.ContextCustomizerFactory;
-import org.springframework.test.context.MergedContextConfiguration;
 
 /**
  * {@link ContextCustomizerFactory} to register a {@link WebDriverScope} and configure
@@ -38,32 +36,7 @@ class WebDriverContextCustomizerFactory implements ContextCustomizerFactory {
 	@Override
 	public ContextCustomizer createContextCustomizer(Class<?> testClass,
 			List<ContextConfigurationAttributes> configAttributes) {
-		return new Customizer();
-	}
-
-	private static class Customizer implements ContextCustomizer {
-
-		@Override
-		public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) {
-			WebDriverScope.registerWith(context);
-		}
-
-		@Override
-		public boolean equals(Object obj) {
-			if (obj == this) {
-				return true;
-			}
-			if (obj == null || obj.getClass() != getClass()) {
-				return false;
-			}
-			return true;
-		}
-
-		@Override
-		public int hashCode() {
-			return getClass().hashCode();
-		}
-
+		return new WebDriverContextCustomizer();
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/WebDriverScope.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/WebDriverScope.java
index 5b91ef38e44f..ec04e6a986fd 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/WebDriverScope.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/WebDriverScope.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -107,7 +107,7 @@ boolean reset() {
 
 	/**
 	 * Register this scope with the specified context and reassign appropriate bean
-	 * definitions to used it.
+	 * definitions to use it.
 	 * @param context the application context
 	 */
 	static void registerWith(ConfigurableApplicationContext context) {
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.cassandra.AutoConfigureDataCassandra.imports b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.cassandra.AutoConfigureDataCassandra.imports
index 676de9b223b9..980f52b5ad41 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.cassandra.AutoConfigureDataCassandra.imports
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.cassandra.AutoConfigureDataCassandra.imports
@@ -5,3 +5,4 @@ org.springframework.boot.autoconfigure.data.cassandra.CassandraReactiveDataAutoC
 org.springframework.boot.autoconfigure.data.cassandra.CassandraReactiveRepositoriesAutoConfiguration
 org.springframework.boot.autoconfigure.data.cassandra.CassandraRepositoriesAutoConfiguration
 org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration
+optional:org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.couchbase.AutoConfigureDataCouchbase.imports b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.couchbase.AutoConfigureDataCouchbase.imports
index 09237e1bfa99..bd6a6cc5c81c 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.couchbase.AutoConfigureDataCouchbase.imports
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.couchbase.AutoConfigureDataCouchbase.imports
@@ -6,3 +6,4 @@ org.springframework.boot.autoconfigure.data.couchbase.CouchbaseReactiveDataAutoC
 org.springframework.boot.autoconfigure.data.couchbase.CouchbaseReactiveRepositoriesAutoConfiguration
 org.springframework.boot.autoconfigure.data.couchbase.CouchbaseRepositoriesAutoConfiguration
 org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration
+optional:org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.elasticsearch.AutoConfigureDataElasticsearch.imports b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.elasticsearch.AutoConfigureDataElasticsearch.imports
index 6a34f70cb30f..cebaf196db72 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.elasticsearch.AutoConfigureDataElasticsearch.imports
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.elasticsearch.AutoConfigureDataElasticsearch.imports
@@ -8,3 +8,4 @@ org.springframework.boot.autoconfigure.elasticsearch.ReactiveElasticsearchClient
 org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration
 org.springframework.boot.autoconfigure.jsonb.JsonbAutoConfiguration
 org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration
+optional:org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.jdbc.AutoConfigureDataJdbc.imports b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.jdbc.AutoConfigureDataJdbc.imports
index e276c6de14f6..eb4b3faada1b 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.jdbc.AutoConfigureDataJdbc.imports
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.jdbc.AutoConfigureDataJdbc.imports
@@ -3,7 +3,9 @@ org.springframework.boot.autoconfigure.data.jdbc.JdbcRepositoriesAutoConfigurati
 org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration
 org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
 org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration
+org.springframework.boot.autoconfigure.jdbc.JdbcClientAutoConfiguration
 org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration
 org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration
 org.springframework.boot.autoconfigure.sql.init.SqlInitializationAutoConfiguration
 org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration
+optional:org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.mongo.AutoConfigureDataMongo.imports b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.mongo.AutoConfigureDataMongo.imports
index 53cff5454f3c..cd75eda62b4a 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.mongo.AutoConfigureDataMongo.imports
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.mongo.AutoConfigureDataMongo.imports
@@ -7,3 +7,4 @@ org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration
 org.springframework.boot.autoconfigure.mongo.MongoReactiveAutoConfiguration
 org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration
 org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration
+optional:org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.neo4j.AutoConfigureDataNeo4j.imports b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.neo4j.AutoConfigureDataNeo4j.imports
index 5d25aa7db0f8..96aef94577bd 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.neo4j.AutoConfigureDataNeo4j.imports
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.neo4j.AutoConfigureDataNeo4j.imports
@@ -4,4 +4,5 @@ org.springframework.boot.autoconfigure.data.neo4j.Neo4jDataAutoConfiguration
 org.springframework.boot.autoconfigure.data.neo4j.Neo4jReactiveDataAutoConfiguration
 org.springframework.boot.autoconfigure.data.neo4j.Neo4jReactiveRepositoriesAutoConfiguration
 org.springframework.boot.autoconfigure.data.neo4j.Neo4jRepositoriesAutoConfiguration
-org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration
\ No newline at end of file
+org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration
+optional:org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.r2dbc.AutoConfigureDataR2dbc.imports b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.r2dbc.AutoConfigureDataR2dbc.imports
index ed0f4bec9234..678494ab914a 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.r2dbc.AutoConfigureDataR2dbc.imports
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.r2dbc.AutoConfigureDataR2dbc.imports
@@ -6,4 +6,5 @@ org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration
 org.springframework.boot.autoconfigure.r2dbc.R2dbcAutoConfiguration
 org.springframework.boot.autoconfigure.r2dbc.R2dbcTransactionManagerAutoConfiguration
 org.springframework.boot.autoconfigure.sql.init.SqlInitializationAutoConfiguration
-org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration
\ No newline at end of file
+org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration
+optional:org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.redis.AutoConfigureDataRedis.imports b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.redis.AutoConfigureDataRedis.imports
index 3014db3633b3..2db18c955ff0 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.redis.AutoConfigureDataRedis.imports
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.redis.AutoConfigureDataRedis.imports
@@ -3,3 +3,4 @@ org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
 org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration
 org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration
 org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration
+optional:org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureJdbc.imports b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureJdbc.imports
index b3700d9cc94b..480dcff0e7c1 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureJdbc.imports
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureJdbc.imports
@@ -2,7 +2,9 @@
 org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration
 org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
 org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration
+org.springframework.boot.autoconfigure.jdbc.JdbcClientAutoConfiguration
 org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration
 org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration
 org.springframework.boot.autoconfigure.sql.init.SqlInitializationAutoConfiguration
-org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration
\ No newline at end of file
+org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration
+optional:org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.imports b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.imports
index 11ab8a552a5e..53caeea39c35 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.imports
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.imports
@@ -1,3 +1,4 @@
 # AutoConfigureTestDatabase auto-configuration imports
 org.springframework.boot.test.autoconfigure.jdbc.TestDatabaseAutoConfiguration
-org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
\ No newline at end of file
+org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
+optional:org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.jooq.AutoConfigureJooq.imports b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.jooq.AutoConfigureJooq.imports
index c35cbedeefc7..1e042e2c0858 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.jooq.AutoConfigureJooq.imports
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.jooq.AutoConfigureJooq.imports
@@ -5,4 +5,5 @@ org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConf
 org.springframework.boot.autoconfigure.jooq.JooqAutoConfiguration
 org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration
 org.springframework.boot.autoconfigure.sql.init.SqlInitializationAutoConfiguration
-org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration
\ No newline at end of file
+org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration
+optional:org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.orm.jpa.AutoConfigureDataJpa.imports b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.orm.jpa.AutoConfigureDataJpa.imports
index 22c50b61e7e9..83465fdeba7e 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.orm.jpa.AutoConfigureDataJpa.imports
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.orm.jpa.AutoConfigureDataJpa.imports
@@ -3,8 +3,10 @@ org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration
 org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration
 org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
 org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration
+org.springframework.boot.autoconfigure.jdbc.JdbcClientAutoConfiguration
 org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration
 org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration
 org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
 org.springframework.boot.autoconfigure.sql.init.SqlInitializationAutoConfiguration
-org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration
\ No newline at end of file
+org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration
+optional:org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.web.client.AutoConfigureWebClient.imports b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.web.client.AutoConfigureWebClient.imports
index c789b0b5c278..cad2d5fb96fe 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.web.client.AutoConfigureWebClient.imports
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.web.client.AutoConfigureWebClient.imports
@@ -6,4 +6,5 @@ org.springframework.boot.autoconfigure.http.codec.CodecsAutoConfiguration
 org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration
 org.springframework.boot.autoconfigure.jsonb.JsonbAutoConfiguration
 org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration
+org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration
 org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/AutoConfigurationImportedCondition.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/AutoConfigurationImportedCondition.java
index 0cf44e791d7e..8b3663f88ab2 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/AutoConfigurationImportedCondition.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/AutoConfigurationImportedCondition.java
@@ -41,7 +41,8 @@ private AutoConfigurationImportedCondition(Class<?> autoConfigurationClass) {
 	public boolean matches(ApplicationContext context) {
 		ConditionEvaluationReport report = ConditionEvaluationReport
 			.get((ConfigurableListableBeanFactory) context.getAutowireCapableBeanFactory());
-		return report.getConditionAndOutcomesBySource().containsKey(this.autoConfigurationClass.getName());
+		return report.getConditionAndOutcomesBySource().containsKey(this.autoConfigurationClass.getName())
+				|| report.getUnconditionalClasses().contains(this.autoConfigurationClass.getName());
 	}
 
 	/**
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetricsMissingIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetricsMissingIntegrationTests.java
deleted file mode 100644
index ff4e924a993a..000000000000
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetricsMissingIntegrationTests.java
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.test.autoconfigure.actuate.metrics;
-
-import io.micrometer.core.instrument.MeterRegistry;
-import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
-import io.micrometer.prometheus.PrometheusMeterRegistry;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.context.ApplicationContext;
-import org.springframework.core.env.Environment;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Integration test to verify behaviour when
- * {@link AutoConfigureMetrics @AutoConfigureMetrics} is not present on the test class.
- *
- * @author Chris Bono
- */
-@SpringBootTest
-class AutoConfigureMetricsMissingIntegrationTests {
-
-	@Test
-	void customizerRunsAndOnlyEnablesSimpleMeterRegistryWhenNoAnnotationPresent(
-			@Autowired ApplicationContext applicationContext) {
-		assertThat(applicationContext.getBean(MeterRegistry.class)).isInstanceOf(SimpleMeterRegistry.class);
-		assertThat(applicationContext.getBeansOfType(PrometheusMeterRegistry.class)).isEmpty();
-	}
-
-	@Test
-	void customizerRunsAndSetsExclusionPropertiesWhenNoAnnotationPresent(@Autowired Environment environment) {
-		assertThat(environment.getProperty("management.defaults.metrics.export.enabled")).isEqualTo("false");
-		assertThat(environment.getProperty("management.simple.metrics.export.enabled")).isEqualTo("true");
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetricsPresentIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetricsPresentIntegrationTests.java
deleted file mode 100644
index dfdef02bdb2c..000000000000
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetricsPresentIntegrationTests.java
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.test.autoconfigure.actuate.metrics;
-
-import io.micrometer.prometheus.PrometheusMeterRegistry;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.context.ApplicationContext;
-import org.springframework.core.env.Environment;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Integration test to verify behaviour when
- * {@link AutoConfigureMetrics @AutoConfigureMetrics} is present on the test class.
- *
- * @author Chris Bono
- */
-@SuppressWarnings("removal")
-@SpringBootTest
-@AutoConfigureMetrics
-@Deprecated(since = "3.0.0", forRemoval = true)
-class AutoConfigureMetricsPresentIntegrationTests {
-
-	@Test
-	void customizerDoesNotDisableAvailableMeterRegistriesWhenAnnotationPresent(
-			@Autowired ApplicationContext applicationContext) {
-		assertThat(applicationContext.getBeansOfType(PrometheusMeterRegistry.class)).hasSize(1);
-	}
-
-	@Test
-	void customizerDoesNotSetExclusionPropertiesWhenAnnotationPresent(@Autowired Environment environment) {
-		assertThat(environment.containsProperty("management.defaults.metrics.export.enabled")).isFalse();
-		assertThat(environment.containsProperty("management.simple.metrics.export.enabled")).isFalse();
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetricsSpringBootApplication.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetricsSpringBootApplication.java
deleted file mode 100644
index 31c699a44b8d..000000000000
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetricsSpringBootApplication.java
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.test.autoconfigure.actuate.metrics;
-
-import org.springframework.boot.SpringBootConfiguration;
-import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
-import org.springframework.boot.autoconfigure.SpringBootApplication;
-import org.springframework.boot.autoconfigure.cassandra.CassandraAutoConfiguration;
-import org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration;
-import org.springframework.boot.autoconfigure.mongo.MongoReactiveAutoConfiguration;
-
-/**
- * Example {@link SpringBootApplication @SpringBootApplication} for use with
- * {@link AutoConfigureMetrics @AutoConfigureMetrics} tests.
- *
- * @author Chris Bono
- */
-@SpringBootConfiguration
-@EnableAutoConfiguration(exclude = { CassandraAutoConfiguration.class, MongoAutoConfiguration.class,
-		MongoReactiveAutoConfiguration.class })
-class AutoConfigureMetricsSpringBootApplication {
-
-}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/observability/ObservabilityContextCustomizerFactoryTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/observability/ObservabilityContextCustomizerFactoryTests.java
index d8e46424c52e..c8604bf53071 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/observability/ObservabilityContextCustomizerFactoryTests.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/observability/ObservabilityContextCustomizerFactoryTests.java
@@ -18,22 +18,15 @@
 
 import java.util.Collections;
 
-import io.micrometer.tracing.Tracer;
 import org.junit.jupiter.api.Test;
 
-import org.springframework.boot.context.annotation.UserConfigurations;
-import org.springframework.boot.test.context.FilteredClassLoader;
-import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.context.ApplicationContextInitializer;
 import org.springframework.context.ConfigurableApplicationContext;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
 import org.springframework.context.support.GenericApplicationContext;
 import org.springframework.mock.env.MockEnvironment;
 import org.springframework.test.context.ContextCustomizer;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.Mockito.mock;
 
 /**
  * Tests for {@link AutoConfigureObservability} and
@@ -82,59 +75,6 @@ void shouldEnableBothWhenAnnotated() {
 		assertThatTracingIsEnabled(context);
 	}
 
-	@Test
-	void shouldRegisterNoopTracerIfTracingIsDisabled() {
-		ContextCustomizer customizer = createContextCustomizer(NoAnnotation.class);
-		ConfigurableApplicationContext context = new GenericApplicationContext();
-		applyCustomizerToContext(customizer, context);
-		context.refresh();
-		Tracer tracer = context.getBean(Tracer.class);
-		assertThat(tracer).isNotNull();
-		assertThat(tracer.nextSpan().isNoop()).isTrue();
-	}
-
-	@Test
-	void shouldNotRegisterNoopTracerIfTracingIsEnabled() {
-		ContextCustomizer customizer = createContextCustomizer(WithAnnotation.class);
-		ConfigurableApplicationContext context = new GenericApplicationContext();
-		applyCustomizerToContext(customizer, context);
-		context.refresh();
-		assertThat(context.getBeanProvider(Tracer.class).getIfAvailable()).as("Tracer bean").isNull();
-	}
-
-	@Test
-	void shouldNotRegisterNoopTracerIfMicrometerTracingIsNotPresent() throws Exception {
-		try (FilteredClassLoader filteredClassLoader = new FilteredClassLoader("io.micrometer.tracing")) {
-			ContextCustomizer customizer = createContextCustomizer(NoAnnotation.class);
-			new ApplicationContextRunner().withClassLoader(filteredClassLoader)
-				.withInitializer(applyCustomizer(customizer))
-				.run((context) -> {
-					assertThat(context).doesNotHaveBean(Tracer.class);
-					assertThatMetricsAreDisabled(context);
-					assertThatTracingIsDisabled(context);
-				});
-		}
-	}
-
-	@Test
-	void shouldBackOffOnCustomTracer() {
-		ContextCustomizer customizer = createContextCustomizer(NoAnnotation.class);
-		new ApplicationContextRunner().withConfiguration(UserConfigurations.of(CustomTracer.class))
-			.withInitializer(applyCustomizer(customizer))
-			.run((context) -> {
-				assertThat(context).hasSingleBean(Tracer.class);
-				assertThat(context).hasBean("customTracer");
-			});
-	}
-
-	@Test
-	void shouldNotRunIfAotIsEnabled() {
-		ContextCustomizer customizer = createContextCustomizer(NoAnnotation.class);
-		new ApplicationContextRunner().withSystemProperties("spring.aot.enabled:true")
-			.withInitializer(applyCustomizer(customizer))
-			.run((context) -> assertThat(context).doesNotHaveBean(Tracer.class));
-	}
-
 	@Test
 	void notEquals() {
 		ContextCustomizer customizer1 = createContextCustomizer(OnlyMetrics.class);
@@ -256,14 +196,4 @@ static class WithDisabledAnnotation {
 
 	}
 
-	@Configuration(proxyBeanMethods = false)
-	static class CustomTracer {
-
-		@Bean
-		Tracer customTracer() {
-			return mock(Tracer.class);
-		}
-
-	}
-
 }
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/cassandra/DataCassandraTestIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/cassandra/DataCassandraTestIntegrationTests.java
index c4cbf3cb6113..33e9d853b084 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/cassandra/DataCassandraTestIntegrationTests.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/cassandra/DataCassandraTestIntegrationTests.java
@@ -29,6 +29,7 @@
 import org.springframework.boot.test.autoconfigure.data.redis.ExampleService;
 import org.springframework.boot.test.context.TestConfiguration;
 import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration;
 import org.springframework.boot.testsupport.testcontainers.CassandraContainer;
 import org.springframework.context.ApplicationContext;
 import org.springframework.context.annotation.Bean;
@@ -36,6 +37,7 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.springframework.boot.test.autoconfigure.AutoConfigurationImportedCondition.importedAutoConfiguration;
 
 /**
  * Integration test for {@link DataCassandraTest @DataCassandraTest}.
@@ -84,6 +86,11 @@ void testRepository() {
 		this.exampleRepository.deleteAll();
 	}
 
+	@Test
+	void serviceConnectionAutoConfigurationWasImported() {
+		assertThat(this.applicationContext).has(importedAutoConfiguration(ServiceConnectionAutoConfiguration.class));
+	}
+
 	@TestConfiguration(proxyBeanMethods = false)
 	static class KeyspaceTestConfiguration {
 
@@ -91,7 +98,7 @@ static class KeyspaceTestConfiguration {
 		CqlSession cqlSession(CqlSessionBuilder cqlSessionBuilder) {
 			try (CqlSession session = cqlSessionBuilder.build()) {
 				session.execute("CREATE KEYSPACE IF NOT EXISTS boot_test"
-						+ "  WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 };");
+						+ " WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 };");
 			}
 			return cqlSessionBuilder.withKeyspace("boot_test").build();
 		}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/cassandra/DataCassandraTestWithIncludeFilterIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/cassandra/DataCassandraTestWithIncludeFilterIntegrationTests.java
index 05741e8a91de..7766fa22b3ff 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/cassandra/DataCassandraTestWithIncludeFilterIntegrationTests.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/cassandra/DataCassandraTestWithIncludeFilterIntegrationTests.java
@@ -77,7 +77,7 @@ static class KeyspaceTestConfiguration {
 		CqlSession cqlSession(CqlSessionBuilder cqlSessionBuilder) {
 			try (CqlSession session = cqlSessionBuilder.build()) {
 				session.execute("CREATE KEYSPACE IF NOT EXISTS boot_test"
-						+ "  WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 };");
+						+ " WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 };");
 			}
 			return cqlSessionBuilder.withKeyspace("boot_test").build();
 		}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/couchbase/DataCouchbaseTestIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/couchbase/DataCouchbaseTestIntegrationTests.java
index 950b59ad996b..2ebef2224b6f 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/couchbase/DataCouchbaseTestIntegrationTests.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/couchbase/DataCouchbaseTestIntegrationTests.java
@@ -21,18 +21,21 @@
 import org.junit.jupiter.api.Test;
 import org.testcontainers.couchbase.BucketDefinition;
 import org.testcontainers.couchbase.CouchbaseContainer;
+import org.testcontainers.couchbase.CouchbaseService;
 import org.testcontainers.junit.jupiter.Container;
 import org.testcontainers.junit.jupiter.Testcontainers;
 
 import org.springframework.beans.factory.NoSuchBeanDefinitionException;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration;
 import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
 import org.springframework.context.ApplicationContext;
 import org.springframework.data.couchbase.core.CouchbaseTemplate;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.springframework.boot.test.autoconfigure.AutoConfigurationImportedCondition.importedAutoConfiguration;
 
 /**
  * Integration test for {@link DataCouchbaseTest @DataCouchbaseTest}.
@@ -42,8 +45,8 @@
  * @author Andy Wilkinson
  * @author Phillip Webb
  */
-@DataCouchbaseTest(
-		properties = { "spring.couchbase.env.timeouts.connect=2m", "spring.data.couchbase.bucket-name=cbbucket" })
+@DataCouchbaseTest(properties = { "spring.couchbase.env.timeouts.connect=2m",
+		"spring.couchbase.env.timeouts.key-value=1m", "spring.data.couchbase.bucket-name=cbbucket" })
 @Testcontainers(disabledWithoutDocker = true)
 class DataCouchbaseTestIntegrationTests {
 
@@ -52,6 +55,7 @@ class DataCouchbaseTestIntegrationTests {
 	@Container
 	@ServiceConnection
 	static final CouchbaseContainer couchbase = new CouchbaseContainer(DockerImageNames.couchbase())
+		.withEnabledServices(CouchbaseService.KV, CouchbaseService.INDEX, CouchbaseService.QUERY)
 		.withStartupAttempts(5)
 		.withStartupTimeout(Duration.ofMinutes(10))
 		.withBucket(new BucketDefinition(BUCKET_NAME));
@@ -81,4 +85,9 @@ void testRepository() {
 		this.exampleRepository.deleteAll();
 	}
 
+	@Test
+	void serviceConnectionAutoConfigurationWasImported() {
+		assertThat(this.applicationContext).has(importedAutoConfiguration(ServiceConnectionAutoConfiguration.class));
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/couchbase/DataCouchbaseTestReactiveIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/couchbase/DataCouchbaseTestReactiveIntegrationTests.java
index 3fdc33b3ac5d..7730acd9426d 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/couchbase/DataCouchbaseTestReactiveIntegrationTests.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/couchbase/DataCouchbaseTestReactiveIntegrationTests.java
@@ -21,6 +21,7 @@
 import org.junit.jupiter.api.Test;
 import org.testcontainers.couchbase.BucketDefinition;
 import org.testcontainers.couchbase.CouchbaseContainer;
+import org.testcontainers.couchbase.CouchbaseService;
 import org.testcontainers.junit.jupiter.Container;
 import org.testcontainers.junit.jupiter.Testcontainers;
 
@@ -40,7 +41,8 @@
  * @author Andy Wilkinson
  * @author Phillip Webb
  */
-@DataCouchbaseTest(properties = "spring.data.couchbase.bucket-name=cbbucket")
+@DataCouchbaseTest(properties = { "spring.data.couchbase.bucket-name=cbbucket",
+		"spring.couchbase.env.timeouts.connect=2m", "spring.couchbase.env.timeouts.key-value=1m" })
 @Testcontainers(disabledWithoutDocker = true)
 class DataCouchbaseTestReactiveIntegrationTests {
 
@@ -49,6 +51,7 @@ class DataCouchbaseTestReactiveIntegrationTests {
 	@Container
 	@ServiceConnection
 	static final CouchbaseContainer couchbase = new CouchbaseContainer(DockerImageNames.couchbase())
+		.withEnabledServices(CouchbaseService.KV, CouchbaseService.INDEX, CouchbaseService.QUERY)
 		.withStartupAttempts(5)
 		.withStartupTimeout(Duration.ofMinutes(10))
 		.withBucket(new BucketDefinition(BUCKET_NAME));
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/couchbase/DataCouchbaseTestWithIncludeFilterIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/couchbase/DataCouchbaseTestWithIncludeFilterIntegrationTests.java
index 4931fe3990aa..7c6c89f1cfaa 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/couchbase/DataCouchbaseTestWithIncludeFilterIntegrationTests.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/couchbase/DataCouchbaseTestWithIncludeFilterIntegrationTests.java
@@ -21,6 +21,7 @@
 import org.junit.jupiter.api.Test;
 import org.testcontainers.couchbase.BucketDefinition;
 import org.testcontainers.couchbase.CouchbaseContainer;
+import org.testcontainers.couchbase.CouchbaseService;
 import org.testcontainers.junit.jupiter.Container;
 import org.testcontainers.junit.jupiter.Testcontainers;
 
@@ -41,7 +42,9 @@
  * @author Andy Wilkinson
  * @author Phillip Webb
  */
-@DataCouchbaseTest(includeFilters = @Filter(Service.class), properties = "spring.data.couchbase.bucket-name=cbbucket")
+@DataCouchbaseTest(includeFilters = @Filter(Service.class),
+		properties = { "spring.data.couchbase.bucket-name=cbbucket", "spring.couchbase.env.timeouts.connect=2m",
+				"spring.couchbase.env.timeouts.key-value=1m" })
 @Testcontainers(disabledWithoutDocker = true)
 class DataCouchbaseTestWithIncludeFilterIntegrationTests {
 
@@ -50,6 +53,7 @@ class DataCouchbaseTestWithIncludeFilterIntegrationTests {
 	@Container
 	@ServiceConnection
 	static final CouchbaseContainer couchbase = new CouchbaseContainer(DockerImageNames.couchbase())
+		.withEnabledServices(CouchbaseService.KV, CouchbaseService.INDEX, CouchbaseService.QUERY)
 		.withStartupAttempts(5)
 		.withStartupTimeout(Duration.ofMinutes(10))
 		.withBucket(new BucketDefinition(BUCKET_NAME));
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/elasticsearch/DataElasticsearchTestIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/elasticsearch/DataElasticsearchTestIntegrationTests.java
index 5f428b68d001..5cdf5a56624a 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/elasticsearch/DataElasticsearchTestIntegrationTests.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/elasticsearch/DataElasticsearchTestIntegrationTests.java
@@ -27,12 +27,14 @@
 import org.springframework.beans.factory.NoSuchBeanDefinitionException;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration;
 import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
 import org.springframework.context.ApplicationContext;
 import org.springframework.data.elasticsearch.client.elc.ElasticsearchTemplate;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.springframework.boot.test.autoconfigure.AutoConfigurationImportedCondition.importedAutoConfiguration;
 
 /**
  * Sample test for {@link DataElasticsearchTest @DataElasticsearchTest}
@@ -82,4 +84,9 @@ void testRepository() {
 		this.exampleRepository.deleteAll();
 	}
 
+	@Test
+	void serviceConnectionAutoConfigurationWasImported() {
+		assertThat(this.applicationContext).has(importedAutoConfiguration(ServiceConnectionAutoConfiguration.class));
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/jdbc/DataJdbcTestIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/jdbc/DataJdbcTestIntegrationTests.java
index 5d4eb2d60c2f..245b3a72dfa1 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/jdbc/DataJdbcTestIntegrationTests.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/jdbc/DataJdbcTestIntegrationTests.java
@@ -24,6 +24,7 @@
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
 import org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration;
 import org.springframework.context.ApplicationContext;
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.test.context.TestPropertySource;
@@ -83,4 +84,9 @@ void liquibaseAutoConfigurationWasImported() {
 		assertThat(this.applicationContext).has(importedAutoConfiguration(LiquibaseAutoConfiguration.class));
 	}
 
+	@Test
+	void serviceConnectionAutoConfigurationWasImported() {
+		assertThat(this.applicationContext).has(importedAutoConfiguration(ServiceConnectionAutoConfiguration.class));
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/mongo/DataMongoTestIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/mongo/DataMongoTestIntegrationTests.java
index 9fcb57b86ed4..aaa55491c716 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/mongo/DataMongoTestIntegrationTests.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/mongo/DataMongoTestIntegrationTests.java
@@ -26,12 +26,14 @@
 import org.springframework.beans.factory.NoSuchBeanDefinitionException;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration;
 import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
 import org.springframework.context.ApplicationContext;
 import org.springframework.data.mongodb.core.MongoTemplate;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.springframework.boot.test.autoconfigure.AutoConfigurationImportedCondition.importedAutoConfiguration;
 
 /**
  * Sample test for {@link DataMongoTest @DataMongoTest}
@@ -74,4 +76,9 @@ void didNotInjectExampleService() {
 			.isThrownBy(() -> this.applicationContext.getBean(ExampleService.class));
 	}
 
+	@Test
+	void serviceConnectionAutoConfigurationWasImported() {
+		assertThat(this.applicationContext).has(importedAutoConfiguration(ServiceConnectionAutoConfiguration.class));
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/neo4j/DataNeo4jTestIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/neo4j/DataNeo4jTestIntegrationTests.java
index b61612322cb6..0d1f95995c7b 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/neo4j/DataNeo4jTestIntegrationTests.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/neo4j/DataNeo4jTestIntegrationTests.java
@@ -26,12 +26,14 @@
 import org.springframework.beans.factory.NoSuchBeanDefinitionException;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration;
 import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
 import org.springframework.context.ApplicationContext;
 import org.springframework.data.neo4j.core.Neo4jTemplate;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.springframework.boot.test.autoconfigure.AutoConfigurationImportedCondition.importedAutoConfiguration;
 
 /**
  * Integration test for {@link DataNeo4jTest @DataNeo4jTest}.
@@ -76,4 +78,9 @@ void didNotInjectExampleService() {
 			.isThrownBy(() -> this.applicationContext.getBean(ExampleService.class));
 	}
 
+	@Test
+	void serviceConnectionAutoConfigurationWasImported() {
+		assertThat(this.applicationContext).has(importedAutoConfiguration(ServiceConnectionAutoConfiguration.class));
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/r2dbc/DataR2dbcTestIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/r2dbc/DataR2dbcTestIntegrationTests.java
index 5afe0575b615..7b8253585931 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/r2dbc/DataR2dbcTestIntegrationTests.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/r2dbc/DataR2dbcTestIntegrationTests.java
@@ -25,10 +25,12 @@
 import reactor.test.StepVerifier;
 
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration;
 import org.springframework.context.ApplicationContext;
 import org.springframework.r2dbc.core.DatabaseClient;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.boot.test.autoconfigure.AutoConfigurationImportedCondition.importedAutoConfiguration;
 
 /**
  * Integration tests for {@link DataR2dbcTest}.
@@ -65,4 +67,9 @@ void registersExampleRepository() {
 		assertThat(this.applicationContext.getBeanNamesForType(ExampleRepository.class)).isNotEmpty();
 	}
 
+	@Test
+	void serviceConnectionAutoConfigurationWasImported() {
+		assertThat(this.applicationContext).has(importedAutoConfiguration(ServiceConnectionAutoConfiguration.class));
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/redis/DataRedisTestIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/redis/DataRedisTestIntegrationTests.java
index 98331d0d2140..0018b31976fa 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/redis/DataRedisTestIntegrationTests.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/redis/DataRedisTestIntegrationTests.java
@@ -26,12 +26,14 @@
 import org.springframework.beans.factory.NoSuchBeanDefinitionException;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration;
 import org.springframework.boot.testsupport.testcontainers.RedisContainer;
 import org.springframework.context.ApplicationContext;
 import org.springframework.data.redis.core.RedisOperations;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.springframework.boot.test.autoconfigure.AutoConfigurationImportedCondition.importedAutoConfiguration;
 
 /**
  * Integration test for {@link DataRedisTest @DataRedisTest}.
@@ -80,4 +82,9 @@ void didNotInjectExampleService() {
 			.isThrownBy(() -> this.applicationContext.getBean(ExampleService.class));
 	}
 
+	@Test
+	void serviceConnectionAutoConfigurationWasImported() {
+		assertThat(this.applicationContext).has(importedAutoConfiguration(ServiceConnectionAutoConfiguration.class));
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/ExampleEntityRowMapper.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/ExampleEntityRowMapper.java
new file mode 100644
index 000000000000..5897f26716bc
--- /dev/null
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/ExampleEntityRowMapper.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.test.autoconfigure.jdbc;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+import org.springframework.jdbc.core.RowMapper;
+
+/**
+ * @author Stephane Nicoll
+ */
+class ExampleEntityRowMapper implements RowMapper<ExampleEntity> {
+
+	@Override
+	public ExampleEntity mapRow(ResultSet rs, int rowNum) throws SQLException {
+		int id = rs.getInt("id");
+		String name = rs.getString("name");
+		return new ExampleEntity(id, name);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/ExampleJdbcClientRepository.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/ExampleJdbcClientRepository.java
new file mode 100644
index 000000000000..a104017292ab
--- /dev/null
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/ExampleJdbcClientRepository.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2023-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.test.autoconfigure.jdbc;
+
+import java.util.Collection;
+
+import org.springframework.jdbc.core.simple.JdbcClient;
+
+/**
+ * Example repository used with {@link JdbcClient JdbcClient} and
+ * {@link JdbcTest @JdbcTest} tests.
+ *
+ * @author Yanming Zhou
+ */
+class ExampleJdbcClientRepository {
+
+	private static final ExampleEntityRowMapper ROW_MAPPER = new ExampleEntityRowMapper();
+
+	private final JdbcClient jdbcClient;
+
+	ExampleJdbcClientRepository(JdbcClient jdbcClient) {
+		this.jdbcClient = jdbcClient;
+	}
+
+	void save(ExampleEntity entity) {
+		this.jdbcClient.sql("insert into example (id, name) values (:id, :name)")
+			.param("id", entity.getId())
+			.param("name", entity.getName())
+			.update();
+	}
+
+	ExampleEntity findById(int id) {
+		return this.jdbcClient.sql("select id, name from example where id = :id")
+			.param("id", id)
+			.query(ROW_MAPPER)
+			.single();
+	}
+
+	Collection<ExampleEntity> findAll() {
+		return this.jdbcClient.sql("select id, name from example").query(ROW_MAPPER).list();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/ExampleRepository.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/ExampleRepository.java
index 9ac32aeba4d4..346a524e7de1 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/ExampleRepository.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/ExampleRepository.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,14 +16,11 @@
 
 package org.springframework.boot.test.autoconfigure.jdbc;
 
-import java.sql.ResultSet;
-import java.sql.SQLException;
 import java.util.Collection;
 
 import jakarta.transaction.Transactional;
 
 import org.springframework.jdbc.core.JdbcTemplate;
-import org.springframework.jdbc.core.RowMapper;
 import org.springframework.stereotype.Repository;
 
 /**
@@ -32,38 +29,27 @@
  * @author Stephane Nicoll
  */
 @Repository
-public class ExampleRepository {
+class ExampleRepository {
 
 	private static final ExampleEntityRowMapper ROW_MAPPER = new ExampleEntityRowMapper();
 
 	private final JdbcTemplate jdbcTemplate;
 
-	public ExampleRepository(JdbcTemplate jdbcTemplate) {
+	ExampleRepository(JdbcTemplate jdbcTemplate) {
 		this.jdbcTemplate = jdbcTemplate;
 	}
 
 	@Transactional
-	public void save(ExampleEntity entity) {
+	void save(ExampleEntity entity) {
 		this.jdbcTemplate.update("insert into example (id, name) values (?, ?)", entity.getId(), entity.getName());
 	}
 
-	public ExampleEntity findById(int id) {
+	ExampleEntity findById(int id) {
 		return this.jdbcTemplate.queryForObject("select id, name from example where id =?", ROW_MAPPER, id);
 	}
 
-	public Collection<ExampleEntity> findAll() {
+	Collection<ExampleEntity> findAll() {
 		return this.jdbcTemplate.query("select id, name from example", ROW_MAPPER);
 	}
 
-	static class ExampleEntityRowMapper implements RowMapper<ExampleEntity> {
-
-		@Override
-		public ExampleEntity mapRow(ResultSet rs, int rowNum) throws SQLException {
-			int id = rs.getInt("id");
-			String name = rs.getString("name");
-			return new ExampleEntity(id, name);
-		}
-
-	}
-
 }
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/JdbcTestIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/JdbcTestIntegrationTests.java
index 15c1ca839ef3..44d6fc2ed241 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/JdbcTestIntegrationTests.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/JdbcTestIntegrationTests.java
@@ -26,8 +26,10 @@
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
 import org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration;
 import org.springframework.context.ApplicationContext;
 import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.simple.JdbcClient;
 import org.springframework.test.context.TestPropertySource;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -38,12 +40,16 @@
  * Integration tests for {@link JdbcTest @JdbcTest}.
  *
  * @author Stephane Nicoll
+ * @author Yanming Zhou
  */
 @JdbcTest
 @TestPropertySource(
 		properties = "spring.sql.init.schemaLocations=classpath:org/springframework/boot/test/autoconfigure/jdbc/schema.sql")
 class JdbcTestIntegrationTests {
 
+	@Autowired
+	private JdbcClient jdbcClient;
+
 	@Autowired
 	private JdbcTemplate jdbcTemplate;
 
@@ -53,13 +59,30 @@ class JdbcTestIntegrationTests {
 	@Autowired
 	private ApplicationContext applicationContext;
 
+	@Test
+	void testJdbcClient() {
+		ExampleJdbcClientRepository repository = new ExampleJdbcClientRepository(this.jdbcClient);
+		repository.save(new ExampleEntity(1, "John"));
+		ExampleEntity entity = repository.findById(1);
+		assertThat(entity.getId()).isOne();
+		assertThat(entity.getName()).isEqualTo("John");
+		Collection<ExampleEntity> entities = repository.findAll();
+		assertThat(entities).hasSize(1);
+		entity = entities.iterator().next();
+		assertThat(entity.getId()).isOne();
+		assertThat(entity.getName()).isEqualTo("John");
+	}
+
 	@Test
 	void testJdbcTemplate() {
 		ExampleRepository repository = new ExampleRepository(this.jdbcTemplate);
 		repository.save(new ExampleEntity(1, "John"));
+		ExampleEntity entity = repository.findById(1);
+		assertThat(entity.getId()).isOne();
+		assertThat(entity.getName()).isEqualTo("John");
 		Collection<ExampleEntity> entities = repository.findAll();
 		assertThat(entities).hasSize(1);
-		ExampleEntity entity = entities.iterator().next();
+		entity = entities.iterator().next();
 		assertThat(entity.getId()).isOne();
 		assertThat(entity.getName()).isEqualTo("John");
 	}
@@ -86,4 +109,9 @@ void liquibaseAutoConfigurationWasImported() {
 		assertThat(this.applicationContext).has(importedAutoConfiguration(LiquibaseAutoConfiguration.class));
 	}
 
+	@Test
+	void serviceConnectionAutoConfigurationWasImported() {
+		assertThat(this.applicationContext).has(importedAutoConfiguration(ServiceConnectionAutoConfiguration.class));
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jooq/JooqTestIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jooq/JooqTestIntegrationTests.java
index da8bda872f97..b842c91b0ac9 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jooq/JooqTestIntegrationTests.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jooq/JooqTestIntegrationTests.java
@@ -28,6 +28,7 @@
 import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
 import org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration;
 import org.springframework.boot.test.autoconfigure.orm.jpa.ExampleComponent;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration;
 import org.springframework.context.ApplicationContext;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -85,4 +86,9 @@ void cacheAutoConfigurationWasImported() {
 		assertThat(this.applicationContext).has(importedAutoConfiguration(CacheAutoConfiguration.class));
 	}
 
+	@Test
+	void serviceConnectionAutoConfigurationWasImported() {
+		assertThat(this.applicationContext).has(importedAutoConfiguration(ServiceConnectionAutoConfiguration.class));
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/orm/jpa/DataJpaTestIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/orm/jpa/DataJpaTestIntegrationTests.java
index e3e54de73f83..f1f84cf1fe81 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/orm/jpa/DataJpaTestIntegrationTests.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/orm/jpa/DataJpaTestIntegrationTests.java
@@ -24,9 +24,11 @@
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
 import org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration;
 import org.springframework.context.ApplicationContext;
 import org.springframework.data.repository.config.BootstrapMode;
 import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.simple.JdbcClient;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -38,6 +40,7 @@
  * @author Phillip Webb
  * @author Andy Wilkinson
  * @author Scott Frederick
+ * @author Yanming Zhou
  */
 @DataJpaTest
 class DataJpaTestIntegrationTests {
@@ -45,6 +48,9 @@ class DataJpaTestIntegrationTests {
 	@Autowired
 	private TestEntityManager entities;
 
+	@Autowired
+	private JdbcClient jdbcClient;
+
 	@Autowired
 	private JdbcTemplate jdbcTemplate;
 
@@ -71,8 +77,10 @@ void testEntityManagerPersistAndGetId() {
 		Long id = this.entities.persistAndGetId(new ExampleEntity("spring", "123"), Long.class);
 		this.entities.flush();
 		assertThat(id).isNotNull();
-		String reference = this.jdbcTemplate.queryForObject("SELECT REFERENCE FROM EXAMPLE_ENTITY WHERE ID = ?",
-				String.class, id);
+		String sql = "SELECT REFERENCE FROM EXAMPLE_ENTITY WHERE ID = ?";
+		String reference = this.jdbcTemplate.queryForObject(sql, String.class, id);
+		assertThat(reference).isEqualTo("123");
+		reference = this.jdbcClient.sql(sql).param(id).query(String.class).single();
 		assertThat(reference).isEqualTo("123");
 	}
 
@@ -107,6 +115,11 @@ void liquibaseAutoConfigurationWasImported() {
 		assertThat(this.applicationContext).has(importedAutoConfiguration(LiquibaseAutoConfiguration.class));
 	}
 
+	@Test
+	void serviceConnectionAutoConfigurationWasImported() {
+		assertThat(this.applicationContext).has(importedAutoConfiguration(ServiceConnectionAutoConfiguration.class));
+	}
+
 	@Test
 	void bootstrapModeIsDefaultByDefault() {
 		assertThat(this.applicationContext.getEnvironment().getProperty("spring.data.jpa.repositories.bootstrap-mode"))
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AnotherExampleRestClient.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AnotherExampleRestClient.java
deleted file mode 100644
index e7452ca904e4..000000000000
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AnotherExampleRestClient.java
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * Copyright 2012-2020 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.test.autoconfigure.web.client;
-
-import org.springframework.boot.web.client.RestTemplateBuilder;
-import org.springframework.stereotype.Service;
-import org.springframework.web.client.RestTemplate;
-
-/**
- * A second example web client used with {@link RestClientTest @RestClientTest} tests.
- *
- * @author Phillip Webb
- */
-@Service
-public class AnotherExampleRestClient {
-
-	private final RestTemplate restTemplate;
-
-	public AnotherExampleRestClient(RestTemplateBuilder builder) {
-		this.restTemplate = builder.rootUri("https://example.com").build();
-	}
-
-	protected RestTemplate getRestTemplate() {
-		return this.restTemplate;
-	}
-
-	public String test() {
-		return this.restTemplate.getForEntity("/test", String.class).getBody();
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AnotherExampleRestClientService.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AnotherExampleRestClientService.java
new file mode 100644
index 000000000000..29d459100f41
--- /dev/null
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AnotherExampleRestClientService.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.test.autoconfigure.web.client;
+
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.client.RestClient.Builder;
+
+/**
+ * A second example web client used with {@link RestClientTest @RestClientTest} tests.
+ *
+ * @author Scott Frederick
+ */
+@Service
+public class AnotherExampleRestClientService {
+
+	private final Builder builder;
+
+	private final RestClient restClient;
+
+	public AnotherExampleRestClientService(RestClient.Builder builder) {
+		this.builder = builder;
+		this.restClient = builder.baseUrl("https://example.com").build();
+	}
+
+	protected Builder getRestClientBuilder() {
+		return this.builder;
+	}
+
+	public String test() {
+		return this.restClient.get().uri("/test").retrieve().toEntity(String.class).getBody();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AnotherExampleRestTemplateService.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AnotherExampleRestTemplateService.java
new file mode 100644
index 000000000000..a781d7cc80cc
--- /dev/null
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AnotherExampleRestTemplateService.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.test.autoconfigure.web.client;
+
+import org.springframework.boot.web.client.RestTemplateBuilder;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+/**
+ * A second example web client used with {@link RestClientTest @RestClientTest} tests.
+ *
+ * @author Phillip Webb
+ */
+@Service
+public class AnotherExampleRestTemplateService {
+
+	private final RestTemplate restTemplate;
+
+	public AnotherExampleRestTemplateService(RestTemplateBuilder builder) {
+		this.restTemplate = builder.rootUri("https://example.com").build();
+	}
+
+	protected RestTemplate getRestTemplate() {
+		return this.restTemplate;
+	}
+
+	public String test() {
+		return this.restTemplate.getForEntity("/test", String.class).getBody();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerEnabledFalseIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerEnabledFalseIntegrationTests.java
index 68c31ee5bd0e..fc55c4ad9974 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerEnabledFalseIntegrationTests.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerEnabledFalseIntegrationTests.java
@@ -20,6 +20,7 @@
 
 import org.springframework.beans.factory.NoSuchBeanDefinitionException;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.web.client.MockServerRestClientCustomizer;
 import org.springframework.boot.test.web.client.MockServerRestTemplateCustomizer;
 import org.springframework.context.ApplicationContext;
 
@@ -43,6 +44,8 @@ class AutoConfigureMockRestServiceServerEnabledFalseIntegrationTests {
 	void mockServerRestTemplateCustomizerShouldNotBeRegistered() {
 		assertThatExceptionOfType(NoSuchBeanDefinitionException.class)
 			.isThrownBy(() -> this.applicationContext.getBean(MockServerRestTemplateCustomizer.class));
+		assertThatExceptionOfType(NoSuchBeanDefinitionException.class)
+			.isThrownBy(() -> this.applicationContext.getBean(MockServerRestClientCustomizer.class));
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerWithRestClientIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerWithRestClientIntegrationTests.java
new file mode 100644
index 000000000000..7e7b5adcbd46
--- /dev/null
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerWithRestClientIntegrationTests.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.test.autoconfigure.web.client;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.autoconfigure.cassandra.CassandraAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.client.RestClient.Builder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+/**
+ * Tests for
+ * {@link AutoConfigureMockRestServiceServer @AutoConfigureMockRestServiceServer} with a
+ * {@link RestClient} configured with a base URL.
+ *
+ * @author Scott Frederick
+ */
+@SpringBootTest
+@AutoConfigureMockRestServiceServer
+class AutoConfigureMockRestServiceServerWithRestClientIntegrationTests {
+
+	@Autowired
+	private RestClient restClient;
+
+	@Autowired
+	private MockRestServiceServer server;
+
+	@Test
+	void mockServerExpectationsAreMatched() {
+		this.server.expect(requestTo("/rest/test")).andRespond(withSuccess("hello", MediaType.TEXT_HTML));
+		ResponseEntity<String> entity = this.restClient.get().uri("/test").retrieve().toEntity(String.class);
+		assertThat(entity.getBody()).isEqualTo("hello");
+	}
+
+	@EnableAutoConfiguration(exclude = CassandraAutoConfiguration.class)
+	@Configuration(proxyBeanMethods = false)
+	static class RootUriConfiguration {
+
+		@Bean
+		RestClient restClient(Builder restClientBuilder) {
+			return restClientBuilder.baseUrl("/rest").build();
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerWithRestTemplateRootUriIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerWithRestTemplateRootUriIntegrationTests.java
new file mode 100644
index 000000000000..fabdbf9602ad
--- /dev/null
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerWithRestTemplateRootUriIntegrationTests.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.test.autoconfigure.web.client;
+
+import io.micrometer.core.instrument.MeterRegistry;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.autoconfigure.cassandra.CassandraAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.web.client.RestTemplateBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.web.client.RestTemplate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+/**
+ * Tests for
+ * {@link AutoConfigureMockRestServiceServer @AutoConfigureMockRestServiceServer} with a
+ * {@link RestTemplate} configured with a root URI.
+ *
+ * @author Andy Wilkinson
+ */
+@SpringBootTest
+@AutoConfigureMockRestServiceServer
+class AutoConfigureMockRestServiceServerWithRestTemplateRootUriIntegrationTests {
+
+	@Autowired
+	private RestTemplate restTemplate;
+
+	@Autowired
+	private MockRestServiceServer server;
+
+	@Autowired
+	MeterRegistry meterRegistry;
+
+	@Test
+	void whenRestTemplateAppliesARootUriThenMockServerExpectationsAreStillMatched() {
+		this.server.expect(requestTo("/test")).andRespond(withSuccess("hello", MediaType.TEXT_HTML));
+		ResponseEntity<String> entity = this.restTemplate.getForEntity("/test", String.class);
+		assertThat(entity.getBody()).isEqualTo("hello");
+		assertThat(this.meterRegistry.find("http.client.requests").tag("uri", "/test").timer()).isNotNull();
+	}
+
+	@EnableAutoConfiguration(exclude = CassandraAutoConfiguration.class)
+	@Configuration(proxyBeanMethods = false)
+	static class RootUriConfiguration {
+
+		@Bean
+		RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
+			return restTemplateBuilder.rootUri("/rest").build();
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerWithRootUriIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerWithRootUriIntegrationTests.java
deleted file mode 100644
index 2ac9b41f4144..000000000000
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerWithRootUriIntegrationTests.java
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.test.autoconfigure.web.client;
-
-import io.micrometer.core.instrument.MeterRegistry;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
-import org.springframework.boot.autoconfigure.cassandra.CassandraAutoConfiguration;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.boot.web.client.RestTemplateBuilder;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.http.MediaType;
-import org.springframework.http.ResponseEntity;
-import org.springframework.test.web.client.MockRestServiceServer;
-import org.springframework.web.client.RestTemplate;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
-import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
-
-/**
- * Tests for
- * {@link AutoConfigureMockRestServiceServer @AutoConfigureMockRestServiceServer} with a
- * {@link RestTemplate} configured with a root URI.
- *
- * @author Andy Wilkinson
- */
-@SpringBootTest
-@AutoConfigureMockRestServiceServer
-class AutoConfigureMockRestServiceServerWithRootUriIntegrationTests {
-
-	@Autowired
-	private RestTemplate restTemplate;
-
-	@Autowired
-	private MockRestServiceServer server;
-
-	@Autowired
-	MeterRegistry meterRegistry;
-
-	@Test
-	void whenRestTemplateAppliesARootUriThenMockServerExpectationsAreStillMatched() {
-		this.server.expect(requestTo("/test")).andRespond(withSuccess("hello", MediaType.TEXT_HTML));
-		ResponseEntity<String> entity = this.restTemplate.getForEntity("/test", String.class);
-		assertThat(entity.getBody()).isEqualTo("hello");
-		assertThat(this.meterRegistry.find("http.client.requests").tag("uri", "/test").timer()).isNotNull();
-	}
-
-	@EnableAutoConfiguration(exclude = CassandraAutoConfiguration.class)
-	@Configuration(proxyBeanMethods = false)
-	static class RootUriConfiguration {
-
-		@Bean
-		RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
-			return restTemplateBuilder.rootUri("/rest").build();
-		}
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/ExampleRestClient.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/ExampleRestClient.java
deleted file mode 100644
index 06b923f7acbf..000000000000
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/ExampleRestClient.java
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.test.autoconfigure.web.client;
-
-import org.springframework.boot.web.client.RestTemplateBuilder;
-import org.springframework.stereotype.Service;
-import org.springframework.web.client.RestTemplate;
-
-/**
- * Example web client used with {@link RestClientTest @RestClientTest} tests.
- *
- * @author Phillip Webb
- */
-@Service
-public class ExampleRestClient {
-
-	private final RestTemplate restTemplate;
-
-	public ExampleRestClient(RestTemplateBuilder builder) {
-		this.restTemplate = builder.rootUri("https://example.com").build();
-	}
-
-	protected RestTemplate getRestTemplate() {
-		return this.restTemplate;
-	}
-
-	public String test() {
-		return this.restTemplate.getForEntity("/test", String.class).getBody();
-	}
-
-	public void testPostWithBody(String body) {
-		this.restTemplate.postForObject("/test", body, String.class);
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/ExampleRestClientService.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/ExampleRestClientService.java
new file mode 100644
index 000000000000..f4e4c922a860
--- /dev/null
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/ExampleRestClientService.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.test.autoconfigure.web.client;
+
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.client.RestClient.Builder;
+
+/**
+ * Example web client using {@code RestClient} with {@link RestClientTest @RestClientTest}
+ * tests.
+ *
+ * @author Scott Frederick
+ */
+@Service
+public class ExampleRestClientService {
+
+	private final Builder builder;
+
+	private final RestClient restClient;
+
+	public ExampleRestClientService(RestClient.Builder builder) {
+		this.builder = builder;
+		this.restClient = builder.baseUrl("https://example.com").build();
+	}
+
+	protected Builder getRestClientBuilder() {
+		return this.builder;
+	}
+
+	public String test() {
+		return this.restClient.get().uri("/test").retrieve().toEntity(String.class).getBody();
+	}
+
+	public void testPostWithBody(String body) {
+		this.restClient.post().uri("/test").body(body).retrieve().toBodilessEntity();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/ExampleRestTemplateService.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/ExampleRestTemplateService.java
new file mode 100644
index 000000000000..95f55211753c
--- /dev/null
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/ExampleRestTemplateService.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.test.autoconfigure.web.client;
+
+import org.springframework.boot.web.client.RestTemplateBuilder;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+/**
+ * Example web client using {@code RestTemplate} with
+ * {@link RestClientTest @RestClientTest} tests.
+ *
+ * @author Phillip Webb
+ */
+@Service
+public class ExampleRestTemplateService {
+
+	private final RestTemplate restTemplate;
+
+	public ExampleRestTemplateService(RestTemplateBuilder builder) {
+		this.restTemplate = builder.rootUri("https://example.com").build();
+	}
+
+	protected RestTemplate getRestTemplate() {
+		return this.restTemplate;
+	}
+
+	public String test() {
+		return this.restTemplate.getForEntity("/test", String.class).getBody();
+	}
+
+	public void testPostWithBody(String body) {
+		this.restTemplate.postForObject("/test", body, String.class);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientRestIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientRestIntegrationTests.java
deleted file mode 100644
index 3966b197e28c..000000000000
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientRestIntegrationTests.java
+++ /dev/null
@@ -1,64 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.test.autoconfigure.web.client;
-
-import org.junit.jupiter.api.Test;
-
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.http.MediaType;
-import org.springframework.test.web.client.MockRestServiceServer;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
-import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
-import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
-
-/**
- * Tests for {@link RestClientTest @RestClientTest} gets reset after test methods.
- *
- * @author Phillip Webb
- */
-@RestClientTest(ExampleRestClient.class)
-class RestClientRestIntegrationTests {
-
-	@Autowired
-	private MockRestServiceServer server;
-
-	@Autowired
-	private ExampleRestClient client;
-
-	@Test
-	void mockServerCall1() {
-		this.server.expect(requestTo("/test")).andRespond(withSuccess("1", MediaType.TEXT_HTML));
-		assertThat(this.client.test()).isEqualTo("1");
-	}
-
-	@Test
-	void mockServerCall2() {
-		this.server.expect(requestTo("/test")).andRespond(withSuccess("2", MediaType.TEXT_HTML));
-		assertThat(this.client.test()).isEqualTo("2");
-	}
-
-	@Test
-	void mockServerCallWithContent() {
-		this.server.expect(requestTo("/test"))
-			.andExpect(content().string("test"))
-			.andRespond(withSuccess("1", MediaType.TEXT_HTML));
-		this.client.testPostWithBody("test");
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestNoComponentIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestNoComponentIntegrationTests.java
index 6ed3e63e46d5..3da4654a6b8d 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestNoComponentIntegrationTests.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestNoComponentIntegrationTests.java
@@ -50,7 +50,7 @@ class RestClientTestNoComponentIntegrationTests {
 	@Test
 	void exampleRestClientIsNotInjected() {
 		assertThatExceptionOfType(NoSuchBeanDefinitionException.class)
-			.isThrownBy(() -> this.applicationContext.getBean(ExampleRestClient.class));
+			.isThrownBy(() -> this.applicationContext.getBean(ExampleRestTemplateService.class));
 	}
 
 	@Test
@@ -61,7 +61,7 @@ void examplePropertiesIsNotInjected() {
 
 	@Test
 	void manuallyCreateBean() {
-		ExampleRestClient client = new ExampleRestClient(this.restTemplateBuilder);
+		ExampleRestTemplateService client = new ExampleRestTemplateService(this.restTemplateBuilder);
 		this.server.expect(requestTo("/test")).andRespond(withSuccess("hello", MediaType.TEXT_HTML));
 		assertThat(client.test()).isEqualTo("hello");
 	}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestClientIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestClientIntegrationTests.java
new file mode 100644
index 000000000000..098f6456a9b5
--- /dev/null
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestClientIntegrationTests.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.test.autoconfigure.web.client;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.web.client.RestClient;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+/**
+ * Tests for {@link RestClientTest @RestClientTest} with a {@link RestClient}.
+ *
+ * @author Scott Frederick
+ */
+@RestClientTest(ExampleRestClientService.class)
+class RestClientTestRestClientIntegrationTests {
+
+	@Autowired
+	private MockRestServiceServer server;
+
+	@Autowired
+	private ExampleRestClientService client;
+
+	@Test
+	void mockServerCall1() {
+		this.server.expect(requestTo(uri("/test"))).andRespond(withSuccess("1", MediaType.TEXT_HTML));
+		assertThat(this.client.test()).isEqualTo("1");
+	}
+
+	@Test
+	void mockServerCall2() {
+		this.server.expect(requestTo(uri("/test"))).andRespond(withSuccess("2", MediaType.TEXT_HTML));
+		assertThat(this.client.test()).isEqualTo("2");
+	}
+
+	@Test
+	void mockServerCallWithContent() {
+		this.server.expect(requestTo(uri("/test")))
+			.andExpect(content().string("test"))
+			.andRespond(withSuccess("1", MediaType.TEXT_HTML));
+		this.client.testPostWithBody("test");
+	}
+
+	private static String uri(String path) {
+		return "https://example.com" + path;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestClientTwoComponentsIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestClientTwoComponentsIntegrationTests.java
new file mode 100644
index 000000000000..15695607fe99
--- /dev/null
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestClientTwoComponentsIntegrationTests.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.test.autoconfigure.web.client;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.web.client.MockServerRestClientCustomizer;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.client.MockRestServiceServer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+/**
+ * Tests for {@link RestClientTest @RestClientTest} with two {@code RestClient} clients.
+ *
+ * @author Phillip Webb
+ * @author Scott Frederick
+ */
+@RestClientTest({ ExampleRestClientService.class, AnotherExampleRestClientService.class })
+class RestClientTestRestClientTwoComponentsIntegrationTests {
+
+	@Autowired
+	private ExampleRestClientService client1;
+
+	@Autowired
+	private AnotherExampleRestClientService client2;
+
+	@Autowired
+	private MockServerRestClientCustomizer customizer;
+
+	@Autowired
+	private MockRestServiceServer server;
+
+	@Test
+	void serverShouldNotWork() {
+		assertThatIllegalStateException().isThrownBy(
+				() -> this.server.expect(requestTo(uri("/test"))).andRespond(withSuccess("hello", MediaType.TEXT_HTML)))
+			.withMessageContaining("Unable to use auto-configured");
+	}
+
+	@Test
+	void client1RestCallViaCustomizer() {
+		this.customizer.getServer(this.client1.getRestClientBuilder())
+			.expect(requestTo(uri("/test")))
+			.andRespond(withSuccess("hello", MediaType.TEXT_HTML));
+		assertThat(this.client1.test()).isEqualTo("hello");
+	}
+
+	@Test
+	void client2RestCallViaCustomizer() {
+		this.customizer.getServer(this.client2.getRestClientBuilder())
+			.expect(requestTo(uri("/test")))
+			.andRespond(withSuccess("there", MediaType.TEXT_HTML));
+		assertThat(this.client2.test()).isEqualTo("there");
+	}
+
+	private static String uri(String path) {
+		return "https://example.com" + path;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestTemplateIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestTemplateIntegrationTests.java
new file mode 100644
index 000000000000..7f92f7550070
--- /dev/null
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestTemplateIntegrationTests.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.test.autoconfigure.web.client;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.client.MockRestServiceServer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+/**
+ * Tests for {@link RestClientTest @RestClientTest} gets reset after test methods.
+ *
+ * @author Phillip Webb
+ */
+@RestClientTest(ExampleRestTemplateService.class)
+class RestClientTestRestTemplateIntegrationTests {
+
+	@Autowired
+	private MockRestServiceServer server;
+
+	@Autowired
+	private ExampleRestTemplateService client;
+
+	@Test
+	void mockServerCall1() {
+		this.server.expect(requestTo("/test")).andRespond(withSuccess("1", MediaType.TEXT_HTML));
+		assertThat(this.client.test()).isEqualTo("1");
+	}
+
+	@Test
+	void mockServerCall2() {
+		this.server.expect(requestTo("/test")).andRespond(withSuccess("2", MediaType.TEXT_HTML));
+		assertThat(this.client.test()).isEqualTo("2");
+	}
+
+	@Test
+	void mockServerCallWithContent() {
+		this.server.expect(requestTo("/test"))
+			.andExpect(content().string("test"))
+			.andRespond(withSuccess("1", MediaType.TEXT_HTML));
+		this.client.testPostWithBody("test");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestTemplateTwoComponentsIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestTemplateTwoComponentsIntegrationTests.java
new file mode 100644
index 000000000000..c106df3776fc
--- /dev/null
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestTemplateTwoComponentsIntegrationTests.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.test.autoconfigure.web.client;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.web.client.MockServerRestTemplateCustomizer;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.client.MockRestServiceServer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+/**
+ * Tests for {@link RestClientTest @RestClientTest} with two {@code RestTemplate} clients.
+ *
+ * @author Phillip Webb
+ */
+@RestClientTest({ ExampleRestTemplateService.class, AnotherExampleRestTemplateService.class })
+class RestClientTestRestTemplateTwoComponentsIntegrationTests {
+
+	@Autowired
+	private ExampleRestTemplateService client1;
+
+	@Autowired
+	private AnotherExampleRestTemplateService client2;
+
+	@Autowired
+	private MockServerRestTemplateCustomizer customizer;
+
+	@Autowired
+	private MockRestServiceServer server;
+
+	@Test
+	void serverShouldNotWork() {
+		assertThatIllegalStateException()
+			.isThrownBy(
+					() -> this.server.expect(requestTo("/test")).andRespond(withSuccess("hello", MediaType.TEXT_HTML)))
+			.withMessageContaining("Unable to use auto-configured");
+	}
+
+	@Test
+	void client1RestCallViaCustomizer() {
+		this.customizer.getServer(this.client1.getRestTemplate())
+			.expect(requestTo("/test"))
+			.andRespond(withSuccess("hello", MediaType.TEXT_HTML));
+		assertThat(this.client1.test()).isEqualTo("hello");
+	}
+
+	@Test
+	void client2RestCallViaCustomizer() {
+		this.customizer.getServer(this.client2.getRestTemplate())
+			.expect(requestTo("/test"))
+			.andRespond(withSuccess("there", MediaType.TEXT_HTML));
+		assertThat(this.client2.test()).isEqualTo("there");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestTwoComponentsIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestTwoComponentsIntegrationTests.java
deleted file mode 100644
index 08e00159c61a..000000000000
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestTwoComponentsIntegrationTests.java
+++ /dev/null
@@ -1,75 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.test.autoconfigure.web.client;
-
-import org.junit.jupiter.api.Test;
-
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.web.client.MockServerRestTemplateCustomizer;
-import org.springframework.http.MediaType;
-import org.springframework.test.web.client.MockRestServiceServer;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
-import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
-import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
-
-/**
- * Tests for {@link RestClientTest @RestClientTest} with two clients.
- *
- * @author Phillip Webb
- */
-@RestClientTest({ ExampleRestClient.class, AnotherExampleRestClient.class })
-class RestClientTestTwoComponentsIntegrationTests {
-
-	@Autowired
-	private ExampleRestClient client1;
-
-	@Autowired
-	private AnotherExampleRestClient client2;
-
-	@Autowired
-	private MockServerRestTemplateCustomizer customizer;
-
-	@Autowired
-	private MockRestServiceServer server;
-
-	@Test
-	void serverShouldNotWork() {
-		assertThatIllegalStateException()
-			.isThrownBy(
-					() -> this.server.expect(requestTo("/test")).andRespond(withSuccess("hello", MediaType.TEXT_HTML)))
-			.withMessageContaining("Unable to use auto-configured");
-	}
-
-	@Test
-	void client1RestCallViaCustomizer() {
-		this.customizer.getServer(this.client1.getRestTemplate())
-			.expect(requestTo("/test"))
-			.andRespond(withSuccess("hello", MediaType.TEXT_HTML));
-		assertThat(this.client1.test()).isEqualTo("hello");
-	}
-
-	@Test
-	void client2RestCallViaCustomizer() {
-		this.customizer.getServer(this.client2.getRestTemplate())
-			.expect(requestTo("/test"))
-			.andRespond(withSuccess("there", MediaType.TEXT_HTML));
-		assertThat(this.client2.test()).isEqualTo("there");
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithComponentIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithComponentIntegrationTests.java
deleted file mode 100644
index 09ba31463e93..000000000000
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithComponentIntegrationTests.java
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.test.autoconfigure.web.client;
-
-import org.junit.jupiter.api.Test;
-
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.http.MediaType;
-import org.springframework.test.web.client.MockRestServiceServer;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
-import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
-
-/**
- * Tests for {@link RestClientTest @RestClientTest} with a single client.
- *
- * @author Phillip Webb
- */
-@RestClientTest(ExampleRestClient.class)
-class RestClientTestWithComponentIntegrationTests {
-
-	@Autowired
-	private MockRestServiceServer server;
-
-	@Autowired
-	private ExampleRestClient client;
-
-	@Test
-	void mockServerCall() {
-		this.server.expect(requestTo("/test")).andRespond(withSuccess("hello", MediaType.TEXT_HTML));
-		assertThat(this.client.test()).isEqualTo("hello");
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithRestClientComponentIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithRestClientComponentIntegrationTests.java
new file mode 100644
index 000000000000..954ecfbb1bb5
--- /dev/null
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithRestClientComponentIntegrationTests.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.test.autoconfigure.web.client;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.client.MockRestServiceServer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+/**
+ * Tests for {@link RestClientTest @RestClientTest} with a single client using
+ * {@code RestClient}.
+ *
+ * @author Phillip Webb
+ * @author Scott Frederick
+ */
+@RestClientTest(ExampleRestClientService.class)
+class RestClientTestWithRestClientComponentIntegrationTests {
+
+	@Autowired
+	private MockRestServiceServer server;
+
+	@Autowired
+	private ExampleRestClientService client;
+
+	@Test
+	void mockServerCall() {
+		this.server.expect(requestTo("https://example.com/test")).andRespond(withSuccess("hello", MediaType.TEXT_HTML));
+		assertThat(this.client.test()).isEqualTo("hello");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithRestTemplateComponentIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithRestTemplateComponentIntegrationTests.java
new file mode 100644
index 000000000000..f495765f1823
--- /dev/null
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithRestTemplateComponentIntegrationTests.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.test.autoconfigure.web.client;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.client.MockRestServiceServer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+/**
+ * Tests for {@link RestClientTest @RestClientTest} with a single client using
+ * {@code RestTemplate}.
+ *
+ * @author Phillip Webb
+ */
+@RestClientTest(ExampleRestTemplateService.class)
+class RestClientTestWithRestTemplateComponentIntegrationTests {
+
+	@Autowired
+	private MockRestServiceServer server;
+
+	@Autowired
+	private ExampleRestTemplateService client;
+
+	@Test
+	void mockServerCall() {
+		this.server.expect(requestTo("/test")).andRespond(withSuccess("hello", MediaType.TEXT_HTML));
+		assertThat(this.client.test()).isEqualTo("hello");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithoutJacksonIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithoutJacksonIntegrationTests.java
index b6aa993467b5..250f50f5d183 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithoutJacksonIntegrationTests.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithoutJacksonIntegrationTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -34,14 +34,14 @@
  * @author Andy Wilkinson
  */
 @ClassPathExclusions("jackson-*.jar")
-@RestClientTest(ExampleRestClient.class)
+@RestClientTest(ExampleRestTemplateService.class)
 class RestClientTestWithoutJacksonIntegrationTests {
 
 	@Autowired
 	private MockRestServiceServer server;
 
 	@Autowired
-	private ExampleRestClient client;
+	private ExampleRestTemplateService client;
 
 	@Test
 	void restClientTestCanBeUsedWhenJacksonIsNotOnTheClassPath() {
diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/servlet/SpringBootMockMvcBuilderCustomizerTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/servlet/SpringBootMockMvcBuilderCustomizerTests.java
index 16c8126396cd..87c5cee78d66 100644
--- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/servlet/SpringBootMockMvcBuilderCustomizerTests.java
+++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/servlet/SpringBootMockMvcBuilderCustomizerTests.java
@@ -18,16 +18,22 @@
 
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 
+import jakarta.servlet.DispatcherType;
 import jakarta.servlet.Filter;
 import jakarta.servlet.FilterChain;
 import jakarta.servlet.FilterConfig;
 import jakarta.servlet.ServletRequest;
 import jakarta.servlet.ServletResponse;
 import jakarta.servlet.http.HttpServlet;
+import org.assertj.core.api.InstanceOfAssertFactories;
 import org.junit.jupiter.api.Test;
 
 import org.springframework.boot.test.autoconfigure.web.servlet.SpringBootMockMvcBuilderCustomizer.DeferredLinesWriter;
@@ -37,11 +43,12 @@
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.mock.web.MockServletContext;
-import org.springframework.test.util.ReflectionTestUtils;
 import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder;
 import org.springframework.test.web.servlet.setup.MockMvcBuilders;
 
+import static org.assertj.core.api.Assertions.as;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
 
 /**
  * Tests for {@link SpringBootMockMvcBuilderCustomizer}.
@@ -50,10 +57,7 @@
  */
 class SpringBootMockMvcBuilderCustomizerTests {
 
-	private SpringBootMockMvcBuilderCustomizer customizer;
-
 	@Test
-	@SuppressWarnings("unchecked")
 	void customizeShouldAddFilters() {
 		AnnotationConfigServletWebApplicationContext context = new AnnotationConfigServletWebApplicationContext();
 		MockServletContext servletContext = new MockServletContext();
@@ -61,14 +65,20 @@ void customizeShouldAddFilters() {
 		context.register(ServletConfiguration.class, FilterConfiguration.class);
 		context.refresh();
 		DefaultMockMvcBuilder builder = MockMvcBuilders.webAppContextSetup(context);
-		this.customizer = new SpringBootMockMvcBuilderCustomizer(context);
-		this.customizer.customize(builder);
-		FilterRegistrationBean<?> registrationBean = (FilterRegistrationBean<?>) context
-			.getBean("filterRegistrationBean");
-		Filter testFilter = (Filter) context.getBean("testFilter");
-		Filter otherTestFilter = registrationBean.getFilter();
-		List<Filter> filters = (List<Filter>) ReflectionTestUtils.getField(builder, "filters");
-		assertThat(filters).containsExactlyInAnyOrder(testFilter, otherTestFilter);
+		SpringBootMockMvcBuilderCustomizer customizer = new SpringBootMockMvcBuilderCustomizer(context);
+		customizer.customize(builder);
+		FilterRegistrationBean<?> registrationBean = (FilterRegistrationBean<?>) context.getBean("otherTestFilter");
+		TestFilter testFilter = context.getBean("testFilter", TestFilter.class);
+		OtherTestFilter otherTestFilter = (OtherTestFilter) registrationBean.getFilter();
+		assertThat(builder).extracting("filters", as(InstanceOfAssertFactories.LIST))
+			.extracting("delegate", "dispatcherTypes")
+			.containsExactlyInAnyOrder(tuple(testFilter, EnumSet.of(DispatcherType.REQUEST)),
+					tuple(otherTestFilter, EnumSet.of(DispatcherType.REQUEST, DispatcherType.ERROR)));
+		builder.build();
+		assertThat(testFilter.filterName).isEqualTo("testFilter");
+		assertThat(testFilter.initParams).isEmpty();
+		assertThat(otherTestFilter.filterName).isEqualTo("otherTestFilter");
+		assertThat(otherTestFilter.initParams).isEqualTo(Map.of("a", "alpha", "b", "bravo"));
 	}
 
 	@Test
@@ -94,7 +104,7 @@ void whenCalledInParallelDeferredLinesWriterSeparatesOutputByThread() throws Exc
 			});
 			thread.start();
 		}
-		latch.await(60, TimeUnit.SECONDS);
+		assertThat(latch.await(60, TimeUnit.SECONDS)).isTrue();
 
 		assertThat(delegate.allWritten).hasSize(10000);
 		assertThat(delegate.allWritten)
@@ -131,8 +141,12 @@ TestServlet testServlet() {
 	static class FilterConfiguration {
 
 		@Bean
-		FilterRegistrationBean<OtherTestFilter> filterRegistrationBean() {
-			return new FilterRegistrationBean<>(new OtherTestFilter());
+		FilterRegistrationBean<OtherTestFilter> otherTestFilter() {
+			FilterRegistrationBean<OtherTestFilter> filterRegistrationBean = new FilterRegistrationBean<>(
+					new OtherTestFilter());
+			filterRegistrationBean.setInitParameters(Map.of("a", "alpha", "b", "bravo"));
+			filterRegistrationBean.setDispatcherTypes(EnumSet.of(DispatcherType.REQUEST, DispatcherType.ERROR));
+			return filterRegistrationBean;
 		}
 
 		@Bean
@@ -148,9 +162,15 @@ static class TestServlet extends HttpServlet {
 
 	static class TestFilter implements Filter {
 
+		private String filterName;
+
+		private Map<String, String> initParams = new HashMap<>();
+
 		@Override
 		public void init(FilterConfig filterConfig) {
-
+			this.filterName = filterConfig.getFilterName();
+			Collections.list(filterConfig.getInitParameterNames())
+				.forEach((name) -> this.initParams.put(name, filterConfig.getInitParameter(name)));
 		}
 
 		@Override
@@ -167,9 +187,15 @@ public void destroy() {
 
 	static class OtherTestFilter implements Filter {
 
+		private String filterName;
+
+		private Map<String, String> initParams = new HashMap<>();
+
 		@Override
 		public void init(FilterConfig filterConfig) {
-
+			this.filterName = filterConfig.getFilterName();
+			Collections.list(filterConfig.getInitParameterNames())
+				.forEach((name) -> this.initParams.put(name, filterConfig.getInitParameter(name)));
 		}
 
 		@Override
diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/DefaultTestExecutionListenersPostProcessor.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/DefaultTestExecutionListenersPostProcessor.java
deleted file mode 100644
index 695c3f460f6b..000000000000
--- a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/DefaultTestExecutionListenersPostProcessor.java
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.test.context;
-
-import java.util.List;
-
-import org.springframework.test.context.ApplicationContextFailureProcessor;
-import org.springframework.test.context.TestExecutionListener;
-
-/**
- * Callback interface trigger from {@link SpringBootTestContextBootstrapper} that can be
- * used to post-process the list of default {@link TestExecutionListener
- * TestExecutionListeners} to be used by a test. Can be used to add or remove existing
- * listeners.
- *
- * @author Phillip Webb
- * @since 1.4.1
- * @deprecated since 3.0.0 removal in 3.2.0 in favor of
- * {@link ApplicationContextFailureProcessor}
- */
-@FunctionalInterface
-@Deprecated(since = "3.0.0", forRemoval = true)
-public interface DefaultTestExecutionListenersPostProcessor {
-
-	/**
-	 * Post process the list of default {@link TestExecutionListener listeners} to be
-	 * used.
-	 * @param listeners the source listeners
-	 * @return the actual listeners that should be used
-	 * @since 3.0.0
-	 */
-	List<TestExecutionListener> postProcessDefaultTestExecutionListeners(List<TestExecutionListener> listeners);
-
-}
diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/ImportsContextCustomizer.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/ImportsContextCustomizer.java
index fc4a8babbf6a..61b7d430349e 100644
--- a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/ImportsContextCustomizer.java
+++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/ImportsContextCustomizer.java
@@ -17,12 +17,12 @@
 package org.springframework.boot.test.context;
 
 import java.lang.annotation.Annotation;
-import java.lang.reflect.AnnotatedElement;
 import java.lang.reflect.Constructor;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.LinkedHashSet;
 import java.util.Set;
+import java.util.stream.Collectors;
 
 import org.springframework.beans.BeansException;
 import org.springframework.beans.factory.BeanFactory;
@@ -36,16 +36,16 @@
 import org.springframework.context.ApplicationContext;
 import org.springframework.context.ConfigurableApplicationContext;
 import org.springframework.context.annotation.AnnotatedBeanDefinitionReader;
+import org.springframework.context.annotation.ComponentScan;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Import;
 import org.springframework.context.annotation.ImportBeanDefinitionRegistrar;
 import org.springframework.context.annotation.ImportSelector;
 import org.springframework.context.support.AbstractApplicationContext;
 import org.springframework.core.Ordered;
-import org.springframework.core.annotation.AnnotationUtils;
+import org.springframework.core.annotation.AnnotationFilter;
 import org.springframework.core.annotation.MergedAnnotation;
 import org.springframework.core.annotation.MergedAnnotations;
-import org.springframework.core.annotation.MergedAnnotations.SearchStrategy;
 import org.springframework.core.annotation.Order;
 import org.springframework.core.style.ToStringCreator;
 import org.springframework.core.type.AnnotationMetadata;
@@ -59,6 +59,7 @@
  *
  * @author Phillip Webb
  * @author Andy Wilkinson
+ * @author Laurent Martelli
  * @see ImportsContextCustomizerFactory
  */
 class ImportsContextCustomizer implements ContextCustomizer {
@@ -217,68 +218,48 @@ public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) t
 	 */
 	static class ContextCustomizerKey {
 
-		private static final Class<?>[] NO_IMPORTS = {};
-
 		private static final Set<AnnotationFilter> ANNOTATION_FILTERS;
-
 		static {
-			Set<AnnotationFilter> filters = new HashSet<>();
-			filters.add(new JavaLangAnnotationFilter());
-			filters.add(new KotlinAnnotationFilter());
-			filters.add(new SpockAnnotationFilter());
-			filters.add(new JUnitAnnotationFilter());
-			ANNOTATION_FILTERS = Collections.unmodifiableSet(filters);
+			Set<AnnotationFilter> annotationFilters = new LinkedHashSet<>();
+			annotationFilters.add(AnnotationFilter.PLAIN);
+			annotationFilters.add("kotlin.Metadata"::equals);
+			annotationFilters.add(AnnotationFilter.packages("kotlin.annotation"));
+			annotationFilters.add(AnnotationFilter.packages("org.spockframework", "spock"));
+			annotationFilters.add(AnnotationFilter.packages("org.junit"));
+			ANNOTATION_FILTERS = Collections.unmodifiableSet(annotationFilters);
 		}
-
 		private final Set<Object> key;
 
 		ContextCustomizerKey(Class<?> testClass) {
-			Set<Annotation> annotations = new HashSet<>();
-			Set<Class<?>> seen = new HashSet<>();
-			collectClassAnnotations(testClass, annotations, seen);
+			MergedAnnotations annotations = MergedAnnotations.search(MergedAnnotations.SearchStrategy.TYPE_HIERARCHY)
+				.withAnnotationFilter(this::isFilteredAnnotation)
+				.from(testClass);
 			Set<Object> determinedImports = determineImports(annotations, testClass);
-			this.key = Collections.unmodifiableSet((determinedImports != null) ? determinedImports : annotations);
-		}
-
-		private void collectClassAnnotations(Class<?> classType, Set<Annotation> annotations, Set<Class<?>> seen) {
-			if (seen.add(classType)) {
-				collectElementAnnotations(classType, annotations, seen);
-				for (Class<?> interfaceType : classType.getInterfaces()) {
-					collectClassAnnotations(interfaceType, annotations, seen);
-				}
-				if (classType.getSuperclass() != null) {
-					collectClassAnnotations(classType.getSuperclass(), annotations, seen);
-				}
+			if (determinedImports == null) {
+				this.key = Collections.unmodifiableSet(synthesize(annotations));
 			}
-		}
-
-		private void collectElementAnnotations(AnnotatedElement element, Set<Annotation> annotations,
-				Set<Class<?>> seen) {
-			for (MergedAnnotation<Annotation> mergedAnnotation : MergedAnnotations.from(element,
-					SearchStrategy.DIRECT)) {
-				Annotation annotation = mergedAnnotation.synthesize();
-				if (!isIgnoredAnnotation(annotation)) {
-					annotations.add(annotation);
-					collectClassAnnotations(annotation.annotationType(), annotations, seen);
-				}
+			else {
+				Set<Object> key = new HashSet<>();
+				key.addAll(determinedImports);
+				Set<Annotation> componentScanning = annotations.stream()
+					.filter((annotation) -> annotation.getType().equals(ComponentScan.class))
+					.map(MergedAnnotation::synthesize)
+					.collect(Collectors.toSet());
+				key.addAll(componentScanning);
+				this.key = Collections.unmodifiableSet(key);
 			}
 		}
 
-		private boolean isIgnoredAnnotation(Annotation annotation) {
-			for (AnnotationFilter annotationFilter : ANNOTATION_FILTERS) {
-				if (annotationFilter.isIgnored(annotation)) {
-					return true;
-				}
-			}
-			return false;
+		private boolean isFilteredAnnotation(String typeName) {
+			return ANNOTATION_FILTERS.stream().anyMatch((filter) -> filter.matches(typeName));
 		}
 
-		private Set<Object> determineImports(Set<Annotation> annotations, Class<?> testClass) {
+		private Set<Object> determineImports(MergedAnnotations annotations, Class<?> testClass) {
 			Set<Object> determinedImports = new LinkedHashSet<>();
-			AnnotationMetadata testClassMetadata = AnnotationMetadata.introspect(testClass);
-			for (Annotation annotation : annotations) {
-				for (Class<?> source : getImports(annotation)) {
-					Set<Object> determinedSourceImports = determineImports(source, testClassMetadata);
+			AnnotationMetadata metadata = AnnotationMetadata.introspect(testClass);
+			for (MergedAnnotation<Import> annotation : annotations.stream(Import.class).toList()) {
+				for (Class<?> source : annotation.getClassArray(MergedAnnotation.VALUE)) {
+					Set<Object> determinedSourceImports = determineImports(source, metadata);
 					if (determinedSourceImports == null) {
 						return null;
 					}
@@ -288,13 +269,6 @@ private Set<Object> determineImports(Set<Annotation> annotations, Class<?> testC
 			return determinedImports;
 		}
 
-		private Class<?>[] getImports(Annotation annotation) {
-			if (annotation instanceof Import importAnnotation) {
-				return importAnnotation.value();
-			}
-			return NO_IMPORTS;
-		}
-
 		private Set<Object> determineImports(Class<?> source, AnnotationMetadata metadata) {
 			if (DeterminableImports.class.isAssignableFrom(source)) {
 				// We can determine the imports
@@ -310,6 +284,10 @@ private Set<Object> determineImports(Class<?> source, AnnotationMetadata metadat
 			return Collections.singleton(source.getName());
 		}
 
+		private Set<Object> synthesize(MergedAnnotations annotations) {
+			return annotations.stream().map(MergedAnnotation::synthesize).collect(Collectors.toSet());
+		}
+
 		@SuppressWarnings("unchecked")
 		private <T> T instantiate(Class<T> source) {
 			try {
@@ -340,67 +318,4 @@ public String toString() {
 
 	}
 
-	/**
-	 * Filter used to limit considered annotations.
-	 */
-	private interface AnnotationFilter {
-
-		boolean isIgnored(Annotation annotation);
-
-	}
-
-	/**
-	 * {@link AnnotationFilter} for {@literal java.lang} annotations.
-	 */
-	private static final class JavaLangAnnotationFilter implements AnnotationFilter {
-
-		@Override
-		public boolean isIgnored(Annotation annotation) {
-			return AnnotationUtils.isInJavaLangAnnotationPackage(annotation);
-		}
-
-	}
-
-	/**
-	 * {@link AnnotationFilter} for Kotlin annotations.
-	 */
-	private static final class KotlinAnnotationFilter implements AnnotationFilter {
-
-		@Override
-		public boolean isIgnored(Annotation annotation) {
-			return "kotlin.Metadata".equals(annotation.annotationType().getName())
-					|| isInKotlinAnnotationPackage(annotation);
-		}
-
-		private boolean isInKotlinAnnotationPackage(Annotation annotation) {
-			return annotation.annotationType().getName().startsWith("kotlin.annotation.");
-		}
-
-	}
-
-	/**
-	 * {@link AnnotationFilter} for Spock annotations.
-	 */
-	private static final class SpockAnnotationFilter implements AnnotationFilter {
-
-		@Override
-		public boolean isIgnored(Annotation annotation) {
-			return annotation.annotationType().getName().startsWith("org.spockframework.")
-					|| annotation.annotationType().getName().startsWith("spock.");
-		}
-
-	}
-
-	/**
-	 * {@link AnnotationFilter} for JUnit annotations.
-	 */
-	private static final class JUnitAnnotationFilter implements AnnotationFilter {
-
-		@Override
-		public boolean isIgnored(Annotation annotation) {
-			return annotation.annotationType().getName().startsWith("org.junit.");
-		}
-
-	}
-
 }
diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootContextLoader.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootContextLoader.java
index 1e673f8f2b49..fa192d3ff007 100644
--- a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootContextLoader.java
+++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootContextLoader.java
@@ -187,7 +187,7 @@ private void configure(MergedContextConfiguration mergedConfig, SpringApplicatio
 		if (mergedConfig instanceof WebMergedContextConfiguration) {
 			application.setWebApplicationType(WebApplicationType.SERVLET);
 			if (!isEmbeddedWebEnvironment(mergedConfig)) {
-				new WebConfigurer().configure(mergedConfig, application, initializers);
+				new WebConfigurer().configure(mergedConfig, initializers);
 			}
 		}
 		else if (mergedConfig instanceof ReactiveWebMergedContextConfiguration) {
@@ -196,8 +196,7 @@ else if (mergedConfig instanceof ReactiveWebMergedContextConfiguration) {
 		else {
 			application.setWebApplicationType(WebApplicationType.NONE);
 		}
-		application.setApplicationContextFactory(
-				(webApplicationType) -> getApplicationContextFactory(mergedConfig, webApplicationType));
+		application.setApplicationContextFactory(getApplicationContextFactory(mergedConfig));
 		if (mergedConfig.getParent() != null) {
 			application.setBannerMode(Banner.Mode.OFF);
 		}
@@ -212,17 +211,26 @@ else if (mergedConfig instanceof ReactiveWebMergedContextConfiguration) {
 		}
 	}
 
-	private ConfigurableApplicationContext getApplicationContextFactory(MergedContextConfiguration mergedConfig,
-			WebApplicationType webApplicationType) {
-		if (webApplicationType != WebApplicationType.NONE && !isEmbeddedWebEnvironment(mergedConfig)) {
-			if (webApplicationType == WebApplicationType.REACTIVE) {
-				return new GenericReactiveWebApplicationContext();
-			}
-			if (webApplicationType == WebApplicationType.SERVLET) {
-				return new GenericWebApplicationContext();
+	/**
+	 * Return the {@link ApplicationContextFactory} that should be used for the test. By
+	 * default this method will return a factory that will create an appropriate
+	 * {@link ApplicationContext} for the {@link WebApplicationType}.
+	 * @param mergedConfig the merged context configuration
+	 * @return the application context factory to use
+	 * @since 3.2.0
+	 */
+	protected ApplicationContextFactory getApplicationContextFactory(MergedContextConfiguration mergedConfig) {
+		return (webApplicationType) -> {
+			if (webApplicationType != WebApplicationType.NONE && !isEmbeddedWebEnvironment(mergedConfig)) {
+				if (webApplicationType == WebApplicationType.REACTIVE) {
+					return new GenericReactiveWebApplicationContext();
+				}
+				if (webApplicationType == WebApplicationType.SERVLET) {
+					return new GenericWebApplicationContext();
+				}
 			}
-		}
-		return ApplicationContextFactory.DEFAULT.create(webApplicationType);
+			return ApplicationContextFactory.DEFAULT.create(webApplicationType);
+		};
 	}
 
 	private void prepareEnvironment(MergedContextConfiguration mergedConfig, SpringApplication application,
@@ -230,8 +238,8 @@ private void prepareEnvironment(MergedContextConfiguration mergedConfig, SpringA
 		setActiveProfiles(environment, mergedConfig.getActiveProfiles(), applicationEnvironment);
 		ResourceLoader resourceLoader = (application.getResourceLoader() != null) ? application.getResourceLoader()
 				: new DefaultResourceLoader(null);
-		TestPropertySourceUtils.addPropertiesFilesToEnvironment(environment, resourceLoader,
-				mergedConfig.getPropertySourceLocations());
+		TestPropertySourceUtils.addPropertySourcesToEnvironment(environment, resourceLoader,
+				mergedConfig.getPropertySourceDescriptors());
 		TestPropertySourceUtils.addInlinedPropertiesToEnvironment(environment, getInlinedProperties(mergedConfig));
 	}
 
@@ -374,8 +382,7 @@ private enum Mode {
 	 */
 	private static class WebConfigurer {
 
-		void configure(MergedContextConfiguration mergedConfig, SpringApplication application,
-				List<ApplicationContextInitializer<?>> initializers) {
+		void configure(MergedContextConfiguration mergedConfig, List<ApplicationContextInitializer<?>> initializers) {
 			WebMergedContextConfiguration webMergedConfig = (WebMergedContextConfiguration) mergedConfig;
 			addMockServletContext(initializers, webMergedConfig);
 		}
diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTestContextBootstrapper.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTestContextBootstrapper.java
index 0f812a30ebba..40970888546f 100644
--- a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTestContextBootstrapper.java
+++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTestContextBootstrapper.java
@@ -38,7 +38,6 @@
 import org.springframework.core.annotation.MergedAnnotations;
 import org.springframework.core.annotation.MergedAnnotations.SearchStrategy;
 import org.springframework.core.env.Environment;
-import org.springframework.core.io.support.SpringFactoriesLoader;
 import org.springframework.test.context.ContextConfiguration;
 import org.springframework.test.context.ContextConfigurationAttributes;
 import org.springframework.test.context.ContextCustomizer;
@@ -48,7 +47,6 @@
 import org.springframework.test.context.TestContext;
 import org.springframework.test.context.TestContextAnnotationUtils;
 import org.springframework.test.context.TestContextBootstrapper;
-import org.springframework.test.context.TestExecutionListener;
 import org.springframework.test.context.aot.AotTestAttributes;
 import org.springframework.test.context.support.DefaultTestContextBootstrapper;
 import org.springframework.test.context.support.TestPropertySourceUtils;
@@ -122,18 +120,6 @@ else if (webEnvironment != null && webEnvironment.isEmbedded()) {
 		return context;
 	}
 
-	@Override
-	@SuppressWarnings("removal")
-	protected List<TestExecutionListener> getDefaultTestExecutionListeners() {
-		List<TestExecutionListener> listeners = new ArrayList<>(super.getDefaultTestExecutionListeners());
-		List<DefaultTestExecutionListenersPostProcessor> postProcessors = SpringFactoriesLoader
-			.loadFactories(DefaultTestExecutionListenersPostProcessor.class, getClass().getClassLoader());
-		for (DefaultTestExecutionListenersPostProcessor postProcessor : postProcessors) {
-			listeners = postProcessor.postProcessDefaultTestExecutionListeners(listeners);
-		}
-		return listeners;
-	}
-
 	@Override
 	protected ContextLoader resolveContextLoader(Class<?> testClass,
 			List<ContextConfigurationAttributes> configAttributesList) {
@@ -390,7 +376,7 @@ protected final MergedContextConfiguration createModifiedConfig(MergedContextCon
 		contextCustomizers.add(new SpringBootTestAnnotation(mergedConfig.getTestClass()));
 		return new MergedContextConfiguration(mergedConfig.getTestClass(), mergedConfig.getLocations(), classes,
 				mergedConfig.getContextInitializerClasses(), mergedConfig.getActiveProfiles(),
-				mergedConfig.getPropertySourceLocations(), propertySourceProperties, contextCustomizers,
+				mergedConfig.getPropertySourceDescriptors(), propertySourceProperties, contextCustomizers,
 				mergedConfig.getContextLoader(), getCacheAwareContextLoaderDelegate(), mergedConfig.getParent());
 	}
 
diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/mock/mockito/MockitoPostProcessor.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/mock/mockito/MockitoPostProcessor.java
index dfc97c91eaf1..334d90dcfb1a 100644
--- a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/mock/mockito/MockitoPostProcessor.java
+++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/mock/mockito/MockitoPostProcessor.java
@@ -252,18 +252,16 @@ private Set<String> getExistingBeans(ConfigurableListableBeanFactory beanFactory
 		Set<String> beans = new LinkedHashSet<>(
 				Arrays.asList(beanFactory.getBeanNamesForType(resolvableType, true, false)));
 		Class<?> type = resolvableType.resolve(Object.class);
-		String typeName = type.getName();
 		for (String beanName : beanFactory.getBeanNamesForType(FactoryBean.class, true, false)) {
 			beanName = BeanFactoryUtils.transformedBeanName(beanName);
 			BeanDefinition beanDefinition = beanFactory.getBeanDefinition(beanName);
 			Object attribute = beanDefinition.getAttribute(FactoryBean.OBJECT_TYPE_ATTRIBUTE);
-			if (resolvableType.equals(attribute) || type.equals(attribute) || typeName.equals(attribute)) {
+			if (resolvableType.equals(attribute) || type.equals(attribute)) {
 				beans.add(beanName);
 			}
 		}
 		beans.removeIf(this::isScopedTarget);
 		return beans;
-
 	}
 
 	private boolean isScopedTarget(String beanName) {
diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/mock/web/SpringBootMockServletContext.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/mock/web/SpringBootMockServletContext.java
index ec810bac3fd5..d1c005c78d46 100644
--- a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/mock/web/SpringBootMockServletContext.java
+++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/mock/web/SpringBootMockServletContext.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/system/OutputCaptureExtension.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/system/OutputCaptureExtension.java
index 6d08448dea15..3a6528bcddbd 100644
--- a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/system/OutputCaptureExtension.java
+++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/system/OutputCaptureExtension.java
@@ -56,6 +56,30 @@
  *     }
  *
  * }
+ * </pre>
+ * <p>
+ * To ensure that their output can be captured, Java Util Logging (JUL) and Log4j2 require
+ * additional configuration.
+ * <p>
+ * To reliably capture output from Java Util Logging, reset its configuration after each
+ * test:
+ *
+ * <pre class="code">
+ * &#064;AfterEach
+ * void reset() throws Exception {
+ *     LogManager.getLogManager().readConfiguration();
+ * }
+ * </pre>
+ * <p>
+ * To reliably capture output from Log4j2, set the <code>follow</code> attribute of the
+ * console appender to <code>true</code>:
+ *
+ * <pre class="code">
+ * &lt;Appenders&gt;
+ *     &lt;Console name="Console" target="SYSTEM_OUT" follow="true"&gt;
+ *         ...
+ *     &lt;/Console&gt;
+*  &lt;/Appenders&gt;
  * </pre>
  *
  * @author Madhura Bhave
diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/web/client/MockServerRestClientCustomizer.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/web/client/MockServerRestClientCustomizer.java
new file mode 100644
index 000000000000..29b8345140b8
--- /dev/null
+++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/web/client/MockServerRestClientCustomizer.java
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.test.web.client;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Supplier;
+
+import org.springframework.beans.BeanUtils;
+import org.springframework.boot.web.client.RestClientCustomizer;
+import org.springframework.http.client.BufferingClientHttpRequestFactory;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.test.web.client.MockRestServiceServer.MockRestServiceServerBuilder;
+import org.springframework.test.web.client.RequestExpectationManager;
+import org.springframework.test.web.client.SimpleRequestExpectationManager;
+import org.springframework.util.Assert;
+import org.springframework.web.client.RestClient;
+
+/**
+ * {@link RestClientCustomizer} that can be applied to {@link RestClient.Builder}
+ * instances to add {@link MockRestServiceServer} support.
+ * <p>
+ * Typically applied to an existing builder before it is used, for example:
+ * <pre class="code">
+ * MockServerRestClientCustomizer customizer = new MockServerRestClientCustomizer();
+ * RestClient.Builder builder = RestClient.builder();
+ * customizer.customize(builder);
+ * MyBean bean = new MyBean(client.build());
+ * customizer.getServer().expect(requestTo("/hello")).andRespond(withSuccess());
+ * bean.makeRestCall();
+ * </pre>
+ * <p>
+ * If the customizer is only used once, the {@link #getServer()} method can be used to
+ * obtain the mock server. If the customizer has been used more than once the
+ * {@link #getServer(RestClient.Builder)} or {@link #getServers()} method must be used to
+ * access the related server.
+ *
+ * @author Scott Frederick
+ * @since 3.2.0
+ * @see #getServer()
+ * @see #getServer(RestClient.Builder)
+ */
+public class MockServerRestClientCustomizer implements RestClientCustomizer {
+
+	private final Map<RestClient.Builder, RequestExpectationManager> expectationManagers = new ConcurrentHashMap<>();
+
+	private final Map<RestClient.Builder, MockRestServiceServer> servers = new ConcurrentHashMap<>();
+
+	private final Supplier<? extends RequestExpectationManager> expectationManagerSupplier;
+
+	private boolean bufferContent = false;
+
+	public MockServerRestClientCustomizer() {
+		this(SimpleRequestExpectationManager::new);
+	}
+
+	/**
+	 * Crate a new {@link MockServerRestClientCustomizer} instance.
+	 * @param expectationManager the expectation manager class to use
+	 */
+	public MockServerRestClientCustomizer(Class<? extends RequestExpectationManager> expectationManager) {
+		this(() -> BeanUtils.instantiateClass(expectationManager));
+		Assert.notNull(expectationManager, "ExpectationManager must not be null");
+	}
+
+	/**
+	 * Crate a new {@link MockServerRestClientCustomizer} instance.
+	 * @param expectationManagerSupplier a supplier that provides the
+	 * {@link RequestExpectationManager} to use
+	 * @since 3.0.0
+	 */
+	public MockServerRestClientCustomizer(Supplier<? extends RequestExpectationManager> expectationManagerSupplier) {
+		Assert.notNull(expectationManagerSupplier, "ExpectationManagerSupplier must not be null");
+		this.expectationManagerSupplier = expectationManagerSupplier;
+	}
+
+	/**
+	 * Set if the {@link BufferingClientHttpRequestFactory} wrapper should be used to
+	 * buffer the input and output streams, and for example, allow multiple reads of the
+	 * response body.
+	 * @param bufferContent if request and response content should be buffered
+	 * @since 3.1.0
+	 */
+	public void setBufferContent(boolean bufferContent) {
+		this.bufferContent = bufferContent;
+	}
+
+	@Override
+	public void customize(RestClient.Builder restClientBuilder) {
+		RequestExpectationManager expectationManager = createExpectationManager();
+		MockRestServiceServerBuilder serverBuilder = MockRestServiceServer.bindTo(restClientBuilder);
+		if (this.bufferContent) {
+			serverBuilder.bufferContent();
+		}
+		MockRestServiceServer server = serverBuilder.build(expectationManager);
+		this.expectationManagers.put(restClientBuilder, expectationManager);
+		this.servers.put(restClientBuilder, server);
+	}
+
+	protected RequestExpectationManager createExpectationManager() {
+		return this.expectationManagerSupplier.get();
+	}
+
+	public MockRestServiceServer getServer() {
+		Assert.state(!this.servers.isEmpty(), "Unable to return a single MockRestServiceServer since "
+				+ "MockServerRestClientCustomizer has not been bound to a RestClient");
+		Assert.state(this.servers.size() == 1, "Unable to return a single MockRestServiceServer since "
+				+ "MockServerRestClientCustomizer has been bound to more than one RestClient");
+		return this.servers.values().iterator().next();
+	}
+
+	public Map<RestClient.Builder, RequestExpectationManager> getExpectationManagers() {
+		return this.expectationManagers;
+	}
+
+	public MockRestServiceServer getServer(RestClient.Builder restClientBuilder) {
+		return this.servers.get(restClientBuilder);
+	}
+
+	public Map<RestClient.Builder, MockRestServiceServer> getServers() {
+		return Collections.unmodifiableMap(this.servers);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/web/client/TestRestTemplate.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/web/client/TestRestTemplate.java
index dca0856b7089..24e38b8ab503 100644
--- a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/web/client/TestRestTemplate.java
+++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/web/client/TestRestTemplate.java
@@ -1021,9 +1021,6 @@ public CustomHttpComponentsClientHttpRequestFactory(HttpClientOption[] httpClien
 			if (settings.connectTimeout() != null) {
 				setConnectTimeout((int) settings.connectTimeout().toMillis());
 			}
-			if (settings.bufferRequestBody() != null) {
-				setBufferRequestBody(settings.bufferRequestBody());
-			}
 		}
 
 		private HttpClient createHttpClient(Duration readTimeout, boolean ssl) {
diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/ImportsContextCustomizerFactoryTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/ImportsContextCustomizerFactoryTests.java
index 48794759f05a..46ef48e1cfa3 100644
--- a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/ImportsContextCustomizerFactoryTests.java
+++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/ImportsContextCustomizerFactoryTests.java
@@ -23,6 +23,7 @@
 
 import org.springframework.context.annotation.AnnotationConfigApplicationContext;
 import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ComponentScan;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Import;
 import org.springframework.stereotype.Component;
@@ -74,6 +75,20 @@ void contextCustomizerEqualsAndHashCode() {
 		assertThat(customizer3).isEqualTo(customizer4);
 	}
 
+	@Test
+	void contextCustomizerEqualsAndHashCodeConsidersComponentScan() {
+		ContextCustomizer customizer1 = this.factory
+			.createContextCustomizer(TestWithImportAndComponentScanOfSomePackage.class, null);
+		ContextCustomizer customizer2 = this.factory
+			.createContextCustomizer(TestWithImportAndComponentScanOfSomePackage.class, null);
+		ContextCustomizer customizer3 = this.factory
+			.createContextCustomizer(TestWithImportAndComponentScanOfAnotherPackage.class, null);
+		assertThat(customizer1.hashCode()).isEqualTo(customizer2.hashCode());
+		assertThat(customizer1).isEqualTo(customizer2);
+		assertThat(customizer3.hashCode()).isNotEqualTo(customizer2.hashCode()).isNotEqualTo(customizer1.hashCode());
+		assertThat(customizer3).isNotEqualTo(customizer2).isNotEqualTo(customizer1);
+	}
+
 	@Test
 	void getContextCustomizerWhenClassHasBeanMethodsShouldThrowException() {
 		assertThatIllegalStateException()
@@ -105,6 +120,18 @@ static class TestWithImport {
 
 	}
 
+	@Import(ImportedBean.class)
+	@ComponentScan("some.package")
+	static class TestWithImportAndComponentScanOfSomePackage {
+
+	}
+
+	@Import(ImportedBean.class)
+	@ComponentScan("another.package")
+	static class TestWithImportAndComponentScanOfAnotherPackage {
+
+	}
+
 	@MetaImport
 	static class TestWithMetaImport {
 
diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/SpringBootContextLoaderTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/SpringBootContextLoaderTests.java
index 6715719eab48..ee8777e176a7 100644
--- a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/SpringBootContextLoaderTests.java
+++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/SpringBootContextLoaderTests.java
@@ -26,12 +26,14 @@
 import org.junit.jupiter.api.Test;
 
 import org.springframework.beans.factory.BeanCreationException;
+import org.springframework.boot.ApplicationContextFactory;
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.SpringBootConfiguration;
 import org.springframework.boot.test.context.SpringBootTest.UseMainMethod;
 import org.springframework.boot.test.util.TestPropertyValues;
 import org.springframework.boot.web.reactive.context.GenericReactiveWebApplicationContext;
 import org.springframework.context.ApplicationContext;
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.core.env.ConfigurableEnvironment;
@@ -246,6 +248,13 @@ void whenUseMainMethodWithContextHierarchyThrowsException() {
 			.withMessage("UseMainMethod.ALWAYS cannot be used with @ContextHierarchy tests");
 	}
 
+	@Test
+	void whenSubclassProvidesCustomApplicationContextFactory() {
+		TestContext testContext = new ExposedTestContextManager(CustomApplicationContextTest.class)
+			.getExposedTestContext();
+		assertThat(testContext.getApplicationContext()).isInstanceOf(CustomAnnotationConfigApplicationContext.class);
+	}
+
 	private String[] getActiveProfiles(Class<?> testClass) {
 		TestContext testContext = new ExposedTestContextManager(testClass).getExposedTestContext();
 		ApplicationContext applicationContext = testContext.getApplicationContext();
@@ -255,7 +264,7 @@ private String[] getActiveProfiles(Class<?> testClass) {
 	private Map<String, Object> getMergedContextConfigurationProperties(Class<?> testClass) {
 		TestContext context = new ExposedTestContextManager(testClass).getExposedTestContext();
 		MergedContextConfiguration config = (MergedContextConfiguration) ReflectionTestUtils.getField(context,
-				"mergedContextConfiguration");
+				"mergedConfig");
 		return TestPropertySourceUtils.convertInlinedPropertiesToMap(config.getPropertySourceProperties());
 	}
 
@@ -370,6 +379,25 @@ static class UseMainMethodWithContextHierarchy {
 
 	}
 
+	@SpringBootTest
+	@ContextConfiguration(classes = Config.class, loader = CustomApplicationContextSpringBootContextLoader.class)
+	static class CustomApplicationContextTest {
+
+	}
+
+	static class CustomApplicationContextSpringBootContextLoader extends SpringBootContextLoader {
+
+		@Override
+		protected ApplicationContextFactory getApplicationContextFactory(MergedContextConfiguration mergedConfig) {
+			return (webApplicationType) -> new CustomAnnotationConfigApplicationContext();
+		}
+
+	}
+
+	static class CustomAnnotationConfigApplicationContext extends AnnotationConfigApplicationContext {
+
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class Config {
 
diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/bootstrap/SpringBootTestContextBootstrapperIntegrationTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/bootstrap/SpringBootTestContextBootstrapperIntegrationTests.java
index 38edac15537b..69f87d827ff0 100644
--- a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/bootstrap/SpringBootTestContextBootstrapperIntegrationTests.java
+++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/bootstrap/SpringBootTestContextBootstrapperIntegrationTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -45,8 +45,6 @@ class SpringBootTestContextBootstrapperIntegrationTests {
 	@Autowired
 	private SpringBootTestContextBootstrapperExampleConfig config;
 
-	boolean defaultTestExecutionListenersPostProcessorCalled = false;
-
 	@Test
 	void findConfigAutomatically() {
 		assertThat(this.config).isNotNull();
@@ -62,11 +60,6 @@ void testConfigurationWasApplied() {
 		assertThat(this.context.getBean(ExampleBean.class)).isNotNull();
 	}
 
-	@Test
-	void defaultTestExecutionListenersPostProcessorShouldBeCalled() {
-		assertThat(this.defaultTestExecutionListenersPostProcessorCalled).isTrue();
-	}
-
 	@TestConfiguration(proxyBeanMethods = false)
 	static class TestConfig {
 
diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/bootstrap/SpringBootTestContextBootstrapperTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/bootstrap/SpringBootTestContextBootstrapperTests.java
index db724a374a1e..c38c81fc8121 100644
--- a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/bootstrap/SpringBootTestContextBootstrapperTests.java
+++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/bootstrap/SpringBootTestContextBootstrapperTests.java
@@ -110,7 +110,7 @@ private TestContext buildTestContext(Class<?> testClass) {
 	}
 
 	private MergedContextConfiguration getMergedContextConfiguration(TestContext context) {
-		return (MergedContextConfiguration) ReflectionTestUtils.getField(context, "mergedContextConfiguration");
+		return (MergedContextConfiguration) ReflectionTestUtils.getField(context, "mergedConfig");
 	}
 
 	@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/bootstrap/TestDefaultTestExecutionListenersPostProcessor.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/bootstrap/TestDefaultTestExecutionListenersPostProcessor.java
deleted file mode 100644
index 7587c23f93f5..000000000000
--- a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/bootstrap/TestDefaultTestExecutionListenersPostProcessor.java
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.test.context.bootstrap;
-
-import java.util.List;
-
-import org.springframework.boot.test.context.DefaultTestExecutionListenersPostProcessor;
-import org.springframework.test.context.TestContext;
-import org.springframework.test.context.TestExecutionListener;
-import org.springframework.test.context.support.AbstractTestExecutionListener;
-
-/**
- * Test {@link DefaultTestExecutionListenersPostProcessor}.
- *
- * @author Phillip Webb
- */
-@SuppressWarnings("removal")
-public class TestDefaultTestExecutionListenersPostProcessor implements DefaultTestExecutionListenersPostProcessor {
-
-	@Override
-	public List<TestExecutionListener> postProcessDefaultTestExecutionListeners(List<TestExecutionListener> listeners) {
-		listeners.add(new ExampleTestExecutionListener());
-		return listeners;
-	}
-
-	static class ExampleTestExecutionListener extends AbstractTestExecutionListener {
-
-		@Override
-		public void prepareTestInstance(TestContext testContext) throws Exception {
-			Object testInstance = testContext.getTestInstance();
-			if (testInstance instanceof SpringBootTestContextBootstrapperIntegrationTests test) {
-				test.defaultTestExecutionListenersPostProcessorCalled = true;
-			}
-		}
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/runner/ContextConsumerTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/runner/ContextConsumerTests.java
index dc3bdb1138a8..370e9d72f85a 100644
--- a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/runner/ContextConsumerTests.java
+++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/runner/ContextConsumerTests.java
@@ -24,8 +24,8 @@
 import org.springframework.context.ApplicationContext;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
 import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.BDDMockito.given;
 import static org.mockito.BDDMockito.then;
 import static org.mockito.Mockito.inOrder;
@@ -59,8 +59,8 @@ void andThenNoInvokedIfThisFails() {
 		given(predicate.test(24)).willReturn(false);
 		ContextConsumer<ApplicationContext> firstConsumer = (context) -> assertThat(predicate.test(42)).isFalse();
 		ContextConsumer<ApplicationContext> secondConsumer = (context) -> assertThat(predicate.test(24)).isFalse();
-		assertThatThrownBy(() -> firstConsumer.andThen(secondConsumer).accept(mock(ApplicationContext.class)))
-			.isInstanceOf(AssertionError.class);
+		assertThatExceptionOfType(AssertionError.class)
+			.isThrownBy(() -> firstConsumer.andThen(secondConsumer).accept(mock(ApplicationContext.class)));
 		then(predicate).should().test(42);
 		then(predicate).shouldHaveNoMoreInteractions();
 	}
diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/mock/mockito/MockBeanContextCachingTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/mock/mockito/MockBeanContextCachingTests.java
index fca0b7799dbe..d209b93ff50e 100644
--- a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/mock/mockito/MockBeanContextCachingTests.java
+++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/mock/mockito/MockBeanContextCachingTests.java
@@ -45,7 +45,7 @@
  */
 class MockBeanContextCachingTests {
 
-	private final DefaultContextCache contextCache = new DefaultContextCache();
+	private final DefaultContextCache contextCache = new DefaultContextCache(2);
 
 	private final DefaultCacheAwareContextLoaderDelegate delegate = new DefaultCacheAwareContextLoaderDelegate(
 			this.contextCache);
diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/mock/mockito/MockitoPostProcessorTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/mock/mockito/MockitoPostProcessorTests.java
index 99969dadd9c9..5bb4bace7047 100644
--- a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/mock/mockito/MockitoPostProcessorTests.java
+++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/mock/mockito/MockitoPostProcessorTests.java
@@ -73,18 +73,6 @@ void cannotMockMultipleQualifiedBeans() {
 					+ " expected a single matching bean to replace but found [example1, example3]");
 	}
 
-	@Test
-	void canMockBeanProducedByFactoryBeanWithStringObjectTypeAttribute() {
-		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
-		MockitoPostProcessor.register(context);
-		RootBeanDefinition factoryBeanDefinition = new RootBeanDefinition(TestFactoryBean.class);
-		factoryBeanDefinition.setAttribute(FactoryBean.OBJECT_TYPE_ATTRIBUTE, SomeInterface.class.getName());
-		context.registerBeanDefinition("beanToBeMocked", factoryBeanDefinition);
-		context.register(MockedFactoryBean.class);
-		context.refresh();
-		assertThat(Mockito.mockingDetails(context.getBean("beanToBeMocked")).isMock()).isTrue();
-	}
-
 	@Test
 	void canMockBeanProducedByFactoryBeanWithClassObjectTypeAttribute() {
 		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/web/client/MockServerRestClientCustomizerTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/web/client/MockServerRestClientCustomizerTests.java
new file mode 100644
index 000000000000..0dffb76514a8
--- /dev/null
+++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/web/client/MockServerRestClientCustomizerTests.java
@@ -0,0 +1,163 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.test.web.client;
+
+import java.util.function.Supplier;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.test.web.client.RequestExpectationManager;
+import org.springframework.test.web.client.SimpleRequestExpectationManager;
+import org.springframework.test.web.client.UnorderedRequestExpectationManager;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.client.RestClient.Builder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+/**
+ * Tests for {@link MockServerRestClientCustomizer}.
+ *
+ * @author Scott Frederick
+ */
+class MockServerRestClientCustomizerTests {
+
+	private MockServerRestClientCustomizer customizer;
+
+	@BeforeEach
+	void setup() {
+		this.customizer = new MockServerRestClientCustomizer();
+	}
+
+	@Test
+	void createShouldUseSimpleRequestExpectationManager() {
+		MockServerRestClientCustomizer customizer = new MockServerRestClientCustomizer();
+		customizer.customize(RestClient.builder());
+		assertThat(customizer.getServer()).extracting("expectationManager")
+			.isInstanceOf(SimpleRequestExpectationManager.class);
+	}
+
+	@Test
+	void createWhenExpectationManagerClassIsNullShouldThrowException() {
+		Class<? extends RequestExpectationManager> expectationManager = null;
+		assertThatIllegalArgumentException().isThrownBy(() -> new MockServerRestClientCustomizer(expectationManager))
+			.withMessageContaining("ExpectationManager must not be null");
+	}
+
+	@Test
+	void createWhenExpectationManagerSupplierIsNullShouldThrowException() {
+		Supplier<? extends RequestExpectationManager> expectationManagerSupplier = null;
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> new MockServerRestClientCustomizer(expectationManagerSupplier))
+			.withMessageContaining("ExpectationManagerSupplier must not be null");
+	}
+
+	@Test
+	void createShouldUseExpectationManagerClass() {
+		MockServerRestClientCustomizer customizer = new MockServerRestClientCustomizer(
+				UnorderedRequestExpectationManager.class);
+		customizer.customize(RestClient.builder());
+		assertThat(customizer.getServer()).extracting("expectationManager")
+			.isInstanceOf(UnorderedRequestExpectationManager.class);
+	}
+
+	@Test
+	void createShouldUseSupplier() {
+		MockServerRestClientCustomizer customizer = new MockServerRestClientCustomizer(
+				UnorderedRequestExpectationManager::new);
+		customizer.customize(RestClient.builder());
+		assertThat(customizer.getServer()).extracting("expectationManager")
+			.isInstanceOf(UnorderedRequestExpectationManager.class);
+	}
+
+	@Test
+	void customizeShouldBindServer() {
+		Builder builder = RestClient.builder();
+		this.customizer.customize(builder);
+		this.customizer.getServer().expect(requestTo("/test")).andRespond(withSuccess());
+		builder.build().get().uri("/test").retrieve().toEntity(String.class);
+		this.customizer.getServer().verify();
+	}
+
+	@Test
+	void getServerWhenNoServersAreBoundShouldThrowException() {
+		assertThatIllegalStateException().isThrownBy(this.customizer::getServer)
+			.withMessageContaining("Unable to return a single MockRestServiceServer since "
+					+ "MockServerRestClientCustomizer has not been bound to a RestClient");
+	}
+
+	@Test
+	void getServerWhenMultipleServersAreBoundShouldThrowException() {
+		this.customizer.customize(RestClient.builder());
+		this.customizer.customize(RestClient.builder());
+		assertThatIllegalStateException().isThrownBy(this.customizer::getServer)
+			.withMessageContaining("Unable to return a single MockRestServiceServer since "
+					+ "MockServerRestClientCustomizer has been bound to more than one RestClient");
+	}
+
+	@Test
+	void getServerWhenSingleServerIsBoundShouldReturnServer() {
+		Builder builder = RestClient.builder();
+		this.customizer.customize(builder);
+		assertThat(this.customizer.getServer()).isEqualTo(this.customizer.getServer(builder));
+	}
+
+	@Test
+	void getServerWhenRestClientBuilderIsFoundShouldReturnServer() {
+		Builder builder1 = RestClient.builder();
+		Builder builder2 = RestClient.builder();
+		this.customizer.customize(builder1);
+		this.customizer.customize(builder2);
+		assertThat(this.customizer.getServer(builder1)).isNotNull();
+		assertThat(this.customizer.getServer(builder2)).isNotNull().isNotSameAs(this.customizer.getServer(builder1));
+	}
+
+	@Test
+	void getServerWhenRestClientBuilderIsNotFoundShouldReturnNull() {
+		Builder builder1 = RestClient.builder();
+		Builder builder2 = RestClient.builder();
+		this.customizer.customize(builder1);
+		assertThat(this.customizer.getServer(builder1)).isNotNull();
+		assertThat(this.customizer.getServer(builder2)).isNull();
+	}
+
+	@Test
+	void getServersShouldReturnServers() {
+		Builder builder1 = RestClient.builder();
+		Builder builder2 = RestClient.builder();
+		this.customizer.customize(builder1);
+		this.customizer.customize(builder2);
+		assertThat(this.customizer.getServers()).containsOnlyKeys(builder1, builder2);
+	}
+
+	@Test
+	void getExpectationManagersShouldReturnExpectationManagers() {
+		Builder builder1 = RestClient.builder();
+		Builder builder2 = RestClient.builder();
+		this.customizer.customize(builder1);
+		this.customizer.customize(builder2);
+		RequestExpectationManager manager1 = this.customizer.getExpectationManagers().get(builder1);
+		RequestExpectationManager manager2 = this.customizer.getExpectationManagers().get(builder2);
+		assertThat(this.customizer.getServer(builder1)).extracting("expectationManager").isEqualTo(manager1);
+		assertThat(this.customizer.getServer(builder2)).extracting("expectationManager").isEqualTo(manager2);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/web/client/TestRestTemplateTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/web/client/TestRestTemplateTests.java
index 53cb7b836672..11b9b881af7c 100644
--- a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/web/client/TestRestTemplateTests.java
+++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/web/client/TestRestTemplateTests.java
@@ -38,7 +38,7 @@
 import org.springframework.http.client.ClientHttpRequest;
 import org.springframework.http.client.ClientHttpRequestFactory;
 import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
-import org.springframework.http.client.OkHttp3ClientHttpRequestFactory;
+import org.springframework.http.client.JettyClientHttpRequestFactory;
 import org.springframework.http.client.SimpleClientHttpRequestFactory;
 import org.springframework.mock.env.MockEnvironment;
 import org.springframework.mock.http.client.MockClientHttpRequest;
@@ -86,15 +86,16 @@ void simple() {
 
 	@Test
 	void doNotReplaceCustomRequestFactory() {
-		RestTemplateBuilder builder = new RestTemplateBuilder().requestFactory(OkHttp3ClientHttpRequestFactory.class);
+		RestTemplateBuilder builder = new RestTemplateBuilder()
+			.requestFactory(HttpComponentsClientHttpRequestFactory.class);
 		TestRestTemplate testRestTemplate = new TestRestTemplate(builder);
 		assertThat(testRestTemplate.getRestTemplate().getRequestFactory())
-			.isInstanceOf(OkHttp3ClientHttpRequestFactory.class);
+			.isInstanceOf(HttpComponentsClientHttpRequestFactory.class);
 	}
 
 	@Test
 	void useTheSameRequestFactoryClassWithBasicAuth() {
-		OkHttp3ClientHttpRequestFactory customFactory = new OkHttp3ClientHttpRequestFactory();
+		JettyClientHttpRequestFactory customFactory = new JettyClientHttpRequestFactory();
 		RestTemplateBuilder builder = new RestTemplateBuilder().requestFactory(() -> customFactory);
 		TestRestTemplate testRestTemplate = new TestRestTemplate(builder).withBasicAuth("test", "test");
 		RestTemplate restTemplate = testRestTemplate.getRestTemplate();
diff --git a/spring-boot-project/spring-boot-testcontainers/build.gradle b/spring-boot-project/spring-boot-testcontainers/build.gradle
index 9cfb9709582e..7d7792b8ade8 100644
--- a/spring-boot-project/spring-boot-testcontainers/build.gradle
+++ b/spring-boot-project/spring-boot-testcontainers/build.gradle
@@ -1,6 +1,7 @@
 plugins {
 	id "java-library"
 	id "org.springframework.boot.auto-configuration"
+	id "org.springframework.boot.configuration-properties"
 	id "org.springframework.boot.conventions"
 	id "org.springframework.boot.deployed"
 	id "org.springframework.boot.optional-dependencies"
@@ -28,7 +29,9 @@ dependencies {
 	optional("org.testcontainers:mysql")
 	optional("org.testcontainers:neo4j")
 	optional("org.testcontainers:oracle-xe")
+	optional("org.testcontainers:oracle-free")
 	optional("org.testcontainers:postgresql")
+	optional("org.testcontainers:pulsar")
 	optional("org.testcontainers:rabbitmq")
 	optional("org.testcontainers:redpanda")
 	optional("org.testcontainers:r2dbc")
@@ -36,6 +39,11 @@ dependencies {
 	testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support"))
 	testImplementation(project(":spring-boot-project:spring-boot-test"))
 	testImplementation("ch.qos.logback:logback-classic")
+	testImplementation("io.micrometer:micrometer-registry-otlp")
+	testImplementation("io.rest-assured:rest-assured") {
+		exclude group: "commons-logging", module: "commons-logging"
+	}
+	testImplementation("org.apache.activemq:activemq-client-jakarta")
 	testImplementation("org.assertj:assertj-core")
 	testImplementation("org.awaitility:awaitility")
 	testImplementation("org.influxdb:influxdb-java")
@@ -45,11 +53,12 @@ dependencies {
 	testImplementation("org.mockito:mockito-core")
 	testImplementation("org.mockito:mockito-junit-jupiter")
 	testImplementation("org.springframework:spring-core-test")
+	testImplementation("org.springframework:spring-jms")
 	testImplementation("org.springframework:spring-r2dbc")
 	testImplementation("org.springframework.amqp:spring-rabbit")
 	testImplementation("org.springframework.kafka:spring-kafka")
+	testImplementation("org.springframework.pulsar:spring-pulsar")
 	testImplementation("org.testcontainers:junit-jupiter")
 
 	testRuntimeOnly("com.oracle.database.r2dbc:oracle-r2dbc")
 }
-
diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializer.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializer.java
index 087c4d71e605..41420bbb6f42 100644
--- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializer.java
+++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializer.java
@@ -47,7 +47,8 @@ public void initialize(ConfigurableApplicationContext applicationContext) {
 		}
 		ConfigurableListableBeanFactory beanFactory = applicationContext.getBeanFactory();
 		applicationContext.addBeanFactoryPostProcessor(new TestcontainersLifecycleBeanFactoryPostProcessor());
-		beanFactory.addBeanPostProcessor(new TestcontainersLifecycleBeanPostProcessor(beanFactory));
+		TestcontainersStartup startup = TestcontainersStartup.get(applicationContext.getEnvironment());
+		beanFactory.addBeanPostProcessor(new TestcontainersLifecycleBeanPostProcessor(beanFactory, startup));
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleBeanPostProcessor.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleBeanPostProcessor.java
index 9a5ec1d10aff..edafed5c7d01 100644
--- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleBeanPostProcessor.java
+++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleBeanPostProcessor.java
@@ -16,9 +16,12 @@
 
 package org.springframework.boot.testcontainers.lifecycle;
 
+import java.util.ArrayList;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Collectors;
 
 import org.apache.commons.logging.Log;
 import org.apache.commons.logging.LogFactory;
@@ -58,46 +61,80 @@ class TestcontainersLifecycleBeanPostProcessor implements DestructionAwareBeanPo
 
 	private final ConfigurableListableBeanFactory beanFactory;
 
-	private volatile boolean containersInitialized = false;
+	private final TestcontainersStartup startup;
 
-	TestcontainersLifecycleBeanPostProcessor(ConfigurableListableBeanFactory beanFactory) {
+	private final AtomicBoolean startablesInitialized = new AtomicBoolean();
+
+	private final AtomicBoolean containersInitialized = new AtomicBoolean();
+
+	TestcontainersLifecycleBeanPostProcessor(ConfigurableListableBeanFactory beanFactory,
+			TestcontainersStartup startup) {
 		this.beanFactory = beanFactory;
+		this.startup = startup;
 	}
 
 	@Override
 	public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
-		if (bean instanceof Startable startable) {
-			startable.start();
-		}
-		if (this.beanFactory.isConfigurationFrozen()) {
+		if (this.beanFactory.isConfigurationFrozen() && this.containersInitialized.compareAndSet(false, true)) {
 			initializeContainers();
 		}
+		if (bean instanceof Startable startableBean) {
+			if (this.startablesInitialized.compareAndSet(false, true)) {
+				initializeStartables(startableBean, beanName);
+			}
+			else {
+				startableBean.start();
+			}
+		}
 		return bean;
 	}
 
-	private void initializeContainers() {
-		if (this.containersInitialized) {
+	private void initializeStartables(Startable startableBean, String startableBeanName) {
+		List<String> beanNames = new ArrayList<>(
+				List.of(this.beanFactory.getBeanNamesForType(Startable.class, false, false)));
+		beanNames.remove(startableBeanName);
+		List<Object> beans = getBeans(beanNames);
+		if (beans == null) {
+			this.startablesInitialized.set(false);
 			return;
 		}
-		this.containersInitialized = true;
-		Set<String> beanNames = new LinkedHashSet<>();
-		beanNames.addAll(List.of(this.beanFactory.getBeanNamesForType(ContainerState.class, false, false)));
-		beanNames.addAll(List.of(this.beanFactory.getBeanNamesForType(Startable.class, false, false)));
+		beanNames.add(startableBeanName);
+		beans.add(startableBean);
+		start(beans);
+		if (!beanNames.isEmpty()) {
+			logger.debug(LogMessage.format("Initialized and started startable beans '%s'", beanNames));
+		}
+	}
+
+	private void start(List<Object> beans) {
+		Set<Startable> startables = beans.stream()
+			.filter(Startable.class::isInstance)
+			.map(Startable.class::cast)
+			.collect(Collectors.toCollection(LinkedHashSet::new));
+		this.startup.start(startables);
+	}
+
+	private void initializeContainers() {
+		List<String> beanNames = List.of(this.beanFactory.getBeanNamesForType(ContainerState.class, false, false));
+		if (getBeans(beanNames) == null) {
+			this.containersInitialized.set(false);
+		}
+	}
+
+	private List<Object> getBeans(List<String> beanNames) {
+		List<Object> beans = new ArrayList<>(beanNames.size());
 		for (String beanName : beanNames) {
 			try {
-				this.beanFactory.getBean(beanName);
+				beans.add(this.beanFactory.getBean(beanName));
 			}
 			catch (BeanCreationException ex) {
 				if (ex.contains(BeanCurrentlyInCreationException.class)) {
-					this.containersInitialized = false;
-					return;
+					return null;
 				}
 				throw ex;
 			}
 		}
-		if (!beanNames.isEmpty()) {
-			logger.debug(LogMessage.format("Initialized container beans '%s'", beanNames));
-		}
+		return beans;
 	}
 
 	@Override
diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersStartup.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersStartup.java
new file mode 100644
index 000000000000..00009a07fa6c
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersStartup.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.lifecycle;
+
+import java.util.Collection;
+
+import org.testcontainers.lifecycle.Startable;
+import org.testcontainers.lifecycle.Startables;
+
+import org.springframework.core.env.ConfigurableEnvironment;
+import org.springframework.core.env.Environment;
+
+/**
+ * Testcontainers startup strategies. The strategy to use can be configured in the Spring
+ * {@link Environment} with a {@value #PROPERTY} property.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public enum TestcontainersStartup {
+
+	/**
+	 * Startup containers sequentially.
+	 */
+	SEQUENTIAL {
+
+		@Override
+		void start(Collection<? extends Startable> startables) {
+			startables.forEach(Startable::start);
+		}
+
+	},
+
+	/**
+	 * Startup containers in parallel.
+	 */
+	PARALLEL {
+
+		@Override
+		void start(Collection<? extends Startable> startables) {
+			Startables.deepStart(startables).join();
+		}
+
+	};
+
+	/**
+	 * The {@link Environment} property used to change the {@link TestcontainersStartup}
+	 * strategy.
+	 */
+	public static final String PROPERTY = "spring.testcontainers.beans.startup";
+
+	abstract void start(Collection<? extends Startable> startables);
+
+	static TestcontainersStartup get(ConfigurableEnvironment environment) {
+		return get((environment != null) ? environment.getProperty(PROPERTY) : null);
+	}
+
+	private static TestcontainersStartup get(String value) {
+		if (value == null) {
+			return SEQUENTIAL;
+		}
+		String canonicalName = getCanonicalName(value);
+		for (TestcontainersStartup candidate : values()) {
+			if (candidate.name().equalsIgnoreCase(canonicalName)) {
+				return candidate;
+			}
+		}
+		throw new IllegalArgumentException("Unknown '%s' property value '%s'".formatted(PROPERTY, value));
+	}
+
+	private static String getCanonicalName(String name) {
+		StringBuilder canonicalName = new StringBuilder(name.length());
+		name.chars()
+			.filter(Character::isLetterOrDigit)
+			.map(Character::toLowerCase)
+			.forEach((c) -> canonicalName.append((char) c));
+		return canonicalName.toString();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionDetailsFactory.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionDetailsFactory.java
index c4f01a7ad347..2a41db031fdc 100644
--- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionDetailsFactory.java
+++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionDetailsFactory.java
@@ -17,15 +17,22 @@
 package org.springframework.boot.testcontainers.service.connection;
 
 import java.util.Arrays;
+import java.util.stream.Stream;
 
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
 import org.testcontainers.containers.Container;
 
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
 import org.springframework.beans.factory.InitializingBean;
 import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
 import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory;
 import org.springframework.boot.origin.Origin;
 import org.springframework.boot.origin.OriginProvider;
 import org.springframework.core.ResolvableType;
+import org.springframework.core.io.support.SpringFactoriesLoader;
+import org.springframework.core.io.support.SpringFactoriesLoader.FailureHandler;
 import org.springframework.util.Assert;
 import org.springframework.util.ClassUtils;
 import org.springframework.util.ObjectUtils;
@@ -153,4 +160,25 @@ public Origin getOrigin() {
 
 	}
 
+	static class ContainerConnectionDetailsFactoriesRuntimeHints implements RuntimeHintsRegistrar {
+
+		private static final Log logger = LogFactory.getLog(ContainerConnectionDetailsFactoriesRuntimeHints.class);
+
+		@Override
+		public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
+			SpringFactoriesLoader.forDefaultResourceLocation(classLoader)
+				.load(ConnectionDetailsFactory.class, FailureHandler.logging(logger))
+				.stream()
+				.flatMap(this::requiredClassNames)
+				.forEach((requiredClassName) -> hints.reflection()
+					.registerTypeIfPresent(classLoader, requiredClassName));
+		}
+
+		private Stream<String> requiredClassNames(ConnectionDetailsFactory<?, ?> connectionDetailsFactory) {
+			return (connectionDetailsFactory instanceof ContainerConnectionDetailsFactory<?, ?> containerConnectionDetailsFactory)
+					? Stream.of(containerConnectionDetailsFactory.requiredClassNames) : Stream.empty();
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionSource.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionSource.java
index 7ef972e1f79a..30aece533d18 100644
--- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionSource.java
+++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionSource.java
@@ -56,7 +56,7 @@ public final class ContainerConnectionSource<C extends Container<?>> implements
 
 	private final Set<Class<?>> connectionDetailsTypes;
 
-	private Supplier<C> containerSupplier;
+	private final Supplier<C> containerSupplier;
 
 	ContainerConnectionSource(String beanNameSuffix, Origin origin, Class<C> containerType, String containerImageName,
 			MergedAnnotation<ServiceConnection> annotation, Supplier<C> containerSupplier) {
diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionContextCustomizerFactory.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionContextCustomizerFactory.java
index 9e1b86d526e7..1f18f06c1a0d 100644
--- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionContextCustomizerFactory.java
+++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionContextCustomizerFactory.java
@@ -41,6 +41,7 @@
  * @author Moritz Halbritter
  * @author Andy Wilkinson
  * @author Phillip Webb
+ * @author Scott Frederick
  */
 class ServiceConnectionContextCustomizerFactory implements ContextCustomizerFactory {
 
@@ -48,19 +49,26 @@ class ServiceConnectionContextCustomizerFactory implements ContextCustomizerFact
 	public ContextCustomizer createContextCustomizer(Class<?> testClass,
 			List<ContextConfigurationAttributes> configAttributes) {
 		List<ContainerConnectionSource<?>> sources = new ArrayList<>();
-		findSources(testClass, sources);
+		collectSources(testClass, sources);
 		return new ServiceConnectionContextCustomizer(sources);
 	}
 
-	private void findSources(Class<?> clazz, List<ContainerConnectionSource<?>> sources) {
-		ReflectionUtils.doWithFields(clazz, (field) -> {
+	private void collectSources(Class<?> candidate, List<ContainerConnectionSource<?>> sources) {
+		if (candidate == Object.class || candidate == null) {
+			return;
+		}
+		ReflectionUtils.doWithLocalFields(candidate, (field) -> {
 			MergedAnnotations annotations = MergedAnnotations.from(field);
 			annotations.stream(ServiceConnection.class)
 				.forEach((annotation) -> sources.add(createSource(field, annotation)));
 		});
-		if (TestContextAnnotationUtils.searchEnclosingClass(clazz)) {
-			findSources(clazz.getEnclosingClass(), sources);
+		if (TestContextAnnotationUtils.searchEnclosingClass(candidate)) {
+			collectSources(candidate.getEnclosingClass(), sources);
+		}
+		for (Class<?> implementedInterface : candidate.getInterfaces()) {
+			collectSources(implementedInterface, sources);
 		}
+		collectSources(candidate.getSuperclass(), sources);
 	}
 
 	@SuppressWarnings("unchecked")
@@ -74,8 +82,12 @@ private <C extends Container<?>> ContainerConnectionSource<?> createSource(Field
 				field.getDeclaringClass().getName(), Container.class.getName()));
 		Class<C> containerType = (Class<C>) fieldValue.getClass();
 		C container = (C) fieldValue;
-		return new ContainerConnectionSource<>("test", origin, containerType, container.getDockerImageName(),
-				annotation, () -> container);
+		// container.getDockerImageName() fails if there is no running docker environment
+		// When running tests that doesn't matter, but running AOT processing should be
+		// possible without a Docker environment
+		String dockerImageName = isAotProcessingInProgress() ? null : container.getDockerImageName();
+		return new ContainerConnectionSource<>("test", origin, containerType, dockerImageName, annotation,
+				() -> container);
 	}
 
 	private Object getFieldValue(Field field) {
@@ -83,4 +95,8 @@ private Object getFieldValue(Field field) {
 		return ReflectionUtils.getField(field, null);
 	}
 
+	private boolean isAotProcessingInProgress() {
+		return Boolean.getBoolean("spring.aot.processing");
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/activemq/ActiveMQContainerConnectionDetailsFactory.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/activemq/ActiveMQContainerConnectionDetailsFactory.java
new file mode 100644
index 000000000000..82324da487ee
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/activemq/ActiveMQContainerConnectionDetailsFactory.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.service.connection.activemq;
+
+import org.testcontainers.containers.Container;
+import org.testcontainers.containers.GenericContainer;
+
+import org.springframework.boot.autoconfigure.jms.activemq.ActiveMQConnectionDetails;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+
+/**
+ * {@link ContainerConnectionDetailsFactory} to create {@link ActiveMQConnectionDetails}
+ * from a {@link ServiceConnection @ServiceConnection}-annotated {@link GenericContainer}
+ * using the {@code "symptoma/activemq"} image.
+ *
+ * @author EddĂș MelĂ©ndez
+ */
+class ActiveMQContainerConnectionDetailsFactory
+		extends ContainerConnectionDetailsFactory<Container<?>, ActiveMQConnectionDetails> {
+
+	ActiveMQContainerConnectionDetailsFactory() {
+		super("symptoma/activemq");
+	}
+
+	@Override
+	protected ActiveMQConnectionDetails getContainerConnectionDetails(ContainerConnectionSource<Container<?>> source) {
+		return new ActiveMQContainerConnectionDetails(source);
+	}
+
+	private static final class ActiveMQContainerConnectionDetails extends ContainerConnectionDetails<Container<?>>
+			implements ActiveMQConnectionDetails {
+
+		private ActiveMQContainerConnectionDetails(ContainerConnectionSource<Container<?>> source) {
+			super(source);
+		}
+
+		@Override
+		public String getBrokerUrl() {
+			return "tcp://" + getContainer().getHost() + ":" + getContainer().getFirstMappedPort();
+		}
+
+		@Override
+		public String getUser() {
+			return getContainer().getEnvMap().get("ACTIVEMQ_USERNAME");
+		}
+
+		@Override
+		public String getPassword() {
+			return getContainer().getEnvMap().get("ACTIVEMQ_PASSWORD");
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/activemq/package-info.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/activemq/package-info.java
new file mode 100644
index 000000000000..0981f1bf9b21
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/activemq/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Support for testcontainers ActiveMQ service connections.
+ */
+package org.springframework.boot.testcontainers.service.connection.activemq;
diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryMetricsContainerConnectionDetailsFactory.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryMetricsContainerConnectionDetailsFactory.java
new file mode 100644
index 000000000000..8dd417ccd1b7
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryMetricsContainerConnectionDetailsFactory.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.service.connection.otlp;
+
+import org.testcontainers.containers.Container;
+import org.testcontainers.containers.GenericContainer;
+
+import org.springframework.boot.actuate.autoconfigure.metrics.export.otlp.OtlpMetricsConnectionDetails;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+
+/**
+ * {@link ContainerConnectionDetailsFactory} to create
+ * {@link OtlpMetricsConnectionDetails} from a
+ * {@link ServiceConnection @ServiceConnection}-annotated {@link GenericContainer} using
+ * the {@code "otel/opentelemetry-collector-contrib"} image.
+ *
+ * @author EddĂș MelĂ©ndez
+ */
+class OpenTelemetryMetricsContainerConnectionDetailsFactory
+		extends ContainerConnectionDetailsFactory<Container<?>, OtlpMetricsConnectionDetails> {
+
+	OpenTelemetryMetricsContainerConnectionDetailsFactory() {
+		super("otel/opentelemetry-collector-contrib",
+				"org.springframework.boot.actuate.autoconfigure.metrics.export.otlp.OtlpMetricsExportAutoConfiguration");
+	}
+
+	@Override
+	protected OtlpMetricsConnectionDetails getContainerConnectionDetails(
+			ContainerConnectionSource<Container<?>> source) {
+		return new OpenTelemetryMetricsContainerConnectionDetails(source);
+	}
+
+	private static final class OpenTelemetryMetricsContainerConnectionDetails
+			extends ContainerConnectionDetails<Container<?>> implements OtlpMetricsConnectionDetails {
+
+		private OpenTelemetryMetricsContainerConnectionDetails(ContainerConnectionSource<Container<?>> source) {
+			super(source);
+		}
+
+		@Override
+		public String getUrl() {
+			return "http://%s:%d/v1/metrics".formatted(getContainer().getHost(), getContainer().getMappedPort(4318));
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryTracingContainerConnectionDetailsFactory.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryTracingContainerConnectionDetailsFactory.java
new file mode 100644
index 000000000000..6c3e72ac797c
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryTracingContainerConnectionDetailsFactory.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.service.connection.otlp;
+
+import org.testcontainers.containers.Container;
+import org.testcontainers.containers.GenericContainer;
+
+import org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpTracingConnectionDetails;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+
+/**
+ * {@link ContainerConnectionDetailsFactory} to create
+ * {@link OtlpTracingConnectionDetails} from a
+ * {@link ServiceConnection @ServiceConnection}-annotated {@link GenericContainer} using
+ * the {@code "otel/opentelemetry-collector-contrib"} image.
+ *
+ * @author EddĂș MelĂ©ndez
+ */
+class OpenTelemetryTracingContainerConnectionDetailsFactory
+		extends ContainerConnectionDetailsFactory<Container<?>, OtlpTracingConnectionDetails> {
+
+	OpenTelemetryTracingContainerConnectionDetailsFactory() {
+		super("otel/opentelemetry-collector-contrib",
+				"org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpAutoConfiguration");
+	}
+
+	@Override
+	protected OtlpTracingConnectionDetails getContainerConnectionDetails(
+			ContainerConnectionSource<Container<?>> source) {
+		return new OpenTelemetryTracingContainerConnectionDetails(source);
+	}
+
+	private static final class OpenTelemetryTracingContainerConnectionDetails
+			extends ContainerConnectionDetails<Container<?>> implements OtlpTracingConnectionDetails {
+
+		private OpenTelemetryTracingContainerConnectionDetails(ContainerConnectionSource<Container<?>> source) {
+			super(source);
+		}
+
+		@Override
+		public String getUrl() {
+			return "http://%s:%d/v1/traces".formatted(getContainer().getHost(), getContainer().getMappedPort(4318));
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/otlp/package-info.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/otlp/package-info.java
new file mode 100644
index 000000000000..59b4a9dce0a2
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/otlp/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Support for testcontainers OpenTelemetry service connections.
+ */
+package org.springframework.boot.testcontainers.service.connection.otlp;
diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/pulsar/PulsarContainerConnectionDetailsFactory.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/pulsar/PulsarContainerConnectionDetailsFactory.java
new file mode 100644
index 000000000000..836c1a127d23
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/pulsar/PulsarContainerConnectionDetailsFactory.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.service.connection.pulsar;
+
+import org.testcontainers.containers.PulsarContainer;
+
+import org.springframework.boot.autoconfigure.pulsar.PulsarConnectionDetails;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+
+/**
+ * {@link ContainerConnectionDetailsFactory} to create {@link PulsarConnectionDetails}
+ * from a {@link ServiceConnection @ServiceConnection}-annotated {@link PulsarContainer}.
+ *
+ * @author Chris Bono
+ */
+class PulsarContainerConnectionDetailsFactory
+		extends ContainerConnectionDetailsFactory<PulsarContainer, PulsarConnectionDetails> {
+
+	@Override
+	protected PulsarConnectionDetails getContainerConnectionDetails(ContainerConnectionSource<PulsarContainer> source) {
+		return new PulsarContainerConnectionDetails(source);
+	}
+
+	/**
+	 * {@link PulsarConnectionDetails} backed by a {@link ContainerConnectionSource}.
+	 */
+	private static final class PulsarContainerConnectionDetails extends ContainerConnectionDetails<PulsarContainer>
+			implements PulsarConnectionDetails {
+
+		private PulsarContainerConnectionDetails(ContainerConnectionSource<PulsarContainer> source) {
+			super(source);
+		}
+
+		@Override
+		public String getBrokerUrl() {
+			return getContainer().getPulsarBrokerUrl();
+		}
+
+		@Override
+		public String getAdminUrl() {
+			return getContainer().getHttpServiceUrl();
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/pulsar/package-info.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/pulsar/package-info.java
new file mode 100644
index 000000000000..4938ad863134
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/pulsar/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Support for testcontainers Pulsar service connections.
+ */
+package org.springframework.boot.testcontainers.service.connection.pulsar;
diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleFreeR2dbcContainerConnectionDetailsFactory.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleFreeR2dbcContainerConnectionDetailsFactory.java
new file mode 100644
index 000000000000..09e381faa0fd
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleFreeR2dbcContainerConnectionDetailsFactory.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.service.connection.r2dbc;
+
+import io.r2dbc.spi.ConnectionFactoryOptions;
+import org.testcontainers.oracle.OracleContainer;
+import org.testcontainers.oracle.OracleR2DBCDatabaseContainer;
+
+import org.springframework.boot.autoconfigure.r2dbc.R2dbcConnectionDetails;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+
+/**
+ * {@link ContainerConnectionDetailsFactory} to create {@link R2dbcConnectionDetails} from
+ * a {@link ServiceConnection @ServiceConnection}-annotated {@link OracleContainer}.
+ *
+ * @author EddĂș MelĂ©ndez
+ */
+class OracleFreeR2dbcContainerConnectionDetailsFactory
+		extends ContainerConnectionDetailsFactory<OracleContainer, R2dbcConnectionDetails> {
+
+	OracleFreeR2dbcContainerConnectionDetailsFactory() {
+		super(ANY_CONNECTION_NAME, "io.r2dbc.spi.ConnectionFactoryOptions");
+	}
+
+	@Override
+	public R2dbcConnectionDetails getContainerConnectionDetails(ContainerConnectionSource<OracleContainer> source) {
+		return new R2dbcDatabaseContainerConnectionDetails(source);
+	}
+
+	/**
+	 * {@link R2dbcConnectionDetails} backed by a {@link ContainerConnectionSource}.
+	 */
+	private static final class R2dbcDatabaseContainerConnectionDetails
+			extends ContainerConnectionDetails<OracleContainer> implements R2dbcConnectionDetails {
+
+		private R2dbcDatabaseContainerConnectionDetails(ContainerConnectionSource<OracleContainer> source) {
+			super(source);
+		}
+
+		@Override
+		public ConnectionFactoryOptions getConnectionFactoryOptions() {
+			return OracleR2DBCDatabaseContainer.getOptions(getContainer());
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleR2dbcContainerConnectionDetailsFactory.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleR2dbcContainerConnectionDetailsFactory.java
deleted file mode 100644
index 783e83a77115..000000000000
--- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleR2dbcContainerConnectionDetailsFactory.java
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.testcontainers.service.connection.r2dbc;
-
-import io.r2dbc.spi.ConnectionFactoryOptions;
-import org.testcontainers.containers.OracleContainer;
-import org.testcontainers.containers.OracleR2DBCDatabaseContainer;
-
-import org.springframework.boot.autoconfigure.r2dbc.R2dbcConnectionDetails;
-import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory;
-import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource;
-import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
-
-/**
- * {@link ContainerConnectionDetailsFactory} to create {@link R2dbcConnectionDetails} from
- * a {@link ServiceConnection @ServiceConnection}-annotated {@link OracleContainer}.
- *
- * @author EddĂș MelĂ©ndez
- */
-class OracleR2dbcContainerConnectionDetailsFactory
-		extends ContainerConnectionDetailsFactory<OracleContainer, R2dbcConnectionDetails> {
-
-	OracleR2dbcContainerConnectionDetailsFactory() {
-		super(ANY_CONNECTION_NAME, "io.r2dbc.spi.ConnectionFactoryOptions");
-	}
-
-	@Override
-	public R2dbcConnectionDetails getContainerConnectionDetails(ContainerConnectionSource<OracleContainer> source) {
-		return new R2dbcDatabaseContainerConnectionDetails(source);
-	}
-
-	/**
-	 * {@link R2dbcConnectionDetails} backed by a {@link ContainerConnectionSource}.
-	 */
-	private static final class R2dbcDatabaseContainerConnectionDetails
-			extends ContainerConnectionDetails<OracleContainer> implements R2dbcConnectionDetails {
-
-		private R2dbcDatabaseContainerConnectionDetails(ContainerConnectionSource<OracleContainer> source) {
-			super(source);
-		}
-
-		@Override
-		public ConnectionFactoryOptions getConnectionFactoryOptions() {
-			return OracleR2DBCDatabaseContainer.getOptions(getContainer());
-		}
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleXeR2dbcContainerConnectionDetailsFactory.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleXeR2dbcContainerConnectionDetailsFactory.java
new file mode 100644
index 000000000000..a5f21c796a46
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleXeR2dbcContainerConnectionDetailsFactory.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.service.connection.r2dbc;
+
+import io.r2dbc.spi.ConnectionFactoryOptions;
+import org.testcontainers.containers.OracleContainer;
+import org.testcontainers.containers.OracleR2DBCDatabaseContainer;
+
+import org.springframework.boot.autoconfigure.r2dbc.R2dbcConnectionDetails;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+
+/**
+ * {@link ContainerConnectionDetailsFactory} to create {@link R2dbcConnectionDetails} from
+ * a {@link ServiceConnection @ServiceConnection}-annotated {@link OracleContainer}.
+ *
+ * @author EddĂș MelĂ©ndez
+ */
+class OracleXeR2dbcContainerConnectionDetailsFactory
+		extends ContainerConnectionDetailsFactory<OracleContainer, R2dbcConnectionDetails> {
+
+	OracleXeR2dbcContainerConnectionDetailsFactory() {
+		super(ANY_CONNECTION_NAME, "io.r2dbc.spi.ConnectionFactoryOptions");
+	}
+
+	@Override
+	public R2dbcConnectionDetails getContainerConnectionDetails(ContainerConnectionSource<OracleContainer> source) {
+		return new R2dbcDatabaseContainerConnectionDetails(source);
+	}
+
+	/**
+	 * {@link R2dbcConnectionDetails} backed by a {@link ContainerConnectionSource}.
+	 */
+	private static final class R2dbcDatabaseContainerConnectionDetails
+			extends ContainerConnectionDetails<OracleContainer> implements R2dbcConnectionDetails {
+
+		private R2dbcDatabaseContainerConnectionDetails(ContainerConnectionSource<OracleContainer> source) {
+			super(source);
+		}
+
+		@Override
+		public ConnectionFactoryOptions getConnectionFactoryOptions() {
+			return OracleR2DBCDatabaseContainer.getOptions(getContainer());
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/additional-spring-configuration-metadata.json
new file mode 100644
index 000000000000..ca82e22875ba
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/additional-spring-configuration-metadata.json
@@ -0,0 +1,10 @@
+{
+  "properties": [
+    {
+      "name": "spring.testcontainers.beans.startup",
+      "type": "org.springframework.boot.testcontainers.lifecycle.TestcontainersStartup",
+       "description": "Testcontainers startup modes.",
+       "defaultValue": "sequential"
+    }
+  ]
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/spring.factories
index 2cfe37359c38..386f2a1a3553 100644
--- a/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/spring.factories
+++ b/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/spring.factories
@@ -8,6 +8,7 @@ org.springframework.boot.testcontainers.service.connection.ServiceConnectionCont
 
 # Connection Details Factories
 org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory=\
+org.springframework.boot.testcontainers.service.connection.activemq.ActiveMQContainerConnectionDetailsFactory,\
 org.springframework.boot.testcontainers.service.connection.amqp.RabbitContainerConnectionDetailsFactory,\
 org.springframework.boot.testcontainers.service.connection.cassandra.CassandraContainerConnectionDetailsFactory,\
 org.springframework.boot.testcontainers.service.connection.couchbase.CouchbaseContainerConnectionDetailsFactory,\
@@ -18,9 +19,13 @@ org.springframework.boot.testcontainers.service.connection.kafka.KafkaContainerC
 org.springframework.boot.testcontainers.service.connection.liquibase.LiquibaseContainerConnectionDetailsFactory,\
 org.springframework.boot.testcontainers.service.connection.mongo.MongoContainerConnectionDetailsFactory,\
 org.springframework.boot.testcontainers.service.connection.neo4j.Neo4jContainerConnectionDetailsFactory,\
+org.springframework.boot.testcontainers.service.connection.otlp.OpenTelemetryMetricsContainerConnectionDetailsFactory,\
+org.springframework.boot.testcontainers.service.connection.otlp.OpenTelemetryTracingContainerConnectionDetailsFactory,\
+org.springframework.boot.testcontainers.service.connection.pulsar.PulsarContainerConnectionDetailsFactory,\
 org.springframework.boot.testcontainers.service.connection.r2dbc.MariaDbR2dbcContainerConnectionDetailsFactory,\
 org.springframework.boot.testcontainers.service.connection.r2dbc.MySqlR2dbcContainerConnectionDetailsFactory,\
-org.springframework.boot.testcontainers.service.connection.r2dbc.OracleR2dbcContainerConnectionDetailsFactory,\
+org.springframework.boot.testcontainers.service.connection.r2dbc.OracleFreeR2dbcContainerConnectionDetailsFactory,\
+org.springframework.boot.testcontainers.service.connection.r2dbc.OracleXeR2dbcContainerConnectionDetailsFactory,\
 org.springframework.boot.testcontainers.service.connection.r2dbc.PostgresR2dbcContainerConnectionDetailsFactory,\
 org.springframework.boot.testcontainers.service.connection.r2dbc.SqlServerR2dbcContainerConnectionDetailsFactory,\
 org.springframework.boot.testcontainers.service.connection.redis.RedisContainerConnectionDetailsFactory,\
diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/spring/aot.factories b/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/spring/aot.factories
index 52cbdbe6b160..5b3d49bd5020 100644
--- a/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/spring/aot.factories
+++ b/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/spring/aot.factories
@@ -1,2 +1,5 @@
 org.springframework.beans.factory.aot.BeanRegistrationExcludeFilter=\
-org.springframework.boot.testcontainers.service.connection.ConnectionDetailsRegistrar.ServiceConnectionBeanRegistrationExcludeFilter
\ No newline at end of file
+org.springframework.boot.testcontainers.service.connection.ConnectionDetailsRegistrar.ServiceConnectionBeanRegistrationExcludeFilter
+
+org.springframework.aot.hint.RuntimeHintsRegistrar=\
+org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory.ContainerConnectionDetailsFactoriesRuntimeHints
diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializerTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializerTests.java
index ee27dae34f70..a00c0da51de9 100644
--- a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializerTests.java
+++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializerTests.java
@@ -16,13 +16,18 @@
 
 package org.springframework.boot.testcontainers.lifecycle;
 
+import java.util.Map;
+
 import org.junit.jupiter.api.Test;
 import org.testcontainers.containers.GenericContainer;
 import org.testcontainers.lifecycle.Startable;
 
+import org.springframework.beans.factory.config.BeanPostProcessor;
+import org.springframework.beans.factory.support.AbstractBeanFactory;
 import org.springframework.context.annotation.AnnotationConfigApplicationContext;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.core.env.MapPropertySource;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.BDDMockito.given;
@@ -104,6 +109,22 @@ void dealsWithBeanCurrentlyInCreationException() {
 		applicationContext.refresh();
 	}
 
+	@Test
+	void setupStartupBasedOnEnvironmentProperty() {
+		AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext();
+		applicationContext.getEnvironment()
+			.getPropertySources()
+			.addLast(new MapPropertySource("test", Map.of("spring.testcontainers.beans.startup", "parallel")));
+		new TestcontainersLifecycleApplicationContextInitializer().initialize(applicationContext);
+		AbstractBeanFactory beanFactory = (AbstractBeanFactory) applicationContext.getBeanFactory();
+		BeanPostProcessor beanPostProcessor = beanFactory.getBeanPostProcessors()
+			.stream()
+			.filter(TestcontainersLifecycleBeanPostProcessor.class::isInstance)
+			.findFirst()
+			.get();
+		assertThat(beanPostProcessor).extracting("startup").isEqualTo(TestcontainersStartup.PARALLEL);
+	}
+
 	private AnnotationConfigApplicationContext createApplicationContext(Startable container) {
 		AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext();
 		new TestcontainersLifecycleApplicationContextInitializer().initialize(applicationContext);
diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersStartupTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersStartupTests.java
new file mode 100644
index 000000000000..848ece580290
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersStartupTests.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.lifecycle;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+import org.testcontainers.lifecycle.Startable;
+
+import org.springframework.mock.env.MockEnvironment;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link TestcontainersStartup}.
+ *
+ * @author Phillip Webb
+ */
+class TestcontainersStartupTests {
+
+	private static final String PROPERTY = TestcontainersStartup.PROPERTY;
+
+	private final AtomicInteger counter = new AtomicInteger();
+
+	@Test
+	void startWhenSquentialStartsSequentially() {
+		List<TestStartable> startables = createTestStartables(100);
+		TestcontainersStartup.SEQUENTIAL.start(startables);
+		for (int i = 0; i < startables.size(); i++) {
+			assertThat(startables.get(i).getIndex()).isEqualTo(i);
+			assertThat(startables.get(i).getThreadName()).isEqualTo(Thread.currentThread().getName());
+		}
+	}
+
+	@Test
+	void startWhenParallelStartsInParallel() {
+		List<TestStartable> startables = createTestStartables(100);
+		TestcontainersStartup.PARALLEL.start(startables);
+		assertThat(startables.stream().map(TestStartable::getThreadName)).hasSizeGreaterThan(1);
+	}
+
+	@Test
+	void getWhenNoPropertyReturnsDefault() {
+		MockEnvironment environment = new MockEnvironment();
+		assertThat(TestcontainersStartup.get(environment)).isEqualTo(TestcontainersStartup.SEQUENTIAL);
+	}
+
+	@Test
+	void getWhenPropertyReturnsBasedOnValue() {
+		MockEnvironment environment = new MockEnvironment();
+		assertThat(TestcontainersStartup.get(environment.withProperty(PROPERTY, "SEQUENTIAL")))
+			.isEqualTo(TestcontainersStartup.SEQUENTIAL);
+		assertThat(TestcontainersStartup.get(environment.withProperty(PROPERTY, "sequential")))
+			.isEqualTo(TestcontainersStartup.SEQUENTIAL);
+		assertThat(TestcontainersStartup.get(environment.withProperty(PROPERTY, "SEQuenTIaL")))
+			.isEqualTo(TestcontainersStartup.SEQUENTIAL);
+		assertThat(TestcontainersStartup.get(environment.withProperty(PROPERTY, "S-E-Q-U-E-N-T-I-A-L")))
+			.isEqualTo(TestcontainersStartup.SEQUENTIAL);
+		assertThat(TestcontainersStartup.get(environment.withProperty(PROPERTY, "parallel")))
+			.isEqualTo(TestcontainersStartup.PARALLEL);
+	}
+
+	@Test
+	void getWhenUnknownPropertyThrowsException() {
+		MockEnvironment environment = new MockEnvironment();
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> TestcontainersStartup.get(environment.withProperty(PROPERTY, "bad")))
+			.withMessage("Unknown 'spring.testcontainers.beans.startup' property value 'bad'");
+	}
+
+	private List<TestStartable> createTestStartables(int size) {
+		List<TestStartable> testStartables = new ArrayList<>(size);
+		for (int i = 0; i < size; i++) {
+			testStartables.add(new TestStartable());
+		}
+		return testStartables;
+	}
+
+	private class TestStartable implements Startable {
+
+		private int index;
+
+		private String threadName;
+
+		@Override
+		public void start() {
+			this.index = TestcontainersStartupTests.this.counter.getAndIncrement();
+			this.threadName = Thread.currentThread().getName();
+		}
+
+		@Override
+		public void stop() {
+		}
+
+		int getIndex() {
+			return this.index;
+		}
+
+		String getThreadName() {
+			return this.threadName;
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionDetailsFactoryHints.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionDetailsFactoryHints.java
new file mode 100644
index 000000000000..0ce8a7acf5d1
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ContainerConnectionDetailsFactoryHints.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.service.connection;
+
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory.ContainerConnectionDetailsFactoriesRuntimeHints;
+
+public final class ContainerConnectionDetailsFactoryHints {
+
+	private ContainerConnectionDetailsFactoryHints() {
+	}
+
+	public static RuntimeHints getRegisteredHints(ClassLoader classLoader) {
+		RuntimeHints hints = new RuntimeHints();
+		new ContainerConnectionDetailsFactoriesRuntimeHints().registerHints(hints, classLoader);
+		return hints;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionContextCustomizerFactoryTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionContextCustomizerFactoryTests.java
index dd7b0a681fe2..ba3071e8aa3d 100644
--- a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionContextCustomizerFactoryTests.java
+++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ServiceConnectionContextCustomizerFactoryTests.java
@@ -71,7 +71,39 @@ void createContextCustomizerWhenEnclosingClassHasServiceConnectionsReturnsCustom
 	}
 
 	@Test
-	void createContextCustomizerWhenClassHasNonStaticServiceConnectionFailsWithHepfulException() {
+	void createContextCustomizerWhenInterfaceHasServiceConnectionsReturnsCustomizer() {
+		ServiceConnectionContextCustomizer customizer = (ServiceConnectionContextCustomizer) this.factory
+			.createContextCustomizer(ServiceConnectionsInterface.class, null);
+		assertThat(customizer).isNotNull();
+		assertThat(customizer.getSources()).hasSize(2);
+	}
+
+	@Test
+	void createContextCustomizerWhenSuperclassHasServiceConnectionsReturnsCustomizer() {
+		ServiceConnectionContextCustomizer customizer = (ServiceConnectionContextCustomizer) this.factory
+			.createContextCustomizer(ServiceConnectionsSubclass.class, null);
+		assertThat(customizer).isNotNull();
+		assertThat(customizer.getSources()).hasSize(2);
+	}
+
+	@Test
+	void createContextCustomizerWhenImplementedInterfaceHasServiceConnectionsReturnsCustomizer() {
+		ServiceConnectionContextCustomizer customizer = (ServiceConnectionContextCustomizer) this.factory
+			.createContextCustomizer(ServiceConnectionsImpl.class, null);
+		assertThat(customizer).isNotNull();
+		assertThat(customizer.getSources()).hasSize(2);
+	}
+
+	@Test
+	void createContextCustomizerWhenInheritedImplementedInterfaceHasServiceConnectionsReturnsCustomizer() {
+		ServiceConnectionContextCustomizer customizer = (ServiceConnectionContextCustomizer) this.factory
+			.createContextCustomizer(ServiceConnectionsImplSubclass.class, null);
+		assertThat(customizer).isNotNull();
+		assertThat(customizer.getSources()).hasSize(2);
+	}
+
+	@Test
+	void createContextCustomizerWhenClassHasNonStaticServiceConnectionFailsWithHelpfulException() {
 		assertThatIllegalStateException()
 			.isThrownBy(() -> this.factory.createContextCustomizer(NonStaticServiceConnection.class, null))
 			.withMessage("@ServiceConnection field 'service' must be static");
@@ -79,7 +111,7 @@ void createContextCustomizerWhenClassHasNonStaticServiceConnectionFailsWithHepfu
 	}
 
 	@Test
-	void createContextCustomizerWhenClassHasAnnotationOnNonConnectionFieldFailsWithHepfulException() {
+	void createContextCustomizerWhenClassHasAnnotationOnNonConnectionFieldFailsWithHelpfulException() {
 		assertThatIllegalStateException()
 			.isThrownBy(() -> this.factory.createContextCustomizer(ServiceConnectionOnWrongFieldType.class, null))
 			.withMessage("Field 'service2' in " + ServiceConnectionOnWrongFieldType.class.getName()
@@ -141,6 +173,31 @@ class NestedClass {
 
 	}
 
+	interface ServiceConnectionsInterface {
+
+		@ServiceConnection
+		Container<?> service1 = new MockContainer();
+
+		@ServiceConnection
+		Container<?> service2 = new MockContainer();
+
+		default void dummy() {
+		}
+
+	}
+
+	static class ServiceConnectionsSubclass extends ServiceConnections {
+
+	}
+
+	static class ServiceConnectionsImpl implements ServiceConnectionsInterface {
+
+	}
+
+	static class ServiceConnectionsImplSubclass extends ServiceConnectionsImpl {
+
+	}
+
 	static class NonStaticServiceConnection {
 
 		@ServiceConnection
diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/activemq/ActiveMQContainerConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/activemq/ActiveMQContainerConnectionDetailsFactoryIntegrationTests.java
new file mode 100644
index 000000000000..647b4861d086
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/activemq/ActiveMQContainerConnectionDetailsFactoryIntegrationTests.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.service.connection.activemq;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.Test;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
+import org.springframework.boot.autoconfigure.jms.JmsAutoConfiguration;
+import org.springframework.boot.autoconfigure.jms.activemq.ActiveMQAutoConfiguration;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.boot.testsupport.testcontainers.ActiveMQContainer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.jms.annotation.JmsListener;
+import org.springframework.jms.core.JmsMessagingTemplate;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ActiveMQContainerConnectionDetailsFactory}.
+ *
+ * @author EddĂș MelĂ©ndez
+ */
+@SpringJUnitConfig
+@Testcontainers(disabledWithoutDocker = true)
+class ActiveMQContainerConnectionDetailsFactoryIntegrationTests {
+
+	@Container
+	@ServiceConnection
+	static final ActiveMQContainer activemq = new ActiveMQContainer();
+
+	@Autowired
+	private JmsMessagingTemplate jmsTemplate;
+
+	@Autowired
+	private TestListener listener;
+
+	@Test
+	void connectionCanBeMadeToActiveMQContainer() {
+		this.jmsTemplate.convertAndSend("sample.queue", "message");
+		Awaitility.waitAtMost(Duration.ofMinutes(1))
+			.untilAsserted(() -> assertThat(this.listener.messages).containsExactly("message"));
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	@ImportAutoConfiguration({ ActiveMQAutoConfiguration.class, JmsAutoConfiguration.class })
+	static class TestConfiguration {
+
+		@Bean
+		TestListener testListener() {
+			return new TestListener();
+		}
+
+	}
+
+	static class TestListener {
+
+		private final List<String> messages = new ArrayList<>();
+
+		@JmsListener(destination = "sample.queue")
+		void processMessage(String message) {
+			this.messages.add(message);
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/mongo/MongoContainerConnectionDetailsFactoryTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/mongo/MongoContainerConnectionDetailsFactoryTests.java
new file mode 100644
index 000000000000..85c08223c671
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/mongo/MongoContainerConnectionDetailsFactoryTests.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.service.connection.mongo;
+
+import com.mongodb.ConnectionString;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactoryHints;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link MongoContainerConnectionDetailsFactory}.
+ *
+ * @author Moritz Halbritter
+ */
+class MongoContainerConnectionDetailsFactoryTests {
+
+	@Test
+	void shouldRegisterHints() {
+		RuntimeHints hints = ContainerConnectionDetailsFactoryHints.getRegisteredHints(getClass().getClassLoader());
+		assertThat(RuntimeHintsPredicates.reflection().onType(ConnectionString.class)).accepts(hints);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/neo4j/Neo4jContainerConnectionDetailsFactoryTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/neo4j/Neo4jContainerConnectionDetailsFactoryTests.java
new file mode 100644
index 000000000000..9c59c3e42be2
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/neo4j/Neo4jContainerConnectionDetailsFactoryTests.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.service.connection.neo4j;
+
+import org.junit.jupiter.api.Test;
+import org.neo4j.driver.AuthToken;
+
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactoryHints;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link Neo4jContainerConnectionDetailsFactory}.
+ *
+ * @author Moritz Halbritter
+ */
+class Neo4jContainerConnectionDetailsFactoryTests {
+
+	@Test
+	void shouldRegisterHints() {
+		RuntimeHints hints = ContainerConnectionDetailsFactoryHints.getRegisteredHints(getClass().getClassLoader());
+		assertThat(RuntimeHintsPredicates.reflection().onType(AuthToken.class)).accepts(hints);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryMetricsContainerConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryMetricsContainerConnectionDetailsFactoryIntegrationTests.java
new file mode 100644
index 000000000000..57a951b4bc54
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryMetricsContainerConnectionDetailsFactoryIntegrationTests.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.service.connection.otlp;
+
+import java.time.Duration;
+
+import io.micrometer.core.instrument.Clock;
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.DistributionSummary;
+import io.micrometer.core.instrument.Gauge;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Timer;
+import io.restassured.RestAssured;
+import io.restassured.response.Response;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.Test;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.MountableFile;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.actuate.autoconfigure.metrics.export.otlp.OtlpMetricsExportAutoConfiguration;
+import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.endsWith;
+import static org.hamcrest.Matchers.matchesPattern;
+
+/**
+ * Tests for {@link OpenTelemetryMetricsContainerConnectionDetailsFactory}.
+ *
+ * @author EddĂș MelĂ©ndez
+ * @author Jonatan Ivanov
+ */
+@SpringJUnitConfig
+@TestPropertySource(properties = { "management.otlp.metrics.export.resource-attributes.service.name=test",
+		"management.otlp.metrics.export.step=1s" })
+@Testcontainers(disabledWithoutDocker = true)
+@DirtiesContext
+class OpenTelemetryMetricsContainerConnectionDetailsFactoryIntegrationTests {
+
+	private static final String OPENMETRICS_001 = "application/openmetrics-text; version=0.0.1; charset=utf-8";
+
+	private static final String CONFIG_FILE_NAME = "collector-config.yml";
+
+	@Container
+	@ServiceConnection
+	static final GenericContainer<?> container = new GenericContainer<>(DockerImageNames.opentelemetry())
+		.withCommand("--config=/etc/" + CONFIG_FILE_NAME)
+		.withCopyToContainer(MountableFile.forClasspathResource(CONFIG_FILE_NAME), "/etc/" + CONFIG_FILE_NAME)
+		.withExposedPorts(4318, 9090);
+
+	@Autowired
+	private MeterRegistry meterRegistry;
+
+	@Test
+	void connectionCanBeMadeToOpenTelemetryCollectorContainer() {
+		Counter.builder("test.counter").register(this.meterRegistry).increment(42);
+		Gauge.builder("test.gauge", () -> 12).register(this.meterRegistry);
+		Timer.builder("test.timer").register(this.meterRegistry).record(Duration.ofMillis(123));
+		DistributionSummary.builder("test.distributionsummary").register(this.meterRegistry).record(24);
+		Awaitility.await()
+			.atMost(Duration.ofSeconds(5))
+			.pollDelay(Duration.ofMillis(100))
+			.pollInterval(Duration.ofMillis(100))
+			.untilAsserted(() -> whenPrometheusScraped().then()
+				.statusCode(200)
+				.contentType(OPENMETRICS_001)
+				.body(endsWith("# EOF\n")));
+		whenPrometheusScraped().then()
+			.body(containsString(
+					"{job=\"test\",service_name=\"test\",telemetry_sdk_language=\"java\",telemetry_sdk_name=\"io.micrometer\""),
+					matchesPattern("(?s)^.*test_counter\\{.+} 42\\.0\\n.*$"),
+					matchesPattern("(?s)^.*test_gauge\\{.+} 12\\.0\\n.*$"),
+					matchesPattern("(?s)^.*test_timer_count\\{.+} 1\\n.*$"),
+					matchesPattern("(?s)^.*test_timer_sum\\{.+} 123\\.0\\n.*$"),
+					matchesPattern("(?s)^.*test_timer_bucket\\{.+,le=\"\\+Inf\"} 1\\n.*$"),
+					matchesPattern("(?s)^.*test_distributionsummary_count\\{.+} 1\\n.*$"),
+					matchesPattern("(?s)^.*test_distributionsummary_sum\\{.+} 24\\.0\\n.*$"),
+					matchesPattern("(?s)^.*test_distributionsummary_bucket\\{.+,le=\"\\+Inf\"} 1\\n.*$"));
+	}
+
+	private Response whenPrometheusScraped() {
+		return RestAssured.given().port(container.getMappedPort(9090)).accept(OPENMETRICS_001).when().get("/metrics");
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	@ImportAutoConfiguration(OtlpMetricsExportAutoConfiguration.class)
+	static class TestConfiguration {
+
+		@Bean
+		Clock customClock() {
+			return Clock.SYSTEM;
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryTracingContainerConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryTracingContainerConnectionDetailsFactoryIntegrationTests.java
new file mode 100644
index 000000000000..ab41e680c555
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryTracingContainerConnectionDetailsFactoryIntegrationTests.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.service.connection.otlp;
+
+import org.junit.jupiter.api.Test;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpAutoConfiguration;
+import org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpTracingConnectionDetails;
+import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link OpenTelemetryTracingContainerConnectionDetailsFactory}.
+ *
+ * @author EddĂș MelĂ©ndez
+ */
+@SpringJUnitConfig
+@Testcontainers(disabledWithoutDocker = true)
+class OpenTelemetryTracingContainerConnectionDetailsFactoryIntegrationTests {
+
+	@Container
+	@ServiceConnection
+	static final GenericContainer<?> container = new GenericContainer<>(DockerImageNames.opentelemetry())
+		.withExposedPorts(4318);
+
+	@Autowired
+	private OtlpTracingConnectionDetails connectionDetails;
+
+	@Test
+	void connectionCanBeMadeToOpenTelemetryContainer() {
+		assertThat(this.connectionDetails.getUrl())
+			.isEqualTo("http://" + container.getHost() + ":" + container.getMappedPort(4318) + "/v1/traces");
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	@ImportAutoConfiguration(OtlpAutoConfiguration.class)
+	static class TestConfiguration {
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/pulsar/PulsarContainerConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/pulsar/PulsarContainerConnectionDetailsFactoryIntegrationTests.java
new file mode 100644
index 000000000000..51f5ec2a1364
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/pulsar/PulsarContainerConnectionDetailsFactoryIntegrationTests.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.service.connection.pulsar;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.pulsar.client.api.PulsarClientException;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.Test;
+import org.testcontainers.containers.PulsarContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
+import org.springframework.boot.autoconfigure.pulsar.PulsarAutoConfiguration;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.pulsar.annotation.PulsarListener;
+import org.springframework.pulsar.core.PulsarTemplate;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link PulsarContainerConnectionDetailsFactory}.
+ *
+ * @author Chris Bono
+ */
+@SpringJUnitConfig
+@Testcontainers(disabledWithoutDocker = true)
+@TestPropertySource(properties = { "spring.pulsar.consumer.subscription.initial-position=earliest" })
+class PulsarContainerConnectionDetailsFactoryIntegrationTests {
+
+	@Container
+	@ServiceConnection
+	@SuppressWarnings("unused")
+	static final PulsarContainer PULSAR = new PulsarContainer(DockerImageNames.pulsar())
+		.withStartupTimeout(Duration.ofMinutes(3));
+
+	@Autowired
+	private PulsarTemplate<String> pulsarTemplate;
+
+	@Autowired
+	private TestListener listener;
+
+	@Test
+	void connectionCanBeMadeToPulsarContainer() throws PulsarClientException {
+		this.pulsarTemplate.send("test-topic", "test-data");
+		Awaitility.waitAtMost(Duration.ofSeconds(30))
+			.untilAsserted(() -> assertThat(this.listener.messages).containsExactly("test-data"));
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	@ImportAutoConfiguration(PulsarAutoConfiguration.class)
+	static class TestConfiguration {
+
+		@Bean
+		TestListener testListener() {
+			return new TestListener();
+		}
+
+	}
+
+	static class TestListener {
+
+		private final List<String> messages = new ArrayList<>();
+
+		@PulsarListener(topics = "test-topic")
+		void processMessage(String message) {
+			this.messages.add(message);
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/MariaDbR2dbcContainerConnectionDetailsFactoryTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/MariaDbR2dbcContainerConnectionDetailsFactoryTests.java
new file mode 100644
index 000000000000..f9b7ee2717b8
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/MariaDbR2dbcContainerConnectionDetailsFactoryTests.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.service.connection.r2dbc;
+
+import io.r2dbc.spi.ConnectionFactoryOptions;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactoryHints;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link MariaDbR2dbcContainerConnectionDetailsFactory}.
+ *
+ * @author Moritz Halbritter
+ */
+class MariaDbR2dbcContainerConnectionDetailsFactoryTests {
+
+	@Test
+	void shouldRegisterHints() {
+		RuntimeHints hints = ContainerConnectionDetailsFactoryHints.getRegisteredHints(getClass().getClassLoader());
+		assertThat(RuntimeHintsPredicates.reflection().onType(ConnectionFactoryOptions.class)).accepts(hints);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/MySqlR2dbcContainerConnectionDetailsFactoryTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/MySqlR2dbcContainerConnectionDetailsFactoryTests.java
new file mode 100644
index 000000000000..36642f20e905
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/MySqlR2dbcContainerConnectionDetailsFactoryTests.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.service.connection.r2dbc;
+
+import io.r2dbc.spi.ConnectionFactoryOptions;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactoryHints;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link MySqlR2dbcContainerConnectionDetailsFactory}.
+ *
+ * @author Moritz Halbritter
+ */
+class MySqlR2dbcContainerConnectionDetailsFactoryTests {
+
+	@Test
+	void shouldRegisterHints() {
+		RuntimeHints hints = ContainerConnectionDetailsFactoryHints.getRegisteredHints(getClass().getClassLoader());
+		assertThat(RuntimeHintsPredicates.reflection().onType(ConnectionFactoryOptions.class)).accepts(hints);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleFreeR2dbcContainerConnectionDetailsFactoryTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleFreeR2dbcContainerConnectionDetailsFactoryTests.java
new file mode 100644
index 000000000000..500910648a54
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleFreeR2dbcContainerConnectionDetailsFactoryTests.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.service.connection.r2dbc;
+
+import java.time.Duration;
+
+import io.r2dbc.spi.ConnectionFactory;
+import io.r2dbc.spi.ConnectionFactoryOptions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.OS;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.oracle.OracleContainer;
+
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
+import org.springframework.boot.autoconfigure.r2dbc.R2dbcAutoConfiguration;
+import org.springframework.boot.jdbc.DatabaseDriver;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactoryHints;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.boot.testsupport.junit.DisabledOnOs;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.r2dbc.core.DatabaseClient;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link OracleFreeR2dbcContainerConnectionDetailsFactory}.
+ *
+ * @author Andy Wilkinson
+ */
+@SpringJUnitConfig
+@Testcontainers(disabledWithoutDocker = true)
+@DisabledOnOs(os = { OS.LINUX, OS.MAC }, architecture = "aarch64",
+		disabledReason = "The Oracle image has no ARM support")
+class OracleFreeR2dbcContainerConnectionDetailsFactoryTests {
+
+	@Container
+	@ServiceConnection
+	static final OracleContainer oracle = new OracleContainer(DockerImageNames.oracleFree())
+		.withStartupTimeout(Duration.ofMinutes(2));
+
+	@Autowired
+	ConnectionFactory connectionFactory;
+
+	@Test
+	void connectionCanBeMadeToOracleContainer() {
+		Object result = DatabaseClient.create(this.connectionFactory)
+			.sql(DatabaseDriver.ORACLE.getValidationQuery())
+			.map((row, metadata) -> row.get(0))
+			.first()
+			.block(Duration.ofSeconds(30));
+		assertThat(result).isEqualTo("Hello");
+	}
+
+	@Test
+	void shouldRegisterHints() {
+		RuntimeHints hints = ContainerConnectionDetailsFactoryHints.getRegisteredHints(getClass().getClassLoader());
+		assertThat(RuntimeHintsPredicates.reflection().onType(ConnectionFactoryOptions.class)).accepts(hints);
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	@ImportAutoConfiguration(R2dbcAutoConfiguration.class)
+	static class TestConfiguration {
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleR2dbcContainerConnectionDetailsFactoryTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleR2dbcContainerConnectionDetailsFactoryTests.java
deleted file mode 100644
index f4b03d4fefe9..000000000000
--- a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleR2dbcContainerConnectionDetailsFactoryTests.java
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.testcontainers.service.connection.r2dbc;
-
-import java.time.Duration;
-
-import io.r2dbc.spi.ConnectionFactory;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.condition.OS;
-import org.testcontainers.containers.OracleContainer;
-import org.testcontainers.junit.jupiter.Container;
-import org.testcontainers.junit.jupiter.Testcontainers;
-
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
-import org.springframework.boot.autoconfigure.r2dbc.R2dbcAutoConfiguration;
-import org.springframework.boot.jdbc.DatabaseDriver;
-import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
-import org.springframework.boot.testsupport.junit.DisabledOnOs;
-import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.r2dbc.core.DatabaseClient;
-import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Tests for {@link OracleR2dbcContainerConnectionDetailsFactory}.
- *
- * @author Andy Wilkinson
- */
-@SpringJUnitConfig
-@Testcontainers(disabledWithoutDocker = true)
-@DisabledOnOs(os = { OS.LINUX, OS.MAC }, architecture = "aarch64",
-		disabledReason = "The Oracle image has no ARM support")
-class OracleR2dbcContainerConnectionDetailsFactoryTests {
-
-	@Container
-	@ServiceConnection
-	static final OracleContainer oracle = new OracleContainer(DockerImageNames.oracleXe())
-		.withStartupTimeout(Duration.ofMinutes(2));
-
-	@Autowired
-	ConnectionFactory connectionFactory;
-
-	@Test
-	void connectionCanBeMadeToOracleContainer() {
-		Object result = DatabaseClient.create(this.connectionFactory)
-			.sql(DatabaseDriver.ORACLE.getValidationQuery())
-			.map((row, metadata) -> row.get(0))
-			.first()
-			.block(Duration.ofSeconds(30));
-		assertThat(result).isEqualTo("Hello");
-	}
-
-	@Configuration(proxyBeanMethods = false)
-	@ImportAutoConfiguration(R2dbcAutoConfiguration.class)
-	static class TestConfiguration {
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleXeR2dbcContainerConnectionDetailsFactoryTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleXeR2dbcContainerConnectionDetailsFactoryTests.java
new file mode 100644
index 000000000000..aa40d6204e70
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleXeR2dbcContainerConnectionDetailsFactoryTests.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.service.connection.r2dbc;
+
+import java.time.Duration;
+
+import io.r2dbc.spi.ConnectionFactory;
+import io.r2dbc.spi.ConnectionFactoryOptions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.OS;
+import org.testcontainers.containers.OracleContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
+import org.springframework.boot.autoconfigure.r2dbc.R2dbcAutoConfiguration;
+import org.springframework.boot.jdbc.DatabaseDriver;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactoryHints;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.boot.testsupport.junit.DisabledOnOs;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.r2dbc.core.DatabaseClient;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link OracleXeR2dbcContainerConnectionDetailsFactory}.
+ *
+ * @author Andy Wilkinson
+ */
+@SpringJUnitConfig
+@Testcontainers(disabledWithoutDocker = true)
+@DisabledOnOs(os = { OS.LINUX, OS.MAC }, architecture = "aarch64",
+		disabledReason = "The Oracle image has no ARM support")
+class OracleXeR2dbcContainerConnectionDetailsFactoryTests {
+
+	@Container
+	@ServiceConnection
+	static final OracleContainer oracle = new OracleContainer(DockerImageNames.oracleXe())
+		.withStartupTimeout(Duration.ofMinutes(2));
+
+	@Autowired
+	ConnectionFactory connectionFactory;
+
+	@Test
+	void connectionCanBeMadeToOracleContainer() {
+		Object result = DatabaseClient.create(this.connectionFactory)
+			.sql(DatabaseDriver.ORACLE.getValidationQuery())
+			.map((row, metadata) -> row.get(0))
+			.first()
+			.block(Duration.ofSeconds(30));
+		assertThat(result).isEqualTo("Hello");
+	}
+
+	@Test
+	void shouldRegisterHints() {
+		RuntimeHints hints = ContainerConnectionDetailsFactoryHints.getRegisteredHints(getClass().getClassLoader());
+		assertThat(RuntimeHintsPredicates.reflection().onType(ConnectionFactoryOptions.class)).accepts(hints);
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	@ImportAutoConfiguration(R2dbcAutoConfiguration.class)
+	static class TestConfiguration {
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/PostgresR2dbcContainerConnectionDetailsFactoryTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/PostgresR2dbcContainerConnectionDetailsFactoryTests.java
new file mode 100644
index 000000000000..a5fdf5d351d2
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/PostgresR2dbcContainerConnectionDetailsFactoryTests.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.service.connection.r2dbc;
+
+import io.r2dbc.spi.ConnectionFactoryOptions;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactoryHints;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link PostgresR2dbcContainerConnectionDetailsFactory}.
+ *
+ * @author Moritz Halbritter
+ */
+class PostgresR2dbcContainerConnectionDetailsFactoryTests {
+
+	@Test
+	void shouldRegisterHints() {
+		RuntimeHints hints = ContainerConnectionDetailsFactoryHints.getRegisteredHints(getClass().getClassLoader());
+		assertThat(RuntimeHintsPredicates.reflection().onType(ConnectionFactoryOptions.class)).accepts(hints);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/SqlServerR2dbcContainerConnectionDetailsFactoryTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/SqlServerR2dbcContainerConnectionDetailsFactoryTests.java
new file mode 100644
index 000000000000..30e0348f0abd
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/SqlServerR2dbcContainerConnectionDetailsFactoryTests.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.service.connection.r2dbc;
+
+import io.r2dbc.spi.ConnectionFactoryOptions;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactoryHints;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link SqlServerR2dbcContainerConnectionDetailsFactory}.
+ *
+ * @author Moritz Halbritter
+ */
+class SqlServerR2dbcContainerConnectionDetailsFactoryTests {
+
+	@Test
+	void shouldRegisterHints() {
+		RuntimeHints hints = ContainerConnectionDetailsFactoryHints.getRegisteredHints(getClass().getClassLoader());
+		assertThat(RuntimeHintsPredicates.reflection().onType(ConnectionFactoryOptions.class)).accepts(hints);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/zipkin/ZipkinContainerConnectionDetailsFactoryTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/zipkin/ZipkinContainerConnectionDetailsFactoryTests.java
new file mode 100644
index 000000000000..9853b8d2daf3
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/zipkin/ZipkinContainerConnectionDetailsFactoryTests.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.service.connection.zipkin;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
+import org.springframework.boot.actuate.autoconfigure.tracing.zipkin.ZipkinAutoConfiguration;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactoryHints;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ZipkinContainerConnectionDetailsFactory}.
+ *
+ * @author Moritz Halbritter
+ */
+class ZipkinContainerConnectionDetailsFactoryTests {
+
+	@Test
+	void shouldRegisterHints() {
+		RuntimeHints hints = ContainerConnectionDetailsFactoryHints.getRegisteredHints(getClass().getClassLoader());
+		assertThat(RuntimeHintsPredicates.reflection().onType(ZipkinAutoConfiguration.class)).accepts(hints);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/zipkin/ZipkinContainerConnectionDetailsFactoryWithoutActuatorTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/zipkin/ZipkinContainerConnectionDetailsFactoryWithoutActuatorTests.java
new file mode 100644
index 000000000000..f4f03994dc10
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/zipkin/ZipkinContainerConnectionDetailsFactoryWithoutActuatorTests.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testcontainers.service.connection.zipkin;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactoryHints;
+import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ZipkinContainerConnectionDetailsFactory}.
+ *
+ * @author Moritz Halbritter
+ */
+@ClassPathExclusions("spring-boot-actuator-*")
+class ZipkinContainerConnectionDetailsFactoryWithoutActuatorTests {
+
+	@Test
+	void shouldRegisterHints() {
+		RuntimeHints hints = ContainerConnectionDetailsFactoryHints.getRegisteredHints(getClass().getClassLoader());
+		assertThat(hints).isNotNull();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/resources/collector-config.yml b/spring-boot-project/spring-boot-testcontainers/src/test/resources/collector-config.yml
new file mode 100644
index 000000000000..c17a371d66c2
--- /dev/null
+++ b/spring-boot-project/spring-boot-testcontainers/src/test/resources/collector-config.yml
@@ -0,0 +1,20 @@
+receivers:
+  otlp:
+    protocols:
+      grpc:
+      http:
+
+exporters:
+  # https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/exporter/prometheusexporter
+  prometheus:
+    endpoint: '0.0.0.0:9090'
+    metric_expiration: 1m
+    enable_open_metrics: true
+    resource_to_telemetry_conversion:
+      enabled: true
+
+service:
+  pipelines:
+    metrics:
+      receivers: [otlp]
+      exporters: [prometheus]
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-antlib/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-antlib/build.gradle
index e00c34aeaf8e..750604b01944 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-antlib/build.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-antlib/build.gradle
@@ -19,7 +19,7 @@ dependencies {
 	antUnit "org.apache.ant:ant-antunit:1.3"
 	antIvy "org.apache.ivy:ivy:2.5.0"
 
-	compileOnly(project(":spring-boot-project:spring-boot-tools:spring-boot-loader"))
+	compileOnly(project(":spring-boot-project:spring-boot-tools:spring-boot-loader-classic"))
 	compileOnly("org.apache.ant:ant:${antVersion}")
 
 	implementation(project(":spring-boot-project:spring-boot-tools:spring-boot-loader-tools"))
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-antlib/src/main/resources/org/springframework/boot/ant/antlib.xml b/spring-boot-project/spring-boot-tools/spring-boot-antlib/src/main/resources/org/springframework/boot/ant/antlib.xml
index bf2f7307866d..980049c0cd2d 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-antlib/src/main/resources/org/springframework/boot/ant/antlib.xml
+++ b/spring-boot-project/spring-boot-tools/spring-boot-antlib/src/main/resources/org/springframework/boot/ant/antlib.xml
@@ -42,7 +42,7 @@
 
 			<echo>Extracting spring-boot-loader to ${destdir}/dependency</echo>
 			<copy todir="${destdir}/dependency">
-				<javaresource name="META-INF/loader/spring-boot-loader.jar"
+				<javaresource name="META-INF/loader/spring-boot-loader-classic.jar"
 					loaderref="spring.boot.antlib.loader" />
 				<flattenmapper />
 			</copy>
@@ -58,10 +58,10 @@
 					<lib />
 					<globmapper from="*" to="BOOT-INF/lib/*" />
 				</mappedresources>
-				<zipfileset src="${destdir}/dependency/spring-boot-loader.jar" />
+				<zipfileset src="${destdir}/dependency/spring-boot-loader-classic.jar" />
 				<manifest>
 					<attribute name="Main-Class"
-						value="org.springframework.boot.loader.JarLauncher" />
+						value="org.springframework.boot.loader.launch.JarLauncher" />
 					<attribute name="Start-Class" value="${start-class}" />
 					<attribute name="Spring-Boot-Classes" value="BOOT-INF/classes/" />
 					<attribute name="Spring-Boot-Lib" value="BOOT-INF/lib/" />
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-autoconfigure-processor/src/test/java/org/springframework/boot/autoconfigureprocessor/AutoConfigureAnnotationProcessorTests.java b/spring-boot-project/spring-boot-tools/spring-boot-autoconfigure-processor/src/test/java/org/springframework/boot/autoconfigureprocessor/AutoConfigureAnnotationProcessorTests.java
index e660563f71a9..3dbd07836982 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-autoconfigure-processor/src/test/java/org/springframework/boot/autoconfigureprocessor/AutoConfigureAnnotationProcessorTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-autoconfigure-processor/src/test/java/org/springframework/boot/autoconfigureprocessor/AutoConfigureAnnotationProcessorTests.java
@@ -27,7 +27,7 @@
 import org.springframework.core.test.tools.TestCompiler;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.fail;
+import static org.assertj.core.api.Assertions.fail;
 
 /**
  * Tests for {@link AutoConfigureAnnotationProcessor}.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/build.gradle
index d890be6a2af9..8f5db820dfba 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/build.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/build.gradle
@@ -14,6 +14,11 @@ configurations.all {
 			if (dependency.requested.group.startsWith("com.fasterxml.jackson")) {
 				dependency.useVersion("2.14.2")
 			}
+			// Downgrade Spring Framework as Gradle cannot cope with 6.1.0-M1's
+			// multi-version jar files with bytecode in META-INF/versions/21
+			if (dependency.requested.group.equals("org.springframework")) {
+				dependency.useVersion("6.0.10")
+			}
 		}
 	}
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/AbstractBuildLog.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/AbstractBuildLog.java
index 7154d4ad43bf..b870d30f9f64 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/AbstractBuildLog.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/AbstractBuildLog.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -68,6 +68,12 @@ public void executingLifecycle(BuildRequest request, LifecycleVersion version, V
 		log(" > Using build cache volume '" + buildCacheVolume + "'");
 	}
 
+	@Override
+	public void executingLifecycle(BuildRequest request, LifecycleVersion version, Cache buildCache) {
+		log(" > Executing lifecycle version " + version);
+		log(" > Using build cache " + buildCache);
+	}
+
 	@Override
 	public Consumer<LogUpdateEvent> runningPhase(BuildRequest request, String name) {
 		log();
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/ApiVersions.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/ApiVersions.java
index 10ceba196dba..2b1f474c06f7 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/ApiVersions.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/ApiVersions.java
@@ -31,7 +31,7 @@ final class ApiVersions {
 	/**
 	 * The platform API versions supported by this release.
 	 */
-	static final ApiVersions SUPPORTED_PLATFORMS = ApiVersions.of(0, IntStream.rangeClosed(3, 11));
+	static final ApiVersions SUPPORTED_PLATFORMS = ApiVersions.of(0, IntStream.rangeClosed(3, 12));
 
 	private final ApiVersion[] apiVersions;
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildLog.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildLog.java
index 0acbbabd224f..e84054773f40 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildLog.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildLog.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -79,6 +79,14 @@ public interface BuildLog {
 	 */
 	void executingLifecycle(BuildRequest request, LifecycleVersion version, VolumeName buildCacheVolume);
 
+	/**
+	 * Log that the lifecycle is executing.
+	 * @param request the build request
+	 * @param version the lifecycle version
+	 * @param buildCache the build cache in use
+	 */
+	void executingLifecycle(BuildRequest request, LifecycleVersion version, Cache buildCache);
+
 	/**
 	 * Log that a specific phase is running.
 	 * @param request the build request
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildRequest.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildRequest.java
index 0bb75fe17e0e..476f0a8917e2 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildRequest.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildRequest.java
@@ -45,7 +45,7 @@
  */
 public class BuildRequest {
 
-	static final String DEFAULT_BUILDER_IMAGE_NAME = "paketobuildpacks/builder:base";
+	static final String DEFAULT_BUILDER_IMAGE_NAME = "paketobuildpacks/builder-jammy-base:latest";
 
 	private static final ImageReference DEFAULT_BUILDER = ImageReference.of(DEFAULT_BUILDER_IMAGE_NAME);
 
@@ -77,6 +77,8 @@ public class BuildRequest {
 
 	private final List<ImageReference> tags;
 
+	private final Cache buildWorkspace;
+
 	private final Cache buildCache;
 
 	private final Cache launchCache;
@@ -85,6 +87,8 @@ public class BuildRequest {
 
 	private final String applicationDirectory;
 
+	private final List<String> securityOptions;
+
 	BuildRequest(ImageReference name, Function<Owner, TarArchive> applicationContent) {
 		Assert.notNull(name, "Name must not be null");
 		Assert.notNull(applicationContent, "ApplicationContent must not be null");
@@ -102,17 +106,19 @@ public class BuildRequest {
 		this.bindings = Collections.emptyList();
 		this.network = null;
 		this.tags = Collections.emptyList();
+		this.buildWorkspace = null;
 		this.buildCache = null;
 		this.launchCache = null;
 		this.createdDate = null;
 		this.applicationDirectory = null;
+		this.securityOptions = null;
 	}
 
 	BuildRequest(ImageReference name, Function<Owner, TarArchive> applicationContent, ImageReference builder,
 			ImageReference runImage, Creator creator, Map<String, String> env, boolean cleanCache,
 			boolean verboseLogging, PullPolicy pullPolicy, boolean publish, List<BuildpackReference> buildpacks,
-			List<Binding> bindings, String network, List<ImageReference> tags, Cache buildCache, Cache launchCache,
-			Instant createdDate, String applicationDirectory) {
+			List<Binding> bindings, String network, List<ImageReference> tags, Cache buildWorkspace, Cache buildCache,
+			Cache launchCache, Instant createdDate, String applicationDirectory, List<String> securityOptions) {
 		this.name = name;
 		this.applicationContent = applicationContent;
 		this.builder = builder;
@@ -127,10 +133,12 @@ public class BuildRequest {
 		this.bindings = bindings;
 		this.network = network;
 		this.tags = tags;
+		this.buildWorkspace = buildWorkspace;
 		this.buildCache = buildCache;
 		this.launchCache = launchCache;
 		this.createdDate = createdDate;
 		this.applicationDirectory = applicationDirectory;
+		this.securityOptions = securityOptions;
 	}
 
 	/**
@@ -142,8 +150,8 @@ public BuildRequest withBuilder(ImageReference builder) {
 		Assert.notNull(builder, "Builder must not be null");
 		return new BuildRequest(this.name, this.applicationContent, builder.inTaggedOrDigestForm(), this.runImage,
 				this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish,
-				this.buildpacks, this.bindings, this.network, this.tags, this.buildCache, this.launchCache,
-				this.createdDate, this.applicationDirectory);
+				this.buildpacks, this.bindings, this.network, this.tags, this.buildWorkspace, this.buildCache,
+				this.launchCache, this.createdDate, this.applicationDirectory, this.securityOptions);
 	}
 
 	/**
@@ -154,8 +162,8 @@ public BuildRequest withBuilder(ImageReference builder) {
 	public BuildRequest withRunImage(ImageReference runImageName) {
 		return new BuildRequest(this.name, this.applicationContent, this.builder, runImageName.inTaggedOrDigestForm(),
 				this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish,
-				this.buildpacks, this.bindings, this.network, this.tags, this.buildCache, this.launchCache,
-				this.createdDate, this.applicationDirectory);
+				this.buildpacks, this.bindings, this.network, this.tags, this.buildWorkspace, this.buildCache,
+				this.launchCache, this.createdDate, this.applicationDirectory, this.securityOptions);
 	}
 
 	/**
@@ -167,8 +175,8 @@ public BuildRequest withCreator(Creator creator) {
 		Assert.notNull(creator, "Creator must not be null");
 		return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, creator, this.env,
 				this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings,
-				this.network, this.tags, this.buildCache, this.launchCache, this.createdDate,
-				this.applicationDirectory);
+				this.network, this.tags, this.buildWorkspace, this.buildCache, this.launchCache, this.createdDate,
+				this.applicationDirectory, this.securityOptions);
 	}
 
 	/**
@@ -184,8 +192,8 @@ public BuildRequest withEnv(String name, String value) {
 		env.put(name, value);
 		return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator,
 				Collections.unmodifiableMap(env), this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish,
-				this.buildpacks, this.bindings, this.network, this.tags, this.buildCache, this.launchCache,
-				this.createdDate, this.applicationDirectory);
+				this.buildpacks, this.bindings, this.network, this.tags, this.buildWorkspace, this.buildCache,
+				this.launchCache, this.createdDate, this.applicationDirectory, this.securityOptions);
 	}
 
 	/**
@@ -199,8 +207,8 @@ public BuildRequest withEnv(Map<String, String> env) {
 		updatedEnv.putAll(env);
 		return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator,
 				Collections.unmodifiableMap(updatedEnv), this.cleanCache, this.verboseLogging, this.pullPolicy,
-				this.publish, this.buildpacks, this.bindings, this.network, this.tags, this.buildCache,
-				this.launchCache, this.createdDate, this.applicationDirectory);
+				this.publish, this.buildpacks, this.bindings, this.network, this.tags, this.buildWorkspace,
+				this.buildCache, this.launchCache, this.createdDate, this.applicationDirectory, this.securityOptions);
 	}
 
 	/**
@@ -211,8 +219,8 @@ public BuildRequest withEnv(Map<String, String> env) {
 	public BuildRequest withCleanCache(boolean cleanCache) {
 		return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
 				cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings,
-				this.network, this.tags, this.buildCache, this.launchCache, this.createdDate,
-				this.applicationDirectory);
+				this.network, this.tags, this.buildWorkspace, this.buildCache, this.launchCache, this.createdDate,
+				this.applicationDirectory, this.securityOptions);
 	}
 
 	/**
@@ -223,8 +231,8 @@ public BuildRequest withCleanCache(boolean cleanCache) {
 	public BuildRequest withVerboseLogging(boolean verboseLogging) {
 		return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
 				this.cleanCache, verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings,
-				this.network, this.tags, this.buildCache, this.launchCache, this.createdDate,
-				this.applicationDirectory);
+				this.network, this.tags, this.buildWorkspace, this.buildCache, this.launchCache, this.createdDate,
+				this.applicationDirectory, this.securityOptions);
 	}
 
 	/**
@@ -235,8 +243,8 @@ public BuildRequest withVerboseLogging(boolean verboseLogging) {
 	public BuildRequest withPullPolicy(PullPolicy pullPolicy) {
 		return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
 				this.cleanCache, this.verboseLogging, pullPolicy, this.publish, this.buildpacks, this.bindings,
-				this.network, this.tags, this.buildCache, this.launchCache, this.createdDate,
-				this.applicationDirectory);
+				this.network, this.tags, this.buildWorkspace, this.buildCache, this.launchCache, this.createdDate,
+				this.applicationDirectory, this.securityOptions);
 	}
 
 	/**
@@ -247,8 +255,8 @@ public BuildRequest withPullPolicy(PullPolicy pullPolicy) {
 	public BuildRequest withPublish(boolean publish) {
 		return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
 				this.cleanCache, this.verboseLogging, this.pullPolicy, publish, this.buildpacks, this.bindings,
-				this.network, this.tags, this.buildCache, this.launchCache, this.createdDate,
-				this.applicationDirectory);
+				this.network, this.tags, this.buildWorkspace, this.buildCache, this.launchCache, this.createdDate,
+				this.applicationDirectory, this.securityOptions);
 	}
 
 	/**
@@ -272,8 +280,8 @@ public BuildRequest withBuildpacks(List<BuildpackReference> buildpacks) {
 		Assert.notNull(buildpacks, "Buildpacks must not be null");
 		return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
 				this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, buildpacks, this.bindings,
-				this.network, this.tags, this.buildCache, this.launchCache, this.createdDate,
-				this.applicationDirectory);
+				this.network, this.tags, this.buildWorkspace, this.buildCache, this.launchCache, this.createdDate,
+				this.applicationDirectory, this.securityOptions);
 	}
 
 	/**
@@ -297,8 +305,8 @@ public BuildRequest withBindings(List<Binding> bindings) {
 		Assert.notNull(bindings, "Bindings must not be null");
 		return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
 				this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, bindings,
-				this.network, this.tags, this.buildCache, this.launchCache, this.createdDate,
-				this.applicationDirectory);
+				this.network, this.tags, this.buildWorkspace, this.buildCache, this.launchCache, this.createdDate,
+				this.applicationDirectory, this.securityOptions);
 	}
 
 	/**
@@ -310,7 +318,8 @@ public BuildRequest withBindings(List<Binding> bindings) {
 	public BuildRequest withNetwork(String network) {
 		return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
 				this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings,
-				network, this.tags, this.buildCache, this.launchCache, this.createdDate, this.applicationDirectory);
+				network, this.tags, this.buildWorkspace, this.buildCache, this.launchCache, this.createdDate,
+				this.applicationDirectory, this.securityOptions);
 	}
 
 	/**
@@ -332,7 +341,22 @@ public BuildRequest withTags(List<ImageReference> tags) {
 		Assert.notNull(tags, "Tags must not be null");
 		return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
 				this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings,
-				this.network, tags, this.buildCache, this.launchCache, this.createdDate, this.applicationDirectory);
+				this.network, tags, this.buildWorkspace, this.buildCache, this.launchCache, this.createdDate,
+				this.applicationDirectory, this.securityOptions);
+	}
+
+	/**
+	 * Return a new {@link BuildRequest} with an updated build workspace.
+	 * @param buildWorkspace the build workspace
+	 * @return an updated build request
+	 * @since 3.2.0
+	 */
+	public BuildRequest withBuildWorkspace(Cache buildWorkspace) {
+		Assert.notNull(buildWorkspace, "BuildWorkspace must not be null");
+		return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
+				this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings,
+				this.network, this.tags, buildWorkspace, this.buildCache, this.launchCache, this.createdDate,
+				this.applicationDirectory, this.securityOptions);
 	}
 
 	/**
@@ -344,7 +368,8 @@ public BuildRequest withBuildCache(Cache buildCache) {
 		Assert.notNull(buildCache, "BuildCache must not be null");
 		return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
 				this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings,
-				this.network, this.tags, buildCache, this.launchCache, this.createdDate, this.applicationDirectory);
+				this.network, this.tags, this.buildWorkspace, buildCache, this.launchCache, this.createdDate,
+				this.applicationDirectory, this.securityOptions);
 	}
 
 	/**
@@ -356,7 +381,8 @@ public BuildRequest withLaunchCache(Cache launchCache) {
 		Assert.notNull(launchCache, "LaunchCache must not be null");
 		return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
 				this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings,
-				this.network, this.tags, this.buildCache, launchCache, this.createdDate, this.applicationDirectory);
+				this.network, this.tags, this.buildWorkspace, this.buildCache, launchCache, this.createdDate,
+				this.applicationDirectory, this.securityOptions);
 	}
 
 	/**
@@ -368,8 +394,8 @@ public BuildRequest withCreatedDate(String createdDate) {
 		Assert.notNull(createdDate, "CreatedDate must not be null");
 		return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
 				this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings,
-				this.network, this.tags, this.buildCache, this.launchCache, parseCreatedDate(createdDate),
-				this.applicationDirectory);
+				this.network, this.tags, this.buildWorkspace, this.buildCache, this.launchCache,
+				parseCreatedDate(createdDate), this.applicationDirectory, this.securityOptions);
 	}
 
 	private Instant parseCreatedDate(String createdDate) {
@@ -393,7 +419,22 @@ public BuildRequest withApplicationDirectory(String applicationDirectory) {
 		Assert.notNull(applicationDirectory, "ApplicationDirectory must not be null");
 		return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
 				this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings,
-				this.network, this.tags, this.buildCache, this.launchCache, this.createdDate, applicationDirectory);
+				this.network, this.tags, this.buildWorkspace, this.buildCache, this.launchCache, this.createdDate,
+				applicationDirectory, this.securityOptions);
+	}
+
+	/**
+	 * Return a new {@link BuildRequest} with an updated security options.
+	 * @param securityOptions the security options
+	 * @return an updated build request
+	 * @since 3.2.0
+	 */
+	public BuildRequest withSecurityOptions(List<String> securityOptions) {
+		Assert.notNull(securityOptions, "SecurityOption must not be null");
+		return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
+				this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings,
+				this.network, this.tags, this.buildWorkspace, this.buildCache, this.launchCache, this.createdDate,
+				this.applicationDirectory, securityOptions);
 	}
 
 	/**
@@ -513,6 +554,15 @@ public List<ImageReference> getTags() {
 		return this.tags;
 	}
 
+	/**
+	 * Return the build workspace that should be used by the lifecycle.
+	 * @return the build workspace or {@code null}
+	 * @since 3.2.0
+	 */
+	public Cache getBuildWorkspace() {
+		return this.buildWorkspace;
+	}
+
 	/**
 	 * Return the custom build cache that should be used by the lifecycle.
 	 * @return the build cache
@@ -545,6 +595,15 @@ public String getApplicationDirectory() {
 		return this.applicationDirectory;
 	}
 
+	/**
+	 * Return the security options that should be used by the lifecycle.
+	 * @return the security options or {@code null}
+	 * @since 3.2.0
+	 */
+	public List<String> getSecurityOptions() {
+		return this.securityOptions;
+	}
+
 	/**
 	 * Factory method to create a new {@link BuildRequest} from a JAR file.
 	 * @param jarFile the source jar file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Cache.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Cache.java
index 9f3087f94c31..704a3418d397 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Cache.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Cache.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -18,6 +18,7 @@
 
 import java.util.Objects;
 
+import org.springframework.boot.buildpack.platform.docker.type.VolumeName;
 import org.springframework.util.Assert;
 import org.springframework.util.ObjectUtils;
 
@@ -37,7 +38,22 @@ public enum Format {
 		/**
 		 * A cache stored as a volume in the Docker daemon.
 		 */
-		VOLUME;
+		VOLUME("volume"),
+
+		/**
+		 * A cache stored as a bind mount.
+		 */
+		BIND("bind mount");
+
+		private final String description;
+
+		Format(String description) {
+			this.description = description;
+		}
+
+		public String getDescription() {
+			return this.description;
+		}
 
 	}
 
@@ -55,16 +71,44 @@ public Volume getVolume() {
 		return (this.format.equals(Format.VOLUME)) ? (Volume) this : null;
 	}
 
+	/**
+	 * Return the details of the cache if it is a bind cache.
+	 * @return the cache, or {@code null} if it is not a bind cache
+	 */
+	public Bind getBind() {
+		return (this.format.equals(Format.BIND)) ? (Bind) this : null;
+	}
+
 	/**
 	 * Create a new {@code Cache} that uses a volume with the provided name.
 	 * @param name the cache volume name
 	 * @return a new cache instance
 	 */
 	public static Cache volume(String name) {
+		Assert.notNull(name, "Name must not be null");
+		return new Volume(VolumeName.of(name));
+	}
+
+	/**
+	 * Create a new {@code Cache} that uses a volume with the provided name.
+	 * @param name the cache volume name
+	 * @return a new cache instance
+	 */
+	public static Cache volume(VolumeName name) {
 		Assert.notNull(name, "Name must not be null");
 		return new Volume(name);
 	}
 
+	/**
+	 * Create a new {@code Cache} that uses a bind mount with the provided source.
+	 * @param source the cache bind mount source
+	 * @return a new cache instance
+	 */
+	public static Cache bind(String source) {
+		Assert.notNull(source, "Source must not be null");
+		return new Bind(source);
+	}
+
 	@Override
 	public boolean equals(Object obj) {
 		if (this == obj) {
@@ -87,14 +131,18 @@ public int hashCode() {
 	 */
 	public static class Volume extends Cache {
 
-		private final String name;
+		private final VolumeName name;
 
-		Volume(String name) {
+		Volume(VolumeName name) {
 			super(Format.VOLUME);
 			this.name = name;
 		}
 
 		public String getName() {
+			return this.name.toString();
+		}
+
+		public VolumeName getVolumeName() {
 			return this.name;
 		}
 
@@ -120,6 +168,56 @@ public int hashCode() {
 			return result;
 		}
 
+		@Override
+		public String toString() {
+			return this.format.getDescription() + " '" + this.name + "'";
+		}
+
+	}
+
+	/**
+	 * Details of a cache stored in a bind mount.
+	 */
+	public static class Bind extends Cache {
+
+		private final String source;
+
+		Bind(String source) {
+			super(Format.BIND);
+			this.source = source;
+		}
+
+		public String getSource() {
+			return this.source;
+		}
+
+		@Override
+		public boolean equals(Object obj) {
+			if (this == obj) {
+				return true;
+			}
+			if (obj == null || getClass() != obj.getClass()) {
+				return false;
+			}
+			if (!super.equals(obj)) {
+				return false;
+			}
+			Bind other = (Bind) obj;
+			return Objects.equals(this.source, other.source);
+		}
+
+		@Override
+		public int hashCode() {
+			int result = super.hashCode();
+			result = 31 * result + ObjectUtils.nullSafeHashCode(this.source);
+			return result;
+		}
+
+		@Override
+		public String toString() {
+			return this.format.getDescription() + " '" + this.source + "'";
+		}
+
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Lifecycle.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Lifecycle.java
index a9bf57caee18..1c7e7183e7a8 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Lifecycle.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Lifecycle.java
@@ -18,6 +18,10 @@
 
 import java.io.Closeable;
 import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
 import java.util.function.Consumer;
 
 import com.sun.jna.Platform;
@@ -34,6 +38,7 @@
 import org.springframework.boot.buildpack.platform.docker.type.VolumeName;
 import org.springframework.boot.buildpack.platform.io.TarArchive;
 import org.springframework.util.Assert;
+import org.springframework.util.FileSystemUtils;
 
 /**
  * A buildpack lifecycle used to run the build {@link Phase phases} needed to package an
@@ -54,6 +59,8 @@ class Lifecycle implements Closeable {
 
 	private static final String DOMAIN_SOCKET_PATH = "/var/run/docker.sock";
 
+	private static final List<String> DEFAULT_SECURITY_OPTIONS = List.of("label=disable");
+
 	private final BuildLog log;
 
 	private final DockerApi docker;
@@ -68,16 +75,18 @@ class Lifecycle implements Closeable {
 
 	private final ApiVersion platformVersion;
 
-	private final VolumeName layersVolume;
+	private final Cache layers;
 
-	private final VolumeName applicationVolume;
+	private final Cache application;
 
-	private final VolumeName buildCacheVolume;
+	private final Cache buildCache;
 
-	private final VolumeName launchCacheVolume;
+	private final Cache launchCache;
 
 	private final String applicationDirectory;
 
+	private final List<String> securityOptions;
+
 	private boolean executed;
 
 	private boolean applicationVolumePopulated;
@@ -99,44 +108,37 @@ class Lifecycle implements Closeable {
 		this.builder = builder;
 		this.lifecycleVersion = LifecycleVersion.parse(builder.getBuilderMetadata().getLifecycle().getVersion());
 		this.platformVersion = getPlatformVersion(builder.getBuilderMetadata().getLifecycle());
-		this.layersVolume = createRandomVolumeName("pack-layers-");
-		this.applicationVolume = createRandomVolumeName("pack-app-");
-		this.buildCacheVolume = getBuildCacheVolumeName(request);
-		this.launchCacheVolume = getLaunchCacheVolumeName(request);
+		this.layers = getLayersBindingSource(request);
+		this.application = getApplicationBindingSource(request);
+		this.buildCache = getBuildCache(request);
+		this.launchCache = getLaunchCache(request);
 		this.applicationDirectory = getApplicationDirectory(request);
+		this.securityOptions = getSecurityOptions(request);
 	}
 
-	protected VolumeName createRandomVolumeName(String prefix) {
-		return VolumeName.random(prefix);
-	}
-
-	private VolumeName getBuildCacheVolumeName(BuildRequest request) {
+	private Cache getBuildCache(BuildRequest request) {
 		if (request.getBuildCache() != null) {
-			return getVolumeName(request.getBuildCache());
+			return request.getBuildCache();
 		}
-		return createCacheVolumeName(request, "build");
+		return createVolumeCache(request, "build");
 	}
 
-	private VolumeName getLaunchCacheVolumeName(BuildRequest request) {
+	private Cache getLaunchCache(BuildRequest request) {
 		if (request.getLaunchCache() != null) {
-			return getVolumeName(request.getLaunchCache());
-		}
-		return createCacheVolumeName(request, "launch");
-	}
-
-	private VolumeName getVolumeName(Cache cache) {
-		if (cache.getVolume() != null) {
-			return VolumeName.of(cache.getVolume().getName());
+			return request.getLaunchCache();
 		}
-		return null;
+		return createVolumeCache(request, "launch");
 	}
 
 	private String getApplicationDirectory(BuildRequest request) {
 		return (request.getApplicationDirectory() != null) ? request.getApplicationDirectory() : Directory.APPLICATION;
 	}
 
-	private VolumeName createCacheVolumeName(BuildRequest request, String suffix) {
-		return VolumeName.basedOn(request.getName(), ImageReference::toLegacyString, "pack-cache-", "." + suffix, 6);
+	private List<String> getSecurityOptions(BuildRequest request) {
+		if (request.getSecurityOptions() != null) {
+			return request.getSecurityOptions();
+		}
+		return (Platform.isWindows()) ? Collections.emptyList() : DEFAULT_SECURITY_OPTIONS;
 	}
 
 	private ApiVersion getPlatformVersion(BuilderMetadata.Lifecycle lifecycle) {
@@ -155,9 +157,9 @@ private ApiVersion getPlatformVersion(BuilderMetadata.Lifecycle lifecycle) {
 	void execute() throws IOException {
 		Assert.state(!this.executed, "Lifecycle has already been executed");
 		this.executed = true;
-		this.log.executingLifecycle(this.request, this.lifecycleVersion, this.buildCacheVolume);
+		this.log.executingLifecycle(this.request, this.lifecycleVersion, this.buildCache);
 		if (this.request.isCleanCache()) {
-			deleteVolume(this.buildCacheVolume);
+			deleteCache(this.buildCache);
 		}
 		run(createPhase());
 		this.log.executedLifecycle(this.request);
@@ -182,10 +184,10 @@ private Phase createPhase() {
 			phase.withArgs("-process-type=web");
 		}
 		phase.withArgs(this.request.getName());
-		phase.withBinding(Binding.from(this.layersVolume, Directory.LAYERS));
-		phase.withBinding(Binding.from(this.applicationVolume, this.applicationDirectory));
-		phase.withBinding(Binding.from(this.buildCacheVolume, Directory.CACHE));
-		phase.withBinding(Binding.from(this.launchCacheVolume, Directory.LAUNCH_CACHE));
+		phase.withBinding(Binding.from(getCacheBindingSource(this.layers), Directory.LAYERS));
+		phase.withBinding(Binding.from(getCacheBindingSource(this.application), this.applicationDirectory));
+		phase.withBinding(Binding.from(getCacheBindingSource(this.buildCache), Directory.CACHE));
+		phase.withBinding(Binding.from(getCacheBindingSource(this.launchCache), Directory.LAUNCH_CACHE));
 		if (this.request.getBindings() != null) {
 			this.request.getBindings().forEach(phase::withBinding);
 		}
@@ -199,6 +201,42 @@ private Phase createPhase() {
 		return phase;
 	}
 
+	private Cache getLayersBindingSource(BuildRequest request) {
+		if (request.getBuildWorkspace() != null) {
+			return getBuildWorkspaceBindingSource(request.getBuildWorkspace(), "layers");
+		}
+		return createVolumeCache("pack-layers-");
+	}
+
+	private Cache getApplicationBindingSource(BuildRequest request) {
+		if (request.getBuildWorkspace() != null) {
+			return getBuildWorkspaceBindingSource(request.getBuildWorkspace(), "app");
+		}
+		return createVolumeCache("pack-app-");
+	}
+
+	private Cache getBuildWorkspaceBindingSource(Cache buildWorkspace, String suffix) {
+		return (buildWorkspace.getVolume() != null) ? Cache.volume(buildWorkspace.getVolume().getName() + "-" + suffix)
+				: Cache.bind(buildWorkspace.getBind().getSource() + "-" + suffix);
+	}
+
+	private String getCacheBindingSource(Cache cache) {
+		return (cache.getVolume() != null) ? cache.getVolume().getName() : cache.getBind().getSource();
+	}
+
+	private Cache createVolumeCache(String prefix) {
+		return Cache.volume(createRandomVolumeName(prefix));
+	}
+
+	private Cache createVolumeCache(BuildRequest request, String suffix) {
+		return Cache.volume(
+				VolumeName.basedOn(request.getName(), ImageReference::toLegacyString, "pack-cache-", "." + suffix, 6));
+	}
+
+	protected VolumeName createRandomVolumeName(String prefix) {
+		return VolumeName.random(prefix);
+	}
+
 	private void configureDaemonAccess(Phase phase) {
 		if (this.dockerHost != null) {
 			if (this.dockerHost.isRemote()) {
@@ -215,8 +253,8 @@ private void configureDaemonAccess(Phase phase) {
 		else {
 			phase.withBinding(Binding.from(DOMAIN_SOCKET_PATH, DOMAIN_SOCKET_PATH));
 		}
-		if (!Platform.isWindows()) {
-			phase.withSecurityOption("label=disable");
+		if (this.securityOptions != null) {
+			this.securityOptions.forEach(phase::withSecurityOption);
 		}
 	}
 
@@ -250,6 +288,9 @@ private ContainerReference createContainer(ContainerConfig config) throws IOExce
 			return this.docker.container().create(config);
 		}
 		try {
+			if (this.application.getBind() != null) {
+				Files.createDirectories(Path.of(this.application.getBind().getSource()));
+			}
 			TarArchive applicationContent = this.request.getApplicationContent(this.builder.getBuildOwner());
 			return this.docker.container()
 				.create(config, ContainerContent.of(applicationContent, this.applicationDirectory));
@@ -261,14 +302,32 @@ private ContainerReference createContainer(ContainerConfig config) throws IOExce
 
 	@Override
 	public void close() throws IOException {
-		deleteVolume(this.layersVolume);
-		deleteVolume(this.applicationVolume);
+		deleteCache(this.layers);
+		deleteCache(this.application);
+	}
+
+	private void deleteCache(Cache cache) throws IOException {
+		if (cache.getVolume() != null) {
+			deleteVolume(cache.getVolume().getVolumeName());
+		}
+		if (cache.getBind() != null) {
+			deleteBind(cache.getBind().getSource());
+		}
 	}
 
 	private void deleteVolume(VolumeName name) throws IOException {
 		this.docker.volume().delete(name, true);
 	}
 
+	private void deleteBind(String source) {
+		try {
+			FileSystemUtils.deleteRecursively(Path.of(source));
+		}
+		catch (IOException ex) {
+			throw new IllegalStateException("Error cleaning bind mount directory '" + source + "'", ex);
+		}
+	}
+
 	/**
 	 * Common directories used by the various phases.
 	 */
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java
index 4ee957b5daea..7365bf267781 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java
@@ -40,7 +40,7 @@
 import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
 import org.apache.hc.core5.net.URIBuilder;
 
-import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost;
+import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration;
 import org.springframework.boot.buildpack.platform.docker.transport.HttpTransport;
 import org.springframework.boot.buildpack.platform.docker.transport.HttpTransport.Response;
 import org.springframework.boot.buildpack.platform.docker.type.ContainerConfig;
@@ -97,7 +97,7 @@ public DockerApi() {
 	 * @param dockerHost the Docker daemon host information
 	 * @since 2.4.0
 	 */
-	public DockerApi(DockerHost dockerHost) {
+	public DockerApi(DockerHostConfiguration dockerHost) {
 		this(HttpTransport.create(dockerHost));
 	}
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfiguration.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfiguration.java
index b82a6b28ac22..fa47c349fbd1 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfiguration.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -27,7 +27,7 @@
  */
 public final class DockerConfiguration {
 
-	private final DockerHost host;
+	private final DockerHostConfiguration host;
 
 	private final DockerRegistryAuthentication builderAuthentication;
 
@@ -39,7 +39,7 @@ public DockerConfiguration() {
 		this(null, null, null, false);
 	}
 
-	private DockerConfiguration(DockerHost host, DockerRegistryAuthentication builderAuthentication,
+	private DockerConfiguration(DockerHostConfiguration host, DockerRegistryAuthentication builderAuthentication,
 			DockerRegistryAuthentication publishAuthentication, boolean bindHostToBuilder) {
 		this.host = host;
 		this.builderAuthentication = builderAuthentication;
@@ -47,7 +47,7 @@ private DockerConfiguration(DockerHost host, DockerRegistryAuthentication builde
 		this.bindHostToBuilder = bindHostToBuilder;
 	}
 
-	public DockerHost getHost() {
+	public DockerHostConfiguration getHost() {
 		return this.host;
 	}
 
@@ -65,7 +65,13 @@ public DockerRegistryAuthentication getPublishRegistryAuthentication() {
 
 	public DockerConfiguration withHost(String address, boolean secure, String certificatePath) {
 		Assert.notNull(address, "Address must not be null");
-		return new DockerConfiguration(new DockerHost(address, secure, certificatePath), this.builderAuthentication,
+		return new DockerConfiguration(DockerHostConfiguration.forAddress(address, secure, certificatePath),
+				this.builderAuthentication, this.publishAuthentication, this.bindHostToBuilder);
+	}
+
+	public DockerConfiguration withContext(String context) {
+		Assert.notNull(context, "Context must not be null");
+		return new DockerConfiguration(DockerHostConfiguration.forContext(context), this.builderAuthentication,
 				this.publishAuthentication, this.bindHostToBuilder);
 	}
 
@@ -107,4 +113,51 @@ public DockerConfiguration withEmptyPublishRegistryAuthentication() {
 				new DockerRegistryUserAuthentication("", "", "", ""), this.bindHostToBuilder);
 	}
 
+	public static class DockerHostConfiguration {
+
+		private final String address;
+
+		private final String context;
+
+		private final boolean secure;
+
+		private final String certificatePath;
+
+		public DockerHostConfiguration(String address, String context, boolean secure, String certificatePath) {
+			this.address = address;
+			this.context = context;
+			this.secure = secure;
+			this.certificatePath = certificatePath;
+		}
+
+		public String getAddress() {
+			return this.address;
+		}
+
+		public String getContext() {
+			return this.context;
+		}
+
+		public boolean isSecure() {
+			return this.secure;
+		}
+
+		public String getCertificatePath() {
+			return this.certificatePath;
+		}
+
+		public static DockerHostConfiguration forAddress(String address) {
+			return new DockerHostConfiguration(address, null, false, null);
+		}
+
+		public static DockerHostConfiguration forAddress(String address, boolean secure, String certificatePath) {
+			return new DockerHostConfiguration(address, null, secure, certificatePath);
+		}
+
+		static DockerHostConfiguration forContext(String context) {
+			return new DockerHostConfiguration(null, context, false, null);
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationMetadata.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationMetadata.java
new file mode 100644
index 000000000000..9ab0fef20192
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationMetadata.java
@@ -0,0 +1,211 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.buildpack.platform.docker.configuration;
+
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.NullNode;
+
+import org.springframework.boot.buildpack.platform.json.MappedObject;
+import org.springframework.boot.buildpack.platform.json.SharedObjectMapper;
+import org.springframework.boot.buildpack.platform.system.Environment;
+
+/**
+ * Docker configuration stored in metadata files managed by the Docker CLI.
+ *
+ * @author Scott Frederick
+ */
+final class DockerConfigurationMetadata {
+
+	private static final String DOCKER_CONFIG = "DOCKER_CONFIG";
+
+	private static final String DEFAULT_CONTEXT = "default";
+
+	private static final String CONFIG_DIR = ".docker";
+
+	private static final String CONTEXTS_DIR = "contexts";
+
+	private static final String META_DIR = "meta";
+
+	private static final String TLS_DIR = "tls";
+
+	private static final String DOCKER_ENDPOINT = "docker";
+
+	private static final String CONFIG_FILE_NAME = "config.json";
+
+	private static final String CONTEXT_FILE_NAME = "meta.json";
+
+	private final String configLocation;
+
+	private final DockerConfig config;
+
+	private final DockerContext context;
+
+	private DockerConfigurationMetadata(String configLocation, DockerConfig config, DockerContext context) {
+		this.configLocation = configLocation;
+		this.config = config;
+		this.context = context;
+	}
+
+	DockerConfig getConfiguration() {
+		return this.config;
+	}
+
+	DockerContext getContext() {
+		return this.context;
+	}
+
+	DockerContext forContext(String context) {
+		return createDockerContext(this.configLocation, context);
+	}
+
+	static DockerConfigurationMetadata from(Environment environment) {
+		String configLocation = (environment.get(DOCKER_CONFIG) != null) ? environment.get(DOCKER_CONFIG)
+				: Path.of(System.getProperty("user.home"), CONFIG_DIR).toString();
+		DockerConfig dockerConfig = createDockerConfig(configLocation);
+		DockerContext dockerContext = createDockerContext(configLocation, dockerConfig.getCurrentContext());
+		return new DockerConfigurationMetadata(configLocation, dockerConfig, dockerContext);
+	}
+
+	private static DockerConfig createDockerConfig(String configLocation) {
+		Path path = Path.of(configLocation, CONFIG_FILE_NAME);
+		if (!path.toFile().exists()) {
+			return DockerConfig.empty();
+		}
+		try {
+			return DockerConfig.fromJson(readPathContent(path));
+		}
+		catch (JsonProcessingException ex) {
+			throw new IllegalStateException("Error parsing Docker configuration file '" + path + "'", ex);
+		}
+	}
+
+	private static DockerContext createDockerContext(String configLocation, String currentContext) {
+		if (currentContext == null || DEFAULT_CONTEXT.equals(currentContext)) {
+			return DockerContext.empty();
+		}
+		Path metaPath = Path.of(configLocation, CONTEXTS_DIR, META_DIR, asHash(currentContext), CONTEXT_FILE_NAME);
+		Path tlsPath = Path.of(configLocation, CONTEXTS_DIR, TLS_DIR, asHash(currentContext), DOCKER_ENDPOINT);
+		if (!metaPath.toFile().exists()) {
+			throw new IllegalArgumentException("Docker context '" + currentContext + "' does not exist");
+		}
+		try {
+			DockerContext context = DockerContext.fromJson(readPathContent(metaPath));
+			if (tlsPath.toFile().isDirectory()) {
+				return context.withTlsPath(tlsPath.toString());
+			}
+			return context;
+		}
+		catch (JsonProcessingException ex) {
+			throw new IllegalStateException("Error parsing Docker context metadata file '" + metaPath + "'", ex);
+		}
+	}
+
+	private static String asHash(String currentContext) {
+		try {
+			MessageDigest digest = MessageDigest.getInstance("SHA-256");
+			byte[] hash = digest.digest(currentContext.getBytes(StandardCharsets.UTF_8));
+			return HexFormat.of().formatHex(hash);
+		}
+		catch (NoSuchAlgorithmException ex) {
+			return null;
+		}
+	}
+
+	private static String readPathContent(Path path) {
+		try {
+			return Files.readString(path);
+		}
+		catch (IOException ex) {
+			throw new IllegalStateException("Error reading Docker configuration file '" + path + "'", ex);
+		}
+	}
+
+	static final class DockerConfig extends MappedObject {
+
+		private final String currentContext;
+
+		private DockerConfig(JsonNode node) {
+			super(node, MethodHandles.lookup());
+			this.currentContext = valueAt("/currentContext", String.class);
+		}
+
+		String getCurrentContext() {
+			return this.currentContext;
+		}
+
+		static DockerConfig fromJson(String json) throws JsonProcessingException {
+			return new DockerConfig(SharedObjectMapper.get().readTree(json));
+		}
+
+		static DockerConfig empty() {
+			return new DockerConfig(NullNode.instance);
+		}
+
+	}
+
+	static final class DockerContext extends MappedObject {
+
+		private final String dockerHost;
+
+		private final Boolean skipTlsVerify;
+
+		private final String tlsPath;
+
+		private DockerContext(JsonNode node, String tlsPath) {
+			super(node, MethodHandles.lookup());
+			this.dockerHost = valueAt("/Endpoints/" + DOCKER_ENDPOINT + "/Host", String.class);
+			this.skipTlsVerify = valueAt("/Endpoints/" + DOCKER_ENDPOINT + "/SkipTLSVerify", Boolean.class);
+			this.tlsPath = tlsPath;
+		}
+
+		String getDockerHost() {
+			return this.dockerHost;
+		}
+
+		Boolean isTlsVerify() {
+			return this.skipTlsVerify != null && !this.skipTlsVerify;
+		}
+
+		String getTlsPath() {
+			return this.tlsPath;
+		}
+
+		DockerContext withTlsPath(String tlsPath) {
+			return new DockerContext(this.getNode(), tlsPath);
+		}
+
+		static DockerContext fromJson(String json) throws JsonProcessingException {
+			return new DockerContext(SharedObjectMapper.get().readTree(json), null);
+		}
+
+		static DockerContext empty() {
+			return new DockerContext(NullNode.instance, null);
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerHost.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerHost.java
index 3db5f5541d57..8d6d381feba4 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerHost.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerHost.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/ResolvedDockerHost.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/ResolvedDockerHost.java
index 95272b19d0d3..e19d592df08d 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/ResolvedDockerHost.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/ResolvedDockerHost.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -21,6 +21,8 @@
 
 import com.sun.jna.Platform;
 
+import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration;
+import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfigurationMetadata.DockerContext;
 import org.springframework.boot.buildpack.platform.system.Environment;
 
 /**
@@ -43,6 +45,12 @@ public class ResolvedDockerHost extends DockerHost {
 
 	private static final String DOCKER_CERT_PATH = "DOCKER_CERT_PATH";
 
+	private static final String DOCKER_CONTEXT = "DOCKER_CONTEXT";
+
+	ResolvedDockerHost(String address) {
+		super(address);
+	}
+
 	ResolvedDockerHost(String address, boolean secure, String certificatePath) {
 		super(address, secure, certificatePath);
 	}
@@ -66,11 +74,20 @@ public boolean isLocalFileReference() {
 		}
 	}
 
-	public static ResolvedDockerHost from(DockerHost dockerHost) {
+	public static ResolvedDockerHost from(DockerHostConfiguration dockerHost) {
 		return from(Environment.SYSTEM, dockerHost);
 	}
 
-	static ResolvedDockerHost from(Environment environment, DockerHost dockerHost) {
+	static ResolvedDockerHost from(Environment environment, DockerHostConfiguration dockerHost) {
+		DockerConfigurationMetadata config = DockerConfigurationMetadata.from(environment);
+		if (environment.get(DOCKER_CONTEXT) != null) {
+			DockerContext context = config.forContext(environment.get(DOCKER_CONTEXT));
+			return new ResolvedDockerHost(context.getDockerHost(), context.isTlsVerify(), context.getTlsPath());
+		}
+		if (dockerHost != null && dockerHost.getContext() != null) {
+			DockerContext context = config.forContext(dockerHost.getContext());
+			return new ResolvedDockerHost(context.getDockerHost(), context.isTlsVerify(), context.getTlsPath());
+		}
 		if (environment.get(DOCKER_HOST) != null) {
 			return new ResolvedDockerHost(environment.get(DOCKER_HOST), isTrue(environment.get(DOCKER_TLS_VERIFY)),
 					environment.get(DOCKER_CERT_PATH));
@@ -79,7 +96,11 @@ static ResolvedDockerHost from(Environment environment, DockerHost dockerHost) {
 			return new ResolvedDockerHost(dockerHost.getAddress(), dockerHost.isSecure(),
 					dockerHost.getCertificatePath());
 		}
-		return new ResolvedDockerHost(Platform.isWindows() ? WINDOWS_NAMED_PIPE_PATH : DOMAIN_SOCKET_PATH, false, null);
+		if (config.getContext().getDockerHost() != null) {
+			DockerContext context = config.getContext();
+			return new ResolvedDockerHost(context.getDockerHost(), context.isTlsVerify(), context.getTlsPath());
+		}
+		return new ResolvedDockerHost(Platform.isWindows() ? WINDOWS_NAMED_PIPE_PATH : DOMAIN_SOCKET_PATH);
 	}
 
 	private static boolean isTrue(String value) {
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/PrivateKeyParser.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/PrivateKeyParser.java
index fdd433bd09b9..e2b5259a11e7 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/PrivateKeyParser.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/PrivateKeyParser.java
@@ -18,10 +18,11 @@
 
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.nio.ByteBuffer;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.security.GeneralSecurityException;
 import java.security.KeyFactory;
+import java.security.NoSuchAlgorithmException;
 import java.security.PrivateKey;
 import java.security.spec.InvalidKeySpecException;
 import java.security.spec.PKCS8EncodedKeySpec;
@@ -33,6 +34,10 @@
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
+import org.springframework.boot.buildpack.platform.docker.ssl.PrivateKeyParser.DerElement.TagType;
+import org.springframework.boot.buildpack.platform.docker.ssl.PrivateKeyParser.DerElement.ValueType;
+import org.springframework.util.Assert;
+
 /**
  * Parser for PKCS private key files in PEM format.
  *
@@ -42,26 +47,28 @@
  */
 final class PrivateKeyParser {
 
-	private static final String PKCS1_HEADER = "-+BEGIN\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+";
+	private static final String PKCS1_RSA_HEADER = "-+BEGIN\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+";
 
-	private static final String PKCS1_FOOTER = "-+END\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+";
+	private static final String PKCS1_RSA_FOOTER = "-+END\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+";
 
 	private static final String PKCS8_HEADER = "-+BEGIN\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+";
 
 	private static final String PKCS8_FOOTER = "-+END\\s+PRIVATE\\s+KEY[^-]*-+";
 
-	private static final String EC_HEADER = "-+BEGIN\\s+EC\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+";
+	private static final String SEC1_EC_HEADER = "-+BEGIN\\s+EC\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+";
 
-	private static final String EC_FOOTER = "-+END\\s+EC\\s+PRIVATE\\s+KEY[^-]*-+";
+	private static final String SEC1_EC_FOOTER = "-+END\\s+EC\\s+PRIVATE\\s+KEY[^-]*-+";
 
 	private static final String BASE64_TEXT = "([a-z0-9+/=\\r\\n]+)";
 
 	private static final List<PemParser> PEM_PARSERS;
 	static {
 		List<PemParser> parsers = new ArrayList<>();
-		parsers.add(new PemParser(PKCS1_HEADER, PKCS1_FOOTER, PrivateKeyParser::createKeySpecForPkcs1, "RSA"));
-		parsers.add(new PemParser(EC_HEADER, EC_FOOTER, PrivateKeyParser::createKeySpecForEc, "EC"));
-		parsers.add(new PemParser(PKCS8_HEADER, PKCS8_FOOTER, PKCS8EncodedKeySpec::new, "RSA", "EC", "DSA", "Ed25519"));
+		parsers
+			.add(new PemParser(PKCS1_RSA_HEADER, PKCS1_RSA_FOOTER, PrivateKeyParser::createKeySpecForPkcs1Rsa, "RSA"));
+		parsers.add(new PemParser(SEC1_EC_HEADER, SEC1_EC_FOOTER, PrivateKeyParser::createKeySpecForSec1Ec, "EC"));
+		parsers.add(new PemParser(PKCS8_HEADER, PKCS8_FOOTER, PKCS8EncodedKeySpec::new, "RSA", "RSASSA-PSS", "EC",
+				"DSA", "EdDSA", "XDH"));
 		PEM_PARSERS = Collections.unmodifiableList(parsers);
 	}
 
@@ -83,12 +90,43 @@ final class PrivateKeyParser {
 	private PrivateKeyParser() {
 	}
 
-	private static PKCS8EncodedKeySpec createKeySpecForPkcs1(byte[] bytes) {
+	private static PKCS8EncodedKeySpec createKeySpecForPkcs1Rsa(byte[] bytes) {
 		return createKeySpecForAlgorithm(bytes, RSA_ALGORITHM, null);
 	}
 
-	private static PKCS8EncodedKeySpec createKeySpecForEc(byte[] bytes) {
-		return createKeySpecForAlgorithm(bytes, EC_ALGORITHM, EC_PARAMETERS);
+	private static PKCS8EncodedKeySpec createKeySpecForSec1Ec(byte[] bytes) {
+		DerElement ecPrivateKey = DerElement.of(bytes);
+		Assert.state(ecPrivateKey.isType(ValueType.ENCODED, TagType.SEQUENCE),
+				"Key spec should be an ASN.1 encoded sequence");
+		DerElement version = DerElement.of(ecPrivateKey.getContents());
+		Assert.state(version != null && version.isType(ValueType.PRIMITIVE, TagType.INTEGER),
+				"Key spec should start with version");
+		Assert.state(version.getContents().remaining() == 1 && version.getContents().get() == 1,
+				"Key spec version must be 1");
+		DerElement privateKey = DerElement.of(ecPrivateKey.getContents());
+		Assert.state(privateKey != null && privateKey.isType(ValueType.PRIMITIVE, TagType.OCTET_STRING),
+				"Key spec should contain private key");
+		DerElement parameters = DerElement.of(ecPrivateKey.getContents());
+		return createKeySpecForAlgorithm(bytes, EC_ALGORITHM, getEcParameters(parameters));
+	}
+
+	private static int[] getEcParameters(DerElement parameters) {
+		if (parameters == null) {
+			return EC_PARAMETERS;
+		}
+		Assert.state(parameters.isType(ValueType.ENCODED), "Key spec should contain encoded parameters");
+		DerElement contents = DerElement.of(parameters.getContents());
+		Assert.state(contents.isType(ValueType.PRIMITIVE, TagType.OBJECT_IDENTIFIER),
+				"Key spec parameters should contain object identifier");
+		return getEcParameters(contents.getContents());
+	}
+
+	private static int[] getEcParameters(ByteBuffer bytes) {
+		int[] result = new int[bytes.remaining()];
+		for (int i = 0; i < result.length; i++) {
+			result[i] = bytes.get() & 0xFF;
+		}
+		return result;
 	}
 
 	private static PKCS8EncodedKeySpec createKeySpecForAlgorithm(byte[] bytes, int[] algorithm, int[] parameters) {
@@ -158,21 +196,16 @@ private static byte[] decodeBase64(String content) {
 		}
 
 		private PrivateKey parse(byte[] bytes) {
-			try {
-				PKCS8EncodedKeySpec keySpec = this.keySpecFactory.apply(bytes);
-				for (String algorithm : this.algorithms) {
+			PKCS8EncodedKeySpec keySpec = this.keySpecFactory.apply(bytes);
+			for (String algorithm : this.algorithms) {
+				try {
 					KeyFactory keyFactory = KeyFactory.getInstance(algorithm);
-					try {
-						return keyFactory.generatePrivate(keySpec);
-					}
-					catch (InvalidKeySpecException ex) {
-					}
+					return keyFactory.generatePrivate(keySpec);
+				}
+				catch (InvalidKeySpecException | NoSuchAlgorithmException ex) {
 				}
-				return null;
-			}
-			catch (GeneralSecurityException ex) {
-				throw new IllegalArgumentException("Unexpected key format", ex);
 			}
+			return null;
 		}
 
 	}
@@ -251,4 +284,102 @@ byte[] toByteArray() {
 
 	}
 
+	/**
+	 * An ASN.1 DER encoded element.
+	 */
+	static final class DerElement {
+
+		private final ValueType valueType;
+
+		private final long tagType;
+
+		private final ByteBuffer contents;
+
+		private DerElement(ByteBuffer bytes) {
+			byte b = bytes.get();
+			this.valueType = ((b & 0x20) == 0) ? ValueType.PRIMITIVE : ValueType.ENCODED;
+			this.tagType = decodeTagType(b, bytes);
+			int length = decodeLength(bytes);
+			bytes.limit(bytes.position() + length);
+			this.contents = bytes.slice();
+			bytes.limit(bytes.capacity());
+			bytes.position(bytes.position() + length);
+		}
+
+		private long decodeTagType(byte b, ByteBuffer bytes) {
+			long tagType = (b & 0x1F);
+			if (tagType != 0x1F) {
+				return tagType;
+			}
+			tagType = 0;
+			b = bytes.get();
+			while ((b & 0x80) != 0) {
+				tagType <<= 7;
+				tagType = tagType | (b & 0x7F);
+				b = bytes.get();
+			}
+			return tagType;
+		}
+
+		private int decodeLength(ByteBuffer bytes) {
+			byte b = bytes.get();
+			if ((b & 0x80) == 0) {
+				return b & 0x7F;
+			}
+			int numberOfLengthBytes = (b & 0x7F);
+			Assert.state(numberOfLengthBytes != 0, "Infinite length encoding is not supported");
+			Assert.state(numberOfLengthBytes != 0x7F, "Reserved length encoding is not supported");
+			Assert.state(numberOfLengthBytes <= 4, "Length overflow");
+			int length = 0;
+			for (int i = 0; i < numberOfLengthBytes; i++) {
+				length <<= 8;
+				length |= (bytes.get() & 0xFF);
+			}
+			return length;
+		}
+
+		boolean isType(ValueType valueType) {
+			return this.valueType == valueType;
+		}
+
+		boolean isType(ValueType valueType, TagType tagType) {
+			return this.valueType == valueType && this.tagType == tagType.getNumber();
+		}
+
+		ByteBuffer getContents() {
+			return this.contents;
+		}
+
+		static DerElement of(byte[] bytes) {
+			return of(ByteBuffer.wrap(bytes));
+		}
+
+		static DerElement of(ByteBuffer bytes) {
+			return (bytes.remaining() > 0) ? new DerElement(bytes) : null;
+		}
+
+		enum ValueType {
+
+			PRIMITIVE, ENCODED
+
+		}
+
+		enum TagType {
+
+			INTEGER(0x02), OCTET_STRING(0x04), OBJECT_IDENTIFIER(0x06), SEQUENCE(0x10);
+
+			private final int number;
+
+			TagType(int number) {
+				this.number = number;
+			}
+
+			int getNumber() {
+				return this.number;
+			}
+
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/HttpTransport.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/HttpTransport.java
index 4a8461fa0907..c428155142d8 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/HttpTransport.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/HttpTransport.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -22,6 +22,7 @@
 import java.io.OutputStream;
 import java.net.URI;
 
+import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration;
 import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost;
 import org.springframework.boot.buildpack.platform.docker.configuration.ResolvedDockerHost;
 import org.springframework.boot.buildpack.platform.io.IOConsumer;
@@ -93,7 +94,7 @@ public interface HttpTransport {
 	 * @param dockerHost the Docker host information
 	 * @return a {@link HttpTransport} instance
 	 */
-	static HttpTransport create(DockerHost dockerHost) {
+	static HttpTransport create(DockerHostConfiguration dockerHost) {
 		ResolvedDockerHost host = ResolvedDockerHost.from(dockerHost);
 		HttpTransport remote = RemoteHttpClientTransport.createIfPossible(host);
 		return (remote != null) ? remote : LocalHttpClientTransport.create(host);
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/LocalHttpClientTransport.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/LocalHttpClientTransport.java
index 13241f726c58..a5b1035a1956 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/LocalHttpClientTransport.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/LocalHttpClientTransport.java
@@ -20,23 +20,22 @@
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.Socket;
-import java.net.URISyntaxException;
 import java.net.UnknownHostException;
 
 import com.sun.jna.Platform;
 import org.apache.hc.client5.http.DnsResolver;
-import org.apache.hc.client5.http.SchemePortResolver;
+import org.apache.hc.client5.http.HttpRoute;
 import org.apache.hc.client5.http.classic.HttpClient;
 import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
 import org.apache.hc.client5.http.impl.classic.HttpClients;
 import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager;
 import org.apache.hc.client5.http.io.HttpClientConnectionManager;
+import org.apache.hc.client5.http.routing.HttpRoutePlanner;
 import org.apache.hc.client5.http.socket.ConnectionSocketFactory;
 import org.apache.hc.core5.http.HttpHost;
 import org.apache.hc.core5.http.config.Registry;
 import org.apache.hc.core5.http.config.RegistryBuilder;
 import org.apache.hc.core5.http.protocol.HttpContext;
-import org.apache.hc.core5.util.Args;
 import org.apache.hc.core5.util.TimeValue;
 
 import org.springframework.boot.buildpack.platform.docker.configuration.ResolvedDockerHost;
@@ -51,26 +50,22 @@
  */
 final class LocalHttpClientTransport extends HttpClientTransport {
 
-	private static final HttpHost LOCAL_DOCKER_HOST;
+	private static final String DOCKER_SCHEME = "docker";
 
-	static {
-		try {
-			LOCAL_DOCKER_HOST = HttpHost.create("docker://localhost");
-		}
-		catch (URISyntaxException ex) {
-			throw new RuntimeException("Error creating local Docker host address", ex);
-		}
-	}
+	private static final int DEFAULT_DOCKER_PORT = 2376;
 
-	private LocalHttpClientTransport(HttpClient client) {
-		super(client, LOCAL_DOCKER_HOST);
+	private static final HttpHost LOCAL_DOCKER_HOST = new HttpHost(DOCKER_SCHEME, "localhost", DEFAULT_DOCKER_PORT);
+
+	private LocalHttpClientTransport(HttpClient client, HttpHost host) {
+		super(client, host);
 	}
 
 	static LocalHttpClientTransport create(ResolvedDockerHost dockerHost) {
 		HttpClientBuilder builder = HttpClients.custom();
 		builder.setConnectionManager(new LocalConnectionManager(dockerHost.getAddress()));
-		builder.setSchemePortResolver(new LocalSchemePortResolver());
-		return new LocalHttpClientTransport(builder.build());
+		builder.setRoutePlanner(new LocalRoutePlanner());
+		HttpHost host = new HttpHost(DOCKER_SCHEME, dockerHost.getAddress());
+		return new LocalHttpClientTransport(builder.build(), host);
 	}
 
 	/**
@@ -84,7 +79,7 @@ private static class LocalConnectionManager extends BasicHttpClientConnectionMan
 
 		private static Registry<ConnectionSocketFactory> getRegistry(String host) {
 			RegistryBuilder<ConnectionSocketFactory> builder = RegistryBuilder.create();
-			builder.register("docker", new LocalConnectionSocketFactory(host));
+			builder.register(DOCKER_SCHEME, new LocalConnectionSocketFactory(host));
 			return builder.build();
 		}
 
@@ -139,20 +134,13 @@ public Socket connectSocket(TimeValue connectTimeout, Socket socket, HttpHost ho
 	}
 
 	/**
-	 * {@link SchemePortResolver} for local Docker.
+	 * {@link HttpRoutePlanner} for local Docker.
 	 */
-	private static class LocalSchemePortResolver implements SchemePortResolver {
-
-		private static final int DEFAULT_DOCKER_PORT = 2376;
+	private static class LocalRoutePlanner implements HttpRoutePlanner {
 
 		@Override
-		public int resolve(HttpHost host) {
-			Args.notNull(host, "HTTP host");
-			String name = host.getSchemeName();
-			if ("docker".equals(name)) {
-				return DEFAULT_DOCKER_PORT;
-			}
-			return -1;
+		public HttpRoute determineRoute(HttpHost target, HttpContext context) {
+			return new HttpRoute(LOCAL_DOCKER_HOST);
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/RemoteHttpClientTransport.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/RemoteHttpClientTransport.java
index 67dade3b65a3..0ab8d1ef6e79 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/RemoteHttpClientTransport.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/RemoteHttpClientTransport.java
@@ -17,17 +17,19 @@
 package org.springframework.boot.buildpack.platform.docker.transport;
 
 import java.net.URISyntaxException;
+import java.util.concurrent.TimeUnit;
 
 import javax.net.ssl.SSLContext;
 
 import org.apache.hc.client5.http.classic.HttpClient;
 import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
 import org.apache.hc.client5.http.impl.classic.HttpClients;
-import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
 import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
 import org.apache.hc.client5.http.socket.LayeredConnectionSocketFactory;
 import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
 import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.io.SocketConfig;
+import org.apache.hc.core5.util.Timeout;
 
 import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost;
 import org.springframework.boot.buildpack.platform.docker.configuration.ResolvedDockerHost;
@@ -42,6 +44,8 @@
  */
 final class RemoteHttpClientTransport extends HttpClientTransport {
 
+	private static final Timeout SOCKET_TIMEOUT = Timeout.of(30, TimeUnit.MINUTES);
+
 	private RemoteHttpClientTransport(HttpClient client, HttpHost host) {
 		super(client, host);
 	}
@@ -65,13 +69,15 @@ static RemoteHttpClientTransport createIfPossible(ResolvedDockerHost dockerHost,
 
 	private static RemoteHttpClientTransport create(DockerHost host, SslContextFactory sslContextFactory,
 			HttpHost tcpHost) {
-		HttpClientBuilder builder = HttpClients.custom();
+		SocketConfig socketConfig = SocketConfig.copy(SocketConfig.DEFAULT).setSoTimeout(SOCKET_TIMEOUT).build();
+		PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder = PoolingHttpClientConnectionManagerBuilder
+			.create()
+			.setDefaultSocketConfig(socketConfig);
 		if (host.isSecure()) {
-			PoolingHttpClientConnectionManager connectionManager = PoolingHttpClientConnectionManagerBuilder.create()
-				.setSSLSocketFactory(getSecureConnectionSocketFactory(host, sslContextFactory))
-				.build();
-			builder.setConnectionManager(connectionManager);
+			connectionManagerBuilder.setSSLSocketFactory(getSecureConnectionSocketFactory(host, sslContextFactory));
 		}
+		HttpClientBuilder builder = HttpClients.custom();
+		builder.setConnectionManager(connectionManagerBuilder.build());
 		String scheme = host.isSecure() ? "https" : "http";
 		HttpHost httpHost = new HttpHost(scheme, tcpHost.getHostName(), tcpHost.getPort());
 		return new RemoteHttpClientTransport(builder.build(), httpHost);
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ImageReference.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ImageReference.java
index f562d22f8317..01a918895a62 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ImageReference.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ImageReference.java
@@ -17,6 +17,7 @@
 package org.springframework.boot.buildpack.platform.docker.type;
 
 import java.io.File;
+import java.util.Locale;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -260,7 +261,8 @@ public static ImageReference of(String value) {
 				path = path.substring(0, tagSplit) + remainder;
 			}
 		}
-		Assert.isTrue(Regex.PATH.matcher(path).matches(),
+
+		Assert.isTrue(isLowerCase(path) && matchesPathRegex(path),
 				() -> "Unable to parse image reference \"" + value + "\". "
 						+ "Image reference must be in the form '[domainHost:port/][path/]name[:tag][@digest]', "
 						+ "with 'path' and 'name' containing only [a-z0-9][.][_][-]");
@@ -268,6 +270,14 @@ public static ImageReference of(String value) {
 		return new ImageReference(name, tag, digest);
 	}
 
+	private static boolean isLowerCase(String path) {
+		return path.toLowerCase(Locale.ENGLISH).equals(path);
+	}
+
+	private static boolean matchesPathRegex(String path) {
+		return Regex.PATH.matcher(path).matches();
+	}
+
 	/**
 	 * Create a new {@link ImageReference} from the given {@link ImageName}.
 	 * @param name the image name
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/json/JsonStream.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/json/JsonStream.java
index 87dd8ecbcb9a..e14964604d73 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/json/JsonStream.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/json/JsonStream.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -64,13 +64,14 @@ public void get(InputStream content, Consumer<ObjectNode> consumer) throws IOExc
 	 */
 	public <T> void get(InputStream content, Class<T> type, Consumer<T> consumer) throws IOException {
 		JsonFactory jsonFactory = this.objectMapper.getFactory();
-		JsonParser parser = jsonFactory.createParser(content);
-		while (!parser.isClosed()) {
-			JsonToken token = parser.nextToken();
-			if (token != null && token != JsonToken.END_OBJECT) {
-				T node = read(parser, type);
-				if (node != null) {
-					consumer.accept(node);
+		try (JsonParser parser = jsonFactory.createParser(content)) {
+			while (!parser.isClosed()) {
+				JsonToken token = parser.nextToken();
+				if (token != null && token != JsonToken.END_OBJECT) {
+					T node = read(parser, type);
+					if (node != null) {
+						consumer.accept(node);
+					}
 				}
 			}
 		}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/socket/DomainSocket.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/socket/DomainSocket.java
index 96f1c4f8c17c..7d885efb5409 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/socket/DomainSocket.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/socket/DomainSocket.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -69,8 +69,14 @@ public abstract class DomainSocket extends AbstractSocket {
 
 	private FileDescriptor open(String path) {
 		int handle = socket(PF_LOCAL, SOCK_STREAM, 0);
-		connect(path, handle);
-		return new FileDescriptor(handle, this::close);
+		try {
+			connect(path, handle);
+			return new FileDescriptor(handle, this::close);
+		}
+		catch (RuntimeException ex) {
+			close(handle);
+			throw ex;
+		}
 	}
 
 	private int read(ByteBuffer buffer) throws IOException {
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuildRequestTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuildRequestTests.java
index 1ded1fa52613..6e6fce7f50a8 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuildRequestTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuildRequestTests.java
@@ -53,6 +53,8 @@
  */
 class BuildRequestTests {
 
+	private static final ZoneId UTC = ZoneId.of("UTC");
+
 	@TempDir
 	File tempDir;
 
@@ -231,6 +233,22 @@ void withTagsWhenTagsIsNullThrowsException() throws IOException {
 			.withMessage("Tags must not be null");
 	}
 
+	@Test
+	void withBuildWorkspaceVolumeAddsWorkspace() throws IOException {
+		BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));
+		BuildRequest withWorkspace = request.withBuildWorkspace(Cache.volume("build-workspace"));
+		assertThat(request.getBuildWorkspace()).isNull();
+		assertThat(withWorkspace.getBuildWorkspace()).isEqualTo(Cache.volume("build-workspace"));
+	}
+
+	@Test
+	void withBuildWorkspaceBindAddsWorkspace() throws IOException {
+		BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));
+		BuildRequest withWorkspace = request.withBuildWorkspace(Cache.bind("/tmp/build-workspace"));
+		assertThat(request.getBuildWorkspace()).isNull();
+		assertThat(withWorkspace.getBuildWorkspace()).isEqualTo(Cache.bind("/tmp/build-workspace"));
+	}
+
 	@Test
 	void withBuildVolumeCacheAddsCache() throws IOException {
 		BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));
@@ -239,6 +257,14 @@ void withBuildVolumeCacheAddsCache() throws IOException {
 		assertThat(withCache.getBuildCache()).isEqualTo(Cache.volume("build-volume"));
 	}
 
+	@Test
+	void withBuildBindCacheAddsCache() throws IOException {
+		BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));
+		BuildRequest withCache = request.withBuildCache(Cache.bind("/tmp/build-cache"));
+		assertThat(request.getBuildCache()).isNull();
+		assertThat(withCache.getBuildCache()).isEqualTo(Cache.bind("/tmp/build-cache"));
+	}
+
 	@Test
 	void withBuildVolumeCacheWhenCacheIsNullThrowsException() throws IOException {
 		BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));
@@ -254,6 +280,14 @@ void withLaunchVolumeCacheAddsCache() throws IOException {
 		assertThat(withCache.getLaunchCache()).isEqualTo(Cache.volume("launch-volume"));
 	}
 
+	@Test
+	void withLaunchBindCacheAddsCache() throws IOException {
+		BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));
+		BuildRequest withCache = request.withLaunchCache(Cache.bind("/tmp/launch-cache"));
+		assertThat(request.getLaunchCache()).isNull();
+		assertThat(withCache.getLaunchCache()).isEqualTo(Cache.bind("/tmp/launch-cache"));
+	}
+
 	@Test
 	void withLaunchVolumeCacheWhenCacheIsNullThrowsException() throws IOException {
 		BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));
@@ -271,15 +305,15 @@ void withCreatedDateSetsCreatedDate() throws Exception {
 
 	@Test
 	void withCreatedDateNowSetsCreatedDate() throws Exception {
-		OffsetDateTime now = OffsetDateTime.now();
+		OffsetDateTime now = OffsetDateTime.now(UTC);
 		BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));
 		BuildRequest withCreatedDate = request.withCreatedDate("now");
-		OffsetDateTime createdDate = OffsetDateTime.ofInstant(withCreatedDate.getCreatedDate(), ZoneId.of("UTC"));
+		OffsetDateTime createdDate = OffsetDateTime.ofInstant(withCreatedDate.getCreatedDate(), UTC);
 		assertThat(createdDate.getYear()).isEqualTo(now.getYear());
 		assertThat(createdDate.getMonth()).isEqualTo(now.getMonth());
 		assertThat(createdDate.getDayOfMonth()).isEqualTo(now.getDayOfMonth());
 		withCreatedDate = request.withCreatedDate("NOW");
-		createdDate = OffsetDateTime.ofInstant(withCreatedDate.getCreatedDate(), ZoneId.of("UTC"));
+		createdDate = OffsetDateTime.ofInstant(withCreatedDate.getCreatedDate(), UTC);
 		assertThat(createdDate.getYear()).isEqualTo(now.getYear());
 		assertThat(createdDate.getMonth()).isEqualTo(now.getMonth());
 		assertThat(createdDate.getDayOfMonth()).isEqualTo(now.getDayOfMonth());
@@ -299,6 +333,13 @@ void withApplicationDirectorySetsApplicationDirectory() throws Exception {
 		assertThat(withAppDir.getApplicationDirectory()).isEqualTo("/application");
 	}
 
+	@Test
+	void withSecurityOptionsSetsSecurityOptions() throws Exception {
+		BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));
+		BuildRequest withAppDir = request.withSecurityOptions(List.of("label=user:USER", "label=role:ROLE"));
+		assertThat(withAppDir.getSecurityOptions()).containsExactly("label=user:USER", "label=role:ROLE");
+	}
+
 	private void hasExpectedJarContent(TarArchive archive) {
 		try {
 			ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/LifecycleTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/LifecycleTests.java
index 78ee54874bb0..6f898d1a9817 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/LifecycleTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/LifecycleTests.java
@@ -23,6 +23,7 @@
 import java.io.PrintStream;
 import java.nio.charset.StandardCharsets;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
@@ -40,7 +41,7 @@
 import org.springframework.boot.buildpack.platform.docker.DockerApi.ContainerApi;
 import org.springframework.boot.buildpack.platform.docker.DockerApi.ImageApi;
 import org.springframework.boot.buildpack.platform.docker.DockerApi.VolumeApi;
-import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost;
+import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration;
 import org.springframework.boot.buildpack.platform.docker.configuration.ResolvedDockerHost;
 import org.springframework.boot.buildpack.platform.docker.type.Binding;
 import org.springframework.boot.buildpack.platform.docker.type.ContainerConfig;
@@ -211,13 +212,27 @@ void executeWithCacheVolumeNamesExecutesPhases() throws Exception {
 		given(this.docker.container().create(any())).willAnswer(answerWithGeneratedContainerId());
 		given(this.docker.container().create(any(), any())).willAnswer(answerWithGeneratedContainerId());
 		given(this.docker.container().wait(any())).willReturn(ContainerStatus.of(0, null));
-		BuildRequest request = getTestRequest().withBuildCache(Cache.volume("build-volume"))
+		BuildRequest request = getTestRequest().withBuildWorkspace(Cache.volume("work-volume"))
+			.withBuildCache(Cache.volume("build-volume"))
 			.withLaunchCache(Cache.volume("launch-volume"));
 		createLifecycle(request).execute();
 		assertPhaseWasRun("creator", withExpectedConfig("lifecycle-creator-cache-volumes.json"));
 		assertThat(this.out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'");
 	}
 
+	@Test
+	void executeWithCacheBindMountsExecutesPhases() throws Exception {
+		given(this.docker.container().create(any())).willAnswer(answerWithGeneratedContainerId());
+		given(this.docker.container().create(any(), any())).willAnswer(answerWithGeneratedContainerId());
+		given(this.docker.container().wait(any())).willReturn(ContainerStatus.of(0, null));
+		BuildRequest request = getTestRequest().withBuildWorkspace(Cache.bind("/tmp/work"))
+			.withBuildCache(Cache.bind("/tmp/build-cache"))
+			.withLaunchCache(Cache.bind("/tmp/launch-cache"));
+		createLifecycle(request).execute();
+		assertPhaseWasRun("creator", withExpectedConfig("lifecycle-creator-cache-bind-mounts.json"));
+		assertThat(this.out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'");
+	}
+
 	@Test
 	void executeWithCreatedDateExecutesPhases() throws Exception {
 		given(this.docker.container().create(any())).willAnswer(answerWithGeneratedContainerId());
@@ -240,13 +255,25 @@ void executeWithApplicationDirectoryExecutesPhases() throws Exception {
 		assertThat(this.out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'");
 	}
 
+	@Test
+	void executeWithSecurityOptionsExecutesPhases() throws Exception {
+		given(this.docker.container().create(any())).willAnswer(answerWithGeneratedContainerId());
+		given(this.docker.container().create(any(), any())).willAnswer(answerWithGeneratedContainerId());
+		given(this.docker.container().wait(any())).willReturn(ContainerStatus.of(0, null));
+		BuildRequest request = getTestRequest().withSecurityOptions(List.of("label=user:USER", "label=role:ROLE"));
+		createLifecycle(request).execute();
+		assertPhaseWasRun("creator", withExpectedConfig("lifecycle-creator-security-opts.json", true));
+		assertThat(this.out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'");
+	}
+
 	@Test
 	void executeWithDockerHostAndRemoteAddressExecutesPhases() throws Exception {
 		given(this.docker.container().create(any())).willAnswer(answerWithGeneratedContainerId());
 		given(this.docker.container().create(any(), any())).willAnswer(answerWithGeneratedContainerId());
 		given(this.docker.container().wait(any())).willReturn(ContainerStatus.of(0, null));
 		BuildRequest request = getTestRequest();
-		createLifecycle(request, ResolvedDockerHost.from(new DockerHost("tcp://192.168.1.2:2376"))).execute();
+		createLifecycle(request, ResolvedDockerHost.from(DockerHostConfiguration.forAddress("tcp://192.168.1.2:2376")))
+			.execute();
 		assertPhaseWasRun("creator", withExpectedConfig("lifecycle-creator-inherit-remote.json"));
 		assertThat(this.out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'");
 	}
@@ -257,7 +284,8 @@ void executeWithDockerHostAndLocalAddressExecutesPhases() throws Exception {
 		given(this.docker.container().create(any(), any())).willAnswer(answerWithGeneratedContainerId());
 		given(this.docker.container().wait(any())).willReturn(ContainerStatus.of(0, null));
 		BuildRequest request = getTestRequest();
-		createLifecycle(request, ResolvedDockerHost.from(new DockerHost("/var/alt.sock"))).execute();
+		createLifecycle(request, ResolvedDockerHost.from(DockerHostConfiguration.forAddress("/var/alt.sock")))
+			.execute();
 		assertPhaseWasRun("creator", withExpectedConfig("lifecycle-creator-inherit-local.json"));
 		assertThat(this.out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'");
 	}
@@ -342,12 +370,16 @@ private void assertPhaseWasRun(String name, IOConsumer<ContainerConfig> configCo
 	}
 
 	private IOConsumer<ContainerConfig> withExpectedConfig(String name) {
+		return withExpectedConfig(name, false);
+	}
+
+	private IOConsumer<ContainerConfig> withExpectedConfig(String name, boolean expectSecurityOptAlways) {
 		return (config) -> {
 			try {
 				InputStream in = getClass().getResourceAsStream(name);
 				String jsonString = FileCopyUtils.copyToString(new InputStreamReader(in, StandardCharsets.UTF_8));
 				JSONObject json = new JSONObject(jsonString);
-				if (Platform.isWindows()) {
+				if (!expectSecurityOptAlways && Platform.isWindows()) {
 					JSONObject hostConfig = json.getJSONObject("HostConfig");
 					hostConfig.remove("SecurityOpt");
 				}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/PrintStreamBuildLogTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/PrintStreamBuildLogTests.java
index 74cefced82ae..1e25ed10a998 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/PrintStreamBuildLogTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/PrintStreamBuildLogTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -67,7 +67,7 @@ void printsExpectedOutput() throws Exception {
 		Consumer<TotalProgressEvent> pullRunImageConsumer = log.pullingImage(runImageReference, ImageType.RUNNER);
 		pullRunImageConsumer.accept(new TotalProgressEvent(100));
 		log.pulledImage(runImage, ImageType.RUNNER);
-		log.executingLifecycle(request, LifecycleVersion.parse("0.5"), VolumeName.of("pack-abc.cache"));
+		log.executingLifecycle(request, LifecycleVersion.parse("0.5"), Cache.volume(VolumeName.of("pack-abc.cache")));
 		Consumer<LogUpdateEvent> phase1Consumer = log.runningPhase(request, "alphabet");
 		phase1Consumer.accept(mockLogEvent("one"));
 		phase1Consumer.accept(mockLogEvent("two"));
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationMetadataTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationMetadataTests.java
new file mode 100644
index 000000000000..b47bbaa3d80c
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationMetadataTests.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.buildpack.platform.docker.configuration;
+
+import java.io.File;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.Paths;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfigurationMetadata.DockerContext;
+import org.springframework.boot.buildpack.platform.json.AbstractJsonTests;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link DockerConfigurationMetadata}.
+ *
+ * @author Scott Frederick
+ */
+class DockerConfigurationMetadataTests extends AbstractJsonTests {
+
+	private final Map<String, String> environment = new LinkedHashMap<>();
+
+	@Test
+	void configWithContextIsRead() throws Exception {
+		this.environment.put("DOCKER_CONFIG", pathToResource("with-context/config.json"));
+		DockerConfigurationMetadata config = DockerConfigurationMetadata.from(this.environment::get);
+		assertThat(config.getConfiguration().getCurrentContext()).isEqualTo("test-context");
+		assertThat(config.getContext().getDockerHost()).isEqualTo("unix:///home/user/.docker/docker.sock");
+		assertThat(config.getContext().isTlsVerify()).isFalse();
+		assertThat(config.getContext().getTlsPath()).isNull();
+	}
+
+	@Test
+	void configWithoutContextIsRead() throws Exception {
+		this.environment.put("DOCKER_CONFIG", pathToResource("without-context/config.json"));
+		DockerConfigurationMetadata config = DockerConfigurationMetadata.from(this.environment::get);
+		assertThat(config.getConfiguration().getCurrentContext()).isNull();
+		assertThat(config.getContext().getDockerHost()).isNull();
+		assertThat(config.getContext().isTlsVerify()).isFalse();
+		assertThat(config.getContext().getTlsPath()).isNull();
+	}
+
+	@Test
+	void configWithDefaultContextIsRead() throws Exception {
+		this.environment.put("DOCKER_CONFIG", pathToResource("with-default-context/config.json"));
+		DockerConfigurationMetadata config = DockerConfigurationMetadata.from(this.environment::get);
+		assertThat(config.getConfiguration().getCurrentContext()).isEqualTo("default");
+		assertThat(config.getContext().getDockerHost()).isNull();
+		assertThat(config.getContext().isTlsVerify()).isFalse();
+		assertThat(config.getContext().getTlsPath()).isNull();
+	}
+
+	@Test
+	void configIsReadWithProvidedContext() throws Exception {
+		this.environment.put("DOCKER_CONFIG", pathToResource("with-default-context/config.json"));
+		DockerConfigurationMetadata config = DockerConfigurationMetadata.from(this.environment::get);
+		DockerContext context = config.forContext("test-context");
+		assertThat(context.getDockerHost()).isEqualTo("unix:///home/user/.docker/docker.sock");
+		assertThat(context.isTlsVerify()).isTrue();
+		assertThat(context.getTlsPath()).matches(String.join(Pattern.quote(File.separator), "^.*",
+				"with-default-context", "contexts", "tls", "[a-zA-z0-9]*", "docker$"));
+	}
+
+	@Test
+	void invalidContextThrowsException() throws Exception {
+		this.environment.put("DOCKER_CONFIG", pathToResource("with-default-context/config.json"));
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> DockerConfigurationMetadata.from(this.environment::get).forContext("invalid-context"))
+			.withMessageContaining("Docker context 'invalid-context' does not exist");
+	}
+
+	@Test
+	void configIsEmptyWhenConfigFileDoesNotExist() {
+		this.environment.put("DOCKER_CONFIG", "docker-config-dummy-path");
+		DockerConfigurationMetadata config = DockerConfigurationMetadata.from(this.environment::get);
+		assertThat(config.getConfiguration().getCurrentContext()).isNull();
+		assertThat(config.getContext().getDockerHost()).isNull();
+		assertThat(config.getContext().isTlsVerify()).isFalse();
+	}
+
+	private String pathToResource(String resource) throws URISyntaxException {
+		URL url = getClass().getResource(resource);
+		return Paths.get(url.toURI()).getParent().toAbsolutePath().toString();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/ResolvedDockerHostTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/ResolvedDockerHostTests.java
index 30a1c358304b..131299849788 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/ResolvedDockerHostTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/ResolvedDockerHostTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -17,8 +17,11 @@
 package org.springframework.boot.buildpack.platform.docker.configuration;
 
 import java.io.IOException;
+import java.net.URISyntaxException;
+import java.net.URL;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.util.LinkedHashMap;
 import java.util.Map;
 
@@ -28,6 +31,8 @@
 import org.junit.jupiter.api.condition.OS;
 import org.junit.jupiter.api.io.TempDir;
 
+import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration;
+
 import static org.assertj.core.api.Assertions.assertThat;
 
 /**
@@ -41,7 +46,8 @@ class ResolvedDockerHostTests {
 
 	@Test
 	@DisabledOnOs(OS.WINDOWS)
-	void resolveWhenDockerHostIsNullReturnsLinuxDefault() {
+	void resolveWhenDockerHostIsNullReturnsLinuxDefault() throws Exception {
+		this.environment.put("DOCKER_CONFIG", pathToResource("with-default-context/config.json"));
 		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get, null);
 		assertThat(dockerHost.getAddress()).isEqualTo("/var/run/docker.sock");
 		assertThat(dockerHost.isSecure()).isFalse();
@@ -50,7 +56,8 @@ void resolveWhenDockerHostIsNullReturnsLinuxDefault() {
 
 	@Test
 	@EnabledOnOs(OS.WINDOWS)
-	void resolveWhenDockerHostIsNullReturnsWindowsDefault() {
+	void resolveWhenDockerHostIsNullReturnsWindowsDefault() throws Exception {
+		this.environment.put("DOCKER_CONFIG", pathToResource("with-default-context/config.json"));
 		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get, null);
 		assertThat(dockerHost.getAddress()).isEqualTo("//./pipe/docker_engine");
 		assertThat(dockerHost.isSecure()).isFalse();
@@ -59,8 +66,10 @@ void resolveWhenDockerHostIsNullReturnsWindowsDefault() {
 
 	@Test
 	@DisabledOnOs(OS.WINDOWS)
-	void resolveWhenDockerHostAddressIsNullReturnsLinuxDefault() {
-		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get, new DockerHost(null));
+	void resolveWhenDockerHostAddressIsNullReturnsLinuxDefault() throws Exception {
+		this.environment.put("DOCKER_CONFIG", pathToResource("with-default-context/config.json"));
+		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get,
+				DockerHostConfiguration.forAddress(null));
 		assertThat(dockerHost.getAddress()).isEqualTo("/var/run/docker.sock");
 		assertThat(dockerHost.isSecure()).isFalse();
 		assertThat(dockerHost.getCertificatePath()).isNull();
@@ -70,7 +79,7 @@ void resolveWhenDockerHostAddressIsNullReturnsLinuxDefault() {
 	void resolveWhenDockerHostAddressIsLocalReturnsAddress(@TempDir Path tempDir) throws IOException {
 		String socketFilePath = Files.createTempFile(tempDir, "remote-transport", null).toAbsolutePath().toString();
 		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get,
-				new DockerHost(socketFilePath, false, null));
+				DockerHostConfiguration.forAddress(socketFilePath));
 		assertThat(dockerHost.isLocalFileReference()).isTrue();
 		assertThat(dockerHost.isRemote()).isFalse();
 		assertThat(dockerHost.getAddress()).isEqualTo(socketFilePath);
@@ -82,7 +91,7 @@ void resolveWhenDockerHostAddressIsLocalReturnsAddress(@TempDir Path tempDir) th
 	void resolveWhenDockerHostAddressIsLocalWithSchemeReturnsAddress(@TempDir Path tempDir) throws IOException {
 		String socketFilePath = Files.createTempFile(tempDir, "remote-transport", null).toAbsolutePath().toString();
 		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get,
-				new DockerHost("unix://" + socketFilePath, false, null));
+				DockerHostConfiguration.forAddress("unix://" + socketFilePath));
 		assertThat(dockerHost.isLocalFileReference()).isTrue();
 		assertThat(dockerHost.isRemote()).isFalse();
 		assertThat(dockerHost.getAddress()).isEqualTo(socketFilePath);
@@ -93,7 +102,7 @@ void resolveWhenDockerHostAddressIsLocalWithSchemeReturnsAddress(@TempDir Path t
 	@Test
 	void resolveWhenDockerHostAddressIsHttpReturnsAddress() {
 		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get,
-				new DockerHost("http://docker.example.com", false, null));
+				DockerHostConfiguration.forAddress("http://docker.example.com"));
 		assertThat(dockerHost.isLocalFileReference()).isFalse();
 		assertThat(dockerHost.isRemote()).isTrue();
 		assertThat(dockerHost.getAddress()).isEqualTo("http://docker.example.com");
@@ -104,7 +113,7 @@ void resolveWhenDockerHostAddressIsHttpReturnsAddress() {
 	@Test
 	void resolveWhenDockerHostAddressIsHttpsReturnsAddress() {
 		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get,
-				new DockerHost("https://docker.example.com", true, "/cert-path"));
+				DockerHostConfiguration.forAddress("https://docker.example.com", true, "/cert-path"));
 		assertThat(dockerHost.isLocalFileReference()).isFalse();
 		assertThat(dockerHost.isRemote()).isTrue();
 		assertThat(dockerHost.getAddress()).isEqualTo("https://docker.example.com");
@@ -115,7 +124,7 @@ void resolveWhenDockerHostAddressIsHttpsReturnsAddress() {
 	@Test
 	void resolveWhenDockerHostAddressIsTcpReturnsAddress() {
 		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get,
-				new DockerHost("tcp://192.168.99.100:2376", true, "/cert-path"));
+				DockerHostConfiguration.forAddress("tcp://192.168.99.100:2376", true, "/cert-path"));
 		assertThat(dockerHost.isLocalFileReference()).isFalse();
 		assertThat(dockerHost.isRemote()).isTrue();
 		assertThat(dockerHost.getAddress()).isEqualTo("tcp://192.168.99.100:2376");
@@ -128,7 +137,7 @@ void resolveWhenEnvironmentAddressIsLocalReturnsAddress(@TempDir Path tempDir) t
 		String socketFilePath = Files.createTempFile(tempDir, "remote-transport", null).toAbsolutePath().toString();
 		this.environment.put("DOCKER_HOST", socketFilePath);
 		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get,
-				new DockerHost("/unused", true, "/unused"));
+				DockerHostConfiguration.forAddress("/unused"));
 		assertThat(dockerHost.isLocalFileReference()).isTrue();
 		assertThat(dockerHost.isRemote()).isFalse();
 		assertThat(dockerHost.getAddress()).isEqualTo(socketFilePath);
@@ -141,7 +150,7 @@ void resolveWhenEnvironmentAddressIsLocalWithSchemeReturnsAddress(@TempDir Path
 		String socketFilePath = Files.createTempFile(tempDir, "remote-transport", null).toAbsolutePath().toString();
 		this.environment.put("DOCKER_HOST", "unix://" + socketFilePath);
 		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get,
-				new DockerHost("/unused", true, "/unused"));
+				DockerHostConfiguration.forAddress("/unused"));
 		assertThat(dockerHost.isLocalFileReference()).isTrue();
 		assertThat(dockerHost.isRemote()).isFalse();
 		assertThat(dockerHost.getAddress()).isEqualTo(socketFilePath);
@@ -155,7 +164,7 @@ void resolveWhenEnvironmentAddressIsTcpReturnsAddress() {
 		this.environment.put("DOCKER_TLS_VERIFY", "1");
 		this.environment.put("DOCKER_CERT_PATH", "/cert-path");
 		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get,
-				new DockerHost("tcp://1.1.1.1", false, "/unused"));
+				DockerHostConfiguration.forAddress("tcp://1.1.1.1"));
 		assertThat(dockerHost.isLocalFileReference()).isFalse();
 		assertThat(dockerHost.isRemote()).isTrue();
 		assertThat(dockerHost.getAddress()).isEqualTo("tcp://192.168.99.100:2376");
@@ -163,4 +172,39 @@ void resolveWhenEnvironmentAddressIsTcpReturnsAddress() {
 		assertThat(dockerHost.getCertificatePath()).isEqualTo("/cert-path");
 	}
 
+	@Test
+	void resolveWithDockerHostContextReturnsAddress() throws Exception {
+		this.environment.put("DOCKER_CONFIG", pathToResource("with-default-context/config.json"));
+		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get,
+				DockerHostConfiguration.forContext("test-context"));
+		assertThat(dockerHost.getAddress()).isEqualTo("/home/user/.docker/docker.sock");
+		assertThat(dockerHost.isSecure()).isTrue();
+		assertThat(dockerHost.getCertificatePath()).isNotNull();
+	}
+
+	@Test
+	void resolveWithDockerConfigMetadataContextReturnsAddress() throws Exception {
+		this.environment.put("DOCKER_CONFIG", pathToResource("with-context/config.json"));
+		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get, null);
+		assertThat(dockerHost.getAddress()).isEqualTo("/home/user/.docker/docker.sock");
+		assertThat(dockerHost.isSecure()).isFalse();
+		assertThat(dockerHost.getCertificatePath()).isNull();
+	}
+
+	@Test
+	void resolveWhenEnvironmentHasAddressAndContextPrefersContext() throws Exception {
+		this.environment.put("DOCKER_CONFIG", pathToResource("with-context/config.json"));
+		this.environment.put("DOCKER_CONTEXT", "test-context");
+		this.environment.put("DOCKER_HOST", "notused");
+		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get, null);
+		assertThat(dockerHost.getAddress()).isEqualTo("/home/user/.docker/docker.sock");
+		assertThat(dockerHost.isSecure()).isFalse();
+		assertThat(dockerHost.getCertificatePath()).isNull();
+	}
+
+	private String pathToResource(String resource) throws URISyntaxException {
+		URL url = getClass().getResource(resource);
+		return Paths.get(url.toURI()).getParent().toAbsolutePath().toString();
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/KeyStoreFactoryTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/KeyStoreFactoryTests.java
index 5168e37b5522..837f5c4017e5 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/KeyStoreFactoryTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/KeyStoreFactoryTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PemFileWriter.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PemFileWriter.java
index 509f59b25f08..6921dd5c3c3b 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PemFileWriter.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PemFileWriter.java
@@ -90,12 +90,16 @@ public class PemFileWriter {
 			-----END RSA PRIVATE KEY-----
 			""".formatted(EXAMPLE_SECRET_QUALIFIER);
 
-	public static final String PRIVATE_EC_KEY = """
-			%s-----BEGIN EC PRIVATE KEY-----
-			MHcCAQEEIIwZkO8Zjbggzi8wwrk5rzSPzUX31gqTRhBYw4AL6w44oAoGCCqGSM49
-			AwEHoUQDQgAE8y28khug747bA68M90IAMCPHAYyen+RsN6i84LORpNDUhv00QZWd
-			hOhjWFCQjnewR98Y8pEb1fnORll4LhHPlQ==
-			-----END EC PRIVATE KEY-----""".formatted(EXAMPLE_SECRET_QUALIFIER);
+	public static final String PRIVATE_EC_KEY = EXAMPLE_SECRET_QUALIFIER + "-----BEGIN EC PRIVATE KEY-----\n"
+			+ "MIGkAgEBBDB21WGGOb1DokKW0MUHO7RQ6jZSUYXfO2iyfCbjmSJhyK8fSuq1V0N2\n"
+			+ "Bj7X+XYhS6ygBwYFK4EEACKhZANiAATsRaYri/tDMvrrB2NJlxWFOZ4YBLYdSM+a\n"
+			+ "FlGh1FuLjOHW9cx8w0iRHd1Hxn4sxqsa62KzGoCj63lGoaJgi67YNCF0lBa/zCLy\n"
+			+ "ktaMsQePDOR8UR0Cfi2J9bh+IjxXd+o=\n" + "-----END EC PRIVATE KEY-----";
+
+	public static final String PRIVATE_EC_KEY_PRIME_256_V1 = EXAMPLE_SECRET_QUALIFIER
+			+ "-----BEGIN EC PRIVATE KEY-----\n" + "MHcCAQEEIIwZkO8Zjbggzi8wwrk5rzSPzUX31gqTRhBYw4AL6w44oAoGCCqGSM49\n"
+			+ "AwEHoUQDQgAE8y28khug747bA68M90IAMCPHAYyen+RsN6i84LORpNDUhv00QZWd\n"
+			+ "hOhjWFCQjnewR98Y8pEb1fnORll4LhHPlQ==\n" + "-----END EC PRIVATE KEY-----";
 
 	public static final String PRIVATE_DSA_KEY = EXAMPLE_SECRET_QUALIFIER + "-----BEGIN PRIVATE KEY-----\n"
 			+ "MIICXAIBADCCAjUGByqGSM44BAEwggIoAoIBAQCPeTXZuarpv6vtiHrPSVG28y7F\n"
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PrivateKeyParserTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PrivateKeyParserTests.java
index f98e1806461c..f9a615fe0f1f 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PrivateKeyParserTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PrivateKeyParserTests.java
@@ -22,6 +22,7 @@
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.security.PrivateKey;
+import java.security.interfaces.ECPrivateKey;
 
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
@@ -89,17 +90,27 @@ void parsePkcs1RsaKeyFile() throws IOException {
 		Path path = this.fileWriter.writeFile("key.pem", PemFileWriter.PRIVATE_RSA_KEY);
 		PrivateKey privateKey = PrivateKeyParser.parse(path);
 		assertThat(privateKey).isNotNull();
-		// keys in PKCS#1 format are converted to PKCS#8 for parsing
 		assertThat(privateKey.getFormat()).isEqualTo("PKCS#8");
 	}
 
 	@Test
-	void parsePkcs1EcKeyFile() throws IOException {
+	void parsePemEcKeyFile() throws IOException {
 		Path path = this.fileWriter.writeFile("key.pem", PemFileWriter.PRIVATE_EC_KEY);
-		PrivateKey privateKey = PrivateKeyParser.parse(path);
+		ECPrivateKey privateKey = (ECPrivateKey) PrivateKeyParser.parse(path);
+		assertThat(privateKey).isNotNull();
+		assertThat(privateKey.getFormat()).isEqualTo("PKCS#8");
+		assertThat(privateKey.getAlgorithm()).isEqualTo("EC");
+		assertThat(privateKey.getParams().toString()).contains("1.3.132.0.34").doesNotContain("prime256v1");
+	}
+
+	@Test
+	void parsePemEcKeyFilePrime256v1() throws IOException {
+		Path path = this.fileWriter.writeFile("key.pem", PemFileWriter.PRIVATE_EC_KEY_PRIME_256_V1);
+		ECPrivateKey privateKey = (ECPrivateKey) PrivateKeyParser.parse(path);
 		assertThat(privateKey).isNotNull();
-		// keys in PKCS#1 format are converted to PKCS#8 for parsing
 		assertThat(privateKey.getFormat()).isEqualTo("PKCS#8");
+		assertThat(privateKey.getAlgorithm()).isEqualTo("EC");
+		assertThat(privateKey.getParams().toString()).contains("prime256v1").doesNotContain("1.3.132.0.34");
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/HttpTransportTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/HttpTransportTests.java
index c04cd5e719db..a383d0240ec9 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/HttpTransportTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/HttpTransportTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -23,7 +23,7 @@
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
-import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost;
+import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -37,21 +37,21 @@ class HttpTransportTests {
 
 	@Test
 	void createWhenDockerHostVariableIsAddressReturnsRemote() {
-		HttpTransport transport = HttpTransport.create(new DockerHost("tcp://192.168.1.0"));
+		HttpTransport transport = HttpTransport.create(DockerHostConfiguration.forAddress("tcp://192.168.1.0"));
 		assertThat(transport).isInstanceOf(RemoteHttpClientTransport.class);
 	}
 
 	@Test
 	void createWhenDockerHostVariableIsFileReturnsLocal(@TempDir Path tempDir) throws IOException {
 		String dummySocketFilePath = Files.createTempFile(tempDir, "http-transport", null).toAbsolutePath().toString();
-		HttpTransport transport = HttpTransport.create(new DockerHost(dummySocketFilePath));
+		HttpTransport transport = HttpTransport.create(DockerHostConfiguration.forAddress(dummySocketFilePath));
 		assertThat(transport).isInstanceOf(LocalHttpClientTransport.class);
 	}
 
 	@Test
 	void createWhenDockerHostVariableIsUnixSchemePrefixedFileReturnsLocal(@TempDir Path tempDir) throws IOException {
 		String dummySocketFilePath = "unix://" + Files.createTempFile(tempDir, "http-transport", null).toAbsolutePath();
-		HttpTransport transport = HttpTransport.create(new DockerHost(dummySocketFilePath));
+		HttpTransport transport = HttpTransport.create(DockerHostConfiguration.forAddress(dummySocketFilePath));
 		assertThat(transport).isInstanceOf(LocalHttpClientTransport.class);
 	}
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/LocalHttpClientTransportTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/LocalHttpClientTransportTests.java
index 78ff1d0c71fe..81cd780c5b04 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/LocalHttpClientTransportTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/LocalHttpClientTransportTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -24,7 +24,7 @@
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
-import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost;
+import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration;
 import org.springframework.boot.buildpack.platform.docker.configuration.ResolvedDockerHost;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -39,24 +39,28 @@ class LocalHttpClientTransportTests {
 	@Test
 	void createWhenDockerHostIsFileReturnsTransport(@TempDir Path tempDir) throws IOException {
 		String socketFilePath = Files.createTempFile(tempDir, "remote-transport", null).toAbsolutePath().toString();
-		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(new DockerHost(socketFilePath));
+		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(DockerHostConfiguration.forAddress(socketFilePath));
 		LocalHttpClientTransport transport = LocalHttpClientTransport.create(dockerHost);
 		assertThat(transport).isNotNull();
+		assertThat(transport.getHost().toHostString()).isEqualTo(socketFilePath);
 	}
 
 	@Test
 	void createWhenDockerHostIsFileThatDoesNotExistReturnsTransport(@TempDir Path tempDir) {
 		String socketFilePath = Paths.get(tempDir.toString(), "dummy").toAbsolutePath().toString();
-		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(new DockerHost(socketFilePath));
+		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(DockerHostConfiguration.forAddress(socketFilePath));
 		LocalHttpClientTransport transport = LocalHttpClientTransport.create(dockerHost);
 		assertThat(transport).isNotNull();
+		assertThat(transport.getHost().toHostString()).isEqualTo(socketFilePath);
 	}
 
 	@Test
 	void createWhenDockerHostIsAddressReturnsTransport() {
-		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(new DockerHost("tcp://192.168.1.2:2376"));
+		ResolvedDockerHost dockerHost = ResolvedDockerHost
+			.from(DockerHostConfiguration.forAddress("tcp://192.168.1.2:2376"));
 		LocalHttpClientTransport transport = LocalHttpClientTransport.create(dockerHost);
 		assertThat(transport).isNotNull();
+		assertThat(transport.getHost().toHostString()).isEqualTo("tcp://192.168.1.2:2376");
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/RemoteHttpClientTransportTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/RemoteHttpClientTransportTests.java
index a56373709eff..529709d5cc38 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/RemoteHttpClientTransportTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/RemoteHttpClientTransportTests.java
@@ -23,7 +23,7 @@
 import org.apache.hc.core5.http.HttpHost;
 import org.junit.jupiter.api.Test;
 
-import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost;
+import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration;
 import org.springframework.boot.buildpack.platform.docker.configuration.ResolvedDockerHost;
 import org.springframework.boot.buildpack.platform.docker.ssl.SslContextFactory;
 
@@ -49,28 +49,31 @@ void createIfPossibleWhenDockerHostIsNotSetReturnsNull() {
 
 	@Test
 	void createIfPossibleWhenDockerHostIsDefaultReturnsNull() {
-		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(new DockerHost(null));
+		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(DockerHostConfiguration.forAddress(null));
 		RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(dockerHost);
 		assertThat(transport).isNull();
 	}
 
 	@Test
 	void createIfPossibleWhenDockerHostIsFileReturnsNull() {
-		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(new DockerHost("unix:///var/run/socket.sock"));
+		ResolvedDockerHost dockerHost = ResolvedDockerHost
+			.from(DockerHostConfiguration.forAddress("unix:///var/run/socket.sock"));
 		RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(dockerHost);
 		assertThat(transport).isNull();
 	}
 
 	@Test
 	void createIfPossibleWhenDockerHostIsAddressReturnsTransport() {
-		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(new DockerHost("tcp://192.168.1.2:2376"));
+		ResolvedDockerHost dockerHost = ResolvedDockerHost
+			.from(DockerHostConfiguration.forAddress("tcp://192.168.1.2:2376"));
 		RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(dockerHost);
 		assertThat(transport).isNotNull();
 	}
 
 	@Test
 	void createIfPossibleWhenNoTlsVerifyUsesHttp() {
-		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(new DockerHost("tcp://192.168.1.2:2376"));
+		ResolvedDockerHost dockerHost = ResolvedDockerHost
+			.from(DockerHostConfiguration.forAddress("tcp://192.168.1.2:2376"));
 		RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(dockerHost);
 		assertThat(transport.getHost()).satisfies(hostOf("http", "192.168.1.2", 2376));
 	}
@@ -80,14 +83,15 @@ void createIfPossibleWhenTlsVerifyUsesHttps() throws Exception {
 		SslContextFactory sslContextFactory = mock(SslContextFactory.class);
 		given(sslContextFactory.forDirectory("/test-cert-path")).willReturn(SSLContext.getDefault());
 		ResolvedDockerHost dockerHost = ResolvedDockerHost
-			.from(new DockerHost("tcp://192.168.1.2:2376", true, "/test-cert-path"));
+			.from(DockerHostConfiguration.forAddress("tcp://192.168.1.2:2376", true, "/test-cert-path"));
 		RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(dockerHost, sslContextFactory);
 		assertThat(transport.getHost()).satisfies(hostOf("https", "192.168.1.2", 2376));
 	}
 
 	@Test
 	void createIfPossibleWhenTlsVerifyWithMissingCertPathThrowsException() {
-		ResolvedDockerHost dockerHost = ResolvedDockerHost.from(new DockerHost("tcp://192.168.1.2:2376", true, null));
+		ResolvedDockerHost dockerHost = ResolvedDockerHost
+			.from(DockerHostConfiguration.forAddress("tcp://192.168.1.2:2376", true, null));
 		assertThatIllegalArgumentException().isThrownBy(() -> RemoteHttpClientTransport.createIfPossible(dockerHost))
 			.withMessageContaining("Docker host TLS verification requires trust material");
 	}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ImageArchiveManifestTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ImageArchiveManifestTests.java
index 31cd8a768cdf..8f0eaccd8453 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ImageArchiveManifestTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ImageArchiveManifestTests.java
@@ -52,7 +52,7 @@ void getLayersWithNoLayersReturnsEmptyList() throws Exception {
 		String content = "[{\"Layers\": []}]";
 		ImageArchiveManifest manifest = new ImageArchiveManifest(getObjectMapper().readTree(content));
 		assertThat(manifest.getEntries()).hasSize(1);
-		assertThat(manifest.getEntries().get(0).getLayers()).hasSize(0);
+		assertThat(manifest.getEntries().get(0).getLayers()).isEmpty();
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ImageReferenceTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ImageReferenceTests.java
index 61940e44c8e5..d33abc7f38b8 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ImageReferenceTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ImageReferenceTests.java
@@ -180,6 +180,14 @@ void ofWhenHasIllegalCharacter() {
 			.withMessageContaining("Unable to parse image reference");
 	}
 
+	@Test
+	void ofWhenContainsUpperCaseThrowsException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> ImageReference
+				.of("europe-west1-docker.pkg.dev/aaaaaa-bbbbb-123456/docker-registry/bootBuildImage:0.0.1"))
+			.withMessageContaining("Unable to parse image reference");
+	}
+
 	@Test
 	void forJarFile() {
 		assertForJarFile("spring-boot.2.0.0.BUILD-SNAPSHOT.jar", "library/spring-boot", "2.0.0.BUILD-SNAPSHOT");
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-cache-bind-mounts.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-cache-bind-mounts.json
new file mode 100644
index 000000000000..7259fc11af77
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-cache-bind-mounts.json
@@ -0,0 +1,39 @@
+{
+  "User": "root",
+  "Image": "pack.local/ephemeral-builder",
+  "Cmd": [
+    "/cnb/lifecycle/creator",
+    "-app",
+    "/workspace",
+    "-platform",
+    "/platform",
+    "-run-image",
+    "docker.io/cloudfoundry/run:latest",
+    "-layers",
+    "/layers",
+    "-cache-dir",
+    "/cache",
+    "-launch-cache",
+    "/launch-cache",
+    "-daemon",
+    "docker.io/library/my-application:latest"
+  ],
+  "Env": [
+    "CNB_PLATFORM_API=0.8"
+  ],
+  "Labels": {
+    "author": "spring-boot"
+  },
+  "HostConfig": {
+    "Binds": [
+      "/var/run/docker.sock:/var/run/docker.sock",
+      "/tmp/work-layers:/layers",
+      "/tmp/work-app:/workspace",
+      "/tmp/build-cache:/cache",
+      "/tmp/launch-cache:/launch-cache"
+    ],
+    "SecurityOpt" : [
+      "label=disable"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-cache-volumes.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-cache-volumes.json
index 7bd3d9a24ca0..0f611d5d059c 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-cache-volumes.json
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-cache-volumes.json
@@ -27,8 +27,8 @@
   "HostConfig": {
     "Binds": [
       "/var/run/docker.sock:/var/run/docker.sock",
-      "pack-layers-aaaaaaaaaa:/layers",
-      "pack-app-aaaaaaaaaa:/workspace",
+      "work-volume-layers:/layers",
+      "work-volume-app:/workspace",
       "build-volume:/cache",
       "launch-volume:/launch-cache"
     ],
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-security-opts.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-security-opts.json
new file mode 100644
index 000000000000..c47bd7f9ffd7
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-security-opts.json
@@ -0,0 +1,40 @@
+{
+  "User": "root",
+  "Image": "pack.local/ephemeral-builder",
+  "Cmd": [
+    "/cnb/lifecycle/creator",
+    "-app",
+    "/workspace",
+    "-platform",
+    "/platform",
+    "-run-image",
+    "docker.io/cloudfoundry/run:latest",
+    "-layers",
+    "/layers",
+    "-cache-dir",
+    "/cache",
+    "-launch-cache",
+    "/launch-cache",
+    "-daemon",
+    "docker.io/library/my-application:latest"
+  ],
+  "Env": [
+    "CNB_PLATFORM_API=0.8"
+  ],
+  "Labels": {
+    "author": "spring-boot"
+  },
+  "HostConfig": {
+    "Binds": [
+      "/var/run/docker.sock:/var/run/docker.sock",
+      "pack-layers-aaaaaaaaaa:/layers",
+      "pack-app-aaaaaaaaaa:/workspace",
+      "pack-cache-b35197ac41ea.build:/cache",
+      "pack-cache-b35197ac41ea.launch:/launch-cache"
+    ],
+    "SecurityOpt" : [
+      "label=user:USER",
+      "label=role:ROLE"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-context/config.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-context/config.json
new file mode 100644
index 000000000000..7e3fa77f5bfe
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-context/config.json
@@ -0,0 +1,3 @@
+{
+  "currentContext": "test-context"
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-context/contexts/meta/ea1b2003cc8155cb8af43960c89a4c1e28777d6fd848ff3422cf375329c2626d/meta.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-context/contexts/meta/ea1b2003cc8155cb8af43960c89a4c1e28777d6fd848ff3422cf375329c2626d/meta.json
new file mode 100644
index 000000000000..fa4655b1a026
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-context/contexts/meta/ea1b2003cc8155cb8af43960c89a4c1e28777d6fd848ff3422cf375329c2626d/meta.json
@@ -0,0 +1,12 @@
+{
+  "Name": "test-context",
+  "Metadata": {
+    "Description": "A context for testing"
+  },
+  "Endpoints": {
+    "docker": {
+      "Host": "unix:///home/user/.docker/docker.sock",
+      "SkipTLSVerify": true
+    }
+  }
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-default-context/config.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-default-context/config.json
new file mode 100644
index 000000000000..6eaf50253da3
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-default-context/config.json
@@ -0,0 +1,3 @@
+{
+  "currentContext": "default"
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-default-context/contexts/meta/ea1b2003cc8155cb8af43960c89a4c1e28777d6fd848ff3422cf375329c2626d/meta.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-default-context/contexts/meta/ea1b2003cc8155cb8af43960c89a4c1e28777d6fd848ff3422cf375329c2626d/meta.json
new file mode 100644
index 000000000000..f072aa2647e2
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-default-context/contexts/meta/ea1b2003cc8155cb8af43960c89a4c1e28777d6fd848ff3422cf375329c2626d/meta.json
@@ -0,0 +1,12 @@
+{
+  "Name": "test-context",
+  "Metadata": {
+    "Description": "A context for testing"
+  },
+  "Endpoints": {
+    "docker": {
+      "Host": "unix:///home/user/.docker/docker.sock",
+      "SkipTLSVerify": false
+    }
+  }
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-default-context/contexts/tls/ea1b2003cc8155cb8af43960c89a4c1e28777d6fd848ff3422cf375329c2626d/docker/cert.pem b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-default-context/contexts/tls/ea1b2003cc8155cb8af43960c89a4c1e28777d6fd848ff3422cf375329c2626d/docker/cert.pem
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-default-context/contexts/tls/ea1b2003cc8155cb8af43960c89a4c1e28777d6fd848ff3422cf375329c2626d/docker/key.pem b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-default-context/contexts/tls/ea1b2003cc8155cb8af43960c89a4c1e28777d6fd848ff3422cf375329c2626d/docker/key.pem
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/without-context/config.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/without-context/config.json
new file mode 100644
index 000000000000..2c63c0851048
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/without-context/config.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-cli/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-cli/build.gradle
index 35714e676f53..bf42c6816213 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-cli/build.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-cli/build.gradle
@@ -35,7 +35,7 @@ dependencies {
 	intTestImplementation("org.junit.jupiter:junit-jupiter")
 	intTestImplementation("org.springframework:spring-core")
 
-	loader(project(":spring-boot-project:spring-boot-tools:spring-boot-loader"))
+	loader(project(":spring-boot-project:spring-boot-tools:spring-boot-loader-classic"))
 
 	testImplementation(project(":spring-boot-project:spring-boot"))
 	testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support"))
@@ -66,7 +66,7 @@ task fullJar(type: Jar) {
 	}
 	manifest {
 		attributes(
-			"Main-Class": "org.springframework.boot.loader.JarLauncher",
+			"Main-Class": "org.springframework.boot.loader.launch.JarLauncher",
 			"Start-Class": "org.springframework.boot.cli.SpringCli"
 		)
 	}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/content/bin/spring.bat b/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/content/bin/spring.bat
index c9c0081c06f7..3bec92853213 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/content/bin/spring.bat
+++ b/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/content/bin/spring.bat
@@ -59,7 +59,7 @@ set CMD_LINE_ARGS=%$
 @rem Setup the command line
 
 set CLASSPATH=%SPRING_HOME%\lib\*
-"%JAVA_EXE%" %JAVA_OPTS% -cp "%CLASSPATH%" org.springframework.boot.loader.JarLauncher %CMD_LINE_ARGS%
+"%JAVA_EXE%" %JAVA_OPTS% -cp "%CLASSPATH%" org.springframework.boot.loader.launch.JarLauncher %CMD_LINE_ARGS%
 
 :end
 @rem End local scope for the variables with windows NT shell
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/executablecontent/bin/spring b/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/executablecontent/bin/spring
index 0e025b27d6f7..dda4e9b2819b 100755
--- a/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/executablecontent/bin/spring
+++ b/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/executablecontent/bin/spring
@@ -115,4 +115,4 @@ if $cygwin; then
 fi
 
 IFS=" " read -r -a javaOpts <<< "$JAVA_OPTS"
-exec "${JAVA_HOME}/bin/java" "${javaOpts[@]}" -cp "$CLASSPATH" org.springframework.boot.loader.JarLauncher "$@"
+exec "${JAVA_HOME}/bin/java" "${javaOpts[@]}" -cp "$CLASSPATH" org.springframework.boot.loader.launch.JarLauncher "$@"
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/encodepassword/EncodePasswordCommand.java b/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/encodepassword/EncodePasswordCommand.java
index 6475e6f52eac..1a9cfefb236c 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/encodepassword/EncodePasswordCommand.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/encodepassword/EncodePasswordCommand.java
@@ -44,6 +44,7 @@
  * {@link Command} to encode passwords for use with Spring Security.
  *
  * @author Phillip Webb
+ * @author Moritz Halbritter
  * @since 2.0.0
  */
 public class EncodePasswordCommand extends OptionParsingCommand {
@@ -70,8 +71,8 @@ public String getUsageHelp() {
 	@Override
 	public Collection<HelpExample> getExamples() {
 		List<HelpExample> examples = new ArrayList<>();
-		examples
-			.add(new HelpExample("To encode a password with the default encoder", "spring encodepassword mypassword"));
+		examples.add(new HelpExample("To encode a password with the default (bcrypt) encoder",
+				"spring encodepassword mypassword"));
 		examples.add(new HelpExample("To encode a password with pbkdf2", "spring encodepassword -a pbkdf2 mypassword"));
 		return examples;
 	}
@@ -82,12 +83,16 @@ private static final class EncodePasswordOptionHandler extends OptionHandler {
 
 		@Override
 		protected void options() {
-			this.algorithm = option(Arrays.asList("algorithm", "a"), "The algorithm to use").withRequiredArg()
+			this.algorithm = option(Arrays.asList("algorithm", "a"),
+					"The algorithm to use. Supported algorithms: "
+							+ StringUtils.collectionToDelimitedString(ENCODERS.keySet(), ", ")
+							+ ". The default algorithm uses bcrypt")
+				.withRequiredArg()
 				.defaultsTo("default");
 		}
 
 		@Override
-		protected ExitStatus run(OptionSet options) throws Exception {
+		protected ExitStatus run(OptionSet options) {
 			if (options.nonOptionArguments().size() != 1) {
 				Log.error("A single password option must be provided");
 				return ExitStatus.ERROR;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/shell/ForkProcessCommand.java b/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/shell/ForkProcessCommand.java
index a3c282faf429..eb2336d75ca8 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/shell/ForkProcessCommand.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/shell/ForkProcessCommand.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -33,7 +33,7 @@
  */
 class ForkProcessCommand extends RunProcessCommand {
 
-	private static final String MAIN_CLASS = "org.springframework.boot.loader.JarLauncher";
+	private static final String MAIN_CLASS = "org.springframework.boot.loader.launch.JarLauncher";
 
 	private final Command command;
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/encodepassword/EncodePasswordCommandTests.java b/spring-boot-project/spring-boot-tools/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/encodepassword/EncodePasswordCommandTests.java
index d406bf554411..f036101f1a12 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/encodepassword/EncodePasswordCommandTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/encodepassword/EncodePasswordCommandTests.java
@@ -36,6 +36,7 @@
  * Tests for {@link EncodePasswordCommand}.
  *
  * @author Phillip Webb
+ * @author Moritz Halbritter
  */
 @ExtendWith(MockitoExtension.class)
 class EncodePasswordCommandTests {
@@ -63,6 +64,17 @@ void encodeWithNoAlgorithmShouldUseBcrypt() throws Exception {
 		assertThat(status).isEqualTo(ExitStatus.OK);
 	}
 
+	@Test
+	void encodeWithDefaultShouldUseBcrypt() throws Exception {
+		EncodePasswordCommand command = new EncodePasswordCommand();
+		ExitStatus status = command.run("-a", "default", "boot");
+		then(this.log).should().info(assertArg((message) -> {
+			assertThat(message).startsWith("{bcrypt}");
+			assertThat(PasswordEncoderFactories.createDelegatingPasswordEncoder().matches("boot", message)).isTrue();
+		}));
+		assertThat(status).isEqualTo(ExitStatus.OK);
+	}
+
 	@Test
 	void encodeWithBCryptShouldUseBCrypt() throws Exception {
 		EncodePasswordCommand command = new EncodePasswordCommand();
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/init/InitCommandTests.java b/spring-boot-project/spring-boot-tools/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/init/InitCommandTests.java
index 7ca915b3d4bc..1abe746cd33e 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/init/InitCommandTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/init/InitCommandTests.java
@@ -34,7 +34,7 @@
 import org.springframework.boot.cli.command.status.ExitStatus;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.fail;
+import static org.assertj.core.api.Assertions.fail;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.assertArg;
 import static org.mockito.ArgumentMatchers.isNull;
@@ -80,7 +80,7 @@ void listServiceCapabilitiesV2() throws Exception {
 
 	@Test
 	void generateProject() throws Exception {
-		String fileName = UUID.randomUUID().toString() + ".zip";
+		String fileName = UUID.randomUUID() + ".zip";
 		File file = new File(fileName);
 		assertThat(file).as("file should not exist").doesNotExist();
 		MockHttpProjectGenerationRequest request = new MockHttpProjectGenerationRequest("application/zip", fileName);
@@ -175,7 +175,7 @@ void generateProjectFileSavedAsFileByDefault() throws Exception {
 
 	@Test
 	void generateProjectAndExtractUnsupportedArchive(@TempDir File tempDir) throws Exception {
-		String fileName = UUID.randomUUID().toString() + ".zip";
+		String fileName = UUID.randomUUID() + ".zip";
 		File file = new File(fileName);
 		assertThat(file).as("file should not exist").doesNotExist();
 		try {
@@ -192,8 +192,8 @@ void generateProjectAndExtractUnsupportedArchive(@TempDir File tempDir) throws E
 	}
 
 	@Test
-	void generateProjectAndExtractUnknownContentType(@TempDir File tempDir) throws Exception {
-		String fileName = UUID.randomUUID().toString() + ".zip";
+	void generateProjectAndExtractUnknownContentType(@TempDir File tempDir) {
+		String fileName = UUID.randomUUID() + ".zip";
 		File file = new File(fileName);
 		assertThat(file).as("file should not exist").doesNotExist();
 		try {
@@ -204,7 +204,7 @@ void generateProjectAndExtractUnknownContentType(@TempDir File tempDir) throws E
 			assertThat(file).as("file should have been saved instead").exists();
 		}
 		catch (Exception ex) {
-			fail(ex);
+			fail(null, ex);
 		}
 		finally {
 			assertThat(file.delete()).as("failed to delete test file").isTrue();
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/build.gradle
new file mode 100644
index 000000000000..186a2cff85a1
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/build.gradle
@@ -0,0 +1,58 @@
+plugins {
+	id "java"
+	id "org.springframework.boot.conventions"
+}
+
+description = "Spring Boot Configuration Metadata Changelog Generator"
+
+configurations {
+	oldMetadata
+	newMetadata
+}
+
+dependencies {
+	implementation(enforcedPlatform(project(":spring-boot-project:spring-boot-dependencies")))
+	implementation(project(":spring-boot-project:spring-boot-tools:spring-boot-configuration-metadata"))
+
+	testImplementation(enforcedPlatform(project(":spring-boot-project:spring-boot-dependencies")))
+	testImplementation("org.assertj:assertj-core")
+	testImplementation("org.junit.jupiter:junit-jupiter")
+}
+
+if (project.hasProperty("oldVersion") && project.hasProperty("newVersion")) {
+	dependencies {
+		["spring-boot",
+		 "spring-boot-actuator",
+		 "spring-boot-actuator-autoconfigure",
+		 "spring-boot-autoconfigure",
+		 "spring-boot-devtools",
+		 "spring-boot-test-autoconfigure"].each {
+			oldMetadata("org.springframework.boot:$it:$oldVersion")
+			newMetadata("org.springframework.boot:$it:$newVersion")
+		}
+	}
+
+	def prepareOldMetadata = tasks.register("prepareOldMetadata", Sync) {
+		from(configurations.oldMetadata)
+		if (project.hasProperty("oldVersion")) {
+			destinationDir = project.file("build/configuration-metadata-diff/$oldVersion")
+		}
+	}
+
+	def prepareNewMetadata = tasks.register("prepareNewMetadata", Sync) {
+		from(configurations.newMetadata)
+		if (project.hasProperty("newVersion")) {
+			destinationDir = project.file("build/configuration-metadata-diff/$newVersion")
+		}
+	}
+
+	tasks.register("generate", JavaExec) {
+		inputs.files(prepareOldMetadata, prepareNewMetadata)
+		outputs.file(project.file("build/configuration-metadata-changelog.adoc"))
+		classpath = sourceSets.main.runtimeClasspath
+		mainClass = 'org.springframework.boot.configurationmetadata.changelog.ChangelogGenerator'
+		if (project.hasProperty("oldVersion") && project.hasProperty("newVersion")) {
+			args = [project.file("build/configuration-metadata-diff/$oldVersion"), project.file("build/configuration-metadata-diff/$newVersion"), project.file("build/configuration-metadata-changelog.adoc")]
+		}
+	}
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/Changelog.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/Changelog.java
new file mode 100644
index 000000000000..964298fe567c
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/Changelog.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.configurationmetadata.changelog;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.springframework.boot.configurationmetadata.ConfigurationMetadataProperty;
+import org.springframework.boot.configurationmetadata.ConfigurationMetadataRepository;
+
+/**
+ * A changelog containing differences computed from two repositories of configuration
+ * metadata.
+ *
+ * @param oldVersionNumber the name of the old version
+ * @param newVersionNumber the name of the new version
+ * @param differences the differences
+ * @author Stephane Nicoll
+ * @author Andy Wilkinson
+ * @author Phillip Webb
+ */
+record Changelog(String oldVersionNumber, String newVersionNumber, List<Difference> differences) {
+
+	static Changelog of(String oldVersionNumber, ConfigurationMetadataRepository oldMetadata, String newVersionNumber,
+			ConfigurationMetadataRepository newMetadata) {
+		return new Changelog(oldVersionNumber, newVersionNumber, computeDifferences(oldMetadata, newMetadata));
+	}
+
+	static List<Difference> computeDifferences(ConfigurationMetadataRepository oldMetadata,
+			ConfigurationMetadataRepository newMetadata) {
+		List<String> seenIds = new ArrayList<>();
+		List<Difference> differences = new ArrayList<>();
+		for (ConfigurationMetadataProperty oldProperty : oldMetadata.getAllProperties().values()) {
+			String id = oldProperty.getId();
+			seenIds.add(id);
+			ConfigurationMetadataProperty newProperty = newMetadata.getAllProperties().get(id);
+			Difference difference = Difference.compute(oldProperty, newProperty);
+			if (difference != null) {
+				differences.add(difference);
+			}
+		}
+		for (ConfigurationMetadataProperty newProperty : newMetadata.getAllProperties().values()) {
+			if ((!seenIds.contains(newProperty.getId())) && (!newProperty.isDeprecated())) {
+				differences.add(new Difference(DifferenceType.ADDED, null, newProperty));
+			}
+		}
+		return List.copyOf(differences);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/ChangelogGenerator.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/ChangelogGenerator.java
new file mode 100644
index 000000000000..9d1ee1d62282
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/ChangelogGenerator.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.configurationmetadata.changelog;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+import org.springframework.boot.configurationmetadata.ConfigurationMetadataRepository;
+import org.springframework.boot.configurationmetadata.ConfigurationMetadataRepositoryJsonBuilder;
+
+/**
+ * Generates a configuration metadata changelog. Requires three arguments:
+ *
+ * <ol>
+ * <li>The path of a directory containing jar files of the old version
+ * <li>The path of a directory containing jar files of the new version
+ * <li>The path of a file to which the asciidoc changelog will be written
+ * </ol>
+ *
+ * The name of each directory will be used as version numbers in generated changelog.
+ *
+ * @author Andy Wilkinson
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public final class ChangelogGenerator {
+
+	private ChangelogGenerator() {
+	}
+
+	public static void main(String[] args) throws IOException {
+		generate(new File(args[0]), new File(args[1]), new File(args[2]));
+	}
+
+	private static void generate(File oldDir, File newDir, File out) throws IOException {
+		String oldVersionNumber = oldDir.getName();
+		ConfigurationMetadataRepository oldMetadata = buildRepository(oldDir);
+		String newVersionNumber = newDir.getName();
+		ConfigurationMetadataRepository newMetadata = buildRepository(newDir);
+		Changelog changelog = Changelog.of(oldVersionNumber, oldMetadata, newVersionNumber, newMetadata);
+		try (ChangelogWriter writer = new ChangelogWriter(out)) {
+			writer.write(changelog);
+		}
+		System.out.println("%nConfiguration metadata changelog written to '%s'".formatted(out));
+	}
+
+	static ConfigurationMetadataRepository buildRepository(File directory) {
+		ConfigurationMetadataRepositoryJsonBuilder builder = ConfigurationMetadataRepositoryJsonBuilder.create();
+		for (File file : directory.listFiles()) {
+			try (JarFile jarFile = new JarFile(file)) {
+				JarEntry metadataEntry = jarFile.getJarEntry("META-INF/spring-configuration-metadata.json");
+				if (metadataEntry != null) {
+					builder.withJsonResource(jarFile.getInputStream(metadataEntry));
+				}
+			}
+			catch (IOException ex) {
+				throw new RuntimeException(ex);
+			}
+		}
+		return builder.build();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/ChangelogWriter.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/ChangelogWriter.java
new file mode 100644
index 000000000000..033d5e20977e
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/ChangelogWriter.java
@@ -0,0 +1,224 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.configurationmetadata.changelog;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.Writer;
+import java.text.BreakIterator;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.springframework.boot.configurationmetadata.ConfigurationMetadataProperty;
+import org.springframework.boot.configurationmetadata.Deprecation;
+
+/**
+ * Writes a {@link Changelog} using asciidoc markup.
+ *
+ * @author Stephane Nicoll
+ * @author Andy Wilkinson
+ * @author Phillip Webb
+ */
+class ChangelogWriter implements AutoCloseable {
+
+	private static final Comparator<ConfigurationMetadataProperty> COMPARING_ID = Comparator
+		.comparing(ConfigurationMetadataProperty::getId);
+
+	private final PrintWriter out;
+
+	ChangelogWriter(File out) throws IOException {
+		this(new FileWriter(out));
+	}
+
+	ChangelogWriter(Writer out) {
+		this.out = new PrintWriter(out);
+	}
+
+	void write(Changelog changelog) {
+		String oldVersionNumber = changelog.oldVersionNumber();
+		String newVersionNumber = changelog.newVersionNumber();
+		Map<DifferenceType, List<Difference>> differencesByType = collateByType(changelog);
+		write("Configuration property changes between `%s` and `%s`%n", oldVersionNumber, newVersionNumber);
+		write("%n%n%n== Deprecated in %s%n", newVersionNumber);
+		writeDeprecated(differencesByType.get(DifferenceType.DEPRECATED));
+		write("%n%n%n== Added in %s%n", newVersionNumber);
+		writeAdded(differencesByType.get(DifferenceType.ADDED));
+		write("%n%n%n== Removed in %s%n", newVersionNumber);
+		writeRemoved(differencesByType.get(DifferenceType.DELETED), differencesByType.get(DifferenceType.DEPRECATED));
+	}
+
+	private Map<DifferenceType, List<Difference>> collateByType(Changelog differences) {
+		Map<DifferenceType, List<Difference>> byType = new HashMap<>();
+		for (DifferenceType type : DifferenceType.values()) {
+			byType.put(type, new ArrayList<>());
+		}
+		for (Difference difference : differences.differences()) {
+			byType.get(difference.type()).add(difference);
+		}
+		return byType;
+	}
+
+	private void writeDeprecated(List<Difference> differences) {
+		List<Difference> rows = sortProperties(differences, Difference::newProperty).stream()
+			.filter(this::isDeprecatedInRelease)
+			.toList();
+		writeTable("| Key | Replacement | Reason", rows, this::writeDeprecated);
+	}
+
+	private void writeDeprecated(Difference difference) {
+		writeDeprecatedPropertyRow(difference.newProperty());
+	}
+
+	private void writeAdded(List<Difference> differences) {
+		List<Difference> rows = sortProperties(differences, Difference::newProperty);
+		writeTable("| Key | Default value | Description", rows, this::writeAdded);
+	}
+
+	private void writeAdded(Difference difference) {
+		writeRegularPropertyRow(difference.newProperty());
+	}
+
+	private void writeRemoved(List<Difference> deleted, List<Difference> deprecated) {
+		List<Difference> rows = getRemoved(deleted, deprecated);
+		writeTable("| Key | Replacement | Reason", rows, this::writeRemoved);
+	}
+
+	private List<Difference> getRemoved(List<Difference> deleted, List<Difference> deprecated) {
+		List<Difference> result = new ArrayList<>(deleted);
+		deprecated.stream().filter(Predicate.not(this::isDeprecatedInRelease)).forEach(result::remove);
+		return sortProperties(result,
+				(difference) -> getFirstNonNull(difference, Difference::oldProperty, Difference::newProperty));
+	}
+
+	private void writeRemoved(Difference difference) {
+		writeDeprecatedPropertyRow(getFirstNonNull(difference, Difference::newProperty, Difference::oldProperty));
+	}
+
+	private List<Difference> sortProperties(List<Difference> differences,
+			Function<Difference, ConfigurationMetadataProperty> extractor) {
+		return differences.stream().sorted(Comparator.comparing(extractor, COMPARING_ID)).toList();
+	}
+
+	@SafeVarargs
+	@SuppressWarnings("varargs")
+	private <T, P> P getFirstNonNull(T t, Function<T, P>... extractors) {
+		return Stream.of(extractors)
+			.map((extractor) -> extractor.apply(t))
+			.filter(Objects::nonNull)
+			.findFirst()
+			.orElse(null);
+	}
+
+	private void writeTable(String header, List<Difference> rows, Consumer<Difference> action) {
+		if (rows.isEmpty()) {
+			write("_None_.%n");
+		}
+		else {
+			writeTableBreak();
+			write(header + "%n%n");
+			for (Iterator<Difference> iterator = rows.iterator(); iterator.hasNext();) {
+				action.accept(iterator.next());
+				write((!iterator.hasNext()) ? null : "%n");
+			}
+			writeTableBreak();
+		}
+	}
+
+	private void writeTableBreak() {
+		write("|======================%n");
+	}
+
+	private void writeRegularPropertyRow(ConfigurationMetadataProperty property) {
+		writeCell(monospace(property.getId()));
+		writeCell(monospace(asString(property.getDefaultValue())));
+		writeCell(property.getShortDescription());
+	}
+
+	private void writeDeprecatedPropertyRow(ConfigurationMetadataProperty property) {
+		Deprecation deprecation = (property.getDeprecation() != null) ? property.getDeprecation() : new Deprecation();
+		writeCell(monospace(property.getId()));
+		writeCell(monospace(deprecation.getReplacement()));
+		writeCell(getFirstSentence(deprecation.getReason()));
+	}
+
+	private String getFirstSentence(String text) {
+		if (text == null) {
+			return null;
+		}
+		int dot = text.indexOf('.');
+		if (dot != -1) {
+			BreakIterator breakIterator = BreakIterator.getSentenceInstance(Locale.US);
+			breakIterator.setText(text);
+			String sentence = text.substring(breakIterator.first(), breakIterator.next()).trim();
+			return removeSpaceBetweenLine(sentence);
+		}
+		String[] lines = text.split(System.lineSeparator());
+		return lines[0].trim();
+	}
+
+	private String removeSpaceBetweenLine(String text) {
+		String[] lines = text.split(System.lineSeparator());
+		return Arrays.stream(lines).map(String::trim).collect(Collectors.joining(" "));
+	}
+
+	private boolean isDeprecatedInRelease(Difference difference) {
+		Deprecation deprecation = difference.newProperty().getDeprecation();
+		return (deprecation != null) && (deprecation.getLevel() != Deprecation.Level.ERROR);
+	}
+
+	private String monospace(String value) {
+		return (value != null) ? "`%s`".formatted(value) : null;
+	}
+
+	private void writeCell(String format, Object... args) {
+		write((format != null) ? "| %s%n".formatted(format) : "|%n", args);
+	}
+
+	private void write(String format, Object... args) {
+		if (format != null) {
+			Object[] strings = Arrays.stream(args).map(this::asString).toArray();
+			this.out.append(format.formatted(strings));
+		}
+	}
+
+	private String asString(Object value) {
+		if (value instanceof Object[] array) {
+			return Stream.of(array).map(this::asString).collect(Collectors.joining(", "));
+		}
+		return (value != null) ? value.toString() : null;
+	}
+
+	@Override
+	public void close() {
+		this.out.close();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/Difference.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/Difference.java
new file mode 100644
index 000000000000..8d0fb66cfa7e
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/Difference.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.configurationmetadata.changelog;
+
+import org.springframework.boot.configurationmetadata.ConfigurationMetadataProperty;
+import org.springframework.boot.configurationmetadata.Deprecation.Level;
+
+/**
+ * A difference the metadata.
+ *
+ * @param type the type of the difference
+ * @param oldProperty the old property
+ * @param newProperty the new property
+ * @author Stephane Nicoll
+ * @author Andy Wilkinson
+ * @author Phillip Webb
+ */
+record Difference(DifferenceType type, ConfigurationMetadataProperty oldProperty,
+		ConfigurationMetadataProperty newProperty) {
+
+	static Difference compute(ConfigurationMetadataProperty oldProperty, ConfigurationMetadataProperty newProperty) {
+		if (newProperty == null) {
+			if (!(oldProperty.isDeprecated() && oldProperty.getDeprecation().getLevel() == Level.ERROR)) {
+				return new Difference(DifferenceType.DELETED, oldProperty, null);
+			}
+			return null;
+		}
+		if (newProperty.isDeprecated() && !oldProperty.isDeprecated()) {
+			return new Difference(DifferenceType.DEPRECATED, oldProperty, newProperty);
+		}
+		if (oldProperty.isDeprecated() && oldProperty.getDeprecation().getLevel() == Level.WARNING
+				&& newProperty.isDeprecated() && newProperty.getDeprecation().getLevel() == Level.ERROR) {
+			return new Difference(DifferenceType.DELETED, oldProperty, newProperty);
+		}
+		return null;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/DifferenceType.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/DifferenceType.java
new file mode 100644
index 000000000000..b673310b4072
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/DifferenceType.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.configurationmetadata.changelog;
+
+/**
+ * The type of a difference in the metadata.
+ *
+ * @author Andy Wilkinson
+ */
+enum DifferenceType {
+
+	/**
+	 * The entry has been added.
+	 */
+	ADDED,
+
+	/**
+	 * The entry has been made deprecated. It may or may not still exist in the previous
+	 * version.
+	 */
+	DEPRECATED,
+
+	/**
+	 * The entry has been deleted.
+	 */
+	DELETED
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/package-info.java
new file mode 100644
index 000000000000..96eca3173f88
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Spring Boot configuration metadata changelog generator.
+ */
+package org.springframework.boot.configurationmetadata.changelog;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/ChangelogGeneratorTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/ChangelogGeneratorTests.java
new file mode 100644
index 000000000000..efa1760c2736
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/ChangelogGeneratorTests.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.configurationmetadata.changelog;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.jar.JarOutputStream;
+import java.util.zip.ZipEntry;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ChangelogGenerator}.
+ *
+ * @author Phillip Webb
+ */
+class ChangelogGeneratorTests {
+
+	@TempDir
+	File temp;
+
+	@Test
+	void generateChangeLog() throws IOException {
+		File oldJars = new File(this.temp, "1.0");
+		addJar(oldJars, "sample-1.0.json");
+		File newJars = new File(this.temp, "2.0");
+		addJar(newJars, "sample-2.0.json");
+		File out = new File(this.temp, "changes.adoc");
+		String[] args = new String[] { oldJars.getAbsolutePath(), newJars.getAbsolutePath(), out.getAbsolutePath() };
+		ChangelogGenerator.main(args);
+		assertThat(out).usingCharset(StandardCharsets.UTF_8)
+			.hasSameTextualContentAs(new File("src/test/resources/sample.adoc"));
+	}
+
+	private void addJar(File directory, String filename) throws IOException {
+		directory.mkdirs();
+		try (JarOutputStream out = new JarOutputStream(new FileOutputStream(new File(directory, "sample.jar")))) {
+			out.putNextEntry(new ZipEntry("META-INF/spring-configuration-metadata.json"));
+			try (InputStream in = new FileInputStream("src/test/resources/" + filename)) {
+				in.transferTo(out);
+				out.closeEntry();
+			}
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/ChangelogTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/ChangelogTests.java
new file mode 100644
index 000000000000..5057cb8087ee
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/ChangelogTests.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.configurationmetadata.changelog;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.configurationmetadata.ConfigurationMetadataProperty;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link Changelog}.
+ *
+ * @author Stephane Nicoll
+ * @author Andy Wilkinson
+ */
+class ChangelogTests {
+
+	@Test
+	void diffContainsDifferencesBetweenLeftAndRightInputs() {
+		Changelog differences = TestChangelog.load();
+		assertThat(differences).isNotNull();
+		assertThat(differences.oldVersionNumber()).isEqualTo("1.0");
+		assertThat(differences.newVersionNumber()).isEqualTo("2.0");
+		assertThat(differences.differences()).hasSize(4);
+		List<Difference> added = differences.differences()
+			.stream()
+			.filter((difference) -> difference.type() == DifferenceType.ADDED)
+			.toList();
+		assertThat(added).hasSize(1);
+		assertProperty(added.get(0).newProperty(), "test.add", String.class, "new");
+		List<Difference> deleted = differences.differences()
+			.stream()
+			.filter((difference) -> difference.type() == DifferenceType.DELETED)
+			.toList();
+		assertThat(deleted).hasSize(2)
+			.anySatisfy((entry) -> assertProperty(entry.oldProperty(), "test.delete", String.class, "delete"))
+			.anySatisfy(
+					(entry) -> assertProperty(entry.newProperty(), "test.delete.deprecated", String.class, "delete"));
+		List<Difference> deprecated = differences.differences()
+			.stream()
+			.filter((difference) -> difference.type() == DifferenceType.DEPRECATED)
+			.toList();
+		assertThat(deprecated).hasSize(1);
+		assertProperty(deprecated.get(0).oldProperty(), "test.deprecate", String.class, "wrong");
+		assertProperty(deprecated.get(0).newProperty(), "test.deprecate", String.class, "wrong");
+	}
+
+	private void assertProperty(ConfigurationMetadataProperty property, String id, Class<?> type, Object defaultValue) {
+		assertThat(property).isNotNull();
+		assertThat(property.getId()).isEqualTo(id);
+		assertThat(property.getType()).isEqualTo(type.getName());
+		assertThat(property.getDefaultValue()).isEqualTo(defaultValue);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/ChangelogWriterTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/ChangelogWriterTests.java
new file mode 100644
index 000000000000..5e72e3b567d0
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/ChangelogWriterTests.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.configurationmetadata.changelog;
+
+import java.io.File;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+
+import org.assertj.core.util.Files;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ChangelogWriter}.
+ *
+ * @author Phillip Webb
+ */
+class ChangelogWriterTests {
+
+	@Test
+	void writeChangelog() {
+		StringWriter out = new StringWriter();
+		try (ChangelogWriter writer = new ChangelogWriter(out)) {
+			writer.write(TestChangelog.load());
+		}
+		String expected = Files.contentOf(new File("src/test/resources/sample.adoc"), StandardCharsets.UTF_8);
+		assertThat(out).hasToString(expected);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/TestChangelog.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/TestChangelog.java
new file mode 100644
index 000000000000..58a1c34642b7
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/TestChangelog.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.configurationmetadata.changelog;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import org.springframework.boot.configurationmetadata.ConfigurationMetadataRepository;
+import org.springframework.boot.configurationmetadata.ConfigurationMetadataRepositoryJsonBuilder;
+
+/**
+ * Factory to create test {@link Changelog} instance.
+ *
+ * @author Andy Wilkinson
+ * @author Phillip Webb
+ */
+final class TestChangelog {
+
+	private TestChangelog() {
+	}
+
+	static Changelog load() {
+		ConfigurationMetadataRepository previousRepository = load("sample-1.0.json");
+		ConfigurationMetadataRepository repository = load("sample-2.0.json");
+		return Changelog.of("1.0", previousRepository, "2.0", repository);
+	}
+
+	private static ConfigurationMetadataRepository load(String filename) {
+		try (InputStream inputStream = new FileInputStream("src/test/resources/" + filename)) {
+			return ConfigurationMetadataRepositoryJsonBuilder.create(inputStream).build();
+		}
+		catch (IOException ex) {
+			throw new RuntimeException(ex);
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample-1.0.json b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample-1.0.json
new file mode 100644
index 000000000000..a0584bc5695b
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample-1.0.json
@@ -0,0 +1,31 @@
+{
+  "properties": [
+    {
+      "name": "test.equal",
+      "type": "java.lang.String",
+      "description": "Test equality.",
+      "defaultValue": "test"
+    },
+    {
+      "name": "test.deprecate",
+      "type": "java.lang.String",
+      "description": "Test deprecate.",
+      "defaultValue": "wrong"
+    },
+    {
+      "name": "test.delete",
+      "type": "java.lang.String",
+      "description": "Test delete.",
+      "defaultValue": "delete"
+    },
+    {
+      "name": "test.delete.deprecated",
+      "type": "java.lang.String",
+      "description": "Test delete deprecated.",
+      "defaultValue": "delete",
+      "deprecation": {
+        "level": "warning"
+      }
+    }
+  ]
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample-2.0.json b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample-2.0.json
new file mode 100644
index 000000000000..ef959d39c9eb
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample-2.0.json
@@ -0,0 +1,36 @@
+{
+  "properties": [
+    {
+      "name": "test.add",
+      "type": "java.lang.String",
+      "description": "Test add.",
+      "defaultValue": "new"
+    },
+    {
+      "name": "test.equal",
+      "type": "java.lang.String",
+      "description": "Test equality.",
+      "defaultValue": "test"
+    },
+    {
+      "name": "test.deprecate",
+      "type": "java.lang.String",
+      "description": "Test deprecate.",
+      "defaultValue": "wrong",
+      "deprecation": {
+        "level": "error"
+      }
+    },
+    {
+      "name": "test.delete.deprecated",
+      "type": "java.lang.String",
+      "description": "Test delete deprecated.",
+      "defaultValue": "delete",
+      "deprecation": {
+        "level": "error",
+        "replacement": "test.add",
+        "reason": "it was just bad"
+      }
+    }
+  ]
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample.adoc b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample.adoc
new file mode 100644
index 000000000000..ac5cc843e16f
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample.adoc
@@ -0,0 +1,32 @@
+Configuration property changes between `1.0` and `2.0`
+
+
+
+== Deprecated in 2.0
+_None_.
+
+
+
+== Added in 2.0
+|======================
+| Key | Default value | Description
+
+| `test.add`
+| `new`
+| Test add.
+|======================
+
+
+
+== Removed in 2.0
+|======================
+| Key | Replacement | Reason
+
+| `test.delete`
+|
+|
+
+| `test.delete.deprecated`
+| `test.add`
+| it was just bad
+|======================
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata/src/main/java/org/springframework/boot/configurationmetadata/SimpleConfigurationMetadataRepository.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata/src/main/java/org/springframework/boot/configurationmetadata/SimpleConfigurationMetadataRepository.java
index bba6c2562787..c196afb5e2f2 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata/src/main/java/org/springframework/boot/configurationmetadata/SimpleConfigurationMetadataRepository.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata/src/main/java/org/springframework/boot/configurationmetadata/SimpleConfigurationMetadataRepository.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -54,11 +54,8 @@ public Map<String, ConfigurationMetadataProperty> getAllProperties() {
 	public void add(Collection<ConfigurationMetadataSource> sources) {
 		for (ConfigurationMetadataSource source : sources) {
 			String groupId = source.getGroupId();
-			ConfigurationMetadataGroup group = this.allGroups.get(groupId);
-			if (group == null) {
-				group = new ConfigurationMetadataGroup(groupId);
-				this.allGroups.put(groupId, group);
-			}
+			ConfigurationMetadataGroup group = this.allGroups.computeIfAbsent(groupId,
+					(key) -> new ConfigurationMetadataGroup(groupId));
 			String sourceType = source.getType();
 			if (sourceType != null) {
 				addOrMergeSource(group.getSources(), sourceType, source);
@@ -74,9 +71,9 @@ public void add(Collection<ConfigurationMetadataSource> sources) {
 	 */
 	public void add(ConfigurationMetadataProperty property, ConfigurationMetadataSource source) {
 		if (source != null) {
-			putIfAbsent(source.getProperties(), property.getId(), property);
+			source.getProperties().putIfAbsent(property.getId(), property);
 		}
-		putIfAbsent(getGroup(source).getProperties(), property.getId(), property);
+		getGroup(source).getProperties().putIfAbsent(property.getId(), property);
 	}
 
 	/**
@@ -91,7 +88,7 @@ public void include(ConfigurationMetadataRepository repository) {
 			}
 			else {
 				// Merge properties
-				group.getProperties().forEach((name, value) -> putIfAbsent(existingGroup.getProperties(), name, value));
+				group.getProperties().forEach((name, value) -> existingGroup.getProperties().putIfAbsent(name, value));
 				// Merge sources
 				group.getSources().forEach((name, value) -> addOrMergeSource(existingGroup.getSources(), name, value));
 			}
@@ -101,12 +98,7 @@ public void include(ConfigurationMetadataRepository repository) {
 
 	private ConfigurationMetadataGroup getGroup(ConfigurationMetadataSource source) {
 		if (source == null) {
-			ConfigurationMetadataGroup rootGroup = this.allGroups.get(ROOT_GROUP);
-			if (rootGroup == null) {
-				rootGroup = new ConfigurationMetadataGroup(ROOT_GROUP);
-				this.allGroups.put(ROOT_GROUP, rootGroup);
-			}
-			return rootGroup;
+			return this.allGroups.computeIfAbsent(ROOT_GROUP, (key) -> new ConfigurationMetadataGroup(ROOT_GROUP));
 		}
 		return this.allGroups.get(source.getGroupId());
 	}
@@ -118,13 +110,7 @@ private void addOrMergeSource(Map<String, ConfigurationMetadataSource> sources,
 			sources.put(name, source);
 		}
 		else {
-			source.getProperties().forEach((k, v) -> putIfAbsent(existingSource.getProperties(), k, v));
-		}
-	}
-
-	private <V> void putIfAbsent(Map<String, V> map, String key, V value) {
-		if (!map.containsKey(key)) {
-			map.put(key, value);
+			source.getProperties().forEach((k, v) -> existingSource.getProperties().putIfAbsent(k, v));
 		}
 	}
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessor.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessor.java
index 6c805144a8d8..4c8b0fcb1bb4 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessor.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessor.java
@@ -20,14 +20,15 @@
 import java.io.PrintWriter;
 import java.io.StringWriter;
 import java.time.Duration;
+import java.util.ArrayDeque;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.Deque;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import java.util.Stack;
 
 import javax.annotation.processing.AbstractProcessor;
 import javax.annotation.processing.ProcessingEnvironment;
@@ -57,6 +58,7 @@
  * @author Phillip Webb
  * @author Kris De Volder
  * @author Jonas Keßler
+ * @author Scott Frederick
  * @since 1.2.0
  */
 @SupportedAnnotationTypes({ ConfigurationMetadataAnnotationProcessor.AUTO_CONFIGURATION_ANNOTATION,
@@ -213,10 +215,10 @@ private void processElement(Element element) {
 			if (annotation != null) {
 				String prefix = getPrefix(annotation);
 				if (element instanceof TypeElement typeElement) {
-					processAnnotatedTypeElement(prefix, typeElement, new Stack<>());
+					processAnnotatedTypeElement(prefix, typeElement, new ArrayDeque<>());
 				}
 				else if (element instanceof ExecutableElement executableElement) {
-					processExecutableElement(prefix, executableElement, new Stack<>());
+					processExecutableElement(prefix, executableElement, new ArrayDeque<>());
 				}
 			}
 		}
@@ -225,13 +227,13 @@ else if (element instanceof ExecutableElement executableElement) {
 		}
 	}
 
-	private void processAnnotatedTypeElement(String prefix, TypeElement element, Stack<TypeElement> seen) {
+	private void processAnnotatedTypeElement(String prefix, TypeElement element, Deque<TypeElement> seen) {
 		String type = this.metadataEnv.getTypeUtils().getQualifiedName(element);
 		this.metadataCollector.add(ItemMetadata.newGroup(prefix, type, type, null));
 		processTypeElement(prefix, element, null, seen);
 	}
 
-	private void processExecutableElement(String prefix, ExecutableElement element, Stack<TypeElement> seen) {
+	private void processExecutableElement(String prefix, ExecutableElement element, Deque<TypeElement> seen) {
 		if ((!element.getModifiers().contains(Modifier.PRIVATE))
 				&& (TypeKind.VOID != element.getReturnType().getKind())) {
 			Element returns = this.processingEnv.getTypeUtils().asElement(element.getReturnType());
@@ -254,7 +256,7 @@ private void processExecutableElement(String prefix, ExecutableElement element,
 	}
 
 	private void processTypeElement(String prefix, TypeElement element, ExecutableElement source,
-			Stack<TypeElement> seen) {
+			Deque<TypeElement> seen) {
 		if (!seen.contains(element)) {
 			seen.push(element);
 			new PropertyDescriptorResolver(this.metadataEnv).resolve(element, source).forEach((descriptor) -> {
@@ -322,16 +324,11 @@ private boolean hasNoOrOptionalParameters(ExecutableElement method) {
 	}
 
 	private String getPrefix(AnnotationMirror annotation) {
-		Map<String, Object> elementValues = this.metadataEnv.getAnnotationElementValues(annotation);
-		Object prefix = elementValues.get("prefix");
-		if (prefix != null && !"".equals(prefix)) {
-			return (String) prefix;
-		}
-		Object value = elementValues.get("value");
-		if (value != null && !"".equals(value)) {
-			return (String) value;
+		String prefix = this.metadataEnv.getAnnotationElementStringValue(annotation, "prefix");
+		if (prefix != null) {
+			return prefix;
 		}
-		return null;
+		return this.metadataEnv.getAnnotationElementStringValue(annotation, "value");
 	}
 
 	protected ConfigurationMetadata writeMetadata() throws Exception {
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataGenerationEnvironment.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataGenerationEnvironment.java
index 5109ffed45d6..6d4cc6c3d2ac 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataGenerationEnvironment.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataGenerationEnvironment.java
@@ -49,6 +49,7 @@
  * Provide utilities to detect and validate configuration properties.
  *
  * @author Stephane Nicoll
+ * @author Scott Frederick
  */
 class MetadataGenerationEnvironment {
 
@@ -174,14 +175,13 @@ ItemDeprecation resolveItemDeprecation(Element element) {
 		AnnotationMirror annotation = getAnnotation(element, this.deprecatedConfigurationPropertyAnnotation);
 		String reason = null;
 		String replacement = null;
+		String since = null;
 		if (annotation != null) {
-			Map<String, Object> elementValues = getAnnotationElementValues(annotation);
-			reason = (String) elementValues.get("reason");
-			replacement = (String) elementValues.get("replacement");
+			reason = getAnnotationElementStringValue(annotation, "reason");
+			replacement = getAnnotationElementStringValue(annotation, "replacement");
+			since = getAnnotationElementStringValue(annotation, "since");
 		}
-		reason = (reason == null || reason.isEmpty()) ? null : reason;
-		replacement = (replacement == null || replacement.isEmpty()) ? null : replacement;
-		return new ItemDeprecation(reason, replacement);
+		return new ItemDeprecation(reason, replacement, since);
 	}
 
 	boolean hasConstructorBindingAnnotation(ExecutableElement element) {
@@ -279,6 +279,16 @@ Map<String, Object> getAnnotationElementValues(AnnotationMirror annotation) {
 		return values;
 	}
 
+	String getAnnotationElementStringValue(AnnotationMirror annotation, String name) {
+		return annotation.getElementValues()
+			.entrySet()
+			.stream()
+			.filter((element) -> element.getKey().getSimpleName().toString().equals(name))
+			.map((element) -> asString(getAnnotationValue(element.getValue())))
+			.findFirst()
+			.orElse(null);
+	}
+
 	private Object getAnnotationValue(AnnotationValue annotationValue) {
 		Object value = annotationValue.getValue();
 		if (value instanceof List) {
@@ -289,6 +299,10 @@ private Object getAnnotationValue(AnnotationValue annotationValue) {
 		return value;
 	}
 
+	private String asString(Object value) {
+		return (value == null || value.toString().isEmpty()) ? null : (String) value;
+	}
+
 	TypeElement getConfigurationPropertiesAnnotationElement() {
 		return this.elements.getTypeElement(this.configurationPropertiesAnnotation);
 	}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolver.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolver.java
index 31e5630a9541..c4808a93a71f 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolver.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolver.java
@@ -91,7 +91,7 @@ Stream<PropertyDescriptor<?>> resolveConstructorProperties(TypeElement type, Typ
 	private String getParameterName(VariableElement parameter) {
 		AnnotationMirror nameAnnotation = this.environment.getNameAnnotation(parameter);
 		if (nameAnnotation != null) {
-			return (String) this.environment.getAnnotationElementValues(nameAnnotation).get("value");
+			return this.environment.getAnnotationElementStringValue(nameAnnotation, "value");
 		}
 		return parameter.getSimpleName().toString();
 	}
@@ -204,7 +204,7 @@ private static ExecutableElement deduceBindConstructor(TypeElement type, List<Ex
 				MetadataGenerationEnvironment env) {
 			if (constructors.size() == 1) {
 				ExecutableElement candidate = constructors.get(0);
-				if (candidate.getParameters().size() > 0 && !env.hasAutowiredAnnotation(candidate)) {
+				if (!candidate.getParameters().isEmpty() && !env.hasAutowiredAnnotation(candidate)) {
 					if (type.getNestingKind() == NestingKind.MEMBER
 							&& candidate.getModifiers().contains(Modifier.PRIVATE)) {
 						return null;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ConfigurationMetadata.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ConfigurationMetadata.java
index 1281954f591c..79c62bd3ed62 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ConfigurationMetadata.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ConfigurationMetadata.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -136,6 +136,9 @@ protected void mergeItemMetadata(ItemMetadata metadata) {
 					if (deprecation.getLevel() != null) {
 						matchingDeprecation.setLevel(deprecation.getLevel());
 					}
+					if (deprecation.getSince() != null) {
+						matchingDeprecation.setSince(deprecation.getSince());
+					}
 				}
 			}
 		}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ItemDeprecation.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ItemDeprecation.java
index 2947e94f1554..e684edf73c6f 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ItemDeprecation.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ItemDeprecation.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -20,6 +20,7 @@
  * Describe an item deprecation.
  *
  * @author Stephane Nicoll
+ * @author Scott Frederick
  * @since 1.3.0
  */
 public class ItemDeprecation {
@@ -28,19 +29,22 @@ public class ItemDeprecation {
 
 	private String replacement;
 
+	private String since;
+
 	private String level;
 
 	public ItemDeprecation() {
-		this(null, null);
+		this(null, null, null);
 	}
 
-	public ItemDeprecation(String reason, String replacement) {
-		this(reason, replacement, null);
+	public ItemDeprecation(String reason, String replacement, String since) {
+		this(reason, replacement, since, null);
 	}
 
-	public ItemDeprecation(String reason, String replacement, String level) {
+	public ItemDeprecation(String reason, String replacement, String since, String level) {
 		this.reason = reason;
 		this.replacement = replacement;
+		this.since = since;
 		this.level = level;
 	}
 
@@ -60,6 +64,14 @@ public void setReplacement(String replacement) {
 		this.replacement = replacement;
 	}
 
+	public String getSince() {
+		return this.since;
+	}
+
+	public void setSince(String since) {
+		this.since = since;
+	}
+
 	public String getLevel() {
 		return this.level;
 	}
@@ -78,7 +90,7 @@ public boolean equals(Object o) {
 		}
 		ItemDeprecation other = (ItemDeprecation) o;
 		return nullSafeEquals(this.reason, other.reason) && nullSafeEquals(this.replacement, other.replacement)
-				&& nullSafeEquals(this.level, other.level);
+				&& nullSafeEquals(this.level, other.level) && nullSafeEquals(this.since, other.since);
 	}
 
 	@Override
@@ -86,13 +98,14 @@ public int hashCode() {
 		int result = nullSafeHashCode(this.reason);
 		result = 31 * result + nullSafeHashCode(this.replacement);
 		result = 31 * result + nullSafeHashCode(this.level);
+		result = 31 * result + nullSafeHashCode(this.since);
 		return result;
 	}
 
 	@Override
 	public String toString() {
 		return "ItemDeprecation{reason='" + this.reason + '\'' + ", replacement='" + this.replacement + '\''
-				+ ", level='" + this.level + '\'' + '}';
+				+ ", level='" + this.level + '\'' + ", since='" + this.since + '\'' + '}';
 	}
 
 	private boolean nullSafeEquals(Object o1, Object o2) {
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/JsonConverter.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/JsonConverter.java
index 4c4cfda98ab4..3853a853fc27 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/JsonConverter.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/JsonConverter.java
@@ -83,6 +83,9 @@ JSONObject toJsonObject(ItemMetadata item) throws Exception {
 			if (deprecation.getReplacement() != null) {
 				deprecationJsonObject.put("replacement", deprecation.getReplacement());
 			}
+			if (deprecation.getSince() != null) {
+				deprecationJsonObject.put("since", deprecation.getSince());
+			}
 			jsonObject.put("deprecation", deprecationJsonObject);
 		}
 		return jsonObject;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/JsonMarshaller.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/JsonMarshaller.java
index 53370badb6da..9f049bb72b23 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/JsonMarshaller.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/JsonMarshaller.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -105,6 +105,7 @@ private ItemDeprecation toItemDeprecation(JSONObject object) throws Exception {
 			deprecation.setLevel(deprecationJsonObject.optString("level", null));
 			deprecation.setReason(deprecationJsonObject.optString("reason", null));
 			deprecation.setReplacement(deprecationJsonObject.optString("replacement", null));
+			deprecation.setSince(deprecationJsonObject.optString("since", null));
 			return deprecation;
 		}
 		return object.optBoolean("deprecated") ? new ItemDeprecation() : null;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessorTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessorTests.java
index 93227ecf6196..d1a831a95105 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessorTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessorTests.java
@@ -104,12 +104,12 @@ void simpleProperties() {
 			.fromSource(SimpleProperties.class)
 			.withDescription("The name of this simple properties.")
 			.withDefaultValue("boot")
-			.withDeprecation(null, null));
+			.withDeprecation());
 		assertThat(metadata).has(Metadata.withProperty("simple.flag", Boolean.class)
 			.withDefaultValue(false)
 			.fromSource(SimpleProperties.class)
 			.withDescription("A simple flag.")
-			.withDeprecation(null, null));
+			.withDeprecation());
 		assertThat(metadata).has(Metadata.withProperty("simple.comparator"));
 		assertThat(metadata).doesNotHave(Metadata.withProperty("simple.counter"));
 		assertThat(metadata).doesNotHave(Metadata.withProperty("simple.size"));
@@ -188,10 +188,9 @@ void deprecatedProperties() {
 		ConfigurationMetadata metadata = compile(type);
 		assertThat(metadata).has(Metadata.withGroup("deprecated").fromSource(type));
 		assertThat(metadata)
-			.has(Metadata.withProperty("deprecated.name", String.class).fromSource(type).withDeprecation(null, null));
-		assertThat(metadata).has(Metadata.withProperty("deprecated.description", String.class)
-			.fromSource(type)
-			.withDeprecation(null, null));
+			.has(Metadata.withProperty("deprecated.name", String.class).fromSource(type).withDeprecation());
+		assertThat(metadata)
+			.has(Metadata.withProperty("deprecated.description", String.class).fromSource(type).withDeprecation());
 	}
 
 	@Test
@@ -202,7 +201,7 @@ void singleDeprecatedProperty() {
 		assertThat(metadata).has(Metadata.withProperty("singledeprecated.new-name", String.class).fromSource(type));
 		assertThat(metadata).has(Metadata.withProperty("singledeprecated.name", String.class)
 			.fromSource(type)
-			.withDeprecation("renamed", "singledeprecated.new-name"));
+			.withDeprecation("renamed", "singledeprecated.new-name", "1.2.3"));
 	}
 
 	@Test
@@ -210,9 +209,8 @@ void singleDeprecatedFieldProperty() {
 		Class<?> type = DeprecatedFieldSingleProperty.class;
 		ConfigurationMetadata metadata = compile(type);
 		assertThat(metadata).has(Metadata.withGroup("singlefielddeprecated").fromSource(type));
-		assertThat(metadata).has(Metadata.withProperty("singlefielddeprecated.name", String.class)
-			.fromSource(type)
-			.withDeprecation(null, null));
+		assertThat(metadata)
+			.has(Metadata.withProperty("singlefielddeprecated.name", String.class).fromSource(type).withDeprecation());
 	}
 
 	@Test
@@ -246,7 +244,7 @@ void deprecatedPropertyOnRecord() {
 		assertThat(metadata).has(Metadata.withGroup("deprecated-record").fromSource(type));
 		assertThat(metadata).has(Metadata.withProperty("deprecated-record.alpha", String.class)
 			.fromSource(type)
-			.withDeprecation("some-reason", null));
+			.withDeprecation("some-reason", null, null));
 		assertThat(metadata).has(Metadata.withProperty("deprecated-record.bravo", String.class).fromSource(type));
 	}
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ImmutablePropertiesMetadataGenerationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ImmutablePropertiesMetadataGenerationTests.java
index 6e3571539ff3..5b87c0137ffe 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ImmutablePropertiesMetadataGenerationTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ImmutablePropertiesMetadataGenerationTests.java
@@ -43,7 +43,7 @@ void immutableSimpleProperties() {
 			.withDefaultValue(false)
 			.fromSource(ImmutableSimpleProperties.class)
 			.withDescription("A simple flag.")
-			.withDeprecation(null, null));
+			.withDeprecation());
 		assertThat(metadata).has(Metadata.withProperty("immutable.comparator"));
 		assertThat(metadata).has(Metadata.withProperty("immutable.counter"));
 		assertThat(metadata.getItems()).hasSize(5);
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/LombokMetadataGenerationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/LombokMetadataGenerationTests.java
index 69f0bb871842..2721f0c28a52 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/LombokMetadataGenerationTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/LombokMetadataGenerationTests.java
@@ -139,10 +139,8 @@ private void assertSimpleLombokProperties(ConfigurationMetadata metadata, Class<
 			.withDescription("Name description."));
 		assertThat(metadata).has(Metadata.withProperty(prefix + ".description"));
 		assertThat(metadata).has(Metadata.withProperty(prefix + ".counter"));
-		assertThat(metadata).has(Metadata.withProperty(prefix + ".number")
-			.fromSource(source)
-			.withDefaultValue(0)
-			.withDeprecation(null, null));
+		assertThat(metadata)
+			.has(Metadata.withProperty(prefix + ".number").fromSource(source).withDefaultValue(0).withDeprecation());
 		assertThat(metadata).has(Metadata.withProperty(prefix + ".items"));
 		assertThat(metadata).doesNotHave(Metadata.withProperty(prefix + ".ignored"));
 	}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MergeMetadataGenerationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MergeMetadataGenerationTests.java
index 167feb22f99e..c35e74755c85 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MergeMetadataGenerationTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MergeMetadataGenerationTests.java
@@ -74,7 +74,7 @@ void mergeExistingPropertyDefaultValue() throws Exception {
 		assertThat(metadata).has(Metadata.withProperty("simple.flag", Boolean.class)
 			.fromSource(SimpleProperties.class)
 			.withDescription("A simple flag.")
-			.withDeprecation(null, null)
+			.withDeprecation()
 			.withDefaultValue(true));
 		assertThat(metadata.getItems()).hasSize(4);
 	}
@@ -125,36 +125,36 @@ void mergeExistingPropertyDescription() throws Exception {
 	@Test
 	void mergeExistingPropertyDeprecation() throws Exception {
 		ItemMetadata property = ItemMetadata.newProperty("simple", "comparator", null, null, null, null, null,
-				new ItemDeprecation("Don't use this.", "simple.complex-comparator", "error"));
+				new ItemDeprecation("Don't use this.", "simple.complex-comparator", "1.2.3", "error"));
 		String additionalMetadata = buildAdditionalMetadata(property);
 		ConfigurationMetadata metadata = compile(additionalMetadata, SimpleProperties.class);
 		assertThat(metadata).has(Metadata.withProperty("simple.comparator", "java.util.Comparator<?>")
 			.fromSource(SimpleProperties.class)
-			.withDeprecation("Don't use this.", "simple.complex-comparator", "error"));
+			.withDeprecation("Don't use this.", "simple.complex-comparator", "1.2.3", "error"));
 		assertThat(metadata.getItems()).hasSize(4);
 	}
 
 	@Test
 	void mergeExistingPropertyDeprecationOverride() throws Exception {
 		ItemMetadata property = ItemMetadata.newProperty("singledeprecated", "name", null, null, null, null, null,
-				new ItemDeprecation("Don't use this.", "single.name"));
+				new ItemDeprecation("Don't use this.", "single.name", "1.2.3"));
 		String additionalMetadata = buildAdditionalMetadata(property);
 		ConfigurationMetadata metadata = compile(additionalMetadata, DeprecatedSingleProperty.class);
 		assertThat(metadata).has(Metadata.withProperty("singledeprecated.name", String.class.getName())
 			.fromSource(DeprecatedSingleProperty.class)
-			.withDeprecation("Don't use this.", "single.name"));
+			.withDeprecation("Don't use this.", "single.name", "1.2.3"));
 		assertThat(metadata.getItems()).hasSize(3);
 	}
 
 	@Test
 	void mergeExistingPropertyDeprecationOverrideLevel() throws Exception {
 		ItemMetadata property = ItemMetadata.newProperty("singledeprecated", "name", null, null, null, null, null,
-				new ItemDeprecation(null, null, "error"));
+				new ItemDeprecation(null, null, null, "error"));
 		String additionalMetadata = buildAdditionalMetadata(property);
 		ConfigurationMetadata metadata = compile(additionalMetadata, DeprecatedSingleProperty.class);
 		assertThat(metadata).has(Metadata.withProperty("singledeprecated.name", String.class.getName())
 			.fromSource(DeprecatedSingleProperty.class)
-			.withDeprecation("renamed", "singledeprecated.new-name", "error"));
+			.withDeprecation("renamed", "singledeprecated.new-name", "1.2.3", "error"));
 		assertThat(metadata.getItems()).hasSize(3);
 	}
 
@@ -175,7 +175,7 @@ void mergingOfSimpleHint() throws Exception {
 			.fromSource(SimpleProperties.class)
 			.withDescription("The name of this simple properties.")
 			.withDefaultValue("boot")
-			.withDeprecation(null, null));
+			.withDeprecation());
 		assertThat(metadata)
 			.has(Metadata.withHint("simple.the-name").withValue(0, "boot", "Bla bla").withValue(1, "spring", null));
 	}
@@ -189,7 +189,7 @@ void mergingOfHintWithNonCanonicalName() throws Exception {
 			.fromSource(SimpleProperties.class)
 			.withDescription("The name of this simple properties.")
 			.withDefaultValue("boot")
-			.withDeprecation(null, null));
+			.withDeprecation());
 		assertThat(metadata).has(Metadata.withHint("simple.the-name").withValue(0, "boot", "Bla bla"));
 	}
 
@@ -203,18 +203,19 @@ void mergingOfHintWithProvider() throws Exception {
 			.fromSource(SimpleProperties.class)
 			.withDescription("The name of this simple properties.")
 			.withDefaultValue("boot")
-			.withDeprecation(null, null));
+			.withDeprecation());
 		assertThat(metadata).has(
 				Metadata.withHint("simple.the-name").withProvider("first", "target", "org.foo").withProvider("second"));
 	}
 
 	@Test
 	void mergingOfAdditionalDeprecation() throws Exception {
-		String deprecations = buildPropertyDeprecations(ItemMetadata.newProperty("simple", "wrongName",
-				"java.lang.String", null, null, null, null, new ItemDeprecation("Lame name.", "simple.the-name")));
+		String deprecations = buildPropertyDeprecations(
+				ItemMetadata.newProperty("simple", "wrongName", "java.lang.String", null, null, null, null,
+						new ItemDeprecation("Lame name.", "simple.the-name", "1.2.3")));
 		ConfigurationMetadata metadata = compile(deprecations, SimpleProperties.class);
 		assertThat(metadata).has(Metadata.withProperty("simple.wrong-name", String.class)
-			.withDeprecation("Lame name.", "simple.the-name"));
+			.withDeprecation("Lame name.", "simple.the-name", "1.2.3"));
 	}
 
 	@Test
@@ -268,6 +269,9 @@ private String buildPropertyDeprecations(ItemMetadata... items) throws Exception
 				if (deprecation.getReplacement() != null) {
 					deprecationJson.put("replacement", deprecation.getReplacement());
 				}
+				if (deprecation.getSince() != null) {
+					deprecationJson.put("since", deprecation.getSince());
+				}
 				jsonObject.put("deprecation", deprecationJson);
 			}
 			propertiesArray.put(jsonObject);
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MethodBasedMetadataGenerationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MethodBasedMetadataGenerationTests.java
index e7391bf98080..591c410b16b0 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MethodBasedMetadataGenerationTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MethodBasedMetadataGenerationTests.java
@@ -114,11 +114,11 @@ void deprecatedMethodConfig() {
 		assertThat(metadata).has(Metadata.withGroup("foo").fromSource(type));
 		assertThat(metadata).has(Metadata.withProperty("foo.name", String.class)
 			.fromSource(DeprecatedMethodConfig.Foo.class)
-			.withDeprecation(null, null));
+			.withDeprecation());
 		assertThat(metadata).has(Metadata.withProperty("foo.flag", Boolean.class)
 			.withDefaultValue(false)
 			.fromSource(DeprecatedMethodConfig.Foo.class)
-			.withDeprecation(null, null));
+			.withDeprecation());
 	}
 
 	@Test
@@ -129,11 +129,11 @@ void deprecatedMethodConfigOnClass() {
 		assertThat(metadata).has(Metadata.withGroup("foo").fromSource(type));
 		assertThat(metadata).has(Metadata.withProperty("foo.name", String.class)
 			.fromSource(org.springframework.boot.configurationsample.method.DeprecatedClassMethodConfig.Foo.class)
-			.withDeprecation(null, null));
+			.withDeprecation());
 		assertThat(metadata).has(Metadata.withProperty("foo.flag", Boolean.class)
 			.withDefaultValue(false)
 			.fromSource(org.springframework.boot.configurationsample.method.DeprecatedClassMethodConfig.Foo.class)
-			.withDeprecation(null, null));
+			.withDeprecation());
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolverTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolverTests.java
index 9f85011de406..9a2b1aeed873 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolverTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolverTests.java
@@ -111,15 +111,16 @@ void propertiesWithLombokValueClass() {
 	void propertiesWithDeducedConstructorBinding() {
 		process(ImmutableDeducedConstructorBindingProperties.class,
 				propertyNames((stream) -> assertThat(stream).containsExactly("theName", "flag")));
-		process(ImmutableDeducedConstructorBindingProperties.class, properties((stream) -> assertThat(stream)
-			.allMatch((predicate) -> predicate instanceof ConstructorParameterPropertyDescriptor)));
+		process(ImmutableDeducedConstructorBindingProperties.class,
+				properties((stream) -> assertThat(stream).isNotEmpty()
+					.allMatch((predicate) -> predicate instanceof ConstructorParameterPropertyDescriptor)));
 	}
 
 	@Test
 	void propertiesWithConstructorWithConstructorBinding() {
 		process(ImmutableSimpleProperties.class, propertyNames(
 				(stream) -> assertThat(stream).containsExactly("theName", "flag", "comparator", "counter")));
-		process(ImmutableSimpleProperties.class, properties((stream) -> assertThat(stream)
+		process(ImmutableSimpleProperties.class, properties((stream) -> assertThat(stream).isNotEmpty()
 			.allMatch((predicate) -> predicate instanceof ConstructorParameterPropertyDescriptor)));
 	}
 
@@ -127,14 +128,14 @@ void propertiesWithConstructorWithConstructorBinding() {
 	void propertiesWithConstructorAndClassConstructorBinding() {
 		process(ImmutableClassConstructorBindingProperties.class,
 				propertyNames((stream) -> assertThat(stream).containsExactly("name", "description")));
-		process(ImmutableClassConstructorBindingProperties.class, properties((stream) -> assertThat(stream)
+		process(ImmutableClassConstructorBindingProperties.class, properties((stream) -> assertThat(stream).isNotEmpty()
 			.allMatch((predicate) -> predicate instanceof ConstructorParameterPropertyDescriptor)));
 	}
 
 	@Test
 	void propertiesWithAutowiredConstructor() {
 		process(AutowiredProperties.class, propertyNames((stream) -> assertThat(stream).containsExactly("theName")));
-		process(AutowiredProperties.class, properties((stream) -> assertThat(stream)
+		process(AutowiredProperties.class, properties((stream) -> assertThat(stream).isNotEmpty()
 			.allMatch((predicate) -> predicate instanceof JavaBeanPropertyDescriptor)));
 	}
 
@@ -142,21 +143,10 @@ void propertiesWithAutowiredConstructor() {
 	void propertiesWithMultiConstructor() {
 		process(ImmutableMultiConstructorProperties.class,
 				propertyNames((stream) -> assertThat(stream).containsExactly("name", "description")));
-		process(ImmutableMultiConstructorProperties.class, properties((stream) -> assertThat(stream)
+		process(ImmutableMultiConstructorProperties.class, properties((stream) -> assertThat(stream).isNotEmpty()
 			.allMatch((predicate) -> predicate instanceof ConstructorParameterPropertyDescriptor)));
 	}
 
-	@Test
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	@SuppressWarnings("removal")
-	void propertiesWithMultiConstructorAndDeprecatedAnnotation() {
-		process(org.springframework.boot.configurationsample.immutable.DeprecatedImmutableMultiConstructorProperties.class,
-				propertyNames((stream) -> assertThat(stream).containsExactly("name", "description")));
-		process(org.springframework.boot.configurationsample.immutable.DeprecatedImmutableMultiConstructorProperties.class,
-				properties((stream) -> assertThat(stream)
-					.allMatch((predicate) -> predicate instanceof ConstructorParameterPropertyDescriptor)));
-	}
-
 	@Test
 	void propertiesWithMultiConstructorNoDirective() {
 		process(TwoConstructorsExample.class, propertyNames((stream) -> assertThat(stream).containsExactly("name")));
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/metadata/JsonMarshallerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/metadata/JsonMarshallerTests.java
index 2cbda570e8a0..f7054f721200 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/metadata/JsonMarshallerTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/metadata/JsonMarshallerTests.java
@@ -39,7 +39,7 @@ class JsonMarshallerTests {
 	void marshallAndUnmarshal() throws Exception {
 		ConfigurationMetadata metadata = new ConfigurationMetadata();
 		metadata.add(ItemMetadata.newProperty("a", "b", StringBuffer.class.getName(), InputStream.class.getName(),
-				"sourceMethod", "desc", "x", new ItemDeprecation("Deprecation comment", "b.c.d")));
+				"sourceMethod", "desc", "x", new ItemDeprecation("Deprecation comment", "b.c.d", "1.2.3")));
 		metadata.add(ItemMetadata.newProperty("b.c.d", null, null, null, null, null, null, null));
 		metadata.add(ItemMetadata.newProperty("c", null, null, null, null, null, 123, null));
 		metadata.add(ItemMetadata.newProperty("d", null, null, null, null, null, true, null));
@@ -59,7 +59,7 @@ void marshallAndUnmarshal() throws Exception {
 			.fromSource(InputStream.class)
 			.withDescription("desc")
 			.withDefaultValue("x")
-			.withDeprecation("Deprecation comment", "b.c.d"));
+			.withDeprecation("Deprecation comment", "b.c.d", "1.2.3"));
 		assertThat(read).has(Metadata.withProperty("b.c.d"));
 		assertThat(read).has(Metadata.withProperty("c").withDefaultValue(123));
 		assertThat(read).has(Metadata.withProperty("d").withDefaultValue(true));
@@ -96,10 +96,10 @@ void marshallPutDeprecatedItemsAtTheEnd() throws IOException {
 		ConfigurationMetadata metadata = new ConfigurationMetadata();
 		metadata.add(ItemMetadata.newProperty("com.example.bravo", "bbb", null, null, null, null, null, null));
 		metadata.add(ItemMetadata.newProperty("com.example.bravo", "aaa", null, null, null, null, null,
-				new ItemDeprecation(null, null, "warning")));
+				new ItemDeprecation(null, null, null, "warning")));
 		metadata.add(ItemMetadata.newProperty("com.example.alpha", "ddd", null, null, null, null, null, null));
 		metadata.add(ItemMetadata.newProperty("com.example.alpha", "ccc", null, null, null, null, null,
-				new ItemDeprecation(null, null, "warning")));
+				new ItemDeprecation(null, null, null, "warning")));
 		ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
 		JsonMarshaller marshaller = new JsonMarshaller();
 		marshaller.write(metadata, outputStream);
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/metadata/Metadata.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/metadata/Metadata.java
index 39be910daf52..0930084cf4cc 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/metadata/Metadata.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/metadata/Metadata.java
@@ -193,13 +193,17 @@ public MetadataItemCondition withDefaultValue(Object defaultValue) {
 					this.description, defaultValue, this.deprecation);
 		}
 
-		public MetadataItemCondition withDeprecation(String reason, String replacement) {
-			return withDeprecation(reason, replacement, null);
+		public MetadataItemCondition withDeprecation() {
+			return withDeprecation(null, null, null, null);
 		}
 
-		public MetadataItemCondition withDeprecation(String reason, String replacement, String level) {
+		public MetadataItemCondition withDeprecation(String reason, String replacement, String since) {
+			return withDeprecation(reason, replacement, since, null);
+		}
+
+		public MetadataItemCondition withDeprecation(String reason, String replacement, String since, String level) {
 			return new MetadataItemCondition(this.itemType, this.name, this.type, this.sourceType, this.sourceMethod,
-					this.description, this.defaultValue, new ItemDeprecation(reason, replacement, level));
+					this.description, this.defaultValue, new ItemDeprecation(reason, replacement, since, level));
 		}
 
 		public MetadataItemCondition withNoDeprecation() {
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/DeprecatedConfigurationProperty.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/DeprecatedConfigurationProperty.java
index 0853090e9388..3aaf2a6540de 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/DeprecatedConfigurationProperty.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/DeprecatedConfigurationProperty.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -50,4 +50,10 @@
 	 */
 	String replacement() default "";
 
+	/**
+	 * The version in which the property became deprecated.
+	 * @return the version
+	 */
+	String since() default "";
+
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/DeprecatedConstructorBinding.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/DeprecatedConstructorBinding.java
deleted file mode 100644
index ee1ae440f81c..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/DeprecatedConstructorBinding.java
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.configurationsample;
-
-import java.lang.annotation.Documented;
-import java.lang.annotation.ElementType;
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-import java.lang.annotation.Target;
-
-/**
- * Alternative to Spring Boot's deprecated
- * {@code @org.springframework.boot.context.properties.ConstructorBinding} for testing
- * (removes the need for a dependency on the real annotation).
- *
- * @author Stephane Nicoll
- */
-@Target(ElementType.CONSTRUCTOR)
-@Retention(RetentionPolicy.RUNTIME)
-@Documented
-@ConstructorBinding
-@Deprecated(since = "3.0.0", forRemoval = true)
-public @interface DeprecatedConstructorBinding {
-
-}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/immutable/DeprecatedImmutableMultiConstructorProperties.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/immutable/DeprecatedImmutableMultiConstructorProperties.java
deleted file mode 100644
index d2e0305fc149..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/immutable/DeprecatedImmutableMultiConstructorProperties.java
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.configurationsample.immutable;
-
-/**
- * Simple immutable properties with several constructors.
- *
- * @author Stephane Nicoll
- */
-@SuppressWarnings("unused")
-@Deprecated(since = "3.0.0", forRemoval = true)
-public class DeprecatedImmutableMultiConstructorProperties {
-
-	private final String name;
-
-	/**
-	 * Test description.
-	 */
-	private final String description;
-
-	public DeprecatedImmutableMultiConstructorProperties(String name) {
-		this(name, null);
-	}
-
-	@SuppressWarnings("removal")
-	@org.springframework.boot.configurationsample.DeprecatedConstructorBinding
-	public DeprecatedImmutableMultiConstructorProperties(String name, String description) {
-		this.name = name;
-		this.description = description;
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/simple/DeprecatedSingleProperty.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/simple/DeprecatedSingleProperty.java
index f8df3f7c4aeb..984c98764386 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/simple/DeprecatedSingleProperty.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/simple/DeprecatedSingleProperty.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -30,7 +30,7 @@ public class DeprecatedSingleProperty {
 	private String newName;
 
 	@Deprecated
-	@DeprecatedConfigurationProperty(reason = "renamed", replacement = "singledeprecated.new-name")
+	@DeprecatedConfigurationProperty(reason = "renamed", replacement = "singledeprecated.new-name", since = "1.2.3")
 	public String getName() {
 		return getNewName();
 	}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/build.gradle
index 80b5f7e9402b..35cd303513d7 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/build.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/build.gradle
@@ -23,6 +23,11 @@ configurations {
 				if (dependency.requested.group.startsWith("com.fasterxml.jackson")) {
 					dependency.useVersion("2.14.2")
 				}
+				// Downgrade Spring Framework as Gradle cannot cope with 6.1.0-M1's
+				// multi-version jar files with bytecode in META-INF/versions/21
+				if (dependency.requested.group.equals("org.springframework")) {
+					dependency.useVersion("6.0.10")
+				}
 			}
 		}
 	}
@@ -70,7 +75,7 @@ gradlePlugin {
 }
 
 task preparePluginValidationClasses(type: Copy) {
-	destinationDir = file("$buildDir/classes/java/pluginValidation")
+	destinationDir = layout.buildDirectory.dir("classes/java/pluginValidation").get().asFile
 	from(sourceSets.main.output.classesDirs) {
 		exclude "**/CreateBootStartScripts.class"
 	}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/index.adoc b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/index.adoc
index 9d5d66f2ce6b..3b6f8ae1fd5d 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/index.adoc
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/index.adoc
@@ -1,6 +1,6 @@
 [[spring-boot-gradle-plugin-documentation]]
 = Spring Boot Gradle Plugin Reference Guide
-Andy Wilkinson; Scott Frederick
+Andy Wilkinson; Scott Frederick; Moritz Halbritter
 v{gradle-project-version}
 :!version-label:
 :doctype: book
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/integrating-with-actuator.adoc b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/integrating-with-actuator.adoc
index 1d016b8a9bdf..4232d13f7f73 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/integrating-with-actuator.adoc
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/integrating-with-actuator.adoc
@@ -95,3 +95,5 @@ include::../gradle/integrating-with-actuator/build-info-additional.gradle[tags=a
 ----
 include::../gradle/integrating-with-actuator/build-info-additional.gradle.kts[tags=additional]
 ----
+
+An additional property's value can be computed lazily by using a `Provider`.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/packaging-oci-image.adoc b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/packaging-oci-image.adoc
index c5e429729335..252b495bf770 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/packaging-oci-image.adoc
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/packaging-oci-image.adoc
@@ -13,7 +13,8 @@ The task is automatically created when the `java` or `war` plugin is applied and
 [[build-image.docker-daemon]]
 == Docker Daemon
 The `bootBuildImage` task requires access to a Docker daemon.
-By default, it will communicate with a Docker daemon over a local connection.
+The task will inspect local Docker CLI https://docs.docker.com/engine/reference/commandline/cli/#configuration-files[configuration files] to determine the current https://docs.docker.com/engine/context/working-with-contexts/[context] and use the context connection information to communicate with a Docker daemon.
+If the current context can not be determined or the context does not have connection information, then the task will use a default local connection.
 This works with https://docs.docker.com/install/[Docker Engine] on all supported platforms without configuration.
 
 Environment variables can be set to configure the `bootBuildImage` task to use an alternative local or remote connection.
@@ -22,6 +23,12 @@ The following table shows the environment variables and their values:
 |===
 | Environment variable | Description
 
+| DOCKER_CONFIG
+| Location of Docker CLI https://docs.docker.com/engine/reference/commandline/cli/#configuration-files[configuration files] used to determine the current context (defaults to `$HOME/.docker`)
+
+| DOCKER_CONTEXT
+| Name of a https://docs.docker.com/engine/context/working-with-contexts/[context] that should be used to retrieve host information from Docker CLI configuration files (overrides `DOCKER_HOST`)
+
 | DOCKER_HOST
 | URL containing the host and port for the Docker daemon - for example `tcp://192.168.99.100:2376`
 
@@ -38,6 +45,9 @@ The following table summarizes the available properties:
 |===
 | Property | Description
 
+| `context`
+| Name of a https://docs.docker.com/engine/context/working-with-contexts/[context] that should be used to retrieve host information from Docker CLI https://docs.docker.com/engine/reference/commandline/cli/#configuration-files[configuration files]
+
 | `host`
 | URL containing the host and port for the Docker daemon - for example `tcp://192.168.99.100:2376`
 
@@ -105,7 +115,7 @@ The following table summarizes the available properties and their default values
 | `builder`
 | `--builder`
 | Name of the Builder image to use.
-| `paketobuildpacks/builder:base` or `paketobuildpacks/builder:tiny` when {nbt-gradle-plugin}[GraalVM Native Image plugin] is applied.
+| `paketobuildpacks/builder-jammy-base:latest` or `paketobuildpacks/builder-jammy-tiny:latest` when {nbt-gradle-plugin}[GraalVM Native Image plugin] is applied.
 
 | `runImage`
 | `--runImage`
@@ -180,17 +190,26 @@ The value supplied will be passed unvalidated to Docker when creating the builde
 | `tags`
 |
 | A list of one or more additional tags to apply to the generated image.
-The values provided to the `tags` option should be full image references in the form of `[image name]:[tag]` or `[repository]/[image name]:[tag]`.
+The values provided to the `tags` option should be *full* image references.
+See <<build-image.customization.tags, the tags section>> for more details.
 |
 
+| `buildWorkspace`
+|
+| A temporary workspace that will be used by the builder and buildpacks to store files during image building.
+The value can be a named volume or a bind mount location.
+| A named volume in the Docker daemon, with a name derived from the image name.
+
 | `buildCache`
 |
 | A cache containing layers created by buildpacks and used by the image building process.
+The value can be a named volume or a bind mount location.
 | A named volume in the Docker daemon, with a name derived from the image name.
 
 | `launchCache`
 |
 | A cache containing layers created by buildpacks and used by the image launching process.
+The value can be a named volume or a bind mount location.
 | A named volume in the Docker daemon, with a name derived from the image name.
 
 | `createdDate`
@@ -205,6 +224,11 @@ The value must be a string in the ISO 8601 instant format, or `now` to use the c
 Application contents will also be in this location in the generated image.
 | `/workspace`
 
+| `securityOptions`
+| `--securityOptions`
+| https://docs.docker.com/engine/reference/run/#security-configuration[Security options] that will be applied to the builder container, provided as an array of string values
+| `["label=disable"]` on Linux and macOS, `[]` on Windows
+
 |===
 
 NOTE: The plugin detects the target Java compatibility of the project using the JavaPlugin's `targetCompatibility` property.
@@ -213,6 +237,24 @@ You can override this behaviour as shown in the <<build-image.examples.builder-c
 
 
 
+[[build-image.customization.tags]]
+=== Tags format
+
+The values provided to the `tags` option should be *full* image references.
+The accepted format is `[domainHost:port/][path/]name[:tag][@digest]`.
+
+If the domain is missing, it defaults to `docker.io`.
+If the path is missing, it defaults to `library`.
+If the tag is missing, it defaults to `latest`.
+
+Some examples:
+
+* `my-image` leads to the image reference `docker.io/library/my-image:latest`
+* `my-repository/my-image` leads to `docker.io/my-repository/my-image:latest`
+* `example.com/my-repository/my-image:1.0.0` will be used as is
+
+
+
 [[build-image.examples]]
 == Examples
 
@@ -409,9 +451,10 @@ The publish option can be specified on the command line as well, as shown in thi
 	$ gradle bootBuildImage --imageName=docker.example.com/library/my-app:v1 --publishImage
 ----
 
-[[build-image.examples.caches]]
-=== Builder Cache Configuration
 
+
+[[build-image.examples.caches]]
+=== Builder Cache and Workspace Configuration
 The CNB builder caches layers that are used when building and launching an image.
 By default, these caches are stored as named volumes in the Docker daemon with names that are derived from the full name of the target image.
 If the image name changes frequently, for example when the project version is used as a tag in the image name, then the caches can be invalidated frequently.
@@ -430,12 +473,32 @@ include::../gradle/packaging/boot-build-image-caches.gradle[tags=caches]
 include::../gradle/packaging/boot-build-image-caches.gradle.kts[tags=caches]
 ----
 
+Builders and buildpacks need a location to store temporary files during image building.
+By default, this temporary build workspace is stored in a named volume.
+
+The caches and the build workspace can be configured to use bind mounts instead of named volumes, as shown in the following example:
+
+[source,groovy,indent=0,subs="verbatim,attributes",role="primary"]
+.Groovy
+----
+include::../gradle/packaging/boot-build-image-bind-caches.gradle[tags=caches]
+----
+
+[source,kotlin,indent=0,subs="verbatim,attributes",role="secondary"]
+.Kotlin
+----
+include::../gradle/packaging/boot-build-image-bind-caches.gradle.kts[tags=caches]
+----
+
+
+
 [[build-image.examples.docker]]
 === Docker Configuration
 
+
+
 [[build-image.examples.docker.minikube]]
 ==== Docker Configuration for minikube
-
 The plugin can communicate with the https://minikube.sigs.k8s.io/docs/tasks/docker_daemon/[Docker daemon provided by minikube] instead of the default local connection.
 
 On Linux and macOS, environment variables can be set using the command `eval $(minikube docker-env)` after minikube has been started.
@@ -454,9 +517,10 @@ include::../gradle/packaging/boot-build-image-docker-host.gradle[tags=docker-hos
 include::../gradle/packaging/boot-build-image-docker-host.gradle.kts[tags=docker-host]
 ----
 
+
+
 [[build-image.examples.docker.podman]]
 ==== Docker Configuration for podman
-
 The plugin can communicate with a https://podman.io/[podman container engine].
 
 The plugin can be configured to use podman local connection by providing connection details similar to those shown in the following example:
@@ -475,9 +539,31 @@ include::../gradle/packaging/boot-build-image-docker-host-podman.gradle.kts[tags
 
 TIP: With the `podman` CLI installed, the command `podman info --format='{{.Host.RemoteSocket.Path}}'` can be used to get the value for the `docker.host` configuration property shown in this example.
 
+
+
+[[build-image.examples.docker.colima]]
+==== Docker Configuration for Colima
+The plugin can communicate with the Docker daemon provided by https://github.com/abiosoft/colima[Colima].
+The `DOCKER_HOST` environment variable can be set by using the command `export DOCKER_HOST=$(docker context inspect colima -f '{{.Endpoints.docker.Host}}').`
+
+The plugin can also be configured to use Colima daemon by providing connection details similar to those shown in the following example:
+
+[source,groovy,indent=0,subs="verbatim,attributes",role="primary"]
+.Groovy
+----
+include::../gradle/packaging/boot-build-image-docker-host-colima.gradle[tags=docker-host]
+----
+
+[source,kotlin,indent=0,subs="verbatim,attributes",role="secondary"]
+.Kotlin
+----
+include::../gradle/packaging/boot-build-image-docker-host-colima.gradle.kts[tags=docker-host]
+----
+
+
+
 [[build-image.examples.docker.auth]]
 ==== Docker Configuration for Authentication
-
 If the builder or run image are stored in a private Docker registry that supports user authentication, authentication details can be provided using `docker.builderRegistry` properties as shown in the following example:
 
 [source,groovy,indent=0,subs="verbatim,attributes",role="primary"]
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/reacting.adoc b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/reacting.adoc
index 660404b1a426..31d1f2c1b32b 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/reacting.adoc
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/reacting.adoc
@@ -9,7 +9,7 @@ This section describes those changes.
 == Reacting to the Java Plugin
 When Gradle's {java-plugin}[`java` plugin] is applied to a project, the Spring Boot plugin:
 
-1. Creates a {boot-jar-javadoc}[`BootJar`] task named `bootJar` that will create an executable, fat jar for the project.
+1. Creates a {boot-jar-javadoc}[`BootJar`] task named `bootJar` that will create an executable, uber jar for the project.
    The jar will contain everything on the runtime classpath of the main source set; classes are packaged in `BOOT-INF/classes` and jars are packaged in `BOOT-INF/lib`
 2. Configures the `assemble` task to depend on the `bootJar` task.
 3. Configures the `jar` task to use `plain` as the convention for its archive classifier.
@@ -18,9 +18,10 @@ When Gradle's {java-plugin}[`java` plugin] is applied to a project, the Spring B
 6. Creates a {boot-run-javadoc}['BootRun`] task named `bootTestRun` that can be used to run your application using the `test` source set to find its main method and provide its runtime classpath.
 7. Creates a configuration named `bootArchives` that contains the artifact produced by the `bootJar` task.
 8. Creates a configuration named `developmentOnly` for dependencies that are only required at development time, such as Spring Boot's Devtools, and should not be packaged in executable jars and wars.
-9. Creates a configuration named `productionRuntimeClasspath`. It is equivalent to `runtimeClasspath` minus any dependencies that only appear in the `developmentOnly` configuration.
-10. Configures any `JavaCompile` tasks with no configured encoding to use `UTF-8`.
-11. Configures any `JavaCompile` tasks to use the `-parameters` compiler argument.
+9. Creats a configuration named `testAndDevelopmentOnly` for dependencies that are only required at development time and when writing and running tests and that should not be packaged in executable jars and wars.
+10. Creates a configuration named `productionRuntimeClasspath`. It is equivalent to `runtimeClasspath` minus any dependencies that only appear in the `developmentOnly` or `testDevelopmentOnly` configurations.
+11. Configures any `JavaCompile` tasks with no configured encoding to use `UTF-8`.
+12. Configures any `JavaCompile` tasks to use the `-parameters` compiler argument.
 
 
 
@@ -81,6 +82,6 @@ When the {nbt-gradle-plugin}[GraalVM Native Image plugin] is applied to a projec
 . Configures the GraalVM extension to disable Toolchain detection.
 . Configures each GraalVM native binary to require GraalVM 22.3 or later.
 . Configures the `bootJar` task to include the reachability metadata produced by the `collectReachabilityMetadata` task in its jar.
-. Configures the `bootBuildImage` task to use `paketobuildpacks/builder:tiny` as its builder and to set `BP_NATIVE_IMAGE` to `true` in its environment.
+. Configures the `bootBuildImage` task to use `paketobuildpacks/builder-jammy-tiny:latest` as its builder and to set `BP_NATIVE_IMAGE` to `true` in its environment.
 
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-bind-caches.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-bind-caches.gradle
new file mode 100644
index 000000000000..875239d07f80
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-bind-caches.gradle
@@ -0,0 +1,36 @@
+plugins {
+	id 'java'
+	id 'org.springframework.boot' version '{gradle-project-version}'
+}
+
+tasks.named("bootJar") {
+	mainClass = 'com.example.ExampleApplication'
+}
+
+// tag::caches[]
+tasks.named("bootBuildImage") {
+	buildWorkspace {
+		bind {
+			source = "/tmp/cache-${rootProject.name}.work"
+		}
+	}
+	buildCache {
+		bind {
+			source = "/tmp/cache-${rootProject.name}.build"
+		}
+	}
+	launchCache {
+		bind {
+			source = "/tmp/cache-${rootProject.name}.launch"
+		}
+	}
+}
+// end::caches[]
+
+tasks.register("bootBuildImageCaches") {
+	doFirst {
+		bootBuildImage.buildWorkspace.asCache().with { print "buildWorkspace=$source" }
+		bootBuildImage.buildCache.asCache().with { println "buildCache=$source" }
+		bootBuildImage.launchCache.asCache().with { println "launchCache=$source" }
+	}
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-bind-caches.gradle.kts b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-bind-caches.gradle.kts
new file mode 100644
index 000000000000..e492703c6f96
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-bind-caches.gradle.kts
@@ -0,0 +1,34 @@
+import org.springframework.boot.gradle.tasks.bundling.BootBuildImage
+
+plugins {
+	java
+	id("org.springframework.boot") version "{gradle-project-version}"
+}
+
+// tag::caches[]
+tasks.named<BootBuildImage>("bootBuildImage") {
+	buildWorkspace {
+		bind {
+			source.set("/tmp/cache-${rootProject.name}.work")
+		}
+	}
+	buildCache {
+		bind {
+			source.set("/tmp/cache-${rootProject.name}.build")
+		}
+	}
+	launchCache {
+		bind {
+			source.set("/tmp/cache-${rootProject.name}.launch")
+		}
+	}
+}
+// end::caches[]
+
+tasks.register("bootBuildImageCaches") {
+	doFirst {
+		println("buildWorkspace=" + tasks.getByName<BootBuildImage>("bootBuildImage").buildWorkspace.asCache().bind.source)
+		println("buildCache=" + tasks.getByName<BootBuildImage>("bootBuildImage").buildCache.asCache().bind.source)
+		println("launchCache=" + tasks.getByName<BootBuildImage>("bootBuildImage").launchCache.asCache().bind.source)
+	}
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-docker-host-colima.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-docker-host-colima.gradle
new file mode 100644
index 000000000000..bf21d5a1de4d
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-docker-host-colima.gradle
@@ -0,0 +1,22 @@
+plugins {
+	id 'java'
+	id 'org.springframework.boot' version '{gradle-project-version}'
+}
+
+tasks.named("bootJar") {
+	mainClass = 'com.example.ExampleApplication'
+}
+
+// tag::docker-host[]
+tasks.named("bootBuildImage") {
+	docker {
+		host = "unix://${System.properties['user.home']}/.colima/docker.sock"
+	}
+}
+// end::docker-host[]
+
+tasks.register("bootBuildImageDocker") {
+	doFirst {
+		println("host=${tasks.bootBuildImage.docker.host.get()}")
+	}
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-docker-host-colima.gradle.kts b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-docker-host-colima.gradle.kts
new file mode 100644
index 000000000000..2a227248d237
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-docker-host-colima.gradle.kts
@@ -0,0 +1,25 @@
+import org.springframework.boot.gradle.tasks.bundling.BootJar
+import org.springframework.boot.gradle.tasks.bundling.BootBuildImage
+
+plugins {
+	java
+	id("org.springframework.boot") version "{gradle-project-version}"
+}
+
+tasks.named<BootJar>("bootJar") {
+	mainClass.set("com.example.ExampleApplication")
+}
+
+// tag::docker-host[]
+tasks.named<BootBuildImage>("bootBuildImage") {
+	docker {
+		host.set("unix://${System.getProperty("user.home")}/.colima/docker.sock")
+	}
+}
+// end::docker-host[]
+
+tasks.register("bootBuildImageDocker") {
+	doFirst {
+		println("host=${tasks.getByName<BootBuildImage>("bootBuildImage").docker.host.get()}")
+	}
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-war-properties-launcher.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-war-properties-launcher.gradle
index 2872469f60fb..6a1897ae3d3b 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-war-properties-launcher.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-war-properties-launcher.gradle
@@ -10,7 +10,7 @@ tasks.named("bootWar") {
 // tag::properties-launcher[]
 tasks.named("bootWar") {
 	manifest {
-		attributes 'Main-Class': 'org.springframework.boot.loader.PropertiesLauncher'
+		attributes 'Main-Class': 'org.springframework.boot.loader.launch.PropertiesLauncher'
 	}
 }
 // end::properties-launcher[]
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-war-properties-launcher.gradle.kts b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-war-properties-launcher.gradle.kts
index 19d723b795fa..f5284eb8f259 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-war-properties-launcher.gradle.kts
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-war-properties-launcher.gradle.kts
@@ -12,7 +12,7 @@ tasks.named<BootWar>("bootWar") {
 // tag::properties-launcher[]
 tasks.named<BootWar>("bootWar") {
 	manifest {
-		attributes("Main-Class" to "org.springframework.boot.loader.PropertiesLauncher")
+		attributes("Main-Class" to "org.springframework.boot.loader.launch.PropertiesLauncher")
 	}
 }
 // end::properties-launcher[]
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/ApplicationPluginAction.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/ApplicationPluginAction.java
index 0a082b259b4b..a4df28854a7a 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/ApplicationPluginAction.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/ApplicationPluginAction.java
@@ -16,12 +16,13 @@
 
 package org.springframework.boot.gradle.plugin;
 
-import java.io.File;
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.io.StringWriter;
+import java.lang.reflect.Method;
 import java.util.concurrent.Callable;
 
+import org.gradle.api.Action;
 import org.gradle.api.GradleException;
 import org.gradle.api.Plugin;
 import org.gradle.api.Project;
@@ -36,6 +37,7 @@
 import org.gradle.api.tasks.TaskProvider;
 import org.gradle.jvm.application.scripts.TemplateBasedScriptGenerator;
 import org.gradle.jvm.application.tasks.CreateStartScripts;
+import org.gradle.util.GradleVersion;
 
 import org.springframework.boot.gradle.tasks.run.BootRun;
 
@@ -57,7 +59,7 @@ public void execute(Project project) {
 			.register("bootStartScripts", CreateStartScripts.class,
 					(task) -> configureCreateStartScripts(project, javaApplication, distribution, task));
 		CopySpec binCopySpec = project.copySpec().into("bin").from(bootStartScripts);
-		binCopySpec.setFileMode(0755);
+		configureFilePermissions(binCopySpec, 0755);
 		distribution.getContents().with(binCopySpec);
 		applyApplicationDefaultJvmArgsToRunTasks(project.getTasks(), javaApplication);
 	}
@@ -89,14 +91,14 @@ private void configureCreateStartScripts(Project project, JavaApplication javaAp
 			}
 		});
 		createStartScripts.getConventionMapping()
-			.map("outputDir", () -> new File(project.getBuildDir(), "bootScripts"));
+			.map("outputDir", () -> project.getLayout().getBuildDirectory().dir("bootScripts").get().getAsFile());
 		createStartScripts.getConventionMapping().map("applicationName", javaApplication::getApplicationName);
 		createStartScripts.getConventionMapping().map("defaultJvmOpts", javaApplication::getApplicationDefaultJvmArgs);
 	}
 
 	private CopySpec artifactFilesToLibCopySpec(Project project, Configuration configuration) {
 		CopySpec copySpec = project.copySpec().into("lib").from(artifactFiles(configuration));
-		copySpec.setFileMode(0644);
+		configureFilePermissions(copySpec, 0644);
 		return copySpec;
 	}
 
@@ -124,4 +126,34 @@ private String loadResource(String name) {
 		}
 	}
 
+	private void configureFilePermissions(CopySpec copySpec, int mode) {
+		if (GradleVersion.current().compareTo(GradleVersion.version("8.3")) >= 0) {
+			try {
+				Method filePermissions = copySpec.getClass().getMethod("filePermissions", Action.class);
+				filePermissions.invoke(copySpec, new Action<Object>() {
+
+					@Override
+					public void execute(Object filePermissions) {
+						String unixPermissions = Integer.toString(mode, 8);
+						try {
+							Method unix = filePermissions.getClass().getMethod("unix", String.class);
+							unix.invoke(filePermissions, unixPermissions);
+						}
+						catch (Exception ex) {
+							throw new GradleException("Failed to set file permissions to '" + unixPermissions + "'",
+									ex);
+						}
+					}
+
+				});
+			}
+			catch (Exception ex) {
+				throw new GradleException("Failed to set file permissions", ex);
+			}
+		}
+		else {
+			copySpec.setFileMode(mode);
+		}
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/JavaPluginAction.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/JavaPluginAction.java
index 945657605240..93b2172a4a78 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/JavaPluginAction.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/JavaPluginAction.java
@@ -26,12 +26,9 @@
 import org.gradle.api.Project;
 import org.gradle.api.Task;
 import org.gradle.api.artifacts.Configuration;
+import org.gradle.api.attributes.Attribute;
 import org.gradle.api.attributes.AttributeContainer;
-import org.gradle.api.attributes.Bundling;
-import org.gradle.api.attributes.LibraryElements;
-import org.gradle.api.attributes.Usage;
 import org.gradle.api.file.FileCollection;
-import org.gradle.api.model.ObjectFactory;
 import org.gradle.api.plugins.ApplicationPlugin;
 import org.gradle.api.plugins.BasePlugin;
 import org.gradle.api.plugins.ExtensionContainer;
@@ -39,6 +36,7 @@
 import org.gradle.api.plugins.JavaPlugin;
 import org.gradle.api.plugins.JavaPluginExtension;
 import org.gradle.api.provider.Provider;
+import org.gradle.api.provider.ProviderFactory;
 import org.gradle.api.tasks.SourceSet;
 import org.gradle.api.tasks.SourceSetContainer;
 import org.gradle.api.tasks.TaskProvider;
@@ -78,7 +76,9 @@ public Class<? extends Plugin<? extends Project>> getPluginClass() {
 	public void execute(Project project) {
 		classifyJarTask(project);
 		configureBuildTask(project);
+		configureProductionRuntimeClasspathConfiguration(project);
 		configureDevelopmentOnlyConfiguration(project);
+		configureTestAndDevelopmentOnlyConfiguration(project);
 		TaskProvider<ResolveMainClassName> resolveMainClassName = configureResolveMainClassNameTask(project);
 		TaskProvider<BootJar> bootJar = configureBootJarTask(project, resolveMainClassName);
 		configureBootBuildImageTask(project, bootJar);
@@ -160,12 +160,15 @@ private TaskProvider<BootJar> configureBootJarTask(Project project,
 			.getByName(SourceSet.MAIN_SOURCE_SET_NAME);
 		Configuration developmentOnly = project.getConfigurations()
 			.getByName(SpringBootPlugin.DEVELOPMENT_ONLY_CONFIGURATION_NAME);
+		Configuration testAndDevelopmentOnly = project.getConfigurations()
+			.getByName(SpringBootPlugin.TEST_AND_DEVELOPMENT_ONLY_CONFIGURATION_NAME);
 		Configuration productionRuntimeClasspath = project.getConfigurations()
 			.getByName(SpringBootPlugin.PRODUCTION_RUNTIME_CLASSPATH_CONFIGURATION_NAME);
 		Configuration runtimeClasspath = project.getConfigurations()
 			.getByName(mainSourceSet.getRuntimeClasspathConfigurationName());
 		Callable<FileCollection> classpath = () -> mainSourceSet.getRuntimeClasspath()
 			.minus((developmentOnly.minus(productionRuntimeClasspath)))
+			.minus((testAndDevelopmentOnly.minus(productionRuntimeClasspath)))
 			.filter(new JarTypeFileSpec());
 		return project.getTasks().register(SpringBootPlugin.BOOT_JAR_TASK_NAME, BootJar.class, (bootJar) -> {
 			bootJar.setDescription(
@@ -270,6 +273,26 @@ private void configureAdditionalMetadataLocations(JavaCompile compile) {
 			.ifPresent((locations) -> compile.doFirst(new AdditionalMetadataLocationsConfigurer(locations)));
 	}
 
+	@SuppressWarnings({ "rawtypes", "unchecked" })
+	private void configureProductionRuntimeClasspathConfiguration(Project project) {
+		Configuration productionRuntimeClasspath = project.getConfigurations()
+			.create(SpringBootPlugin.PRODUCTION_RUNTIME_CLASSPATH_CONFIGURATION_NAME);
+		productionRuntimeClasspath.setVisible(false);
+		Configuration runtimeClasspath = project.getConfigurations()
+			.getByName(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME);
+		productionRuntimeClasspath.attributes((attributes) -> {
+			ProviderFactory providers = project.getProviders();
+			AttributeContainer sourceAttributes = runtimeClasspath.getAttributes();
+			for (Attribute attribute : sourceAttributes.keySet()) {
+				attributes.attributeProvider(attribute,
+						providers.provider(() -> sourceAttributes.getAttribute(attribute)));
+			}
+		});
+		productionRuntimeClasspath.setExtendsFrom(runtimeClasspath.getExtendsFrom());
+		productionRuntimeClasspath.setCanBeResolved(runtimeClasspath.isCanBeResolved());
+		productionRuntimeClasspath.setCanBeConsumed(runtimeClasspath.isCanBeConsumed());
+	}
+
 	private void configureDevelopmentOnlyConfiguration(Project project) {
 		Configuration developmentOnly = project.getConfigurations()
 			.create(SpringBootPlugin.DEVELOPMENT_ONLY_CONFIGURATION_NAME);
@@ -277,21 +300,23 @@ private void configureDevelopmentOnlyConfiguration(Project project) {
 			.setDescription("Configuration for development-only dependencies such as Spring Boot's DevTools.");
 		Configuration runtimeClasspath = project.getConfigurations()
 			.getByName(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME);
-		Configuration productionRuntimeClasspath = project.getConfigurations()
-			.create(SpringBootPlugin.PRODUCTION_RUNTIME_CLASSPATH_CONFIGURATION_NAME);
-		AttributeContainer attributes = productionRuntimeClasspath.getAttributes();
-		ObjectFactory objectFactory = project.getObjects();
-		attributes.attribute(Usage.USAGE_ATTRIBUTE, objectFactory.named(Usage.class, Usage.JAVA_RUNTIME));
-		attributes.attribute(Bundling.BUNDLING_ATTRIBUTE, objectFactory.named(Bundling.class, Bundling.EXTERNAL));
-		attributes.attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE,
-				objectFactory.named(LibraryElements.class, LibraryElements.JAR));
-		productionRuntimeClasspath.setVisible(false);
-		productionRuntimeClasspath.setExtendsFrom(runtimeClasspath.getExtendsFrom());
-		productionRuntimeClasspath.setCanBeResolved(runtimeClasspath.isCanBeResolved());
-		productionRuntimeClasspath.setCanBeConsumed(runtimeClasspath.isCanBeConsumed());
+
 		runtimeClasspath.extendsFrom(developmentOnly);
 	}
 
+	private void configureTestAndDevelopmentOnlyConfiguration(Project project) {
+		Configuration testAndDevelopmentOnly = project.getConfigurations()
+			.create(SpringBootPlugin.TEST_AND_DEVELOPMENT_ONLY_CONFIGURATION_NAME);
+		testAndDevelopmentOnly
+			.setDescription("Configuration for test and development-only dependencies such as Spring Boot's DevTools.");
+		Configuration runtimeClasspath = project.getConfigurations()
+			.getByName(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME);
+		runtimeClasspath.extendsFrom(testAndDevelopmentOnly);
+		Configuration testImplementation = project.getConfigurations()
+			.getByName(JavaPlugin.TEST_IMPLEMENTATION_CONFIGURATION_NAME);
+		testImplementation.extendsFrom(testAndDevelopmentOnly);
+	}
+
 	/**
 	 * Task {@link Action} to add additional meta-data locations. We need to use an
 	 * inner-class rather than a lambda due to
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/NativeImagePluginAction.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/NativeImagePluginAction.java
index 3cc2bf7b2b2d..c4689c80b242 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/NativeImagePluginAction.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/NativeImagePluginAction.java
@@ -47,8 +47,7 @@
 class NativeImagePluginAction implements PluginApplicationAction {
 
 	@Override
-	public Class<? extends Plugin<? extends Project>> getPluginClass()
-			throws ClassNotFoundException, NoClassDefFoundError {
+	public Class<? extends Plugin<? extends Project>> getPluginClass() {
 		return NativeImagePlugin.class;
 	}
 
@@ -84,7 +83,8 @@ private Iterable<Configuration> removeDevelopmentOnly(Set<Configuration> configu
 	}
 
 	private boolean isNotDevelopmentOnly(Configuration configuration) {
-		return !SpringBootPlugin.DEVELOPMENT_ONLY_CONFIGURATION_NAME.equals(configuration.getName());
+		return !SpringBootPlugin.DEVELOPMENT_ONLY_CONFIGURATION_NAME.equals(configuration.getName())
+				&& !SpringBootPlugin.TEST_AND_DEVELOPMENT_ONLY_CONFIGURATION_NAME.equals(configuration.getName());
 	}
 
 	private void configureTestNativeBinaryClasspath(SourceSetContainer sourceSets, GraalVMExtension graalVmExtension) {
@@ -115,7 +115,7 @@ private void configureBootBuildImageToProduceANativeImage(Project project) {
 		project.getTasks()
 			.named(SpringBootPlugin.BOOT_BUILD_IMAGE_TASK_NAME, BootBuildImage.class)
 			.configure((bootBuildImage) -> {
-				bootBuildImage.getBuilder().convention("paketobuildpacks/builder:tiny");
+				bootBuildImage.getBuilder().convention("paketobuildpacks/builder-jammy-tiny:latest");
 				bootBuildImage.getEnvironment().put("BP_NATIVE_IMAGE", "true");
 			});
 	}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/SpringBootAotPlugin.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/SpringBootAotPlugin.java
index 00ba72184993..6c9af4b85306 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/SpringBootAotPlugin.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/SpringBootAotPlugin.java
@@ -35,9 +35,12 @@
 import org.gradle.api.plugins.JavaPluginExtension;
 import org.gradle.api.plugins.PluginContainer;
 import org.gradle.api.provider.Provider;
+import org.gradle.api.provider.ProviderFactory;
 import org.gradle.api.tasks.SourceSet;
 import org.gradle.api.tasks.SourceSetContainer;
 import org.gradle.api.tasks.TaskProvider;
+import org.gradle.jvm.toolchain.JavaToolchainService;
+import org.gradle.jvm.toolchain.JavaToolchainSpec;
 
 import org.springframework.boot.gradle.tasks.aot.AbstractAot;
 import org.springframework.boot.gradle.tasks.aot.ProcessAot;
@@ -78,9 +81,9 @@ public void apply(Project project) {
 			JavaPluginExtension javaPluginExtension = project.getExtensions().getByType(JavaPluginExtension.class);
 			SourceSetContainer sourceSets = javaPluginExtension.getSourceSets();
 			SourceSet mainSourceSet = sourceSets.getByName(SourceSet.MAIN_SOURCE_SET_NAME);
-			SourceSet aotSourceSet = configureSourceSet(project, "aot", mainSourceSet);
+			SourceSet aotSourceSet = configureSourceSet(project, AOT_SOURCE_SET_NAME, mainSourceSet);
 			SourceSet testSourceSet = sourceSets.getByName(SourceSet.TEST_SOURCE_SET_NAME);
-			SourceSet aotTestSourceSet = configureSourceSet(project, "aotTest", testSourceSet);
+			SourceSet aotTestSourceSet = configureSourceSet(project, AOT_TEST_SOURCE_SET_NAME, testSourceSet);
 			plugins.withType(SpringBootPlugin.class).all((bootPlugin) -> {
 				registerProcessAotTask(project, aotSourceSet, mainSourceSet);
 				registerProcessTestAotTask(project, mainSourceSet, aotTestSourceSet, testSourceSet);
@@ -116,7 +119,9 @@ private void configureJavaRuntimeUsageAttribute(Project project, AttributeContai
 	private void registerProcessAotTask(Project project, SourceSet aotSourceSet, SourceSet mainSourceSet) {
 		TaskProvider<ResolveMainClassName> resolveMainClassName = project.getTasks()
 			.named(SpringBootPlugin.RESOLVE_MAIN_CLASS_NAME_TASK_NAME, ResolveMainClassName.class);
-		Configuration aotClasspath = createAotProcessingClasspath(project, PROCESS_AOT_TASK_NAME, mainSourceSet);
+		Configuration aotClasspath = createAotProcessingClasspath(project, PROCESS_AOT_TASK_NAME, mainSourceSet,
+				Set.of(SpringBootPlugin.DEVELOPMENT_ONLY_CONFIGURATION_NAME,
+						SpringBootPlugin.TEST_AND_DEVELOPMENT_ONLY_CONFIGURATION_NAME));
 		project.getDependencies().add(aotClasspath.getName(), project.files(mainSourceSet.getOutput()));
 		Configuration compileClasspath = project.getConfigurations()
 			.getByName(aotSourceSet.getCompileClasspathConfigurationName());
@@ -126,7 +131,7 @@ private void registerProcessAotTask(Project project, SourceSet aotSourceSet, Sou
 			.dir("generated/" + aotSourceSet.getName() + "Resources");
 		TaskProvider<ProcessAot> processAot = project.getTasks()
 			.register(PROCESS_AOT_TASK_NAME, ProcessAot.class, (task) -> {
-				configureAotTask(project, aotSourceSet, task, mainSourceSet, resourcesOutput);
+				configureAotTask(project, aotSourceSet, task, resourcesOutput);
 				task.getApplicationMainClass()
 					.set(resolveMainClassName.flatMap(ResolveMainClassName::readMainClassName));
 				task.setClasspath(aotClasspath);
@@ -139,7 +144,7 @@ private void registerProcessAotTask(Project project, SourceSet aotSourceSet, Sou
 		configureDependsOn(project, aotSourceSet, processAot);
 	}
 
-	private void configureAotTask(Project project, SourceSet sourceSet, AbstractAot task, SourceSet inputSourceSet,
+	private void configureAotTask(Project project, SourceSet sourceSet, AbstractAot task,
 			Provider<Directory> resourcesOutput) {
 		task.getSourcesOutput()
 			.set(project.getLayout().getBuildDirectory().dir("generated/" + sourceSet.getName() + "Sources"));
@@ -148,33 +153,44 @@ private void configureAotTask(Project project, SourceSet sourceSet, AbstractAot
 			.set(project.getLayout().getBuildDirectory().dir("generated/" + sourceSet.getName() + "Classes"));
 		task.getGroupId().set(project.provider(() -> String.valueOf(project.getGroup())));
 		task.getArtifactId().set(project.provider(() -> project.getName()));
+		configureToolchainConvention(project, task);
 	}
 
-	@SuppressWarnings("unchecked")
-	private Configuration createAotProcessingClasspath(Project project, String taskName, SourceSet inputSourceSet) {
+	private void configureToolchainConvention(Project project, AbstractAot aotTask) {
+		JavaToolchainSpec toolchain = project.getExtensions().getByType(JavaPluginExtension.class).getToolchain();
+		JavaToolchainService toolchainService = project.getExtensions().getByType(JavaToolchainService.class);
+		aotTask.getJavaLauncher().convention(toolchainService.launcherFor(toolchain));
+	}
+
+	@SuppressWarnings({ "unchecked", "rawtypes" })
+	private Configuration createAotProcessingClasspath(Project project, String taskName, SourceSet inputSourceSet,
+			Set<String> developmentOnlyConfigurationNames) {
 		Configuration base = project.getConfigurations()
 			.getByName(inputSourceSet.getRuntimeClasspathConfigurationName());
-		Configuration aotClasspath = project.getConfigurations().create(taskName + "Classpath", (classpath) -> {
+		return project.getConfigurations().create(taskName + "Classpath", (classpath) -> {
 			classpath.setCanBeConsumed(false);
+			if (!classpath.isCanBeResolved()) {
+				throw new IllegalStateException("Unexpected");
+			}
 			classpath.setCanBeResolved(true);
 			classpath.setDescription("Classpath of the " + taskName + " task.");
-			removeDevelopmentOnly(base.getExtendsFrom()).forEach(classpath::extendsFrom);
+			removeDevelopmentOnly(base.getExtendsFrom(), developmentOnlyConfigurationNames)
+				.forEach(classpath::extendsFrom);
 			classpath.attributes((attributes) -> {
+				ProviderFactory providers = project.getProviders();
 				AttributeContainer baseAttributes = base.getAttributes();
-				for (Attribute<?> attribute : baseAttributes.keySet()) {
-					attributes.attribute((Attribute<Object>) attribute, baseAttributes.getAttribute(attribute));
+				for (Attribute attribute : baseAttributes.keySet()) {
+					attributes.attributeProvider(attribute,
+							providers.provider(() -> baseAttributes.getAttribute(attribute)));
 				}
 			});
 		});
-		return aotClasspath;
-	}
-
-	private Stream<Configuration> removeDevelopmentOnly(Set<Configuration> configurations) {
-		return configurations.stream().filter(this::isNotDevelopmentOnly);
 	}
 
-	private boolean isNotDevelopmentOnly(Configuration configuration) {
-		return !SpringBootPlugin.DEVELOPMENT_ONLY_CONFIGURATION_NAME.equals(configuration.getName());
+	private Stream<Configuration> removeDevelopmentOnly(Set<Configuration> configurations,
+			Set<String> developmentOnlyConfigurationNames) {
+		return configurations.stream()
+			.filter((configuration) -> !developmentOnlyConfigurationNames.contains(configuration.getName()));
 	}
 
 	private void configureDependsOn(Project project, SourceSet aotSourceSet,
@@ -186,7 +202,8 @@ private void configureDependsOn(Project project, SourceSet aotSourceSet,
 
 	private void registerProcessTestAotTask(Project project, SourceSet mainSourceSet, SourceSet aotTestSourceSet,
 			SourceSet testSourceSet) {
-		Configuration aotClasspath = createAotProcessingClasspath(project, PROCESS_TEST_AOT_TASK_NAME, testSourceSet);
+		Configuration aotClasspath = createAotProcessingClasspath(project, PROCESS_TEST_AOT_TASK_NAME, testSourceSet,
+				Set.of(SpringBootPlugin.DEVELOPMENT_ONLY_CONFIGURATION_NAME));
 		addJUnitPlatformLauncherDependency(project, aotClasspath);
 		Configuration compileClasspath = project.getConfigurations()
 			.getByName(aotTestSourceSet.getCompileClasspathConfigurationName());
@@ -196,7 +213,7 @@ private void registerProcessTestAotTask(Project project, SourceSet mainSourceSet
 			.dir("generated/" + aotTestSourceSet.getName() + "Resources");
 		TaskProvider<ProcessTestAot> processTestAot = project.getTasks()
 			.register(PROCESS_TEST_AOT_TASK_NAME, ProcessTestAot.class, (task) -> {
-				configureAotTask(project, aotTestSourceSet, task, testSourceSet, resourcesOutput);
+				configureAotTask(project, aotTestSourceSet, task, resourcesOutput);
 				task.setClasspath(aotClasspath);
 				task.setClasspathRoots(testSourceSet.getOutput());
 			});
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/SpringBootPlugin.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/SpringBootPlugin.java
index 3e78fb3cee21..b85ccfc86356 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/SpringBootPlugin.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/SpringBootPlugin.java
@@ -80,6 +80,12 @@ public class SpringBootPlugin implements Plugin<Project> {
 	 */
 	public static final String DEVELOPMENT_ONLY_CONFIGURATION_NAME = "developmentOnly";
 
+	/**
+	 * The name of the {@code testAndDevelopmentOnly} configuration.
+	 * @since 3.2.0
+	 */
+	public static final String TEST_AND_DEVELOPMENT_ONLY_CONFIGURATION_NAME = "testAndDevelopmentOnly";
+
 	/**
 	 * The name of the {@code productionRuntimeClasspath} configuration.
 	 */
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/WarPluginAction.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/WarPluginAction.java
index 2e1382700117..6e2517ebaee9 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/WarPluginAction.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/WarPluginAction.java
@@ -72,6 +72,8 @@ private void classifyWarTask(Project project) {
 	private TaskProvider<BootWar> configureBootWarTask(Project project) {
 		Configuration developmentOnly = project.getConfigurations()
 			.getByName(SpringBootPlugin.DEVELOPMENT_ONLY_CONFIGURATION_NAME);
+		Configuration testAndDevelopmentOnly = project.getConfigurations()
+			.getByName(SpringBootPlugin.TEST_AND_DEVELOPMENT_ONLY_CONFIGURATION_NAME);
 		Configuration productionRuntimeClasspath = project.getConfigurations()
 			.getByName(SpringBootPlugin.PRODUCTION_RUNTIME_CLASSPATH_CONFIGURATION_NAME);
 		SourceSet mainSourceSet = project.getExtensions()
@@ -82,6 +84,7 @@ private TaskProvider<BootWar> configureBootWarTask(Project project) {
 		Callable<FileCollection> classpath = () -> mainSourceSet.getRuntimeClasspath()
 			.minus(providedRuntimeConfiguration(project))
 			.minus((developmentOnly.minus(productionRuntimeClasspath)))
+			.minus((testAndDevelopmentOnly.minus(productionRuntimeClasspath)))
 			.filter(new JarTypeFileSpec());
 		TaskProvider<ResolveMainClassName> resolveMainClassName = project.getTasks()
 			.named(SpringBootPlugin.RESOLVE_MAIN_CLASS_NAME_TASK_NAME, ResolveMainClassName.class);
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/buildinfo/BuildInfoProperties.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/buildinfo/BuildInfoProperties.java
index 5ace201ad715..e75f1a3352e4 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/buildinfo/BuildInfoProperties.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/buildinfo/BuildInfoProperties.java
@@ -30,6 +30,7 @@
 import org.gradle.api.Project;
 import org.gradle.api.provider.MapProperty;
 import org.gradle.api.provider.Property;
+import org.gradle.api.provider.Provider;
 import org.gradle.api.provider.SetProperty;
 import org.gradle.api.tasks.Input;
 import org.gradle.api.tasks.Internal;
@@ -155,7 +156,12 @@ private <T> T getIfNotExcluded(Property<T> property, String name, Supplier<T> de
 
 	private Map<String, String> coerceToStringValues(Map<String, Object> input) {
 		Map<String, String> output = new HashMap<>();
-		input.forEach((key, value) -> output.put(key, (value != null) ? value.toString() : null));
+		input.forEach((key, value) -> {
+			if (value instanceof Provider<?> provider) {
+				value = provider.getOrNull();
+			}
+			output.put(key, (value != null) ? value.toString() : null);
+		});
 		return output;
 	}
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootArchive.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootArchive.java
index 1f3875a45aab..2da97a470c8d 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootArchive.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootArchive.java
@@ -33,6 +33,8 @@
 import org.gradle.api.tasks.Nested;
 import org.gradle.api.tasks.Optional;
 
+import org.springframework.boot.loader.tools.LoaderImplementation;
+
 /**
  * A Spring Boot "fat" archive task.
  *
@@ -133,4 +135,13 @@ public interface BootArchive extends Task {
 	 */
 	void resolvedArtifacts(Provider<Set<ResolvedArtifactResult>> resolvedArtifacts);
 
+	/**
+	 * The loader implementation that should be used with the archive.
+	 * @return the loader implementation
+	 * @since 3.2.0
+	 */
+	@Input
+	@Optional
+	Property<LoaderImplementation> getLoaderImplementation();
+
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootArchiveSupport.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootArchiveSupport.java
index 0e8b8e278e33..330bc1aef1cd 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootArchiveSupport.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootArchiveSupport.java
@@ -26,7 +26,9 @@
 import java.util.Set;
 import java.util.TreeMap;
 import java.util.function.Function;
+import java.util.function.Supplier;
 
+import org.gradle.api.GradleException;
 import org.gradle.api.file.CopySpec;
 import org.gradle.api.file.FileCopyDetails;
 import org.gradle.api.file.FileTreeElement;
@@ -36,17 +38,22 @@
 import org.gradle.api.internal.file.copy.FileCopyDetailsInternal;
 import org.gradle.api.java.archives.Attributes;
 import org.gradle.api.java.archives.Manifest;
+import org.gradle.api.provider.Property;
 import org.gradle.api.specs.Spec;
 import org.gradle.api.specs.Specs;
 import org.gradle.api.tasks.WorkResult;
 import org.gradle.api.tasks.bundling.Jar;
 import org.gradle.api.tasks.util.PatternSet;
+import org.gradle.util.GradleVersion;
+
+import org.springframework.boot.loader.tools.LoaderImplementation;
 
 /**
  * Support class for implementations of {@link BootArchive}.
  *
  * @author Andy Wilkinson
  * @author Phillip Webb
+ * @author Scott Frederick
  * @see BootJar
  * @see BootWar
  */
@@ -60,9 +67,9 @@ class BootArchiveSupport {
 
 	static {
 		Set<String> defaultLauncherClasses = new HashSet<>();
-		defaultLauncherClasses.add("org.springframework.boot.loader.JarLauncher");
-		defaultLauncherClasses.add("org.springframework.boot.loader.PropertiesLauncher");
-		defaultLauncherClasses.add("org.springframework.boot.loader.WarLauncher");
+		defaultLauncherClasses.add("org.springframework.boot.loader.launch.JarLauncher");
+		defaultLauncherClasses.add("org.springframework.boot.loader.launch.PropertiesLauncher");
+		defaultLauncherClasses.add("org.springframework.boot.loader.launch.WarLauncher");
 		DEFAULT_LAUNCHER_CLASSES = Collections.unmodifiableSet(defaultLauncherClasses);
 	}
 
@@ -115,15 +122,19 @@ private String determineSpringBootVersion() {
 		return (version != null) ? version : "unknown";
 	}
 
-	CopyAction createCopyAction(Jar jar, ResolvedDependencies resolvedDependencies) {
-		return createCopyAction(jar, resolvedDependencies, null, null);
+	CopyAction createCopyAction(Jar jar, ResolvedDependencies resolvedDependencies,
+			LoaderImplementation loaderImplementation, boolean supportsSignatureFile) {
+		return createCopyAction(jar, resolvedDependencies, loaderImplementation, supportsSignatureFile, null, null);
 	}
 
-	CopyAction createCopyAction(Jar jar, ResolvedDependencies resolvedDependencies, LayerResolver layerResolver,
+	CopyAction createCopyAction(Jar jar, ResolvedDependencies resolvedDependencies,
+			LoaderImplementation loaderImplementation, boolean supportsSignatureFile, LayerResolver layerResolver,
 			String layerToolsLocation) {
 		File output = jar.getArchiveFile().get().getAsFile();
 		Manifest manifest = jar.getManifest();
 		boolean preserveFileTimestamps = jar.isPreserveFileTimestamps();
+		Integer dirMode = getDirMode(jar);
+		Integer fileMode = getFileMode(jar);
 		boolean includeDefaultLoader = isUsingDefaultLoader(jar);
 		Spec<FileTreeElement> requiresUnpack = this.requiresUnpack.getAsSpec();
 		Spec<FileTreeElement> exclusions = this.exclusions.getAsExcludeSpec();
@@ -131,12 +142,37 @@ CopyAction createCopyAction(Jar jar, ResolvedDependencies resolvedDependencies,
 		Spec<FileCopyDetails> librarySpec = this.librarySpec;
 		Function<FileCopyDetails, ZipCompression> compressionResolver = this.compressionResolver;
 		String encoding = jar.getMetadataCharset();
-		CopyAction action = new BootZipCopyAction(output, manifest, preserveFileTimestamps, includeDefaultLoader,
-				layerToolsLocation, requiresUnpack, exclusions, launchScript, librarySpec, compressionResolver,
-				encoding, resolvedDependencies, layerResolver);
+		CopyAction action = new BootZipCopyAction(output, manifest, preserveFileTimestamps, dirMode, fileMode,
+				includeDefaultLoader, layerToolsLocation, requiresUnpack, exclusions, launchScript, librarySpec,
+				compressionResolver, encoding, resolvedDependencies, supportsSignatureFile, layerResolver,
+				loaderImplementation);
 		return jar.isReproducibleFileOrder() ? new ReproducibleOrderingCopyAction(action) : action;
 	}
 
+	private Integer getDirMode(CopySpec copySpec) {
+		return getMode(copySpec, "getDirPermissions", copySpec::getDirMode);
+	}
+
+	private Integer getFileMode(CopySpec copySpec) {
+		return getMode(copySpec, "getFilePermissions", copySpec::getFileMode);
+	}
+
+	@SuppressWarnings("unchecked")
+	private Integer getMode(CopySpec copySpec, String methodName, Supplier<Integer> fallback) {
+		if (GradleVersion.current().compareTo(GradleVersion.version("8.3")) >= 0) {
+			try {
+				Object filePermissions = ((Property<Object>) copySpec.getClass().getMethod(methodName).invoke(copySpec))
+					.getOrNull();
+				return (filePermissions != null)
+						? (int) filePermissions.getClass().getMethod("toUnixNumeric").invoke(filePermissions) : null;
+			}
+			catch (Exception ex) {
+				throw new GradleException("Failed to get permissions", ex);
+			}
+		}
+		return fallback.get();
+	}
+
 	private boolean isUsingDefaultLoader(Jar jar) {
 		return DEFAULT_LAUNCHER_CLASSES.contains(jar.getManifest().getAttributes().get("Main-Class"));
 	}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java
index 6b2af0c45a5e..2ba12dae88e8 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java
@@ -69,6 +69,8 @@ public abstract class BootBuildImage extends DefaultTask {
 
 	private final String projectName;
 
+	private final CacheSpec buildWorkspace;
+
 	private final CacheSpec buildCache;
 
 	private final CacheSpec launchCache;
@@ -91,6 +93,7 @@ public BootBuildImage() {
 		getCleanCache().convention(false);
 		getVerboseLogging().convention(false);
 		getPublish().convention(false);
+		this.buildWorkspace = getProject().getObjects().newInstance(CacheSpec.class);
 		this.buildCache = getProject().getObjects().newInstance(CacheSpec.class);
 		this.launchCache = getProject().getObjects().newInstance(CacheSpec.class);
 		this.docker = getProject().getObjects().newInstance(DockerSpec.class);
@@ -222,6 +225,27 @@ public void setPullPolicy(String pullPolicy) {
 	@Option(option = "network", description = "Connect detect and build containers to network")
 	public abstract Property<String> getNetwork();
 
+	/**
+	 * Returns the build temporary workspace that will be used when building the image.
+	 * @return the cache
+	 * @since 3.2.0
+	 */
+	@Nested
+	@Optional
+	public CacheSpec getBuildWorkspace() {
+		return this.buildWorkspace;
+	}
+
+	/**
+	 * Customizes the {@link CacheSpec} for the build temporary workspace using the given
+	 * {@code action}.
+	 * @param action the action
+	 * @since 3.2.0
+	 */
+	public void buildWorkspace(Action<CacheSpec> action) {
+		action.execute(this.buildWorkspace);
+	}
+
 	/**
 	 * Returns the build cache that will be used when building the image.
 	 * @return the cache
@@ -280,6 +304,15 @@ public void launchCache(Action<CacheSpec> action) {
 	@Option(option = "applicationDirectory", description = "The directory containing application content in the image")
 	public abstract Property<String> getApplicationDirectory();
 
+	/**
+	 * Returns the security options that will be applied to the builder container.
+	 * @return the security options
+	 */
+	@Input
+	@Optional
+	@Option(option = "securityOptions", description = "Security options that will be applied to the builder container")
+	public abstract ListProperty<String> getSecurityOptions();
+
 	/**
 	 * Returns the Docker configuration the builder will use.
 	 * @return docker configuration.
@@ -327,6 +360,7 @@ private BuildRequest customize(BuildRequest request) {
 		request = request.withNetwork(getNetwork().getOrNull());
 		request = customizeCreatedDate(request);
 		request = customizeApplicationDirectory(request);
+		request = customizeSecurityOptions(request);
 		return request;
 	}
 
@@ -400,6 +434,9 @@ private BuildRequest customizeTags(BuildRequest request) {
 	}
 
 	private BuildRequest customizeCaches(BuildRequest request) {
+		if (this.buildWorkspace.asCache() != null) {
+			request = request.withBuildWorkspace((this.buildWorkspace.asCache()));
+		}
 		if (this.buildCache.asCache() != null) {
 			request = request.withBuildCache(this.buildCache.asCache());
 		}
@@ -425,4 +462,12 @@ private BuildRequest customizeApplicationDirectory(BuildRequest request) {
 		return request;
 	}
 
+	private BuildRequest customizeSecurityOptions(BuildRequest request) {
+		List<String> securityOptions = getSecurityOptions().getOrNull();
+		if (securityOptions != null) {
+			return request.withSecurityOptions(securityOptions);
+		}
+		return request;
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootJar.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootJar.java
index 2b2d1cfa2e55..7ed3f998c54f 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootJar.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootJar.java
@@ -37,6 +37,8 @@
 import org.gradle.api.tasks.bundling.Jar;
 import org.gradle.work.DisableCachingByDefault;
 
+import org.springframework.boot.loader.tools.LoaderImplementation;
+
 /**
  * A custom {@link Jar} task that produces a Spring Boot executable jar.
  *
@@ -49,7 +51,7 @@
 @DisableCachingByDefault(because = "Not worth caching")
 public abstract class BootJar extends Jar implements BootArchive {
 
-	private static final String LAUNCHER = "org.springframework.boot.loader.JarLauncher";
+	private static final String LAUNCHER = "org.springframework.boot.loader.launch.JarLauncher";
 
 	private static final String CLASSES_DIRECTORY = "BOOT-INF/classes/";
 
@@ -141,12 +143,14 @@ private boolean isLayeredDisabled() {
 
 	@Override
 	protected CopyAction createCopyAction() {
+		LoaderImplementation loaderImplementation = getLoaderImplementation().getOrElse(LoaderImplementation.DEFAULT);
 		if (!isLayeredDisabled()) {
 			LayerResolver layerResolver = new LayerResolver(this.resolvedDependencies, this.layered, this::isLibrary);
 			String layerToolsLocation = this.layered.getIncludeLayerTools().get() ? LIB_DIRECTORY : null;
-			return this.support.createCopyAction(this, this.resolvedDependencies, layerResolver, layerToolsLocation);
+			return this.support.createCopyAction(this, this.resolvedDependencies, loaderImplementation, true,
+					layerResolver, layerToolsLocation);
 		}
-		return this.support.createCopyAction(this, this.resolvedDependencies);
+		return this.support.createCopyAction(this, this.resolvedDependencies, loaderImplementation, true);
 	}
 
 	@Override
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootWar.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootWar.java
index 47ce5f0c5410..d19f152f84b6 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootWar.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootWar.java
@@ -37,6 +37,8 @@
 import org.gradle.api.tasks.bundling.War;
 import org.gradle.work.DisableCachingByDefault;
 
+import org.springframework.boot.loader.tools.LoaderImplementation;
+
 /**
  * A custom {@link War} task that produces a Spring Boot executable war.
  *
@@ -48,7 +50,7 @@
 @DisableCachingByDefault(because = "Not worth caching")
 public abstract class BootWar extends War implements BootArchive {
 
-	private static final String LAUNCHER = "org.springframework.boot.loader.WarLauncher";
+	private static final String LAUNCHER = "org.springframework.boot.loader.launch.WarLauncher";
 
 	private static final String CLASSES_DIRECTORY = "WEB-INF/classes/";
 
@@ -115,12 +117,14 @@ private boolean isLayeredDisabled() {
 
 	@Override
 	protected CopyAction createCopyAction() {
+		LoaderImplementation loaderImplementation = getLoaderImplementation().getOrElse(LoaderImplementation.DEFAULT);
 		if (!isLayeredDisabled()) {
 			LayerResolver layerResolver = new LayerResolver(this.resolvedDependencies, this.layered, this::isLibrary);
 			String layerToolsLocation = this.layered.getIncludeLayerTools().get() ? LIB_DIRECTORY : null;
-			return this.support.createCopyAction(this, this.resolvedDependencies, layerResolver, layerToolsLocation);
+			return this.support.createCopyAction(this, this.resolvedDependencies, loaderImplementation, false,
+					layerResolver, layerToolsLocation);
 		}
-		return this.support.createCopyAction(this, this.resolvedDependencies);
+		return this.support.createCopyAction(this, this.resolvedDependencies, loaderImplementation, false);
 	}
 
 	@Override
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootZipCopyAction.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootZipCopyAction.java
index 60752181600d..85f509ecf76c 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootZipCopyAction.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootZipCopyAction.java
@@ -49,6 +49,7 @@
 import org.gradle.api.specs.Spec;
 import org.gradle.api.tasks.WorkResult;
 import org.gradle.api.tasks.WorkResults;
+import org.gradle.util.GradleVersion;
 
 import org.springframework.boot.gradle.tasks.bundling.ResolvedDependencies.DependencyDescriptor;
 import org.springframework.boot.loader.tools.DefaultLaunchScript;
@@ -57,6 +58,7 @@
 import org.springframework.boot.loader.tools.Layer;
 import org.springframework.boot.loader.tools.LayersIndex;
 import org.springframework.boot.loader.tools.LibraryCoordinates;
+import org.springframework.boot.loader.tools.LoaderImplementation;
 import org.springframework.boot.loader.tools.NativeImageArgFile;
 import org.springframework.boot.loader.tools.ReachabilityMetadataProperties;
 import org.springframework.util.Assert;
@@ -87,6 +89,10 @@ class BootZipCopyAction implements CopyAction {
 
 	private final boolean preserveFileTimestamps;
 
+	private final Integer dirMode;
+
+	private final Integer fileMode;
+
 	private final boolean includeDefaultLoader;
 
 	private final String layerToolsLocation;
@@ -105,16 +111,23 @@ class BootZipCopyAction implements CopyAction {
 
 	private final ResolvedDependencies resolvedDependencies;
 
+	private final boolean supportsSignatureFile;
+
 	private final LayerResolver layerResolver;
 
-	BootZipCopyAction(File output, Manifest manifest, boolean preserveFileTimestamps, boolean includeDefaultLoader,
-			String layerToolsLocation, Spec<FileTreeElement> requiresUnpack, Spec<FileTreeElement> exclusions,
-			LaunchScriptConfiguration launchScript, Spec<FileCopyDetails> librarySpec,
+	private final LoaderImplementation loaderImplementation;
+
+	BootZipCopyAction(File output, Manifest manifest, boolean preserveFileTimestamps, Integer dirMode, Integer fileMode,
+			boolean includeDefaultLoader, String layerToolsLocation, Spec<FileTreeElement> requiresUnpack,
+			Spec<FileTreeElement> exclusions, LaunchScriptConfiguration launchScript, Spec<FileCopyDetails> librarySpec,
 			Function<FileCopyDetails, ZipCompression> compressionResolver, String encoding,
-			ResolvedDependencies resolvedDependencies, LayerResolver layerResolver) {
+			ResolvedDependencies resolvedDependencies, boolean supportsSignatureFile, LayerResolver layerResolver,
+			LoaderImplementation loaderImplementation) {
 		this.output = output;
 		this.manifest = manifest;
 		this.preserveFileTimestamps = preserveFileTimestamps;
+		this.dirMode = dirMode;
+		this.fileMode = fileMode;
 		this.includeDefaultLoader = includeDefaultLoader;
 		this.layerToolsLocation = layerToolsLocation;
 		this.requiresUnpack = requiresUnpack;
@@ -124,7 +137,9 @@ class BootZipCopyAction implements CopyAction {
 		this.compressionResolver = compressionResolver;
 		this.encoding = encoding;
 		this.resolvedDependencies = resolvedDependencies;
+		this.supportsSignatureFile = supportsSignatureFile;
 		this.layerResolver = layerResolver;
+		this.loaderImplementation = loaderImplementation;
 	}
 
 	@Override
@@ -240,7 +255,7 @@ private boolean skipProcessing(FileCopyDetails details) {
 		private void processDirectory(FileCopyDetails details) throws IOException {
 			String name = details.getRelativePath().getPathString();
 			ZipArchiveEntry entry = new ZipArchiveEntry(name + '/');
-			prepareEntry(entry, name, getTime(details), UnixStat.FILE_FLAG | details.getMode());
+			prepareEntry(entry, name, getTime(details), getFileMode(details));
 			this.out.putArchiveEntry(entry);
 			this.out.closeArchiveEntry();
 			this.writtenDirectories.add(name);
@@ -249,7 +264,7 @@ private void processDirectory(FileCopyDetails details) throws IOException {
 		private void processFile(FileCopyDetails details) throws IOException {
 			String name = details.getRelativePath().getPathString();
 			ZipArchiveEntry entry = new ZipArchiveEntry(name);
-			prepareEntry(entry, name, getTime(details), UnixStat.FILE_FLAG | details.getMode());
+			prepareEntry(entry, name, getTime(details), getFileMode(details));
 			ZipCompression compression = BootZipCopyAction.this.compressionResolver.apply(details);
 			if (compression == ZipCompression.STORED) {
 				prepareStoredEntry(details, entry);
@@ -273,7 +288,7 @@ private void writeParentDirectoriesIfNecessary(String name, Long time) throws IO
 			String parentDirectory = getParentDirectory(name);
 			if (parentDirectory != null && this.writtenDirectories.add(parentDirectory)) {
 				ZipArchiveEntry entry = new ZipArchiveEntry(parentDirectory + '/');
-				prepareEntry(entry, parentDirectory, time, UnixStat.DIR_FLAG | UnixStat.DEFAULT_DIR_PERM);
+				prepareEntry(entry, parentDirectory, time, getDirMode());
 				this.out.putArchiveEntry(entry);
 				this.out.closeArchiveEntry();
 			}
@@ -290,6 +305,7 @@ private String getParentDirectory(String name) {
 		void finish() throws IOException {
 			writeLoaderEntriesIfNecessary(null);
 			writeJarToolsIfNecessary();
+			writeSignatureFileIfNecessary();
 			writeClassPathIndexIfNecessary();
 			writeNativeImageArgFileIfNecessary();
 			// We must write the layer index last
@@ -304,7 +320,8 @@ private void writeLoaderEntriesIfNecessary(FileCopyDetails details) throws IOExc
 				// Always write loader entries after META-INF directory (see gh-16698)
 				return;
 			}
-			LoaderZipEntries loaderEntries = new LoaderZipEntries(getTime());
+			LoaderZipEntries loaderEntries = new LoaderZipEntries(getTime(), getDirMode(), getFileMode(),
+					BootZipCopyAction.this.loaderImplementation);
 			this.writtenLoaderEntries = loaderEntries.writeTo(this.out);
 			if (BootZipCopyAction.this.layerResolver != null) {
 				for (String name : this.writtenLoaderEntries.getFiles()) {
@@ -338,6 +355,22 @@ private void writeJarModeLibrary(String location, JarModeLibrary library) throws
 			}
 		}
 
+		private void writeSignatureFileIfNecessary() throws IOException {
+			if (BootZipCopyAction.this.supportsSignatureFile && hasSignedLibrary()) {
+				writeEntry("META-INF/BOOT.SF", (out) -> {
+				}, false);
+			}
+		}
+
+		private boolean hasSignedLibrary() throws IOException {
+			for (FileCopyDetails writtenLibrary : this.writtenLibraries.values()) {
+				if (FileUtils.isSignedJarFile(writtenLibrary.getFile())) {
+					return true;
+				}
+			}
+			return false;
+		}
+
 		private void writeClassPathIndexIfNecessary() throws IOException {
 			Attributes manifestAttributes = BootZipCopyAction.this.manifest.getAttributes();
 			String classPathIndex = (String) manifestAttributes.get("Spring-Boot-Classpath-Index");
@@ -393,7 +426,7 @@ private void writeEntry(String name, ZipEntryContentWriter entryWriter, boolean
 		private void writeEntry(String name, ZipEntryContentWriter entryWriter, boolean addToLayerIndex,
 				ZipEntryCustomizer entryCustomizer) throws IOException {
 			ZipArchiveEntry entry = new ZipArchiveEntry(name);
-			prepareEntry(entry, name, getTime(), UnixStat.FILE_FLAG | UnixStat.DEFAULT_FILE_PERM);
+			prepareEntry(entry, name, getTime(), getFileMode());
 			entryCustomizer.customize(entry);
 			this.out.putArchiveEntry(entry);
 			entryWriter.writeTo(this.out);
@@ -437,6 +470,34 @@ private Long getTime(FileCopyDetails details) {
 			return null;
 		}
 
+		private int getDirMode() {
+			return (BootZipCopyAction.this.dirMode != null) ? BootZipCopyAction.this.dirMode
+					: UnixStat.DIR_FLAG | UnixStat.DEFAULT_DIR_PERM;
+		}
+
+		private int getFileMode() {
+			return (BootZipCopyAction.this.fileMode != null) ? BootZipCopyAction.this.fileMode
+					: UnixStat.FILE_FLAG | UnixStat.DEFAULT_FILE_PERM;
+		}
+
+		private int getFileMode(FileCopyDetails details) {
+			return (BootZipCopyAction.this.fileMode != null) ? BootZipCopyAction.this.fileMode
+					: UnixStat.FILE_FLAG | getPermissions(details);
+		}
+
+		private int getPermissions(FileCopyDetails details) {
+			if (GradleVersion.current().compareTo(GradleVersion.version("8.3")) >= 0) {
+				try {
+					Object permissions = details.getClass().getMethod("getPermissions").invoke(details);
+					return (int) permissions.getClass().getMethod("toUnixNumeric").invoke(permissions);
+				}
+				catch (Exception ex) {
+					throw new GradleException("Failed to get permissions", ex);
+				}
+			}
+			return details.getMode();
+		}
+
 	}
 
 	/**
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/CacheSpec.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/CacheSpec.java
index d33d6a964966..235a3665f148 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/CacheSpec.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/CacheSpec.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2021-2022 the original author or authors.
+ * Copyright 2021-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -60,6 +60,19 @@ public void volume(Action<VolumeCacheSpec> action) {
 		this.cache = Cache.volume(spec.getName().get());
 	}
 
+	/**
+	 * Configures a bind cache using the given {@code action}.
+	 * @param action the action
+	 */
+	public void bind(Action<BindCacheSpec> action) {
+		if (this.cache != null) {
+			throw new GradleException("Each image building cache can be configured only once");
+		}
+		BindCacheSpec spec = this.objectFactory.newInstance(BindCacheSpec.class);
+		action.execute(spec);
+		this.cache = Cache.bind(spec.getSource().get());
+	}
+
 	/**
 	 * Configuration for an image building cache stored in a Docker volume.
 	 */
@@ -74,4 +87,18 @@ public abstract static class VolumeCacheSpec {
 
 	}
 
+	/**
+	 * Configuration for an image building cache stored in a bind mount.
+	 */
+	public abstract static class BindCacheSpec {
+
+		/**
+		 * Returns the source of the cache.
+		 * @return the cache source
+		 */
+		@Input
+		public abstract Property<String> getSource();
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/DockerSpec.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/DockerSpec.java
index ce3907a4db16..ffed3ddba17c 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/DockerSpec.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/DockerSpec.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -54,6 +54,10 @@ public DockerSpec(ObjectFactory objects) {
 		this.publishRegistry = publishRegistry;
 	}
 
+	@Input
+	@Optional
+	public abstract Property<String> getContext();
+
 	@Input
 	@Optional
 	public abstract Property<String> getHost();
@@ -124,7 +128,15 @@ DockerConfiguration asDockerConfiguration() {
 	}
 
 	private DockerConfiguration customizeHost(DockerConfiguration dockerConfiguration) {
+		String context = getContext().getOrNull();
 		String host = getHost().getOrNull();
+		if (context != null && host != null) {
+			throw new GradleException(
+					"Invalid Docker configuration, either context or host can be provided but not both");
+		}
+		if (context != null) {
+			return dockerConfiguration.withContext(context);
+		}
 		if (host != null) {
 			return dockerConfiguration.withHost(host, getTlsVerify().get(), getCertPath().getOrNull());
 		}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/LoaderZipEntries.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/LoaderZipEntries.java
index 4c17cc1cbff7..64b3ea1685ee 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/LoaderZipEntries.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/LoaderZipEntries.java
@@ -24,11 +24,11 @@
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipInputStream;
 
-import org.apache.commons.compress.archivers.zip.UnixStat;
 import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
 import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
 import org.gradle.api.file.FileTreeElement;
 
+import org.springframework.boot.loader.tools.LoaderImplementation;
 import org.springframework.util.StreamUtils;
 
 /**
@@ -40,24 +40,34 @@
  */
 class LoaderZipEntries {
 
+	private final LoaderImplementation loaderImplementation;
+
 	private final Long entryTime;
 
-	LoaderZipEntries(Long entryTime) {
+	private final int dirMode;
+
+	private final int fileMode;
+
+	LoaderZipEntries(Long entryTime, int dirMode, int fileMode, LoaderImplementation loaderImplementation) {
 		this.entryTime = entryTime;
+		this.dirMode = dirMode;
+		this.fileMode = fileMode;
+		this.loaderImplementation = (loaderImplementation != null) ? loaderImplementation
+				: LoaderImplementation.DEFAULT;
 	}
 
 	WrittenEntries writeTo(ZipArchiveOutputStream out) throws IOException {
 		WrittenEntries written = new WrittenEntries();
 		try (ZipInputStream loaderJar = new ZipInputStream(
-				getClass().getResourceAsStream("/META-INF/loader/spring-boot-loader.jar"))) {
+				getClass().getResourceAsStream("/" + this.loaderImplementation.getJarResourceName()))) {
 			java.util.zip.ZipEntry entry = loaderJar.getNextEntry();
 			while (entry != null) {
 				if (entry.isDirectory() && !entry.getName().equals("META-INF/")) {
 					writeDirectory(new ZipArchiveEntry(entry), out);
 					written.addDirectory(entry);
 				}
-				else if (entry.getName().endsWith(".class")) {
-					writeClass(new ZipArchiveEntry(entry), loaderJar, out);
+				else if (entry.getName().endsWith(".class") || entry.getName().startsWith("META-INF/services/")) {
+					writeFile(new ZipArchiveEntry(entry), loaderJar, out);
 					written.addFile(entry);
 				}
 				entry = loaderJar.getNextEntry();
@@ -67,13 +77,13 @@ else if (entry.getName().endsWith(".class")) {
 	}
 
 	private void writeDirectory(ZipArchiveEntry entry, ZipArchiveOutputStream out) throws IOException {
-		prepareEntry(entry, UnixStat.DIR_FLAG | UnixStat.DEFAULT_DIR_PERM);
+		prepareEntry(entry, this.dirMode);
 		out.putArchiveEntry(entry);
 		out.closeArchiveEntry();
 	}
 
-	private void writeClass(ZipArchiveEntry entry, ZipInputStream in, ZipArchiveOutputStream out) throws IOException {
-		prepareEntry(entry, UnixStat.FILE_FLAG | UnixStat.DEFAULT_FILE_PERM);
+	private void writeFile(ZipArchiveEntry entry, ZipInputStream in, ZipArchiveOutputStream out) throws IOException {
+		prepareEntry(entry, this.fileMode);
 		out.putArchiveEntry(entry);
 		copy(in, out);
 		out.closeArchiveEntry();
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/docs/PackagingDocumentationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/docs/PackagingDocumentationTests.java
index 562a3f5b139f..d9cfc3d49226 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/docs/PackagingDocumentationTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/docs/PackagingDocumentationTests.java
@@ -166,7 +166,7 @@ void bootWarPropertiesLauncher() throws IOException {
 		assertThat(file).isFile();
 		try (JarFile jar = new JarFile(file)) {
 			assertThat(jar.getManifest().getMainAttributes().getValue("Main-Class"))
-				.isEqualTo("org.springframework.boot.loader.PropertiesLauncher");
+				.isEqualTo("org.springframework.boot.loader.launch.PropertiesLauncher");
 		}
 	}
 
@@ -299,6 +299,14 @@ void bootBuildImageWithDockerHostPodman() {
 			.contains("bindHostToBuilder=true");
 	}
 
+	@TestTemplate
+	void bootBuildImageWithDockerHostColima() {
+		BuildResult result = this.gradleBuild.script("src/docs/gradle/packaging/boot-build-image-docker-host-colima")
+			.build("bootBuildImageDocker");
+		assertThat(result.getOutput())
+			.contains("host=unix://" + System.getProperty("user.home") + "/.colima/docker.sock");
+	}
+
 	@TestTemplate
 	void bootBuildImageWithDockerUserAuth() {
 		BuildResult result = this.gradleBuild.script("src/docs/gradle/packaging/boot-build-image-docker-auth-user")
@@ -339,6 +347,15 @@ void bootBuildImageWithCaches() {
 			.containsPattern("launchCache=cache-gradle-[\\d]+.launch");
 	}
 
+	@TestTemplate
+	void bootBuildImageWithBindCaches() {
+		BuildResult result = this.gradleBuild.script("src/docs/gradle/packaging/boot-build-image-bind-caches")
+			.build("bootBuildImageCaches");
+		assertThat(result.getOutput()).containsPattern("buildWorkspace=/tmp/cache-gradle-[\\d]+.work")
+			.containsPattern("buildCache=/tmp/cache-gradle-[\\d]+.build")
+			.containsPattern("launchCache=/tmp/cache-gradle-[\\d]+.launch");
+	}
+
 	protected void jarFile(File file) throws IOException {
 		try (JarOutputStream jar = new JarOutputStream(new FileOutputStream(file))) {
 			jar.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF"));
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests.java
index 0b62f88b5d5c..3c97cff173e7 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests.java
@@ -24,6 +24,8 @@
 import java.util.HashSet;
 import java.util.Set;
 import java.util.jar.JarOutputStream;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 import org.gradle.testkit.runner.BuildResult;
 import org.gradle.testkit.runner.TaskOutcome;
@@ -142,15 +144,61 @@ void additionalMetadataLocationsNotConfiguredWhenProcessorIsAbsent() throws IOEx
 
 	@TestTemplate
 	void applyingJavaPluginCreatesDevelopmentOnlyConfiguration() {
-		assertThat(this.gradleBuild.build("build").getOutput()).contains("developmentOnly exists = true");
+		assertThat(this.gradleBuild.build("help").getOutput()).contains("developmentOnly exists = true");
 	}
 
 	@TestTemplate
-	void productionRuntimeClasspathIsConfiguredWithAttributes() {
-		assertThat(this.gradleBuild.build("build").getOutput()).contains("3 productionRuntimeClasspath attributes:")
-			.contains("org.gradle.usage: java-runtime")
-			.contains("org.gradle.libraryelements: jar")
-			.contains("org.gradle.dependency.bundling: external");
+	void applyingJavaPluginCreatesTestAndDevelopmentOnlyConfiguration() {
+		assertThat(this.gradleBuild.build("help").getOutput()).contains("testAndDevelopmentOnly exists = true");
+	}
+
+	@TestTemplate
+	void testCompileClasspathIncludesTestAndDevelopmentOnlyDependencies() {
+		assertThat(this.gradleBuild.build("help").getOutput()).contains("commons-lang3-3.12.0.jar");
+	}
+
+	@TestTemplate
+	void testRuntimeClasspathIncludesTestAndDevelopmentOnlyDependencies() {
+		assertThat(this.gradleBuild.build("help").getOutput()).contains("commons-lang3-3.12.0.jar");
+	}
+
+	@TestTemplate
+	void testCompileClasspathDoesNotIncludeDevelopmentOnlyDependencies() {
+		assertThat(this.gradleBuild.build("help").getOutput()).doesNotContain("commons-lang3-3.12.0.jar");
+	}
+
+	@TestTemplate
+	void testRuntimeClasspathDoesNotIncludeDevelopmentOnlyDependencies() {
+		assertThat(this.gradleBuild.build("help").getOutput()).doesNotContain("commons-lang3-3.12.0.jar");
+	}
+
+	@TestTemplate
+	void compileClasspathDoesNotIncludeTestAndDevelopmentOnlyDependencies() {
+		assertThat(this.gradleBuild.build("help").getOutput()).doesNotContain("commons-lang3-3.12.0.jar");
+	}
+
+	@TestTemplate
+	void runtimeClasspathIncludesTestAndDevelopmentOnlyDependencies() {
+		assertThat(this.gradleBuild.build("help").getOutput()).contains("commons-lang3-3.12.0.jar");
+	}
+
+	@TestTemplate
+	void compileClasspathDoesNotIncludeDevelopmentOnlyDependencies() {
+		assertThat(this.gradleBuild.build("help").getOutput()).doesNotContain("commons-lang3-3.12.0.jar");
+	}
+
+	@TestTemplate
+	void runtimeClasspathIncludesDevelopmentOnlyDependencies() {
+		assertThat(this.gradleBuild.build("help").getOutput()).contains("commons-lang3-3.12.0.jar");
+	}
+
+	@TestTemplate
+	void productionRuntimeClasspathIsConfiguredWithAttributesThatMatchRuntimeClasspath() {
+		String output = this.gradleBuild.build("build").getOutput();
+		Matcher matcher = Pattern.compile("runtimeClasspath: (\\[.*\\])").matcher(output);
+		assertThat(matcher.find()).as("%s found in %s", matcher, output).isTrue();
+		String attributes = matcher.group(1);
+		assertThat(output).contains("productionRuntimeClasspath: " + attributes);
 	}
 
 	@TestTemplate
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/KotlinPluginActionIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/KotlinPluginActionIntegrationTests.java
index cfb35aa41c71..ab32302702fc 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/KotlinPluginActionIntegrationTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/KotlinPluginActionIntegrationTests.java
@@ -23,11 +23,13 @@
 import java.util.Set;
 
 import org.gradle.testkit.runner.BuildResult;
-import org.gradle.util.GradleVersion;
-import org.junit.jupiter.api.TestTemplate;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledForJreRange;
+import org.junit.jupiter.api.condition.JRE;
+import org.junit.jupiter.api.extension.ExtendWith;
 
-import org.springframework.boot.gradle.junit.GradleCompatibility;
 import org.springframework.boot.testsupport.gradle.testkit.GradleBuild;
+import org.springframework.boot.testsupport.gradle.testkit.GradleBuildExtension;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -36,43 +38,41 @@
  *
  * @author Andy Wilkinson
  */
-@GradleCompatibility
+@DisabledForJreRange(min = JRE.JAVA_20)
+@ExtendWith(GradleBuildExtension.class)
 class KotlinPluginActionIntegrationTests {
 
-	GradleBuild gradleBuild;
+	GradleBuild gradleBuild = new GradleBuild();
 
-	@TestTemplate
+	@Test
 	void noKotlinVersionPropertyWithoutKotlinPlugin() {
 		assertThat(this.gradleBuild.build("kotlinVersion").getOutput()).contains("Kotlin version: none");
 	}
 
-	@TestTemplate
+	@Test
 	void kotlinVersionPropertyIsSet() {
-		String output = this.gradleBuild.expectDeprecationWarningsWithAtLeastVersion("8.1-rc-1")
-			.build("kotlinVersion", "dependencies", "--configuration", "compileClasspath")
+		String output = this.gradleBuild.build("kotlinVersion", "dependencies", "--configuration", "compileClasspath")
 			.getOutput();
 		assertThat(output).containsPattern("Kotlin version: [0-9]\\.[0-9]\\.[0-9]+");
 	}
 
-	@TestTemplate
+	@Test
 	void kotlinCompileTasksUseJavaParametersFlagByDefault() {
-		assertThat(this.gradleBuild.expectDeprecationWarningsWithAtLeastVersion("8.1-rc-1")
-			.build("kotlinCompileTasksJavaParameters")
-			.getOutput()).contains("compileKotlin java parameters: true")
+		assertThat(this.gradleBuild.build("kotlinCompileTasksJavaParameters").getOutput())
+			.contains("compileKotlin java parameters: true")
 			.contains("compileTestKotlin java parameters: true");
 	}
 
-	@TestTemplate
+	@Test
 	void kotlinCompileTasksCanOverrideDefaultJavaParametersFlag() {
-		assertThat(this.gradleBuild.expectDeprecationWarningsWithAtLeastVersion("8.1-rc-1")
-			.build("kotlinCompileTasksJavaParameters")
-			.getOutput()).contains("compileKotlin java parameters: false")
+		assertThat(this.gradleBuild.build("kotlinCompileTasksJavaParameters").getOutput())
+			.contains("compileKotlin java parameters: false")
 			.contains("compileTestKotlin java parameters: false");
 	}
 
-	@TestTemplate
+	@Test
 	void taskConfigurationIsAvoided() throws IOException {
-		BuildResult result = this.gradleBuild.expectDeprecationWarningsWithAtLeastVersion("8.1-rc-1").build("help");
+		BuildResult result = this.gradleBuild.build("help");
 		String output = result.getOutput();
 		BufferedReader reader = new BufferedReader(new StringReader(output));
 		String line;
@@ -82,12 +82,7 @@ void taskConfigurationIsAvoided() throws IOException {
 				configured.add(line.substring("Configuring :".length()));
 			}
 		}
-		if (GradleVersion.version(this.gradleBuild.getGradleVersion()).compareTo(GradleVersion.version("7.3.3")) < 0) {
-			assertThat(configured).containsExactly("help");
-		}
-		else {
-			assertThat(configured).containsExactlyInAnyOrder("help", "clean");
-		}
+		assertThat(configured).containsExactlyInAnyOrder("help", "clean");
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/NativeImagePluginActionIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/NativeImagePluginActionIntegrationTests.java
index 08c094e2efb6..87cc9458aec1 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/NativeImagePluginActionIntegrationTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/NativeImagePluginActionIntegrationTests.java
@@ -93,7 +93,8 @@ void bootBuildImageIsConfiguredToBuildANativeImage() {
 		writeDummySpringApplicationAotProcessorMainClass();
 		BuildResult result = this.gradleBuild.expectDeprecationWarningsWithAtLeastVersion("8.2-rc-1")
 			.build("bootBuildImageConfiguration");
-		assertThat(result.getOutput()).contains("paketobuildpacks/builder:tiny").contains("BP_NATIVE_IMAGE = true");
+		assertThat(result.getOutput()).contains("paketobuildpacks/builder-jammy-tiny")
+			.contains("BP_NATIVE_IMAGE = true");
 	}
 
 	@TestTemplate
@@ -104,6 +105,14 @@ void developmentOnlyDependenciesDoNotAppearInNativeImageClasspath() {
 		assertThat(result.getOutput()).doesNotContain("commons-lang");
 	}
 
+	@TestTemplate
+	void testAndDevelopmentOnlyDependenciesDoNotAppearInNativeImageClasspath() {
+		writeDummySpringApplicationAotProcessorMainClass();
+		BuildResult result = this.gradleBuild.expectDeprecationWarningsWithAtLeastVersion("8.2-rc-1")
+			.build("checkNativeImageClasspath");
+		assertThat(result.getOutput()).doesNotContain("commons-lang");
+	}
+
 	@TestTemplate
 	void classesGeneratedDuringAotProcessingAreOnTheNativeImageClasspath() {
 		BuildResult result = this.gradleBuild.expectDeprecationWarningsWithAtLeastVersion("8.2-rc-1")
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests.java
index b3d9932037ac..7abf855bbeae 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests.java
@@ -23,11 +23,14 @@
 
 import org.gradle.testkit.runner.TaskOutcome;
 import org.junit.jupiter.api.TestTemplate;
+import org.junit.jupiter.api.condition.EnabledOnJre;
+import org.junit.jupiter.api.condition.JRE;
 
 import org.springframework.boot.gradle.junit.GradleCompatibility;
 import org.springframework.boot.testsupport.gradle.testkit.GradleBuild;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNoException;
 
 /**
  * Integration tests for {@link SpringBootAotPlugin}.
@@ -103,10 +106,22 @@ void processAotDoesNotHaveDevelopmentOnlyDependenciesOnItsClasspath() {
 
 	@TestTemplate
 	void processTestAotDoesNotHaveDevelopmentOnlyDependenciesOnItsClasspath() {
-		String output = this.gradleBuild.build("processTestAotClasspath", "--stacktrace").getOutput();
+		String output = this.gradleBuild.build("processTestAotClasspath").getOutput();
 		assertThat(output).doesNotContain("commons-lang");
 	}
 
+	@TestTemplate
+	void processAotDoesNotHaveTestAndDevelopmentOnlyDependenciesOnItsClasspath() {
+		String output = this.gradleBuild.build("processAotClasspath").getOutput();
+		assertThat(output).doesNotContain("commons-lang");
+	}
+
+	@TestTemplate
+	void processTestAotHasTestAndDevelopmentOnlyDependenciesOnItsClasspath() {
+		String output = this.gradleBuild.build("processTestAotClasspath").getOutput();
+		assertThat(output).contains("commons-lang");
+	}
+
 	@TestTemplate
 	void processAotRunsWhenProjectHasMainSource() throws IOException {
 		writeMainClass("org.springframework.boot", "SpringApplicationAotProcessor");
@@ -121,6 +136,13 @@ void processTestAotIsSkippedWhenProjectHasNoTestSource() {
 			.isEqualTo(TaskOutcome.NO_SOURCE);
 	}
 
+	// gh-37343
+	@TestTemplate
+	@EnabledOnJre(JRE.JAVA_17)
+	void applyingAotPluginDoesNotPreventConfigurationOfJavaToolchainLanguageVersion() {
+		assertThatNoException().isThrownBy(() -> this.gradleBuild.build("help").getOutput());
+	}
+
 	private void writeMainClass(String packageName, String className) throws IOException {
 		File java = new File(this.gradleBuild.getProjectDir(),
 				"src/main/java/" + packageName.replace(".", "/") + "/" + className + ".java");
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/buildinfo/BuildInfoTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/buildinfo/BuildInfoTests.java
index 6798c5015d1b..538c385418c2 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/buildinfo/BuildInfoTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/buildinfo/BuildInfoTests.java
@@ -33,7 +33,7 @@
 import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThatException;
 
 /**
  * Tests for {@link BuildInfo}.
@@ -100,7 +100,7 @@ void customNameIsReflectedInProperties() {
 	}
 
 	@Test
-	void nameCanBeExludedRemovedFromProperties() {
+	void nameCanBeExcludedFromProperties() {
 		BuildInfo task = createTask(createProject("test"));
 		task.getExcludes().add("name");
 		assertThat(buildInfoProperties(task)).doesNotContainKey("build.name");
@@ -167,8 +167,8 @@ void additionalPropertiesCanBeExcluded() {
 	@Test
 	void nullAdditionalPropertyProducesInformativeFailure() {
 		BuildInfo task = createTask(createProject("test"));
-		assertThatThrownBy(() -> task.getProperties().getAdditional().put("a", null))
-			.hasMessage("Cannot add an entry with a null value to a property of type Map.");
+		assertThatException().isThrownBy(() -> task.getProperties().getAdditional().put("a", null))
+			.withMessage("Cannot add an entry with a null value to a property of type Map.");
 	}
 
 	private Project createProject(String projectName) {
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveIntegrationTests.java
index af744b2d81d1..45c10a127db4 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveIntegrationTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveIntegrationTests.java
@@ -29,6 +29,7 @@
 import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Enumeration;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Locale;
@@ -44,6 +45,9 @@
 import java.util.stream.Stream;
 import java.util.zip.ZipEntry;
 
+import org.apache.commons.compress.archivers.zip.UnixStat;
+import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
+import org.apache.commons.compress.archivers.zip.ZipFile;
 import org.gradle.testkit.runner.BuildResult;
 import org.gradle.testkit.runner.TaskOutcome;
 import org.junit.jupiter.api.TestTemplate;
@@ -61,6 +65,7 @@
  *
  * @author Andy Wilkinson
  * @author Madhura Bhave
+ * @author Scott Frederick
  */
 abstract class AbstractBootArchiveIntegrationTests {
 
@@ -101,6 +106,16 @@ void reproducibleArchive() throws IOException, InterruptedException {
 		assertThat(firstHash).isEqualTo(secondHash);
 	}
 
+	@TestTemplate
+	void classicLoader() throws IOException {
+		assertThat(this.gradleBuild.build(this.taskName).task(":" + this.taskName).getOutcome())
+			.isEqualTo(TaskOutcome.SUCCESS);
+		File jar = new File(this.gradleBuild.getProjectDir(), "build/libs").listFiles()[0];
+		try (JarFile jarFile = new JarFile(jar)) {
+			assertThat(jarFile.getEntry("org/springframework/boot/loader/LaunchedURLClassLoader.class")).isNotNull();
+		}
+	}
+
 	@TestTemplate
 	void upToDateWhenBuiltTwice() {
 		assertThat(this.gradleBuild.build(this.taskName).task(":" + this.taskName).getOutcome())
@@ -216,6 +231,41 @@ void developmentOnlyDependenciesCanBeIncludedInTheArchive() throws IOException {
 		}
 	}
 
+	@TestTemplate
+	void testAndDevelopmentOnlyDependenciesAreNotIncludedInTheArchiveByDefault() throws IOException {
+		File srcMainResources = new File(this.gradleBuild.getProjectDir(), "src/main/resources");
+		srcMainResources.mkdirs();
+		new File(srcMainResources, "resource").createNewFile();
+		assertThat(this.gradleBuild.build(this.taskName).task(":" + this.taskName).getOutcome())
+			.isEqualTo(TaskOutcome.SUCCESS);
+		try (JarFile jarFile = new JarFile(new File(this.gradleBuild.getProjectDir(), "build/libs").listFiles()[0])) {
+			Stream<String> libEntryNames = jarFile.stream()
+				.filter((entry) -> !entry.isDirectory())
+				.map(JarEntry::getName)
+				.filter((name) -> name.startsWith(this.libPath));
+			assertThat(libEntryNames).containsExactly(this.libPath + "commons-io-2.6.jar");
+			Stream<String> classesEntryNames = jarFile.stream()
+				.filter((entry) -> !entry.isDirectory())
+				.map(JarEntry::getName)
+				.filter((name) -> name.startsWith(this.classesPath));
+			assertThat(classesEntryNames).containsExactly(this.classesPath + "resource");
+		}
+	}
+
+	@TestTemplate
+	void testAndDevelopmentOnlyDependenciesCanBeIncludedInTheArchive() throws IOException {
+		assertThat(this.gradleBuild.build(this.taskName).task(":" + this.taskName).getOutcome())
+			.isEqualTo(TaskOutcome.SUCCESS);
+		try (JarFile jarFile = new JarFile(new File(this.gradleBuild.getProjectDir(), "build/libs").listFiles()[0])) {
+			Stream<String> libEntryNames = jarFile.stream()
+				.filter((entry) -> !entry.isDirectory())
+				.map(JarEntry::getName)
+				.filter((name) -> name.startsWith(this.libPath));
+			assertThat(libEntryNames).containsExactly(this.libPath + "commons-io-2.6.jar",
+					this.libPath + "commons-lang3-3.9.jar");
+		}
+	}
+
 	@TestTemplate
 	void jarTypeFilteringIsApplied() throws IOException {
 		File flatDirRepository = new File(this.gradleBuild.getProjectDir(), "repository");
@@ -523,6 +573,48 @@ void javaVersionIsSetInManifest() throws IOException {
 		}
 	}
 
+	@TestTemplate
+	void defaultDirAndFileModesAreUsed() throws IOException {
+		BuildResult result = this.gradleBuild.build(this.taskName);
+		assertThat(result.task(":" + this.taskName).getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
+		try (ZipFile jarFile = new ZipFile(new File(this.gradleBuild.getProjectDir(), "build/libs").listFiles()[0])) {
+			Enumeration<ZipArchiveEntry> entries = jarFile.getEntries();
+			while (entries.hasMoreElements()) {
+				ZipArchiveEntry entry = entries.nextElement();
+				if (entry.getName().startsWith("META-INF/")) {
+					continue;
+				}
+				if (entry.isDirectory()) {
+					assertEntryMode(entry, UnixStat.DIR_FLAG | UnixStat.DEFAULT_DIR_PERM);
+				}
+				else {
+					assertEntryMode(entry, UnixStat.FILE_FLAG | UnixStat.DEFAULT_FILE_PERM);
+				}
+			}
+		}
+	}
+
+	@TestTemplate
+	void dirModeAndFileModeAreApplied() throws IOException {
+		BuildResult result = this.gradleBuild.build(this.taskName);
+		assertThat(result.task(":" + this.taskName).getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
+		try (ZipFile jarFile = new ZipFile(new File(this.gradleBuild.getProjectDir(), "build/libs").listFiles()[0])) {
+			Enumeration<ZipArchiveEntry> entries = jarFile.getEntries();
+			while (entries.hasMoreElements()) {
+				ZipArchiveEntry entry = entries.nextElement();
+				if (entry.getName().startsWith("META-INF/")) {
+					continue;
+				}
+				if (entry.isDirectory()) {
+					assertEntryMode(entry, 0500);
+				}
+				else {
+					assertEntryMode(entry, 0400);
+				}
+			}
+		}
+	}
+
 	private void copyMainClassApplication() throws IOException {
 		copyApplication("main");
 	}
@@ -660,4 +752,11 @@ private boolean isInIndex(List<String> index, String file) {
 		return false;
 	}
 
+	private static void assertEntryMode(ZipArchiveEntry entry, int expectedMode) {
+		assertThat(entry.getUnixMode())
+			.withFailMessage(() -> "Expected mode " + Integer.toOctalString(expectedMode) + " for entry "
+					+ entry.getName() + " but actual is " + Integer.toOctalString(entry.getUnixMode()))
+			.isEqualTo(expectedMode);
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveTests.java
index ec338420f1b9..c5c78eb5a21c 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveTests.java
@@ -65,6 +65,7 @@
 import org.springframework.boot.gradle.junit.GradleProjectBuilder;
 import org.springframework.boot.loader.tools.DefaultLaunchScript;
 import org.springframework.boot.loader.tools.JarModeLibrary;
+import org.springframework.boot.loader.tools.LoaderImplementation;
 import org.springframework.util.FileCopyUtils;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -255,7 +256,8 @@ void loaderIsWrittenToTheRootOfTheJarAfterManifest() throws IOException {
 		this.task.getMainClass().set("com.example.Main");
 		executeTask();
 		try (JarFile jarFile = new JarFile(this.task.getArchiveFile().get().getAsFile())) {
-			assertThat(jarFile.getEntry("org/springframework/boot/loader/LaunchedURLClassLoader.class")).isNotNull();
+			assertThat(jarFile.getEntry("org/springframework/boot/loader/launch/LaunchedClassLoader.class"))
+				.isNotNull();
 			assertThat(jarFile.getEntry("org/springframework/boot/loader/")).isNotNull();
 		}
 		// gh-16698
@@ -270,7 +272,21 @@ void loaderIsWrittenToTheRootOfTheJarAfterManifest() throws IOException {
 	void loaderIsWrittenToTheRootOfTheJarWhenUsingThePropertiesLauncher() throws IOException {
 		this.task.getMainClass().set("com.example.Main");
 		executeTask();
-		this.task.getManifest().getAttributes().put("Main-Class", "org.springframework.boot.loader.PropertiesLauncher");
+		this.task.getManifest()
+			.getAttributes()
+			.put("Main-Class", "org.springframework.boot.loader.launch.PropertiesLauncher");
+		try (JarFile jarFile = new JarFile(this.task.getArchiveFile().get().getAsFile())) {
+			assertThat(jarFile.getEntry("org/springframework/boot/loader/launch/LaunchedClassLoader.class"))
+				.isNotNull();
+			assertThat(jarFile.getEntry("org/springframework/boot/loader/")).isNotNull();
+		}
+	}
+
+	@Test
+	void loaderIsWrittenToTheRootOfTheJarWhenUsingClassicLoader() throws IOException {
+		this.task.getMainClass().set("com.example.Main");
+		this.task.getLoaderImplementation().set(LoaderImplementation.CLASSIC);
+		executeTask();
 		try (JarFile jarFile = new JarFile(this.task.getArchiveFile().get().getAsFile())) {
 			assertThat(jarFile.getEntry("org/springframework/boot/loader/LaunchedURLClassLoader.class")).isNotNull();
 			assertThat(jarFile.getEntry("org/springframework/boot/loader/")).isNotNull();
@@ -362,7 +378,7 @@ void customMainClassInTheManifestIsHonored() throws IOException {
 			assertThat(jarFile.getManifest().getMainAttributes().getValue("Main-Class"))
 				.isEqualTo("com.example.CustomLauncher");
 			assertThat(jarFile.getManifest().getMainAttributes().getValue("Start-Class")).isEqualTo("com.example.Main");
-			assertThat(jarFile.getEntry("org/springframework/boot/loader/LaunchedURLClassLoader.class")).isNull();
+			assertThat(jarFile.getEntry("org/springframework/boot/loader/launch/LaunchedClassLoader.class")).isNull();
 		}
 	}
 
@@ -488,7 +504,7 @@ void archiveShouldBeLayeredByDefault() throws IOException {
 	void jarWhenLayersDisabledShouldNotContainLayersIndex() throws IOException {
 		List<String> entryNames = getEntryNames(
 				createLayeredJar((configuration) -> configuration.getEnabled().set(false)));
-		assertThat(entryNames).doesNotContain(this.indexPath + "layers.idx");
+		assertThat(entryNames).isNotEmpty().doesNotContain(this.indexPath + "layers.idx");
 	}
 
 	@Test
@@ -605,7 +621,7 @@ void whenArchiveIsLayeredThenLayerToolsAreAddedToTheJar() throws IOException {
 	void whenArchiveIsLayeredAndIncludeLayerToolsIsFalseThenLayerToolsAreNotAddedToTheJar() throws IOException {
 		List<String> entryNames = getEntryNames(
 				createLayeredJar((configuration) -> configuration.getIncludeLayerTools().set(false)));
-		assertThat(entryNames)
+		assertThat(entryNames).isNotEmpty()
 			.doesNotContain(this.indexPath + "layers/dependencies/lib/spring-boot-jarmode-layertools.jar");
 	}
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests.java
index 2a2238c4a9ee..b20924ad7d6b 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests.java
@@ -37,6 +37,7 @@
 import org.gradle.testkit.runner.BuildResult;
 import org.gradle.testkit.runner.TaskOutcome;
 import org.junit.jupiter.api.TestTemplate;
+import org.junit.jupiter.api.condition.EnabledOnOs;
 import org.junit.jupiter.api.condition.OS;
 
 import org.springframework.boot.buildpack.platform.docker.DockerApi;
@@ -50,6 +51,7 @@
 import org.springframework.boot.testsupport.gradle.testkit.GradleBuild;
 import org.springframework.boot.testsupport.junit.DisabledOnOs;
 import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable;
+import org.springframework.util.FileSystemUtils;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -297,6 +299,28 @@ void buildsImageWithVolumeCaches() throws IOException {
 		deleteVolumes("cache-" + projectName + ".build", "cache-" + projectName + ".launch");
 	}
 
+	@TestTemplate
+	@EnabledOnOs(value = OS.LINUX, disabledReason = "Works with Docker Engine on Linux but is not reliable with "
+			+ "Docker Desktop on other OSs")
+	void buildsImageWithBindCaches() throws IOException {
+		writeMainClass();
+		writeLongNameResource();
+		BuildResult result = this.gradleBuild.build("bootBuildImage");
+		String projectName = this.gradleBuild.getProjectDir().getName();
+		assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
+		assertThat(result.getOutput()).contains("docker.io/library/" + projectName);
+		assertThat(result.getOutput()).contains("---> Test Info buildpack building");
+		assertThat(result.getOutput()).contains("---> Test Info buildpack done");
+		removeImages(projectName);
+		String tempDir = System.getProperty("java.io.tmpdir");
+		Path buildCachePath = Paths.get(tempDir, "junit-image-cache-" + projectName + "-build");
+		Path launchCachePath = Paths.get(tempDir, "junit-image-cache-" + projectName + "-launch");
+		assertThat(buildCachePath).exists().isDirectory();
+		assertThat(launchCachePath).exists().isDirectory();
+		FileSystemUtils.deleteRecursively(buildCachePath);
+		FileSystemUtils.deleteRecursively(launchCachePath);
+	}
+
 	@TestTemplate
 	void buildsImageWithCreatedDate() throws IOException {
 		writeMainClass();
@@ -344,6 +368,19 @@ void buildsImageWithApplicationDirectory() throws IOException {
 		removeImages(projectName);
 	}
 
+	@TestTemplate
+	void buildsImageWithEmptySecurityOptions() throws IOException {
+		writeMainClass();
+		writeLongNameResource();
+		BuildResult result = this.gradleBuild.build("bootBuildImage");
+		String projectName = this.gradleBuild.getProjectDir().getName();
+		assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
+		assertThat(result.getOutput()).contains("docker.io/library/" + projectName);
+		assertThat(result.getOutput()).contains("---> Test Info buildpack building");
+		assertThat(result.getOutput()).contains("---> Test Info buildpack done");
+		removeImages(projectName);
+	}
+
 	@TestTemplate
 	void failsWithInvalidCreatedDate() throws IOException {
 		writeMainClass();
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageTests.java
index fdb69d2a532d..33dac8a784e4 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageTests.java
@@ -171,7 +171,8 @@ void whenUsingDefaultConfigurationThenRequestHasPublishDisabled() {
 
 	@Test
 	void whenNoBuilderIsConfiguredThenRequestHasDefaultBuilder() {
-		assertThat(this.buildImage.createRequest().getBuilder().getName()).isEqualTo("paketobuildpacks/builder");
+		assertThat(this.buildImage.createRequest().getBuilder().getName())
+			.isEqualTo("paketobuildpacks/builder-jammy-base");
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests.java
index 146fb595ae60..d83e54ed165c 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests.java
@@ -16,12 +16,15 @@
 
 package org.springframework.boot.gradle.tasks.bundling;
 
+import java.io.File;
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.Set;
 import java.util.TreeSet;
+import java.util.jar.JarFile;
 
 import org.gradle.testkit.runner.BuildResult;
+import org.gradle.testkit.runner.TaskOutcome;
 import org.junit.jupiter.api.TestTemplate;
 
 import org.springframework.boot.gradle.junit.GradleCompatibility;
@@ -42,6 +45,15 @@ class BootJarIntegrationTests extends AbstractBootArchiveIntegrationTests {
 		super("bootJar", "BOOT-INF/lib/", "BOOT-INF/classes/", "BOOT-INF/");
 	}
 
+	@TestTemplate
+	void signed() throws Exception {
+		assertThat(this.gradleBuild.build("bootJar").task(":bootJar").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
+		File jar = new File(this.gradleBuild.getProjectDir(), "build/libs").listFiles()[0];
+		try (JarFile jarFile = new JarFile(jar)) {
+			assertThat(jarFile.getEntry("META-INF/BOOT.SF")).isNotNull();
+		}
+	}
+
 	@TestTemplate
 	void whenAResolvableCopyOfAnUnresolvableConfigurationIsResolvedThenResolutionSucceeds() {
 		this.gradleBuild.expectDeprecationWarningsWithAtLeastVersion("8.0").build("build");
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarTests.java
index 5355e1ba79d8..2cbe89cf5712 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarTests.java
@@ -41,7 +41,7 @@
 class BootJarTests extends AbstractBootArchiveTests<BootJar> {
 
 	BootJarTests() {
-		super(BootJar.class, "org.springframework.boot.loader.JarLauncher", "BOOT-INF/lib/", "BOOT-INF/classes/",
+		super(BootJar.class, "org.springframework.boot.loader.launch.JarLauncher", "BOOT-INF/lib/", "BOOT-INF/classes/",
 				"BOOT-INF/");
 	}
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootWarTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootWarTests.java
index e53080ca779f..8728298b4936 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootWarTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootWarTests.java
@@ -39,7 +39,7 @@
 class BootWarTests extends AbstractBootArchiveTests<BootWar> {
 
 	BootWarTests() {
-		super(BootWar.class, "org.springframework.boot.loader.WarLauncher", "WEB-INF/lib/", "WEB-INF/classes/",
+		super(BootWar.class, "org.springframework.boot.loader.launch.WarLauncher", "WEB-INF/lib/", "WEB-INF/classes/",
 				"WEB-INF/");
 	}
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/DockerSpecTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/DockerSpecTests.java
index 7cce2758b0ad..3252cedb2da0 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/DockerSpecTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/DockerSpecTests.java
@@ -25,7 +25,7 @@
 import org.junit.jupiter.api.io.TempDir;
 
 import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration;
-import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost;
+import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration;
 import org.springframework.boot.gradle.junit.GradleProjectBuilder;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -68,10 +68,11 @@ void asDockerConfigurationWithHostConfiguration() {
 		this.dockerSpec.getTlsVerify().set(true);
 		this.dockerSpec.getCertPath().set("/tmp/ca-cert");
 		DockerConfiguration dockerConfiguration = this.dockerSpec.asDockerConfiguration();
-		DockerHost host = dockerConfiguration.getHost();
+		DockerHostConfiguration host = dockerConfiguration.getHost();
 		assertThat(host.getAddress()).isEqualTo("docker.example.com");
 		assertThat(host.isSecure()).isTrue();
 		assertThat(host.getCertificatePath()).isEqualTo("/tmp/ca-cert");
+		assertThat(host.getContext()).isNull();
 		assertThat(dockerConfiguration.isBindHostToBuilder()).isFalse();
 		assertThat(this.dockerSpec.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull();
 		assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader()))
@@ -85,10 +86,11 @@ void asDockerConfigurationWithHostConfiguration() {
 	void asDockerConfigurationWithHostConfigurationNoTlsVerify() {
 		this.dockerSpec.getHost().set("docker.example.com");
 		DockerConfiguration dockerConfiguration = this.dockerSpec.asDockerConfiguration();
-		DockerHost host = dockerConfiguration.getHost();
+		DockerHostConfiguration host = dockerConfiguration.getHost();
 		assertThat(host.getAddress()).isEqualTo("docker.example.com");
 		assertThat(host.isSecure()).isFalse();
 		assertThat(host.getCertificatePath()).isNull();
+		assertThat(host.getContext()).isNull();
 		assertThat(dockerConfiguration.isBindHostToBuilder()).isFalse();
 		assertThat(this.dockerSpec.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull();
 		assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader()))
@@ -98,12 +100,38 @@ void asDockerConfigurationWithHostConfigurationNoTlsVerify() {
 			.contains("\"serveraddress\" : \"\"");
 	}
 
+	@Test
+	void asDockerConfigurationWithContextConfiguration() {
+		this.dockerSpec.getContext().set("test-context");
+		DockerConfiguration dockerConfiguration = this.dockerSpec.asDockerConfiguration();
+		DockerHostConfiguration host = dockerConfiguration.getHost();
+		assertThat(host.getContext()).isEqualTo("test-context");
+		assertThat(host.getAddress()).isNull();
+		assertThat(host.isSecure()).isFalse();
+		assertThat(host.getCertificatePath()).isNull();
+		assertThat(dockerConfiguration.isBindHostToBuilder()).isFalse();
+		assertThat(this.dockerSpec.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull();
+		assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader()))
+			.contains("\"username\" : \"\"")
+			.contains("\"password\" : \"\"")
+			.contains("\"email\" : \"\"")
+			.contains("\"serveraddress\" : \"\"");
+	}
+
+	@Test
+	void asDockerConfigurationWithHostAndContextFails() {
+		this.dockerSpec.getContext().set("test-context");
+		this.dockerSpec.getHost().set("docker.example.com");
+		assertThatExceptionOfType(GradleException.class).isThrownBy(this.dockerSpec::asDockerConfiguration)
+			.withMessageContaining("Invalid Docker configuration");
+	}
+
 	@Test
 	void asDockerConfigurationWithBindHostToBuilder() {
 		this.dockerSpec.getHost().set("docker.example.com");
 		this.dockerSpec.getBindHostToBuilder().set(true);
 		DockerConfiguration dockerConfiguration = this.dockerSpec.asDockerConfiguration();
-		DockerHost host = dockerConfiguration.getHost();
+		DockerHostConfiguration host = dockerConfiguration.getHost();
 		assertThat(host.getAddress()).isEqualTo("docker.example.com");
 		assertThat(host.isSecure()).isFalse();
 		assertThat(host.getCertificatePath()).isNull();
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/run/BootRunIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/run/BootRunIntegrationTests.java
index 7550399b8824..54ee2a33f0f5 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/run/BootRunIntegrationTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/run/BootRunIntegrationTests.java
@@ -146,6 +146,22 @@ void classesFromASecondarySourceSetCanBeOnTheClasspath() throws IOException {
 		assertThat(result.getOutput()).contains("com.example.bootrun.main.CustomMainClass");
 	}
 
+	@TestTemplate
+	void developmentOnlyDependenciesAreOnTheClasspath() throws IOException {
+		copyClasspathApplication();
+		BuildResult result = this.gradleBuild.build("bootRun");
+		assertThat(result.task(":bootRun").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
+		assertThat(result.getOutput()).contains("commons-lang3-3.12.0.jar");
+	}
+
+	@TestTemplate
+	void testAndDevelopmentOnlyDependenciesAreOnTheClasspath() throws IOException {
+		copyClasspathApplication();
+		BuildResult result = this.gradleBuild.build("bootRun");
+		assertThat(result.task(":bootRun").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
+		assertThat(result.getOutput()).contains("commons-lang3-3.12.0.jar");
+	}
+
 	private void copyMainClassApplication() throws IOException {
 		copyApplication("main");
 	}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/run/BootTestRunIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/run/BootTestRunIntegrationTests.java
index 8bb449d09543..79b884dcb7ef 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/run/BootTestRunIntegrationTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/run/BootTestRunIntegrationTests.java
@@ -117,6 +117,22 @@ void failsGracefullyWhenNoTestMainMethodIsFound() throws IOException {
 		}
 	}
 
+	@TestTemplate
+	void developmentOnlyDependenciesAreNotOnTheClasspath() throws IOException {
+		copyClasspathApplication();
+		BuildResult result = this.gradleBuild.build("bootTestRun");
+		assertThat(result.task(":bootTestRun").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
+		assertThat(result.getOutput()).doesNotContain("commons-lang3-3.12.0.jar");
+	}
+
+	@TestTemplate
+	void testAndDevelopmentOnlyDependenciesAreOnTheClasspath() throws IOException {
+		copyClasspathApplication();
+		BuildResult result = this.gradleBuild.build("bootTestRun");
+		assertThat(result.task(":bootTestRun").getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
+		assertThat(result.getOutput()).contains("commons-lang3-3.12.0.jar");
+	}
+
 	private void copyClasspathApplication() throws IOException {
 		copyApplication("classpath");
 	}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/dsl/BuildInfoDslIntegrationTests-additionalProperties.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/dsl/BuildInfoDslIntegrationTests-additionalProperties.gradle
index 0567e3acb71c..d8d2b8d319e6 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/dsl/BuildInfoDslIntegrationTests-additionalProperties.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/dsl/BuildInfoDslIntegrationTests-additionalProperties.gradle
@@ -10,7 +10,8 @@ springBoot {
 	buildInfo {
 		properties {
 			additional = [
-				'a': 'alpha', 'b': 'bravo'
+				'a': 'alpha',
+				'b': providers.provider({'bravo'})
 			]
 		}
 	}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-applyingJavaPluginCreatesTestAndDevelopmentOnlyConfiguration.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-applyingJavaPluginCreatesTestAndDevelopmentOnlyConfiguration.gradle
new file mode 100644
index 000000000000..ebf12ae42908
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-applyingJavaPluginCreatesTestAndDevelopmentOnlyConfiguration.gradle
@@ -0,0 +1,12 @@
+plugins {
+	id 'org.springframework.boot' version '{version}'
+	id 'java'
+}
+
+springBoot {
+	mainClass = "com.example.Main"
+}
+
+gradle.taskGraph.whenReady {
+	println "testAndDevelopmentOnly exists = ${configurations.findByName('testAndDevelopmentOnly') != null}"
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-compileClasspathDoesNotIncludeDevelopmentOnlyDependencies.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-compileClasspathDoesNotIncludeDevelopmentOnlyDependencies.gradle
new file mode 100644
index 000000000000..b956631b4634
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-compileClasspathDoesNotIncludeDevelopmentOnlyDependencies.gradle
@@ -0,0 +1,20 @@
+plugins {
+	id 'org.springframework.boot' version '{version}'
+	id 'java'
+}
+
+springBoot {
+	mainClass = "com.example.Main"
+}
+
+repositories {
+	mavenCentral()
+}
+
+dependencies {
+	developmentOnly("org.apache.commons:commons-lang3:3.12.0")
+}
+
+gradle.taskGraph.whenReady {
+	configurations.compileClasspath.resolve().each { println it }
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-compileClasspathDoesNotIncludeTestAndDevelopmentOnlyDependencies.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-compileClasspathDoesNotIncludeTestAndDevelopmentOnlyDependencies.gradle
new file mode 100644
index 000000000000..6e53332c97f5
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-compileClasspathDoesNotIncludeTestAndDevelopmentOnlyDependencies.gradle
@@ -0,0 +1,20 @@
+plugins {
+	id 'org.springframework.boot' version '{version}'
+	id 'java'
+}
+
+springBoot {
+	mainClass = "com.example.Main"
+}
+
+repositories {
+	mavenCentral()
+}
+
+dependencies {
+	testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.12.0")
+}
+
+gradle.taskGraph.whenReady {
+	configurations.compileClasspath.resolve().each { println it }
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-productionRuntimeClasspathIsConfiguredWithAttributes.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-productionRuntimeClasspathIsConfiguredWithAttributes.gradle
deleted file mode 100644
index c4ccd15df3ab..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-productionRuntimeClasspathIsConfiguredWithAttributes.gradle
+++ /dev/null
@@ -1,16 +0,0 @@
-plugins {
-	id 'org.springframework.boot' version '{version}'
-	id 'java'
-}
-
-springBoot {
-	mainClass = "com.example.Main"
-}
-
-gradle.taskGraph.whenReady {
-	def attributes = configurations.findByName('productionRuntimeClasspath').attributes
-	println "${attributes.keySet().size()} productionRuntimeClasspath attributes:"		
-	attributes.keySet().each { attribute ->
-		println "    ${attribute}: ${attributes.getAttribute(attribute)}" 
-	}
-}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-productionRuntimeClasspathIsConfiguredWithAttributesThatMatchRuntimeClasspath.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-productionRuntimeClasspathIsConfiguredWithAttributesThatMatchRuntimeClasspath.gradle
new file mode 100644
index 000000000000..7f0744a61844
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-productionRuntimeClasspathIsConfiguredWithAttributesThatMatchRuntimeClasspath.gradle
@@ -0,0 +1,22 @@
+def collectAttributes(String configurationName) {
+	def attributes = configurations.findByName(configurationName).attributes
+	def keys = new TreeSet<>((a1, a2) -> a1.name.compareTo(a2.name))
+	keys.addAll(attributes.keySet())
+	keys.collect { key -> "${key}: ${attributes.getAttribute(key)}" }
+}
+
+plugins {
+	id 'org.springframework.boot' version '{version}'
+	id 'java'
+}
+
+springBoot {
+	mainClass = "com.example.Main"
+}
+
+gradle.taskGraph.whenReady {
+	def runtimeClasspathAttributes = collectAttributes("runtimeClasspath")
+	def productionRuntimeClasspathAttributes = collectAttributes("productionRuntimeClasspath")
+	println("runtimeClasspath: ${runtimeClasspathAttributes}")
+	println("productionRuntimeClasspath: ${productionRuntimeClasspathAttributes}")
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-runtimeClasspathIncludesDevelopmentOnlyDependencies.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-runtimeClasspathIncludesDevelopmentOnlyDependencies.gradle
new file mode 100644
index 000000000000..a8b1f4d3bffd
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-runtimeClasspathIncludesDevelopmentOnlyDependencies.gradle
@@ -0,0 +1,20 @@
+plugins {
+	id 'org.springframework.boot' version '{version}'
+	id 'java'
+}
+
+springBoot {
+	mainClass = "com.example.Main"
+}
+
+repositories {
+	mavenCentral()
+}
+
+dependencies {
+	developmentOnly("org.apache.commons:commons-lang3:3.12.0")
+}
+
+gradle.taskGraph.whenReady {
+	configurations.runtimeClasspath.resolve().each { println it }
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-runtimeClasspathIncludesTestAndDevelopmentOnlyDependencies.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-runtimeClasspathIncludesTestAndDevelopmentOnlyDependencies.gradle
new file mode 100644
index 000000000000..6a51fe371128
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-runtimeClasspathIncludesTestAndDevelopmentOnlyDependencies.gradle
@@ -0,0 +1,20 @@
+plugins {
+	id 'org.springframework.boot' version '{version}'
+	id 'java'
+}
+
+springBoot {
+	mainClass = "com.example.Main"
+}
+
+repositories {
+	mavenCentral()
+}
+
+dependencies {
+	testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.12.0")
+}
+
+gradle.taskGraph.whenReady {
+	configurations.runtimeClasspath.resolve().each { println it }
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testCompileClasspathDoesNotIncludeDevelopmentOnlyDependencies.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testCompileClasspathDoesNotIncludeDevelopmentOnlyDependencies.gradle
new file mode 100644
index 000000000000..264944e602a6
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testCompileClasspathDoesNotIncludeDevelopmentOnlyDependencies.gradle
@@ -0,0 +1,20 @@
+plugins {
+	id 'org.springframework.boot' version '{version}'
+	id 'java'
+}
+
+springBoot {
+	mainClass = "com.example.Main"
+}
+
+repositories {
+	mavenCentral()
+}
+
+dependencies {
+	developmentOnly("org.apache.commons:commons-lang3:3.12.0")
+}
+
+gradle.taskGraph.whenReady {
+	configurations.testCompileClasspath.resolve().each { println it }
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testCompileClasspathIncludesTestAndDevelopmentOnlyDependencies.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testCompileClasspathIncludesTestAndDevelopmentOnlyDependencies.gradle
new file mode 100644
index 000000000000..1f934deadb11
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testCompileClasspathIncludesTestAndDevelopmentOnlyDependencies.gradle
@@ -0,0 +1,20 @@
+plugins {
+	id 'org.springframework.boot' version '{version}'
+	id 'java'
+}
+
+springBoot {
+	mainClass = "com.example.Main"
+}
+
+repositories {
+	mavenCentral()
+}
+
+dependencies {
+	testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.12.0")
+}
+
+gradle.taskGraph.whenReady {
+	configurations.testCompileClasspath.resolve().each { println it }
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testRuntimeClasspathDoesNotIncludeDevelopmentOnlyDependencies.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testRuntimeClasspathDoesNotIncludeDevelopmentOnlyDependencies.gradle
new file mode 100644
index 000000000000..4334e2a97bd2
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testRuntimeClasspathDoesNotIncludeDevelopmentOnlyDependencies.gradle
@@ -0,0 +1,20 @@
+plugins {
+	id 'org.springframework.boot' version '{version}'
+	id 'java'
+}
+
+springBoot {
+	mainClass = "com.example.Main"
+}
+
+repositories {
+	mavenCentral()
+}
+
+dependencies {
+	developmentOnly("org.apache.commons:commons-lang3:3.12.0")
+}
+
+gradle.taskGraph.whenReady {
+	configurations.testRuntimeClasspath.resolve().each { println it }
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testRuntimeClasspathIncludesTestAndDevelopmentOnlyDependencies.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testRuntimeClasspathIncludesTestAndDevelopmentOnlyDependencies.gradle
new file mode 100644
index 000000000000..581c58617968
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testRuntimeClasspathIncludesTestAndDevelopmentOnlyDependencies.gradle
@@ -0,0 +1,20 @@
+plugins {
+	id 'org.springframework.boot' version '{version}'
+	id 'java'
+}
+
+springBoot {
+	mainClass = "com.example.Main"
+}
+
+repositories {
+	mavenCentral()
+}
+
+dependencies {
+	testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.12.0")
+}
+
+gradle.taskGraph.whenReady {
+	configurations.testRuntimeClasspath.resolve().each { println it }
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/NativeImagePluginActionIntegrationTests-testAndDevelopmentOnlyDependenciesDoNotAppearInNativeImageClasspath.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/NativeImagePluginActionIntegrationTests-testAndDevelopmentOnlyDependenciesDoNotAppearInNativeImageClasspath.gradle
new file mode 100644
index 000000000000..62d4912299a1
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/NativeImagePluginActionIntegrationTests-testAndDevelopmentOnlyDependenciesDoNotAppearInNativeImageClasspath.gradle
@@ -0,0 +1,20 @@
+plugins {
+	id 'org.springframework.boot' version '{version}'
+	id 'java'
+}
+
+apply plugin: 'org.graalvm.buildtools.native'
+
+repositories {
+	mavenCentral()
+}
+
+dependencies {
+	testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.12.0")
+}
+
+task('checkNativeImageClasspath') {
+	doFirst {
+		tasks.nativeCompile.options.get().classpath.each { println it }
+	}
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests-applyingAotPluginDoesNotPreventConfigurationOfJavaToolchainLanguageVersion.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests-applyingAotPluginDoesNotPreventConfigurationOfJavaToolchainLanguageVersion.gradle
new file mode 100644
index 000000000000..da216cf930bf
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests-applyingAotPluginDoesNotPreventConfigurationOfJavaToolchainLanguageVersion.gradle
@@ -0,0 +1,11 @@
+plugins {
+	id 'org.springframework.boot'
+	id 'org.springframework.boot.aot'
+	id 'java'
+}
+
+java {
+    toolchain {
+        languageVersion.set(JavaLanguageVersion.of(17))
+    }
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests-processAotDoesNotHaveTestAndDevelopmentOnlyDependenciesOnItsClasspath.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests-processAotDoesNotHaveTestAndDevelopmentOnlyDependenciesOnItsClasspath.gradle
new file mode 100644
index 000000000000..0568dc1a9c21
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests-processAotDoesNotHaveTestAndDevelopmentOnlyDependenciesOnItsClasspath.gradle
@@ -0,0 +1,20 @@
+plugins {
+	id 'org.springframework.boot' version '{version}'
+	id 'java'
+}
+
+apply plugin: 'org.springframework.boot.aot'
+
+repositories {
+	mavenCentral()
+}
+
+dependencies {
+	testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.12.0")
+}
+
+task('processAotClasspath') {
+	doFirst {
+		tasks.processAot.classpath.each { println it }
+	}
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests-processTestAotHasTestAndDevelopmentOnlyDependenciesOnItsClasspath.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests-processTestAotHasTestAndDevelopmentOnlyDependenciesOnItsClasspath.gradle
new file mode 100644
index 000000000000..fe8815af3f30
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests-processTestAotHasTestAndDevelopmentOnlyDependenciesOnItsClasspath.gradle
@@ -0,0 +1,31 @@
+plugins {
+	id 'org.springframework.boot' version '{version}'
+	id 'java'
+}
+
+apply plugin: 'org.springframework.boot.aot'
+
+repositories {
+	mavenCentral()
+	maven { url 'file:repository' }
+}
+
+configurations.all {
+	resolutionStrategy {
+		eachDependency {
+			if (it.requested.group == 'org.springframework.boot') {
+				it.useVersion project.bootVersion
+			}
+		}
+	}
+}
+
+dependencies {
+	testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.12.0")
+}
+
+task('processTestAotClasspath') {
+	doFirst {
+		tasks.processTestAot.classpath.each { println it }
+	}
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBindCaches.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBindCaches.gradle
new file mode 100644
index 000000000000..e4c4e40a455e
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBindCaches.gradle
@@ -0,0 +1,24 @@
+plugins {
+	id 'java'
+	id 'org.springframework.boot' version '{version}'
+}
+
+bootBuildImage {
+	builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2"
+	pullPolicy = "IF_NOT_PRESENT"
+	buildWorkspace {
+		bind {
+			source = System.getProperty('java.io.tmpdir') + "/junit-image-pack-${rootProject.name}-work"
+		}
+	}
+	buildCache {
+		bind {
+			source = System.getProperty('java.io.tmpdir') + "/junit-image-cache-${rootProject.name}-build"
+		}
+	}
+	launchCache {
+		bind {
+			source = System.getProperty('java.io.tmpdir') + "/junit-image-cache-${rootProject.name}-launch"
+		}
+	}
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBinding.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBinding.gradle
index 3bffd3d8e1bb..28c1feb24630 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBinding.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBinding.gradle
@@ -3,11 +3,6 @@ plugins {
 	id 'org.springframework.boot' version '{version}'
 }
 
-java {
-	sourceCompatibility = '1.8'
-	targetCompatibility = '1.8'
-}
-
 bootBuildImage {
 	builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2"
 	pullPolicy = "IF_NOT_PRESENT"
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBuildpackFromBuilder.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBuildpackFromBuilder.gradle
index 71cddf15f745..20b9bcf689b1 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBuildpackFromBuilder.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBuildpackFromBuilder.gradle
@@ -3,11 +3,6 @@ plugins {
 	id 'org.springframework.boot' version '{version}'
 }
 
-java {
-	sourceCompatibility = '1.8'
-	targetCompatibility = '1.8'
-}
-
 bootBuildImage {
 	builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2"
 	pullPolicy = "IF_NOT_PRESENT"
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBuildpackFromDirectory.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBuildpackFromDirectory.gradle
index cf3151de2074..346c18457101 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBuildpackFromDirectory.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBuildpackFromDirectory.gradle
@@ -3,11 +3,6 @@ plugins {
 	id 'org.springframework.boot' version '{version}'
 }
 
-java {
-	sourceCompatibility = '1.8'
-	targetCompatibility = '1.8'
-}
-
 bootBuildImage {
 	builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2"
 	pullPolicy = "IF_NOT_PRESENT"
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBuildpackFromTarGzip.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBuildpackFromTarGzip.gradle
index 7972e62ec022..274494fd3b71 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBuildpackFromTarGzip.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBuildpackFromTarGzip.gradle
@@ -3,11 +3,6 @@ plugins {
 	id 'org.springframework.boot' version '{version}'
 }
 
-java {
-	sourceCompatibility = '1.8'
-	targetCompatibility = '1.8'
-}
-
 bootBuildImage {
 	builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2"
 	pullPolicy = "IF_NOT_PRESENT"
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBuildpacksFromImages.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBuildpacksFromImages.gradle
index f69d952e8248..a271b6135725 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBuildpacksFromImages.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBuildpacksFromImages.gradle
@@ -3,11 +3,6 @@ plugins {
 	id 'org.springframework.boot' version '{version}'
 }
 
-java {
-	sourceCompatibility = '1.8'
-	targetCompatibility = '1.8'
-}
-
 bootBuildImage {
 	builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2"
 	pullPolicy = "IF_NOT_PRESENT"
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithCommandLineOptions.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithCommandLineOptions.gradle
index 930f80c0b0cd..feb1992eebd7 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithCommandLineOptions.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithCommandLineOptions.gradle
@@ -2,8 +2,3 @@ plugins {
 	id 'java'
 	id 'org.springframework.boot' version '{version}'
 }
-
-java {
-	sourceCompatibility = '1.8'
-	targetCompatibility = '1.8'
-}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithCustomBuilderAndRunImage.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithCustomBuilderAndRunImage.gradle
index ce6a5a743242..38e40757f487 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithCustomBuilderAndRunImage.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithCustomBuilderAndRunImage.gradle
@@ -3,11 +3,6 @@ plugins {
 	id 'org.springframework.boot' version '{version}'
 }
 
-java {
-	sourceCompatibility = '1.8'
-	targetCompatibility = '1.8'
-}
-
 bootBuildImage {
 	imageName = "example/test-image-custom"
 	builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2"
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithCustomName.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithCustomName.gradle
index 332bb9328303..6e48d8872f27 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithCustomName.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithCustomName.gradle
@@ -3,11 +3,6 @@ plugins {
 	id 'org.springframework.boot' version '{version}'
 }
 
-java {
-	sourceCompatibility = '1.8'
-	targetCompatibility = '1.8'
-}
-
 bootBuildImage {
 	imageName = "example/test-image-name"
 	builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2"
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithEmptySecurityOptions.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithEmptySecurityOptions.gradle
new file mode 100644
index 000000000000..5cac53acfd9b
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithEmptySecurityOptions.gradle
@@ -0,0 +1,10 @@
+plugins {
+	id 'java'
+	id 'org.springframework.boot' version '{version}'
+}
+
+bootBuildImage {
+	builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2"
+	pullPolicy = "IF_NOT_PRESENT"
+	securityOptions = []
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithLaunchScript.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithLaunchScript.gradle
index 8c07e29734c3..98eafe4e33fa 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithLaunchScript.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithLaunchScript.gradle
@@ -3,11 +3,6 @@ plugins {
 	id 'org.springframework.boot' version '{version}'
 }
 
-java {
-	sourceCompatibility = '1.8'
-	targetCompatibility = '1.8'
-}
-
 bootJar {
 	launchScript()
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithNetworkModeNone.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithNetworkModeNone.gradle
index a7932ab792d6..ecb021bfdd00 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithNetworkModeNone.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithNetworkModeNone.gradle
@@ -7,11 +7,6 @@ if (project.hasProperty('applyWarPlugin')) {
 	apply plugin: 'war'
 }
 
-java {
-	sourceCompatibility = '1.8'
-	targetCompatibility = '1.8'
-}
-
 bootBuildImage {
 	builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2"
 	pullPolicy = "IF_NOT_PRESENT"
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithPullPolicy.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithPullPolicy.gradle
index cdebef9afb44..700a043d27bb 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithPullPolicy.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithPullPolicy.gradle
@@ -9,11 +9,6 @@ if (project.hasProperty('applyWarPlugin')) {
 	apply plugin: 'war'
 }
 
-java {
-	sourceCompatibility = '1.8'
-	targetCompatibility = '1.8'
-}
-
 bootBuildImage {
 	builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2"
 	pullPolicy = PullPolicy.ALWAYS
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithTag.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithTag.gradle
index 3daac74579df..35f877d84f65 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithTag.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithTag.gradle
@@ -3,11 +3,6 @@ plugins {
 	id 'org.springframework.boot' version '{version}'
 }
 
-java {
-	sourceCompatibility = '1.8'
-	targetCompatibility = '1.8'
-}
-
 bootBuildImage {
 	builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2"
 	pullPolicy = "IF_NOT_PRESENT"
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithVolumeCaches.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithVolumeCaches.gradle
index c4bc44c6e505..a9e1be87fb78 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithVolumeCaches.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithVolumeCaches.gradle
@@ -3,14 +3,14 @@ plugins {
 	id 'org.springframework.boot' version '{version}'
 }
 
-java {
-	sourceCompatibility = '1.8'
-	targetCompatibility = '1.8'
-}
-
 bootBuildImage {
 	builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2"
 	pullPolicy = "IF_NOT_PRESENT"
+	buildWorkspace {
+		volume {
+			name = "pack-${rootProject.name}.work"
+		}
+	}
 	buildCache {
 		volume {
 			name = "cache-${rootProject.name}.build"
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithWarPackagingAndJarConfiguration.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithWarPackagingAndJarConfiguration.gradle
index 92f643bc3436..3ca72dd5895c 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithWarPackagingAndJarConfiguration.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithWarPackagingAndJarConfiguration.gradle
@@ -3,11 +3,6 @@ plugins {
 	id 'org.springframework.boot' version '{version}'
 }
 
-java {
-	sourceCompatibility = '1.8'
-	targetCompatibility = '1.8'
-}
-
 bootBuildImage {
 	builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2"
 	pullPolicy = "IF_NOT_PRESENT"
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWhenCachesAreConfiguredTwice.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWhenCachesAreConfiguredTwice.gradle
index 7b3c343aab7d..77068a791fcc 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWhenCachesAreConfiguredTwice.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWhenCachesAreConfiguredTwice.gradle
@@ -3,19 +3,14 @@ plugins {
 	id 'org.springframework.boot' version '{version}'
 }
 
-java {
-	sourceCompatibility = '1.8'
-	targetCompatibility = '1.8'
-}
-
 bootBuildImage {
 	builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2"
 	buildCache {
 		volume {
-			name = "build-cache-volume1"
+			name = "build-cache-volume"
 		}
-		volume {
-			name = "build-cache-volum2"
+		bind {
+			name = "/tmp/build-cache-bind"
 		}
 	}
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWithBuilderError.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWithBuilderError.gradle
index 1a77f0dc9a00..dfa84603d153 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWithBuilderError.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWithBuilderError.gradle
@@ -3,11 +3,6 @@ plugins {
 	id 'org.springframework.boot' version '{version}'
 }
 
-java {
-	sourceCompatibility = '1.8'
-	targetCompatibility = '1.8'
-}
-
 bootBuildImage {
 	builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2"
 	pullPolicy = "IF_NOT_PRESENT"
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWithBuildpackNotInBuilder.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWithBuildpackNotInBuilder.gradle
index f844f92c1db1..27b9facfd3c4 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWithBuildpackNotInBuilder.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWithBuildpackNotInBuilder.gradle
@@ -3,11 +3,6 @@ plugins {
 	id 'org.springframework.boot' version '{version}'
 }
 
-java {
-	sourceCompatibility = '1.8'
-	targetCompatibility = '1.8'
-}
-
 bootBuildImage {
 	builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2"
 	pullPolicy = "IF_NOT_PRESENT"
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWithInvalidTag.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWithInvalidTag.gradle
index 342e745159c4..08cb7a1ab99e 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWithInvalidTag.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWithInvalidTag.gradle
@@ -3,11 +3,6 @@ plugins {
 	id 'org.springframework.boot' version '{version}'
 }
 
-java {
-	sourceCompatibility = '1.8'
-	targetCompatibility = '1.8'
-}
-
 bootBuildImage {
 	builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2"
 	pullPolicy = "IF_NOT_PRESENT"
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests.gradle
index ec211fbc3fd1..5ba1162191eb 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests.gradle
@@ -7,11 +7,6 @@ if (project.hasProperty('applyWarPlugin')) {
 	apply plugin: 'war'
 }
 
-java {
-	sourceCompatibility = '1.8'
-	targetCompatibility = '1.8'
-}
-
 bootBuildImage {
 	builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2"
 	pullPolicy = "IF_NOT_PRESENT"
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageRegistryIntegrationTests.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageRegistryIntegrationTests.gradle
index bdc976cff3d3..1134648e8521 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageRegistryIntegrationTests.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageRegistryIntegrationTests.gradle
@@ -3,11 +3,6 @@ plugins {
 	id 'org.springframework.boot' version '{version}'
 }
 
-java {
-	sourceCompatibility = '1.8'
-	targetCompatibility = '1.8'
-}
-
 bootBuildImage {
 	builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2"
 	publish = true
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-classicLoader.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-classicLoader.gradle
new file mode 100644
index 000000000000..2e9e26c99cad
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-classicLoader.gradle
@@ -0,0 +1,9 @@
+plugins {
+	id 'java'
+	id 'org.springframework.boot' version '{version}'
+}
+
+bootJar {
+	mainClass = 'com.example.Application'
+	loaderImplementation = org.springframework.boot.loader.tools.LoaderImplementation.CLASSIC
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-dirModeAndFileModeAreApplied.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-dirModeAndFileModeAreApplied.gradle
new file mode 100644
index 000000000000..506858a4da9b
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-dirModeAndFileModeAreApplied.gradle
@@ -0,0 +1,10 @@
+plugins {
+	id 'java'
+	id 'org.springframework.boot' version '{version}'
+}
+
+tasks.named("bootJar") {
+	fileMode = 0400
+	dirMode = 0500
+	mainClass = 'com.example.Application'
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-explodedApplicationClasspath.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-explodedApplicationClasspath.gradle
index f97b7df5b6bd..c0139a8a971d 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-explodedApplicationClasspath.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-explodedApplicationClasspath.gradle
@@ -15,11 +15,11 @@ dependencies {
 
 task explode(type: Sync) {
 	dependsOn(bootJar)
-	destinationDir = file("$buildDir/exploded")
+	destinationDir = layout.buildDirectory.dir("exploded").get().asFile
 	from zipTree(files(bootJar).singleFile)
 }
 
 task launch(type: JavaExec) {
 	classpath = files(explode)
-	mainClass = 'org.springframework.boot.loader.JarLauncher'
+	mainClass = 'org.springframework.boot.loader.launch.JarLauncher'
 }
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-signed.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-signed.gradle
new file mode 100644
index 000000000000..e879cc96e8a0
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-signed.gradle
@@ -0,0 +1,17 @@
+plugins {
+	id 'java'
+	id 'org.springframework.boot' version '{version}'
+}
+
+bootJar {
+	mainClass = 'com.example.Application'
+}
+
+repositories {
+	mavenCentral()
+	maven { url "file:repository" }
+}
+
+dependencies {
+	implementation("org.bouncycastle:bcprov-jdk18on:1.76")
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-testAndDevelopmentOnlyDependenciesAreNotIncludedInTheArchiveByDefault.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-testAndDevelopmentOnlyDependenciesAreNotIncludedInTheArchiveByDefault.gradle
new file mode 100644
index 000000000000..7f4ca313065c
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-testAndDevelopmentOnlyDependenciesAreNotIncludedInTheArchiveByDefault.gradle
@@ -0,0 +1,24 @@
+plugins {
+	id 'java'
+	id 'org.springframework.boot' version '{version}'
+}
+
+bootJar {
+	mainClass = 'com.example.Application'
+}
+
+repositories {
+	mavenCentral()
+}
+
+dependencies {
+	testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.9")
+	testAndDevelopmentOnly("commons-io:commons-io:2.6")
+	implementation("commons-io:commons-io:2.6")
+}
+
+bootJar {
+	layered {
+		enabled = false
+	}
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-testAndDevelopmentOnlyDependenciesCanBeIncludedInTheArchive.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-testAndDevelopmentOnlyDependenciesCanBeIncludedInTheArchive.gradle
new file mode 100644
index 000000000000..45041d1c1908
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-testAndDevelopmentOnlyDependenciesCanBeIncludedInTheArchive.gradle
@@ -0,0 +1,27 @@
+plugins {
+	id 'java'
+	id 'org.springframework.boot' version '{version}'
+}
+
+bootJar {
+	mainClass = 'com.example.Application'
+}
+
+repositories {
+	mavenCentral()
+}
+
+dependencies {
+	testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.9")
+	implementation("commons-io:commons-io:2.6")
+}
+
+bootJar {
+	classpath configurations.testAndDevelopmentOnly
+}
+
+bootJar {
+	layered {
+		enabled = false
+	}
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootWarIntegrationTests-classicLoader.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootWarIntegrationTests-classicLoader.gradle
new file mode 100644
index 000000000000..fd14cc1a64af
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootWarIntegrationTests-classicLoader.gradle
@@ -0,0 +1,9 @@
+plugins {
+	id 'war'
+	id 'org.springframework.boot' version '{version}'
+}
+
+bootWar {
+	mainClass = 'com.example.Application'
+	loaderImplementation = org.springframework.boot.loader.tools.LoaderImplementation.CLASSIC
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootWarIntegrationTests-dirModeAndFileModeAreApplied.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootWarIntegrationTests-dirModeAndFileModeAreApplied.gradle
new file mode 100644
index 000000000000..0e352e739331
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootWarIntegrationTests-dirModeAndFileModeAreApplied.gradle
@@ -0,0 +1,10 @@
+plugins {
+	id 'war'
+	id 'org.springframework.boot' version '{version}'
+}
+
+tasks.named("bootWar") {
+	fileMode = 0400
+	dirMode = 0500
+	mainClass = 'com.example.Application'
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootWarIntegrationTests-testAndDevelopmentOnlyDependenciesAreNotIncludedInTheArchiveByDefault.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootWarIntegrationTests-testAndDevelopmentOnlyDependenciesAreNotIncludedInTheArchiveByDefault.gradle
new file mode 100644
index 000000000000..f2d285e40810
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootWarIntegrationTests-testAndDevelopmentOnlyDependenciesAreNotIncludedInTheArchiveByDefault.gradle
@@ -0,0 +1,24 @@
+plugins {
+	id 'war'
+	id 'org.springframework.boot' version '{version}'
+}
+
+bootWar {
+	mainClass = 'com.example.Application'
+}
+
+repositories {
+	mavenCentral()
+}
+
+dependencies {
+	testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.9")
+	testAndDevelopmentOnly("commons-io:commons-io:2.6")
+	implementation("commons-io:commons-io:2.6")
+}
+
+bootWar {
+	layered {
+		enabled = false
+	}
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootWarIntegrationTests-testAndDevelopmentOnlyDependenciesCanBeIncludedInTheArchive.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootWarIntegrationTests-testAndDevelopmentOnlyDependenciesCanBeIncludedInTheArchive.gradle
new file mode 100644
index 000000000000..de8e9d652170
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootWarIntegrationTests-testAndDevelopmentOnlyDependenciesCanBeIncludedInTheArchive.gradle
@@ -0,0 +1,27 @@
+plugins {
+	id 'war'
+	id 'org.springframework.boot' version '{version}'
+}
+
+bootWar {
+	mainClass = 'com.example.Application'
+}
+
+repositories {
+	mavenCentral()
+}
+
+dependencies {
+	testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.9")
+	implementation("commons-io:commons-io:2.6")
+}
+
+bootWar {
+	classpath configurations.testAndDevelopmentOnly
+}
+
+bootWar {
+	layered {
+		enabled = false
+	}
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/MavenIntegrationTests-bootJarCanBeUploaded.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/MavenIntegrationTests-bootJarCanBeUploaded.gradle
index 06fddc9cf194..427c958388b7 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/MavenIntegrationTests-bootJarCanBeUploaded.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/MavenIntegrationTests-bootJarCanBeUploaded.gradle
@@ -14,7 +14,7 @@ version = '1.0'
 uploadBootArchives {
 	repositories {
 		mavenDeployer {
-		 	repository(url: "file:${buildDir}/repo")
+		 	repository(url: "file:${layout.buildDirectory.dir("repo").get().asFile}")
 		}
 	}
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/MavenIntegrationTests-bootWarCanBeUploaded.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/MavenIntegrationTests-bootWarCanBeUploaded.gradle
index 5a3b86ddff1c..ee0a2e37d382 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/MavenIntegrationTests-bootWarCanBeUploaded.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/MavenIntegrationTests-bootWarCanBeUploaded.gradle
@@ -14,7 +14,7 @@ version = '1.0'
 uploadBootArchives {
 	repositories {
 		mavenDeployer {
-		 	repository(url: "file:${buildDir}/repo")
+			repository(url: "file:${layout.buildDirectory.dir("repo").get().asFile}")
 		}
 	}
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/MavenPublishingIntegrationTests-bootJarCanBePublished.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/MavenPublishingIntegrationTests-bootJarCanBePublished.gradle
index 4c0506b7dbb3..f2d3d60c77c2 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/MavenPublishingIntegrationTests-bootJarCanBePublished.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/MavenPublishingIntegrationTests-bootJarCanBePublished.gradle
@@ -14,7 +14,7 @@ version = '1.0'
 publishing {
 	repositories {
 		maven {
-			url "${buildDir}/repo"
+			url = layout.buildDirectory.dir("repo")
 		}
 	}
 	publications {
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/MavenPublishingIntegrationTests-bootWarCanBePublished.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/MavenPublishingIntegrationTests-bootWarCanBePublished.gradle
index cf6d104d42ee..085f9373affc 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/MavenPublishingIntegrationTests-bootWarCanBePublished.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/MavenPublishingIntegrationTests-bootWarCanBePublished.gradle
@@ -14,7 +14,7 @@ version = '1.0'
 publishing {
 	repositories {
 		maven {
-			url "${buildDir}/repo"
+			url = layout.buildDirectory.dir("repo")
 		}
 	}
 	publications {
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootRunIntegrationTests-developmentOnlyDependenciesAreOnTheClasspath.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootRunIntegrationTests-developmentOnlyDependenciesAreOnTheClasspath.gradle
new file mode 100644
index 000000000000..39b58bb700d6
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootRunIntegrationTests-developmentOnlyDependenciesAreOnTheClasspath.gradle
@@ -0,0 +1,12 @@
+plugins {
+	id 'java'
+	id 'org.springframework.boot' version '{version}'
+}
+
+repositories {
+	mavenCentral()
+}
+
+dependencies {
+	developmentOnly("org.apache.commons:commons-lang3:3.12.0")
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootRunIntegrationTests-testAndDevelopmentOnlyDependenciesAreOnTheClasspath.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootRunIntegrationTests-testAndDevelopmentOnlyDependenciesAreOnTheClasspath.gradle
new file mode 100644
index 000000000000..6d4b5ce828fb
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootRunIntegrationTests-testAndDevelopmentOnlyDependenciesAreOnTheClasspath.gradle
@@ -0,0 +1,12 @@
+plugins {
+	id 'java'
+	id 'org.springframework.boot' version '{version}'
+}
+
+repositories {
+	mavenCentral()
+}
+
+dependencies {
+	testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.12.0")
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootTestRunIntegrationTests-developmentOnlyDependenciesAreNotOnTheClasspath.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootTestRunIntegrationTests-developmentOnlyDependenciesAreNotOnTheClasspath.gradle
new file mode 100644
index 000000000000..39b58bb700d6
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootTestRunIntegrationTests-developmentOnlyDependenciesAreNotOnTheClasspath.gradle
@@ -0,0 +1,12 @@
+plugins {
+	id 'java'
+	id 'org.springframework.boot' version '{version}'
+}
+
+repositories {
+	mavenCentral()
+}
+
+dependencies {
+	developmentOnly("org.apache.commons:commons-lang3:3.12.0")
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootTestRunIntegrationTests-testAndDevelopmentOnlyDependenciesAreOnTheClasspath.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootTestRunIntegrationTests-testAndDevelopmentOnlyDependenciesAreOnTheClasspath.gradle
new file mode 100644
index 000000000000..6d4b5ce828fb
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootTestRunIntegrationTests-testAndDevelopmentOnlyDependenciesAreOnTheClasspath.gradle
@@ -0,0 +1,12 @@
+plugins {
+	id 'java'
+	id 'org.springframework.boot' version '{version}'
+}
+
+repositories {
+	mavenCentral()
+}
+
+dependencies {
+	testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.12.0")
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-test-support/src/main/java/org/springframework/boot/testsupport/gradle/testkit/GradleBuild.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-test-support/src/main/java/org/springframework/boot/testsupport/gradle/testkit/GradleBuild.java
index 82f901650704..6fb983549f13 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-test-support/src/main/java/org/springframework/boot/testsupport/gradle/testkit/GradleBuild.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-test-support/src/main/java/org/springframework/boot/testsupport/gradle/testkit/GradleBuild.java
@@ -61,7 +61,7 @@
 import org.springframework.util.FileSystemUtils;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.fail;
+import static org.assertj.core.api.Assertions.fail;
 
 /**
  * A {@code GradleBuild} is used to run a Gradle build using {@link GradleRunner}.
@@ -118,7 +118,6 @@ private List<File> pluginClasspath() {
 				new File(pathOfJarContaining(ClassVisitor.class)),
 				new File(pathOfJarContaining(DependencyManagementPlugin.class)),
 				new File(pathOfJarContaining("org.jetbrains.kotlin.cli.common.PropertiesKt")),
-				new File(pathOfJarContaining("org.jetbrains.kotlin.compilerRunner.KotlinLogger")),
 				new File(pathOfJarContaining(KotlinPlatformJvmPlugin.class)),
 				new File(pathOfJarContaining(KotlinProject.class)),
 				new File(pathOfJarContaining(KotlinToolingVersion.class)),
@@ -236,8 +235,8 @@ public GradleRunner prepareRunner(String... arguments) throws IOException {
 		GradleRunner gradleRunner = GradleRunner.create()
 			.withProjectDir(this.projectDir)
 			.withPluginClasspath(pluginClasspath());
-		if (this.dsl != Dsl.KOTLIN && !this.configurationCache) {
-			// see https://github.com/gradle/gradle/issues/6862
+		if (!this.configurationCache) {
+			// See https://github.com/gradle/gradle/issues/14125
 			gradleRunner.withDebug(true);
 		}
 		if (this.gradleVersion != null) {
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-test-support/src/main/java/org/springframework/boot/testsupport/gradle/testkit/GradleVersions.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-test-support/src/main/java/org/springframework/boot/testsupport/gradle/testkit/GradleVersions.java
index 43dd86c30a64..a5031c7e7447 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-test-support/src/main/java/org/springframework/boot/testsupport/gradle/testkit/GradleVersions.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-test-support/src/main/java/org/springframework/boot/testsupport/gradle/testkit/GradleVersions.java
@@ -35,15 +35,20 @@ private GradleVersions() {
 	@SuppressWarnings("UnstableApiUsage")
 	public static List<String> allCompatible() {
 		if (isJavaVersion(JavaVersion.VERSION_20)) {
-			return Arrays.asList("8.1.1", "8.2");
+			return Arrays.asList("8.1.1", "8.3", "8.4");
 		}
-		return Arrays.asList("7.5.1", GradleVersion.current().getVersion(), "8.0.2", "8.2");
+		return Arrays.asList("7.5.1", GradleVersion.current().getVersion(), "8.0.2", "8.3", "8.4");
 	}
 
 	public static String minimumCompatible() {
 		return allCompatible().get(0);
 	}
 
+	public static String maximumCompatible() {
+		List<String> versions = allCompatible();
+		return versions.get(versions.size() - 1);
+	}
+
 	private static boolean isJavaVersion(JavaVersion version) {
 		return JavaVersion.current().isCompatibleWith(version);
 	}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/build.gradle
index 1f78242394e5..96d503924997 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/build.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/build.gradle
@@ -7,7 +7,7 @@ plugins {
 description = "Spring Boot Layers Tools"
 
 dependencies {
-	implementation(project(":spring-boot-project:spring-boot-tools:spring-boot-loader"))
+	implementation(project(":spring-boot-project:spring-boot-tools:spring-boot-loader-classic"))
 	implementation("org.springframework:spring-core")
 
 	testImplementation("org.assertj:assertj-core")
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/HelpCommand.java b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/HelpCommand.java
index 70f5d385c3fb..4f25c80dc259 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/HelpCommand.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/HelpCommand.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -40,10 +40,10 @@ class HelpCommand extends Command {
 
 	@Override
 	protected void run(Map<Option, String> options, List<String> parameters) {
-		run(System.out, options, parameters);
+		run(System.out, parameters);
 	}
 
-	void run(PrintStream out, Map<Option, String> options, List<String> parameters) {
+	void run(PrintStream out, List<String> parameters) {
 		Command command = (!parameters.isEmpty()) ? Command.find(this.commands, parameters.get(0)) : null;
 		if (command != null) {
 			printCommandHelp(out, command);
@@ -66,8 +66,7 @@ private void printCommandHelp(PrintStream out, Command command) {
 	}
 
 	private void printOptionSummary(PrintStream out, Option option, int padding) {
-		out.println(String.format("  --%-" + padding + "s  %s", option.getNameAndValueDescription(),
-				option.getDescription()));
+		out.printf("  --%-" + padding + "s  %s%n", option.getNameAndValueDescription(), option.getDescription());
 	}
 
 	private String getUsage(Command command) {
@@ -76,7 +75,7 @@ private String getUsage(Command command) {
 		if (!command.getOptions().isEmpty()) {
 			usage.append(" [options]");
 		}
-		command.getParameters().getDescriptions().forEach((param) -> usage.append(" " + param));
+		command.getParameters().getDescriptions().forEach((param) -> usage.append(" ").append(param));
 		return usage.toString();
 	}
 
@@ -95,7 +94,7 @@ private int getMaxLength(int minimum, Stream<String> strings) {
 	}
 
 	private void printCommandSummary(PrintStream out, Command command, int padding) {
-		out.println(String.format("  %-" + padding + "s  %s", command.getName(), command.getDescription()));
+		out.printf("  %-" + padding + "s  %s%n", command.getName(), command.getDescription());
 	}
 
 	private String getJavaCommand() {
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/ExtractCommandTests.java b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/ExtractCommandTests.java
index 7407d91109c6..b8abc1ac3f6c 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/ExtractCommandTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/ExtractCommandTests.java
@@ -167,6 +167,7 @@ void runWithJarFileThatWouldWriteEntriesOutsideDestinationFails() throws Excepti
 			}
 		});
 		given(this.context.getArchiveFile()).willReturn(this.jarFile);
+		given(this.context.getWorkingDir()).willReturn(this.extract);
 		assertThatIllegalStateException()
 			.isThrownBy(() -> this.command.run(Collections.emptyMap(), Collections.emptyList()))
 			.withMessageContaining("Entry 'e/../../e.jar' would be written");
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/HelpCommandTests.java b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/HelpCommandTests.java
index 4491d8798f18..9acb9c9c6ca8 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/HelpCommandTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/HelpCommandTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -62,13 +62,13 @@ void setup() throws Exception {
 
 	@Test
 	void runWhenHasNoParametersPrintsUsage() {
-		this.command.run(this.out, Collections.emptyMap(), Collections.emptyList());
+		this.command.run(this.out, Collections.emptyList());
 		assertThat(this.out).hasSameContentAsResource("help-output.txt");
 	}
 
 	@Test
 	void runWhenHasNoCommandParameterPrintsUsage() {
-		this.command.run(this.out, Collections.emptyMap(), Arrays.asList("extract"));
+		this.command.run(this.out, Arrays.asList("extract"));
 		System.out.println(this.out);
 		assertThat(this.out).hasSameContentAsResource("help-extract-output.txt");
 	}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/build.gradle
new file mode 100644
index 000000000000..17d2a7b519a5
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/build.gradle
@@ -0,0 +1,23 @@
+plugins {
+	id "java-library"
+	id "org.springframework.boot.conventions"
+	id "org.springframework.boot.deployed"
+}
+
+description = "Spring Boot Classic Loader"
+
+dependencies {
+	compileOnly("org.springframework:spring-core")
+
+	testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support"))
+	testImplementation("org.assertj:assertj-core")
+	testImplementation("org.awaitility:awaitility")
+	testImplementation("org.junit.jupiter:junit-jupiter")
+	testImplementation("org.mockito:mockito-core")
+	testImplementation("org.springframework:spring-test")
+	testImplementation("org.springframework:spring-core-test")
+
+	testRuntimeOnly("ch.qos.logback:logback-classic")
+	testRuntimeOnly("org.bouncycastle:bcprov-jdk18on:1.71")
+	testRuntimeOnly("org.springframework:spring-webmvc")
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/ClassPathIndexFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/ClassPathIndexFile.java
new file mode 100644
index 000000000000..5ad01e507127
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/ClassPathIndexFile.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.MalformedURLException;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A class path index file that provides ordering information for JARs.
+ *
+ * @author Madhura Bhave
+ * @author Phillip Webb
+ */
+final class ClassPathIndexFile {
+
+	private final File root;
+
+	private final List<String> lines;
+
+	private ClassPathIndexFile(File root, List<String> lines) {
+		this.root = root;
+		this.lines = lines.stream().map(this::extractName).toList();
+	}
+
+	private String extractName(String line) {
+		if (line.startsWith("- \"") && line.endsWith("\"")) {
+			return line.substring(3, line.length() - 1);
+		}
+		throw new IllegalStateException("Malformed classpath index line [" + line + "]");
+	}
+
+	int size() {
+		return this.lines.size();
+	}
+
+	boolean containsEntry(String name) {
+		if (name == null || name.isEmpty()) {
+			return false;
+		}
+		return this.lines.contains(name);
+	}
+
+	List<URL> getUrls() {
+		return this.lines.stream().map(this::asUrl).toList();
+	}
+
+	private URL asUrl(String line) {
+		try {
+			return new File(this.root, line).toURI().toURL();
+		}
+		catch (MalformedURLException ex) {
+			throw new IllegalStateException(ex);
+		}
+	}
+
+	static ClassPathIndexFile loadIfPossible(URL root, String location) throws IOException {
+		return loadIfPossible(asFile(root), location);
+	}
+
+	private static ClassPathIndexFile loadIfPossible(File root, String location) throws IOException {
+		return loadIfPossible(root, new File(root, location));
+	}
+
+	private static ClassPathIndexFile loadIfPossible(File root, File indexFile) throws IOException {
+		if (indexFile.exists() && indexFile.isFile()) {
+			try (InputStream inputStream = new FileInputStream(indexFile)) {
+				return new ClassPathIndexFile(root, loadLines(inputStream));
+			}
+		}
+		return null;
+	}
+
+	private static List<String> loadLines(InputStream inputStream) throws IOException {
+		List<String> lines = new ArrayList<>();
+		BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
+		String line = reader.readLine();
+		while (line != null) {
+			if (!line.trim().isEmpty()) {
+				lines.add(line);
+			}
+			line = reader.readLine();
+		}
+		return Collections.unmodifiableList(lines);
+	}
+
+	private static File asFile(URL url) {
+		if (!"file".equals(url.getProtocol())) {
+			throw new IllegalArgumentException("URL does not reference a file");
+		}
+		try {
+			return new File(url.toURI());
+		}
+		catch (URISyntaxException ex) {
+			return new File(url.getPath());
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java
new file mode 100644
index 000000000000..d2ceaf61c565
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java
@@ -0,0 +1,207 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader;
+
+import java.io.IOException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.jar.Attributes;
+import java.util.jar.Manifest;
+
+import org.springframework.boot.loader.archive.Archive;
+import org.springframework.boot.loader.archive.ExplodedArchive;
+
+/**
+ * Base class for executable archive {@link Launcher}s.
+ *
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ * @author Madhura Bhave
+ * @author Scott Frederick
+ * @since 1.0.0
+ */
+public abstract class ExecutableArchiveLauncher extends Launcher {
+
+	private static final String START_CLASS_ATTRIBUTE = "Start-Class";
+
+	protected static final String BOOT_CLASSPATH_INDEX_ATTRIBUTE = "Spring-Boot-Classpath-Index";
+
+	protected static final String DEFAULT_CLASSPATH_INDEX_FILE_NAME = "classpath.idx";
+
+	private final Archive archive;
+
+	private final ClassPathIndexFile classPathIndex;
+
+	public ExecutableArchiveLauncher() {
+		try {
+			this.archive = createArchive();
+			this.classPathIndex = getClassPathIndex(this.archive);
+		}
+		catch (Exception ex) {
+			throw new IllegalStateException(ex);
+		}
+	}
+
+	protected ExecutableArchiveLauncher(Archive archive) {
+		try {
+			this.archive = archive;
+			this.classPathIndex = getClassPathIndex(this.archive);
+		}
+		catch (Exception ex) {
+			throw new IllegalStateException(ex);
+		}
+	}
+
+	protected ClassPathIndexFile getClassPathIndex(Archive archive) throws IOException {
+		// Only needed for exploded archives, regular ones already have a defined order
+		if (archive instanceof ExplodedArchive) {
+			String location = getClassPathIndexFileLocation(archive);
+			return ClassPathIndexFile.loadIfPossible(archive.getUrl(), location);
+		}
+		return null;
+	}
+
+	private String getClassPathIndexFileLocation(Archive archive) throws IOException {
+		Manifest manifest = archive.getManifest();
+		Attributes attributes = (manifest != null) ? manifest.getMainAttributes() : null;
+		String location = (attributes != null) ? attributes.getValue(BOOT_CLASSPATH_INDEX_ATTRIBUTE) : null;
+		return (location != null) ? location : getArchiveEntryPathPrefix() + DEFAULT_CLASSPATH_INDEX_FILE_NAME;
+	}
+
+	@Override
+	protected String getMainClass() throws Exception {
+		Manifest manifest = this.archive.getManifest();
+		String mainClass = null;
+		if (manifest != null) {
+			mainClass = manifest.getMainAttributes().getValue(START_CLASS_ATTRIBUTE);
+		}
+		if (mainClass == null) {
+			throw new IllegalStateException("No 'Start-Class' manifest entry specified in " + this);
+		}
+		return mainClass;
+	}
+
+	@Override
+	protected ClassLoader createClassLoader(Iterator<Archive> archives) throws Exception {
+		List<URL> urls = new ArrayList<>(guessClassPathSize());
+		while (archives.hasNext()) {
+			urls.add(archives.next().getUrl());
+		}
+		if (this.classPathIndex != null) {
+			urls.addAll(this.classPathIndex.getUrls());
+		}
+		return createClassLoader(urls.toArray(new URL[0]));
+	}
+
+	private int guessClassPathSize() {
+		if (this.classPathIndex != null) {
+			return this.classPathIndex.size() + 10;
+		}
+		return 50;
+	}
+
+	@Override
+	protected Iterator<Archive> getClassPathArchivesIterator() throws Exception {
+		Archive.EntryFilter searchFilter = this::isSearchCandidate;
+		Iterator<Archive> archives = this.archive.getNestedArchives(searchFilter,
+				(entry) -> isNestedArchive(entry) && !isEntryIndexed(entry));
+		if (isPostProcessingClassPathArchives()) {
+			archives = applyClassPathArchivePostProcessing(archives);
+		}
+		return archives;
+	}
+
+	private boolean isEntryIndexed(Archive.Entry entry) {
+		if (this.classPathIndex != null) {
+			return this.classPathIndex.containsEntry(entry.getName());
+		}
+		return false;
+	}
+
+	private Iterator<Archive> applyClassPathArchivePostProcessing(Iterator<Archive> archives) throws Exception {
+		List<Archive> list = new ArrayList<>();
+		while (archives.hasNext()) {
+			list.add(archives.next());
+		}
+		postProcessClassPathArchives(list);
+		return list.iterator();
+	}
+
+	/**
+	 * Determine if the specified entry is a candidate for further searching.
+	 * @param entry the entry to check
+	 * @return {@code true} if the entry is a candidate for further searching
+	 * @since 2.3.0
+	 */
+	protected boolean isSearchCandidate(Archive.Entry entry) {
+		if (getArchiveEntryPathPrefix() == null) {
+			return true;
+		}
+		return entry.getName().startsWith(getArchiveEntryPathPrefix());
+	}
+
+	/**
+	 * Determine if the specified entry is a nested item that should be added to the
+	 * classpath.
+	 * @param entry the entry to check
+	 * @return {@code true} if the entry is a nested item (jar or directory)
+	 */
+	protected abstract boolean isNestedArchive(Archive.Entry entry);
+
+	/**
+	 * Return if post-processing needs to be applied to the archives. For back
+	 * compatibility this method returns {@code true}, but subclasses that don't override
+	 * {@link #postProcessClassPathArchives(List)} should provide an implementation that
+	 * returns {@code false}.
+	 * @return if the {@link #postProcessClassPathArchives(List)} method is implemented
+	 * @since 2.3.0
+	 */
+	protected boolean isPostProcessingClassPathArchives() {
+		return true;
+	}
+
+	/**
+	 * Called to post-process archive entries before they are used. Implementations can
+	 * add and remove entries.
+	 * @param archives the archives
+	 * @throws Exception if the post-processing fails
+	 * @see #isPostProcessingClassPathArchives()
+	 */
+	protected void postProcessClassPathArchives(List<Archive> archives) throws Exception {
+	}
+
+	/**
+	 * Return the path prefix for entries in the archive.
+	 * @return the path prefix
+	 */
+	protected String getArchiveEntryPathPrefix() {
+		return null;
+	}
+
+	@Override
+	protected boolean isExploded() {
+		return this.archive.isExploded();
+	}
+
+	@Override
+	protected final Archive getArchive() {
+		return this.archive;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/JarLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/JarLauncher.java
new file mode 100644
index 000000000000..5061573e2460
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/JarLauncher.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader;
+
+import org.springframework.boot.loader.archive.Archive;
+import org.springframework.boot.loader.archive.Archive.EntryFilter;
+
+/**
+ * {@link Launcher} for JAR based archives. This launcher assumes that dependency jars are
+ * included inside a {@code /BOOT-INF/lib} directory and that application classes are
+ * included inside a {@code /BOOT-INF/classes} directory.
+ *
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ * @author Madhura Bhave
+ * @author Scott Frederick
+ * @since 1.0.0
+ */
+public class JarLauncher extends ExecutableArchiveLauncher {
+
+	static final EntryFilter NESTED_ARCHIVE_ENTRY_FILTER = (entry) -> {
+		if (entry.isDirectory()) {
+			return entry.getName().equals("BOOT-INF/classes/");
+		}
+		return entry.getName().startsWith("BOOT-INF/lib/");
+	};
+
+	public JarLauncher() {
+	}
+
+	protected JarLauncher(Archive archive) {
+		super(archive);
+	}
+
+	@Override
+	protected boolean isPostProcessingClassPathArchives() {
+		return false;
+	}
+
+	@Override
+	protected boolean isNestedArchive(Archive.Entry entry) {
+		return NESTED_ARCHIVE_ENTRY_FILTER.matches(entry);
+	}
+
+	@Override
+	protected String getArchiveEntryPathPrefix() {
+		return "BOOT-INF/";
+	}
+
+	public static void main(String[] args) throws Exception {
+		new JarLauncher().launch(args);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/LaunchedURLClassLoader.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/LaunchedURLClassLoader.java
similarity index 99%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/LaunchedURLClassLoader.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/LaunchedURLClassLoader.java
index 8c7cf98ae130..7e3e2fa22392 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/LaunchedURLClassLoader.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/LaunchedURLClassLoader.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/Launcher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/Launcher.java
new file mode 100644
index 000000000000..2f4cac944408
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/Launcher.java
@@ -0,0 +1,159 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader;
+
+import java.io.File;
+import java.net.URI;
+import java.net.URL;
+import java.security.CodeSource;
+import java.security.ProtectionDomain;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+import org.springframework.boot.loader.archive.Archive;
+import org.springframework.boot.loader.archive.ExplodedArchive;
+import org.springframework.boot.loader.archive.JarFileArchive;
+import org.springframework.boot.loader.jar.JarFile;
+
+/**
+ * Base class for launchers that can start an application with a fully configured
+ * classpath backed by one or more {@link Archive}s.
+ *
+ * @author Phillip Webb
+ * @author Dave Syer
+ * @since 1.0.0
+ */
+public abstract class Launcher {
+
+	private static final String JAR_MODE_LAUNCHER = "org.springframework.boot.loader.jarmode.JarModeLauncher";
+
+	/**
+	 * Launch the application. This method is the initial entry point that should be
+	 * called by a subclass {@code public static void main(String[] args)} method.
+	 * @param args the incoming arguments
+	 * @throws Exception if the application fails to launch
+	 */
+	protected void launch(String[] args) throws Exception {
+		if (!isExploded()) {
+			JarFile.registerUrlProtocolHandler();
+		}
+		ClassLoader classLoader = createClassLoader(getClassPathArchivesIterator());
+		String jarMode = System.getProperty("jarmode");
+		String launchClass = (jarMode != null && !jarMode.isEmpty()) ? JAR_MODE_LAUNCHER : getMainClass();
+		launch(args, launchClass, classLoader);
+	}
+
+	/**
+	 * Create a classloader for the specified archives.
+	 * @param archives the archives
+	 * @return the classloader
+	 * @throws Exception if the classloader cannot be created
+	 * @since 2.3.0
+	 */
+	protected ClassLoader createClassLoader(Iterator<Archive> archives) throws Exception {
+		List<URL> urls = new ArrayList<>(50);
+		while (archives.hasNext()) {
+			urls.add(archives.next().getUrl());
+		}
+		return createClassLoader(urls.toArray(new URL[0]));
+	}
+
+	/**
+	 * Create a classloader for the specified URLs.
+	 * @param urls the URLs
+	 * @return the classloader
+	 * @throws Exception if the classloader cannot be created
+	 */
+	protected ClassLoader createClassLoader(URL[] urls) throws Exception {
+		return new LaunchedURLClassLoader(isExploded(), getArchive(), urls, getClass().getClassLoader());
+	}
+
+	/**
+	 * Launch the application given the archive file and a fully configured classloader.
+	 * @param args the incoming arguments
+	 * @param launchClass the launch class to run
+	 * @param classLoader the classloader
+	 * @throws Exception if the launch fails
+	 */
+	protected void launch(String[] args, String launchClass, ClassLoader classLoader) throws Exception {
+		Thread.currentThread().setContextClassLoader(classLoader);
+		createMainMethodRunner(launchClass, args, classLoader).run();
+	}
+
+	/**
+	 * Create the {@code MainMethodRunner} used to launch the application.
+	 * @param mainClass the main class
+	 * @param args the incoming arguments
+	 * @param classLoader the classloader
+	 * @return the main method runner
+	 */
+	protected MainMethodRunner createMainMethodRunner(String mainClass, String[] args, ClassLoader classLoader) {
+		return new MainMethodRunner(mainClass, args);
+	}
+
+	/**
+	 * Returns the main class that should be launched.
+	 * @return the name of the main class
+	 * @throws Exception if the main class cannot be obtained
+	 */
+	protected abstract String getMainClass() throws Exception;
+
+	/**
+	 * Returns the archives that will be used to construct the class path.
+	 * @return the class path archives
+	 * @throws Exception if the class path archives cannot be obtained
+	 * @since 2.3.0
+	 */
+	protected abstract Iterator<Archive> getClassPathArchivesIterator() throws Exception;
+
+	protected final Archive createArchive() throws Exception {
+		ProtectionDomain protectionDomain = getClass().getProtectionDomain();
+		CodeSource codeSource = protectionDomain.getCodeSource();
+		URI location = (codeSource != null) ? codeSource.getLocation().toURI() : null;
+		String path = (location != null) ? location.getSchemeSpecificPart() : null;
+		if (path == null) {
+			throw new IllegalStateException("Unable to determine code source archive");
+		}
+		File root = new File(path);
+		if (!root.exists()) {
+			throw new IllegalStateException("Unable to determine code source archive from " + root);
+		}
+		return (root.isDirectory() ? new ExplodedArchive(root) : new JarFileArchive(root));
+	}
+
+	/**
+	 * Returns if the launcher is running in an exploded mode. If this method returns
+	 * {@code true} then only regular JARs are supported and the additional URL and
+	 * ClassLoader support infrastructure can be optimized.
+	 * @return if the jar is exploded.
+	 * @since 2.3.0
+	 */
+	protected boolean isExploded() {
+		return false;
+	}
+
+	/**
+	 * Return the root archive.
+	 * @return the root archive
+	 * @since 2.3.1
+	 */
+	protected Archive getArchive() {
+		return null;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/MainMethodRunner.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/MainMethodRunner.java
similarity index 96%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/MainMethodRunner.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/MainMethodRunner.java
index 9b7a551a8b6d..12355a2bef46 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/MainMethodRunner.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/MainMethodRunner.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/PropertiesLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/PropertiesLauncher.java
similarity index 100%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/PropertiesLauncher.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/PropertiesLauncher.java
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/WarLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/WarLauncher.java
new file mode 100644
index 000000000000..482832c1f722
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/WarLauncher.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader;
+
+import org.springframework.boot.loader.archive.Archive;
+
+/**
+ * {@link Launcher} for WAR based archives. This launcher for standard WAR archives.
+ * Supports dependencies in {@code WEB-INF/lib} as well as {@code WEB-INF/lib-provided},
+ * classes are loaded from {@code WEB-INF/classes}.
+ *
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ * @author Scott Frederick
+ * @since 1.0.0
+ */
+public class WarLauncher extends ExecutableArchiveLauncher {
+
+	public WarLauncher() {
+	}
+
+	protected WarLauncher(Archive archive) {
+		super(archive);
+	}
+
+	@Override
+	protected boolean isPostProcessingClassPathArchives() {
+		return false;
+	}
+
+	@Override
+	public boolean isNestedArchive(Archive.Entry entry) {
+		if (entry.isDirectory()) {
+			return entry.getName().equals("WEB-INF/classes/");
+		}
+		return entry.getName().startsWith("WEB-INF/lib/") || entry.getName().startsWith("WEB-INF/lib-provided/");
+	}
+
+	@Override
+	protected String getArchiveEntryPathPrefix() {
+		return "WEB-INF/";
+	}
+
+	public static void main(String[] args) throws Exception {
+		new WarLauncher().launch(args);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/Archive.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/Archive.java
new file mode 100644
index 000000000000..c1f2bbb2f75b
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/Archive.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.archive;
+
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.Iterator;
+import java.util.jar.Manifest;
+
+import org.springframework.boot.loader.Launcher;
+
+/**
+ * An archive that can be launched by the {@link Launcher}.
+ *
+ * @author Phillip Webb
+ * @since 1.0.0
+ * @see JarFileArchive
+ */
+public interface Archive extends Iterable<Archive.Entry>, AutoCloseable {
+
+	/**
+	 * Returns a URL that can be used to load the archive.
+	 * @return the archive URL
+	 * @throws MalformedURLException if the URL is malformed
+	 */
+	URL getUrl() throws MalformedURLException;
+
+	/**
+	 * Returns the manifest of the archive.
+	 * @return the manifest
+	 * @throws IOException if the manifest cannot be read
+	 */
+	Manifest getManifest() throws IOException;
+
+	/**
+	 * Returns nested {@link Archive}s for entries that match the specified filters.
+	 * @param searchFilter filter used to limit when additional sub-entry searching is
+	 * required or {@code null} if all entries should be considered.
+	 * @param includeFilter filter used to determine which entries should be included in
+	 * the result or {@code null} if all entries should be included
+	 * @return the nested archives
+	 * @throws IOException on IO error
+	 * @since 2.3.0
+	 */
+	Iterator<Archive> getNestedArchives(EntryFilter searchFilter, EntryFilter includeFilter) throws IOException;
+
+	/**
+	 * Return if the archive is exploded (already unpacked).
+	 * @return if the archive is exploded
+	 * @since 2.3.0
+	 */
+	default boolean isExploded() {
+		return false;
+	}
+
+	/**
+	 * Closes the {@code Archive}, releasing any open resources.
+	 * @throws Exception if an error occurs during close processing
+	 * @since 2.2.0
+	 */
+	@Override
+	default void close() throws Exception {
+
+	}
+
+	/**
+	 * Represents a single entry in the archive.
+	 */
+	interface Entry {
+
+		/**
+		 * Returns {@code true} if the entry represents a directory.
+		 * @return if the entry is a directory
+		 */
+		boolean isDirectory();
+
+		/**
+		 * Returns the name of the entry.
+		 * @return the name of the entry
+		 */
+		String getName();
+
+	}
+
+	/**
+	 * Strategy interface to filter {@link Entry Entries}.
+	 */
+	@FunctionalInterface
+	interface EntryFilter {
+
+		/**
+		 * Apply the jar entry filter.
+		 * @param entry the entry to filter
+		 * @return {@code true} if the filter matches
+		 */
+		boolean matches(Entry entry);
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/ExplodedArchive.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/ExplodedArchive.java
new file mode 100644
index 000000000000..f8cd52dc16f1
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/ExplodedArchive.java
@@ -0,0 +1,342 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.archive;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URL;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Deque;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.NoSuchElementException;
+import java.util.Set;
+import java.util.jar.Manifest;
+
+/**
+ * {@link Archive} implementation backed by an exploded archive directory.
+ *
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ * @author Madhura Bhave
+ * @since 1.0.0
+ */
+public class ExplodedArchive implements Archive {
+
+	private static final Set<String> SKIPPED_NAMES = new HashSet<>(Arrays.asList(".", ".."));
+
+	private final File root;
+
+	private final boolean recursive;
+
+	private final File manifestFile;
+
+	private Manifest manifest;
+
+	/**
+	 * Create a new {@link ExplodedArchive} instance.
+	 * @param root the root directory
+	 */
+	public ExplodedArchive(File root) {
+		this(root, true);
+	}
+
+	/**
+	 * Create a new {@link ExplodedArchive} instance.
+	 * @param root the root directory
+	 * @param recursive if recursive searching should be used to locate the manifest.
+	 * Defaults to {@code true}, directories with a large tree might want to set this to
+	 * {@code false}.
+	 */
+	public ExplodedArchive(File root, boolean recursive) {
+		if (!root.exists() || !root.isDirectory()) {
+			throw new IllegalArgumentException("Invalid source directory " + root);
+		}
+		this.root = root;
+		this.recursive = recursive;
+		this.manifestFile = getManifestFile(root);
+	}
+
+	private File getManifestFile(File root) {
+		File metaInf = new File(root, "META-INF");
+		return new File(metaInf, "MANIFEST.MF");
+	}
+
+	@Override
+	public URL getUrl() throws MalformedURLException {
+		return this.root.toURI().toURL();
+	}
+
+	@Override
+	public Manifest getManifest() throws IOException {
+		if (this.manifest == null && this.manifestFile.exists()) {
+			try (FileInputStream inputStream = new FileInputStream(this.manifestFile)) {
+				this.manifest = new Manifest(inputStream);
+			}
+		}
+		return this.manifest;
+	}
+
+	@Override
+	public Iterator<Archive> getNestedArchives(EntryFilter searchFilter, EntryFilter includeFilter) throws IOException {
+		return new ArchiveIterator(this.root, this.recursive, searchFilter, includeFilter);
+	}
+
+	@Override
+	@Deprecated(since = "2.3.10", forRemoval = false)
+	public Iterator<Entry> iterator() {
+		return new EntryIterator(this.root, this.recursive, null, null);
+	}
+
+	protected Archive getNestedArchive(Entry entry) {
+		File file = ((FileEntry) entry).getFile();
+		return (file.isDirectory() ? new ExplodedArchive(file) : new SimpleJarFileArchive((FileEntry) entry));
+	}
+
+	@Override
+	public boolean isExploded() {
+		return true;
+	}
+
+	@Override
+	public String toString() {
+		try {
+			return getUrl().toString();
+		}
+		catch (Exception ex) {
+			return "exploded archive";
+		}
+	}
+
+	/**
+	 * File based {@link Entry} {@link Iterator}.
+	 */
+	private abstract static class AbstractIterator<T> implements Iterator<T> {
+
+		private static final Comparator<File> entryComparator = Comparator.comparing(File::getAbsolutePath);
+
+		private final File root;
+
+		private final boolean recursive;
+
+		private final EntryFilter searchFilter;
+
+		private final EntryFilter includeFilter;
+
+		private final Deque<Iterator<File>> stack = new LinkedList<>();
+
+		private FileEntry current;
+
+		private final String rootUrl;
+
+		AbstractIterator(File root, boolean recursive, EntryFilter searchFilter, EntryFilter includeFilter) {
+			this.root = root;
+			this.rootUrl = this.root.toURI().getPath();
+			this.recursive = recursive;
+			this.searchFilter = searchFilter;
+			this.includeFilter = includeFilter;
+			this.stack.add(listFiles(root));
+			this.current = poll();
+		}
+
+		@Override
+		public boolean hasNext() {
+			return this.current != null;
+		}
+
+		@Override
+		public T next() {
+			FileEntry entry = this.current;
+			if (entry == null) {
+				throw new NoSuchElementException();
+			}
+			this.current = poll();
+			return adapt(entry);
+		}
+
+		private FileEntry poll() {
+			while (!this.stack.isEmpty()) {
+				while (this.stack.peek().hasNext()) {
+					File file = this.stack.peek().next();
+					if (SKIPPED_NAMES.contains(file.getName())) {
+						continue;
+					}
+					FileEntry entry = getFileEntry(file);
+					if (isListable(entry)) {
+						this.stack.addFirst(listFiles(file));
+					}
+					if (this.includeFilter == null || this.includeFilter.matches(entry)) {
+						return entry;
+					}
+				}
+				this.stack.poll();
+			}
+			return null;
+		}
+
+		private FileEntry getFileEntry(File file) {
+			URI uri = file.toURI();
+			String name = uri.getPath().substring(this.rootUrl.length());
+			try {
+				return new FileEntry(name, file, uri.toURL());
+			}
+			catch (MalformedURLException ex) {
+				throw new IllegalStateException(ex);
+			}
+		}
+
+		private boolean isListable(FileEntry entry) {
+			return entry.isDirectory() && (this.recursive || entry.getFile().getParentFile().equals(this.root))
+					&& (this.searchFilter == null || this.searchFilter.matches(entry))
+					&& (this.includeFilter == null || !this.includeFilter.matches(entry));
+		}
+
+		private Iterator<File> listFiles(File file) {
+			File[] files = file.listFiles();
+			if (files == null) {
+				return Collections.emptyIterator();
+			}
+			Arrays.sort(files, entryComparator);
+			return Arrays.asList(files).iterator();
+		}
+
+		@Override
+		public void remove() {
+			throw new UnsupportedOperationException("remove");
+		}
+
+		protected abstract T adapt(FileEntry entry);
+
+	}
+
+	private static class EntryIterator extends AbstractIterator<Entry> {
+
+		EntryIterator(File root, boolean recursive, EntryFilter searchFilter, EntryFilter includeFilter) {
+			super(root, recursive, searchFilter, includeFilter);
+		}
+
+		@Override
+		protected Entry adapt(FileEntry entry) {
+			return entry;
+		}
+
+	}
+
+	private static class ArchiveIterator extends AbstractIterator<Archive> {
+
+		ArchiveIterator(File root, boolean recursive, EntryFilter searchFilter, EntryFilter includeFilter) {
+			super(root, recursive, searchFilter, includeFilter);
+		}
+
+		@Override
+		protected Archive adapt(FileEntry entry) {
+			File file = entry.getFile();
+			return (file.isDirectory() ? new ExplodedArchive(file) : new SimpleJarFileArchive(entry));
+		}
+
+	}
+
+	/**
+	 * {@link Entry} backed by a File.
+	 */
+	private static class FileEntry implements Entry {
+
+		private final String name;
+
+		private final File file;
+
+		private final URL url;
+
+		FileEntry(String name, File file, URL url) {
+			this.name = name;
+			this.file = file;
+			this.url = url;
+		}
+
+		File getFile() {
+			return this.file;
+		}
+
+		@Override
+		public boolean isDirectory() {
+			return this.file.isDirectory();
+		}
+
+		@Override
+		public String getName() {
+			return this.name;
+		}
+
+		URL getUrl() {
+			return this.url;
+		}
+
+	}
+
+	/**
+	 * {@link Archive} implementation backed by a simple JAR file that doesn't itself
+	 * contain nested archives.
+	 */
+	private static class SimpleJarFileArchive implements Archive {
+
+		private final URL url;
+
+		SimpleJarFileArchive(FileEntry file) {
+			this.url = file.getUrl();
+		}
+
+		@Override
+		public URL getUrl() throws MalformedURLException {
+			return this.url;
+		}
+
+		@Override
+		public Manifest getManifest() throws IOException {
+			return null;
+		}
+
+		@Override
+		public Iterator<Archive> getNestedArchives(EntryFilter searchFilter, EntryFilter includeFilter)
+				throws IOException {
+			return Collections.emptyIterator();
+		}
+
+		@Override
+		@Deprecated(since = "2.3.10", forRemoval = false)
+		public Iterator<Entry> iterator() {
+			return Collections.emptyIterator();
+		}
+
+		@Override
+		public String toString() {
+			try {
+				return getUrl().toString();
+			}
+			catch (Exception ex) {
+				return "jar archive";
+			}
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/JarFileArchive.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/JarFileArchive.java
new file mode 100755
index 000000000000..91e7bc53a486
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/JarFileArchive.java
@@ -0,0 +1,310 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.archive;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.file.FileSystem;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.FileAttribute;
+import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.EnumSet;
+import java.util.Iterator;
+import java.util.UUID;
+import java.util.jar.JarEntry;
+import java.util.jar.Manifest;
+
+import org.springframework.boot.loader.jar.JarFile;
+
+/**
+ * {@link Archive} implementation backed by a {@link JarFile}.
+ *
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ * @since 1.0.0
+ */
+public class JarFileArchive implements Archive {
+
+	private static final String UNPACK_MARKER = "UNPACK:";
+
+	private static final int BUFFER_SIZE = 32 * 1024;
+
+	private static final FileAttribute<?>[] NO_FILE_ATTRIBUTES = {};
+
+	private static final EnumSet<PosixFilePermission> DIRECTORY_PERMISSIONS = EnumSet.of(PosixFilePermission.OWNER_READ,
+			PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE);
+
+	private static final EnumSet<PosixFilePermission> FILE_PERMISSIONS = EnumSet.of(PosixFilePermission.OWNER_READ,
+			PosixFilePermission.OWNER_WRITE);
+
+	private final JarFile jarFile;
+
+	private URL url;
+
+	private Path tempUnpackDirectory;
+
+	public JarFileArchive(File file) throws IOException {
+		this(file, file.toURI().toURL());
+	}
+
+	public JarFileArchive(File file, URL url) throws IOException {
+		this(new JarFile(file));
+		this.url = url;
+	}
+
+	public JarFileArchive(JarFile jarFile) {
+		this.jarFile = jarFile;
+	}
+
+	@Override
+	public URL getUrl() throws MalformedURLException {
+		if (this.url != null) {
+			return this.url;
+		}
+		return this.jarFile.getUrl();
+	}
+
+	@Override
+	public Manifest getManifest() throws IOException {
+		return this.jarFile.getManifest();
+	}
+
+	@Override
+	public Iterator<Archive> getNestedArchives(EntryFilter searchFilter, EntryFilter includeFilter) throws IOException {
+		return new NestedArchiveIterator(this.jarFile.iterator(), searchFilter, includeFilter);
+	}
+
+	@Override
+	@Deprecated(since = "2.3.10", forRemoval = false)
+	public Iterator<Entry> iterator() {
+		return new EntryIterator(this.jarFile.iterator(), null, null);
+	}
+
+	@Override
+	public void close() throws IOException {
+		this.jarFile.close();
+	}
+
+	protected Archive getNestedArchive(Entry entry) throws IOException {
+		JarEntry jarEntry = ((JarFileEntry) entry).getJarEntry();
+		if (jarEntry.getComment().startsWith(UNPACK_MARKER)) {
+			return getUnpackedNestedArchive(jarEntry);
+		}
+		try {
+			JarFile jarFile = this.jarFile.getNestedJarFile(jarEntry);
+			return new JarFileArchive(jarFile);
+		}
+		catch (Exception ex) {
+			throw new IllegalStateException("Failed to get nested archive for entry " + entry.getName(), ex);
+		}
+	}
+
+	private Archive getUnpackedNestedArchive(JarEntry jarEntry) throws IOException {
+		String name = jarEntry.getName();
+		if (name.lastIndexOf('/') != -1) {
+			name = name.substring(name.lastIndexOf('/') + 1);
+		}
+		Path path = getTempUnpackDirectory().resolve(name);
+		if (!Files.exists(path) || Files.size(path) != jarEntry.getSize()) {
+			unpack(jarEntry, path);
+		}
+		return new JarFileArchive(path.toFile(), path.toUri().toURL());
+	}
+
+	private Path getTempUnpackDirectory() {
+		if (this.tempUnpackDirectory == null) {
+			Path tempDirectory = Paths.get(System.getProperty("java.io.tmpdir"));
+			this.tempUnpackDirectory = createUnpackDirectory(tempDirectory);
+		}
+		return this.tempUnpackDirectory;
+	}
+
+	private Path createUnpackDirectory(Path parent) {
+		int attempts = 0;
+		while (attempts++ < 1000) {
+			String fileName = Paths.get(this.jarFile.getName()).getFileName().toString();
+			Path unpackDirectory = parent.resolve(fileName + "-spring-boot-libs-" + UUID.randomUUID());
+			try {
+				createDirectory(unpackDirectory);
+				return unpackDirectory;
+			}
+			catch (IOException ex) {
+			}
+		}
+		throw new IllegalStateException("Failed to create unpack directory in directory '" + parent + "'");
+	}
+
+	private void unpack(JarEntry entry, Path path) throws IOException {
+		createFile(path);
+		path.toFile().deleteOnExit();
+		try (InputStream inputStream = this.jarFile.getInputStream(entry);
+				OutputStream outputStream = Files.newOutputStream(path, StandardOpenOption.WRITE,
+						StandardOpenOption.TRUNCATE_EXISTING)) {
+			byte[] buffer = new byte[BUFFER_SIZE];
+			int bytesRead;
+			while ((bytesRead = inputStream.read(buffer)) != -1) {
+				outputStream.write(buffer, 0, bytesRead);
+			}
+			outputStream.flush();
+		}
+	}
+
+	private void createDirectory(Path path) throws IOException {
+		Files.createDirectory(path, getFileAttributes(path.getFileSystem(), DIRECTORY_PERMISSIONS));
+	}
+
+	private void createFile(Path path) throws IOException {
+		Files.createFile(path, getFileAttributes(path.getFileSystem(), FILE_PERMISSIONS));
+	}
+
+	private FileAttribute<?>[] getFileAttributes(FileSystem fileSystem, EnumSet<PosixFilePermission> ownerReadWrite) {
+		if (!fileSystem.supportedFileAttributeViews().contains("posix")) {
+			return NO_FILE_ATTRIBUTES;
+		}
+		return new FileAttribute<?>[] { PosixFilePermissions.asFileAttribute(ownerReadWrite) };
+	}
+
+	@Override
+	public String toString() {
+		try {
+			return getUrl().toString();
+		}
+		catch (Exception ex) {
+			return "jar archive";
+		}
+	}
+
+	/**
+	 * Abstract base class for iterator implementations.
+	 */
+	private abstract static class AbstractIterator<T> implements Iterator<T> {
+
+		private final Iterator<JarEntry> iterator;
+
+		private final EntryFilter searchFilter;
+
+		private final EntryFilter includeFilter;
+
+		private Entry current;
+
+		AbstractIterator(Iterator<JarEntry> iterator, EntryFilter searchFilter, EntryFilter includeFilter) {
+			this.iterator = iterator;
+			this.searchFilter = searchFilter;
+			this.includeFilter = includeFilter;
+			this.current = poll();
+		}
+
+		@Override
+		public boolean hasNext() {
+			return this.current != null;
+		}
+
+		@Override
+		public T next() {
+			T result = adapt(this.current);
+			this.current = poll();
+			return result;
+		}
+
+		private Entry poll() {
+			while (this.iterator.hasNext()) {
+				JarFileEntry candidate = new JarFileEntry(this.iterator.next());
+				if ((this.searchFilter == null || this.searchFilter.matches(candidate))
+						&& (this.includeFilter == null || this.includeFilter.matches(candidate))) {
+					return candidate;
+				}
+			}
+			return null;
+		}
+
+		protected abstract T adapt(Entry entry);
+
+	}
+
+	/**
+	 * {@link Archive.Entry} iterator implementation backed by {@link JarEntry}.
+	 */
+	private static class EntryIterator extends AbstractIterator<Entry> {
+
+		EntryIterator(Iterator<JarEntry> iterator, EntryFilter searchFilter, EntryFilter includeFilter) {
+			super(iterator, searchFilter, includeFilter);
+		}
+
+		@Override
+		protected Entry adapt(Entry entry) {
+			return entry;
+		}
+
+	}
+
+	/**
+	 * Nested {@link Archive} iterator implementation backed by {@link JarEntry}.
+	 */
+	private class NestedArchiveIterator extends AbstractIterator<Archive> {
+
+		NestedArchiveIterator(Iterator<JarEntry> iterator, EntryFilter searchFilter, EntryFilter includeFilter) {
+			super(iterator, searchFilter, includeFilter);
+		}
+
+		@Override
+		protected Archive adapt(Entry entry) {
+			try {
+				return getNestedArchive(entry);
+			}
+			catch (IOException ex) {
+				throw new IllegalStateException(ex);
+			}
+		}
+
+	}
+
+	/**
+	 * {@link Archive.Entry} implementation backed by a {@link JarEntry}.
+	 */
+	private static class JarFileEntry implements Entry {
+
+		private final JarEntry jarEntry;
+
+		JarFileEntry(JarEntry jarEntry) {
+			this.jarEntry = jarEntry;
+		}
+
+		JarEntry getJarEntry() {
+			return this.jarEntry;
+		}
+
+		@Override
+		public boolean isDirectory() {
+			return this.jarEntry.isDirectory();
+		}
+
+		@Override
+		public String getName() {
+			return this.jarEntry.getName();
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/package-info.java
new file mode 100644
index 000000000000..27ce99b006f0
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Abstraction over logical Archives be they backed by a JAR file or unpacked into a
+ * directory.
+ *
+ * @see org.springframework.boot.loader.archive.Archive
+ */
+package org.springframework.boot.loader.archive;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/RandomAccessData.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/data/RandomAccessData.java
similarity index 97%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/RandomAccessData.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/data/RandomAccessData.java
index ec1aa5e4a1e8..e96d5ea81a05 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/RandomAccessData.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/data/RandomAccessData.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/RandomAccessDataFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/data/RandomAccessDataFile.java
similarity index 99%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/RandomAccessDataFile.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/data/RandomAccessDataFile.java
index 06d9abcda51a..4bd5d205418c 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/RandomAccessDataFile.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/data/RandomAccessDataFile.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/data/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/data/package-info.java
new file mode 100644
index 000000000000..34bf2ead4378
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/data/package-info.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Classes and interfaces to allow random access to a block of data.
+ *
+ * @see org.springframework.boot.loader.data.RandomAccessData
+ */
+package org.springframework.boot.loader.data;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/AbstractJarFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/AbstractJarFile.java
similarity index 97%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/AbstractJarFile.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/AbstractJarFile.java
index 88726e373754..6a98ef682189 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/AbstractJarFile.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/AbstractJarFile.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java
similarity index 99%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java
index 4b6e2678b3ec..cfe121b68996 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/Bytes.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/Bytes.java
similarity index 94%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/Bytes.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/Bytes.java
index 7f53bac6297f..d46a22555dcb 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/Bytes.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/Bytes.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryEndRecord.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryEndRecord.java
similarity index 99%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryEndRecord.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryEndRecord.java
index b971b590abd1..61db0b73f422 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryEndRecord.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryEndRecord.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryFileHeader.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryFileHeader.java
similarity index 100%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryFileHeader.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryFileHeader.java
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryParser.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryParser.java
similarity index 98%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryParser.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryParser.java
index 71a767853561..eff96a56e2cc 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryParser.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryParser.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryVisitor.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryVisitor.java
similarity index 94%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryVisitor.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryVisitor.java
index d160cbf84772..22e04b329c30 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryVisitor.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryVisitor.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/FileHeader.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/FileHeader.java
similarity index 96%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/FileHeader.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/FileHeader.java
index 4b8de5008cf4..7e4134fe5649 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/FileHeader.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/FileHeader.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/Handler.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/Handler.java
new file mode 100644
index 000000000000..932dea654867
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/Handler.java
@@ -0,0 +1,466 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.jar;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.ref.SoftReference;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URL;
+import java.net.URLConnection;
+import java.net.URLStreamHandler;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.regex.Pattern;
+
+/**
+ * {@link URLStreamHandler} for Spring Boot loader {@link JarFile}s.
+ *
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ * @since 1.0.0
+ * @see JarFile#registerUrlProtocolHandler()
+ */
+public class Handler extends URLStreamHandler {
+
+	// NOTE: in order to be found as a URL protocol handler, this class must be public,
+	// must be named Handler and must be in a package ending '.jar'
+
+	private static final String JAR_PROTOCOL = "jar:";
+
+	private static final String FILE_PROTOCOL = "file:";
+
+	private static final String TOMCAT_WARFILE_PROTOCOL = "war:file:";
+
+	private static final String SEPARATOR = "!/";
+
+	private static final Pattern SEPARATOR_PATTERN = Pattern.compile(SEPARATOR, Pattern.LITERAL);
+
+	private static final String CURRENT_DIR = "/./";
+
+	private static final Pattern CURRENT_DIR_PATTERN = Pattern.compile(CURRENT_DIR, Pattern.LITERAL);
+
+	private static final String PARENT_DIR = "/../";
+
+	private static final String PROTOCOL_HANDLER = "java.protocol.handler.pkgs";
+
+	private static final String[] FALLBACK_HANDLERS = { "sun.net.www.protocol.jar.Handler" };
+
+	private static URL jarContextUrl;
+
+	private static SoftReference<Map<File, JarFile>> rootFileCache;
+
+	static {
+		rootFileCache = new SoftReference<>(null);
+	}
+
+	private final JarFile jarFile;
+
+	private URLStreamHandler fallbackHandler;
+
+	public Handler() {
+		this(null);
+	}
+
+	public Handler(JarFile jarFile) {
+		this.jarFile = jarFile;
+	}
+
+	@Override
+	protected URLConnection openConnection(URL url) throws IOException {
+		if (this.jarFile != null && isUrlInJarFile(url, this.jarFile)) {
+			return JarURLConnection.get(url, this.jarFile);
+		}
+		try {
+			return JarURLConnection.get(url, getRootJarFileFromUrl(url));
+		}
+		catch (Exception ex) {
+			return openFallbackConnection(url, ex);
+		}
+	}
+
+	private boolean isUrlInJarFile(URL url, JarFile jarFile) throws MalformedURLException {
+		// Try the path first to save building a new url string each time
+		return url.getPath().startsWith(jarFile.getUrl().getPath())
+				&& url.toString().startsWith(jarFile.getUrlString());
+	}
+
+	private URLConnection openFallbackConnection(URL url, Exception reason) throws IOException {
+		try {
+			URLConnection connection = openFallbackTomcatConnection(url);
+			connection = (connection != null) ? connection : openFallbackContextConnection(url);
+			return (connection != null) ? connection : openFallbackHandlerConnection(url);
+		}
+		catch (Exception ex) {
+			if (reason instanceof IOException ioException) {
+				log(false, "Unable to open fallback handler", ex);
+				throw ioException;
+			}
+			log(true, "Unable to open fallback handler", ex);
+			if (reason instanceof RuntimeException runtimeException) {
+				throw runtimeException;
+			}
+			throw new IllegalStateException(reason);
+		}
+	}
+
+	/**
+	 * Attempt to open a Tomcat formatted 'jar:war:file:...' URL. This method allows us to
+	 * use our own nested JAR support to open the content rather than the logic in
+	 * {@code sun.net.www.protocol.jar.URLJarFile} which will extract the nested jar to
+	 * the temp folder to that its content can be accessed.
+	 * @param url the URL to open
+	 * @return a {@link URLConnection} or {@code null}
+	 */
+	private URLConnection openFallbackTomcatConnection(URL url) {
+		String file = url.getFile();
+		if (isTomcatWarUrl(file)) {
+			file = file.substring(TOMCAT_WARFILE_PROTOCOL.length());
+			file = file.replaceFirst("\\*/", "!/");
+			try {
+				URLConnection connection = openConnection(new URL("jar:file:" + file));
+				connection.getInputStream().close();
+				return connection;
+			}
+			catch (IOException ex) {
+			}
+		}
+		return null;
+	}
+
+	private boolean isTomcatWarUrl(String file) {
+		if (file.startsWith(TOMCAT_WARFILE_PROTOCOL) || !file.contains("*/")) {
+			try {
+				URLConnection connection = new URL(file).openConnection();
+				if (connection.getClass().getName().startsWith("org.apache.catalina")) {
+					return true;
+				}
+			}
+			catch (Exception ex) {
+			}
+		}
+		return false;
+	}
+
+	/**
+	 * Attempt to open a fallback connection by using a context URL captured before the
+	 * jar handler was replaced with our own version. Since this method doesn't use
+	 * reflection it won't trigger "illegal reflective access operation has occurred"
+	 * warnings on Java 13+.
+	 * @param url the URL to open
+	 * @return a {@link URLConnection} or {@code null}
+	 */
+	private URLConnection openFallbackContextConnection(URL url) {
+		try {
+			if (jarContextUrl != null) {
+				return new URL(jarContextUrl, url.toExternalForm()).openConnection();
+			}
+		}
+		catch (Exception ex) {
+		}
+		return null;
+	}
+
+	/**
+	 * Attempt to open a fallback connection by using reflection to access Java's default
+	 * jar {@link URLStreamHandler}.
+	 * @param url the URL to open
+	 * @return the {@link URLConnection}
+	 * @throws Exception if not connection could be opened
+	 */
+	private URLConnection openFallbackHandlerConnection(URL url) throws Exception {
+		URLStreamHandler fallbackHandler = getFallbackHandler();
+		return new URL(null, url.toExternalForm(), fallbackHandler).openConnection();
+	}
+
+	private URLStreamHandler getFallbackHandler() {
+		if (this.fallbackHandler != null) {
+			return this.fallbackHandler;
+		}
+		for (String handlerClassName : FALLBACK_HANDLERS) {
+			try {
+				Class<?> handlerClass = Class.forName(handlerClassName);
+				this.fallbackHandler = (URLStreamHandler) handlerClass.getDeclaredConstructor().newInstance();
+				return this.fallbackHandler;
+			}
+			catch (Exception ex) {
+				// Ignore
+			}
+		}
+		throw new IllegalStateException("Unable to find fallback handler");
+	}
+
+	private void log(boolean warning, String message, Exception cause) {
+		try {
+			Level level = warning ? Level.WARNING : Level.FINEST;
+			Logger.getLogger(getClass().getName()).log(level, message, cause);
+		}
+		catch (Exception ex) {
+			if (warning) {
+				System.err.println("WARNING: " + message);
+			}
+		}
+	}
+
+	@Override
+	protected void parseURL(URL context, String spec, int start, int limit) {
+		if (spec.regionMatches(true, 0, JAR_PROTOCOL, 0, JAR_PROTOCOL.length())) {
+			setFile(context, getFileFromSpec(spec.substring(start, limit)));
+		}
+		else {
+			setFile(context, getFileFromContext(context, spec.substring(start, limit)));
+		}
+	}
+
+	private String getFileFromSpec(String spec) {
+		int separatorIndex = spec.lastIndexOf("!/");
+		if (separatorIndex == -1) {
+			throw new IllegalArgumentException("No !/ in spec '" + spec + "'");
+		}
+		try {
+			new URL(spec.substring(0, separatorIndex));
+			return spec;
+		}
+		catch (MalformedURLException ex) {
+			throw new IllegalArgumentException("Invalid spec URL '" + spec + "'", ex);
+		}
+	}
+
+	private String getFileFromContext(URL context, String spec) {
+		String file = context.getFile();
+		if (spec.startsWith("/")) {
+			return trimToJarRoot(file) + SEPARATOR + spec.substring(1);
+		}
+		if (file.endsWith("/")) {
+			return file + spec;
+		}
+		int lastSlashIndex = file.lastIndexOf('/');
+		if (lastSlashIndex == -1) {
+			throw new IllegalArgumentException("No / found in context URL's file '" + file + "'");
+		}
+		return file.substring(0, lastSlashIndex + 1) + spec;
+	}
+
+	private String trimToJarRoot(String file) {
+		int lastSeparatorIndex = file.lastIndexOf(SEPARATOR);
+		if (lastSeparatorIndex == -1) {
+			throw new IllegalArgumentException("No !/ found in context URL's file '" + file + "'");
+		}
+		return file.substring(0, lastSeparatorIndex);
+	}
+
+	private void setFile(URL context, String file) {
+		String path = normalize(file);
+		String query = null;
+		int queryIndex = path.lastIndexOf('?');
+		if (queryIndex != -1) {
+			query = path.substring(queryIndex + 1);
+			path = path.substring(0, queryIndex);
+		}
+		setURL(context, JAR_PROTOCOL, null, -1, null, null, path, query, context.getRef());
+	}
+
+	private String normalize(String file) {
+		if (!file.contains(CURRENT_DIR) && !file.contains(PARENT_DIR)) {
+			return file;
+		}
+		int afterLastSeparatorIndex = file.lastIndexOf(SEPARATOR) + SEPARATOR.length();
+		String afterSeparator = file.substring(afterLastSeparatorIndex);
+		afterSeparator = replaceParentDir(afterSeparator);
+		afterSeparator = replaceCurrentDir(afterSeparator);
+		return file.substring(0, afterLastSeparatorIndex) + afterSeparator;
+	}
+
+	private String replaceParentDir(String file) {
+		int parentDirIndex;
+		while ((parentDirIndex = file.indexOf(PARENT_DIR)) >= 0) {
+			int precedingSlashIndex = file.lastIndexOf('/', parentDirIndex - 1);
+			if (precedingSlashIndex >= 0) {
+				file = file.substring(0, precedingSlashIndex) + file.substring(parentDirIndex + 3);
+			}
+			else {
+				file = file.substring(parentDirIndex + 4);
+			}
+		}
+		return file;
+	}
+
+	private String replaceCurrentDir(String file) {
+		return CURRENT_DIR_PATTERN.matcher(file).replaceAll("/");
+	}
+
+	@Override
+	protected int hashCode(URL u) {
+		return hashCode(u.getProtocol(), u.getFile());
+	}
+
+	private int hashCode(String protocol, String file) {
+		int result = (protocol != null) ? protocol.hashCode() : 0;
+		int separatorIndex = file.indexOf(SEPARATOR);
+		if (separatorIndex == -1) {
+			return result + file.hashCode();
+		}
+		String source = file.substring(0, separatorIndex);
+		String entry = canonicalize(file.substring(separatorIndex + 2));
+		try {
+			result += new URL(source).hashCode();
+		}
+		catch (MalformedURLException ex) {
+			result += source.hashCode();
+		}
+		result += entry.hashCode();
+		return result;
+	}
+
+	@Override
+	protected boolean sameFile(URL u1, URL u2) {
+		if (!u1.getProtocol().equals("jar") || !u2.getProtocol().equals("jar")) {
+			return false;
+		}
+		int separator1 = u1.getFile().indexOf(SEPARATOR);
+		int separator2 = u2.getFile().indexOf(SEPARATOR);
+		if (separator1 == -1 || separator2 == -1) {
+			return super.sameFile(u1, u2);
+		}
+		String nested1 = u1.getFile().substring(separator1 + SEPARATOR.length());
+		String nested2 = u2.getFile().substring(separator2 + SEPARATOR.length());
+		if (!nested1.equals(nested2)) {
+			String canonical1 = canonicalize(nested1);
+			String canonical2 = canonicalize(nested2);
+			if (!canonical1.equals(canonical2)) {
+				return false;
+			}
+		}
+		String root1 = u1.getFile().substring(0, separator1);
+		String root2 = u2.getFile().substring(0, separator2);
+		try {
+			return super.sameFile(new URL(root1), new URL(root2));
+		}
+		catch (MalformedURLException ex) {
+			// Continue
+		}
+		return super.sameFile(u1, u2);
+	}
+
+	private String canonicalize(String path) {
+		return SEPARATOR_PATTERN.matcher(path).replaceAll("/");
+	}
+
+	public JarFile getRootJarFileFromUrl(URL url) throws IOException {
+		String spec = url.getFile();
+		int separatorIndex = spec.indexOf(SEPARATOR);
+		if (separatorIndex == -1) {
+			throw new MalformedURLException("Jar URL does not contain !/ separator");
+		}
+		String name = spec.substring(0, separatorIndex);
+		return getRootJarFile(name);
+	}
+
+	private JarFile getRootJarFile(String name) throws IOException {
+		try {
+			if (!name.startsWith(FILE_PROTOCOL)) {
+				throw new IllegalStateException("Not a file URL");
+			}
+			File file = new File(URI.create(name));
+			Map<File, JarFile> cache = rootFileCache.get();
+			JarFile result = (cache != null) ? cache.get(file) : null;
+			if (result == null) {
+				result = new JarFile(file);
+				addToRootFileCache(file, result);
+			}
+			return result;
+		}
+		catch (Exception ex) {
+			throw new IOException("Unable to open root Jar file '" + name + "'", ex);
+		}
+	}
+
+	/**
+	 * Add the given {@link JarFile} to the root file cache.
+	 * @param sourceFile the source file to add
+	 * @param jarFile the jar file.
+	 */
+	static void addToRootFileCache(File sourceFile, JarFile jarFile) {
+		Map<File, JarFile> cache = rootFileCache.get();
+		if (cache == null) {
+			cache = new ConcurrentHashMap<>();
+			rootFileCache = new SoftReference<>(cache);
+		}
+		cache.put(sourceFile, jarFile);
+	}
+
+	/**
+	 * If possible, capture a URL that is configured with the original jar handler so that
+	 * we can use it as a fallback context later. We can only do this if we know that we
+	 * can reset the handlers after.
+	 */
+	static void captureJarContextUrl() {
+		if (canResetCachedUrlHandlers()) {
+			String handlers = System.getProperty(PROTOCOL_HANDLER);
+			try {
+				System.clearProperty(PROTOCOL_HANDLER);
+				try {
+					resetCachedUrlHandlers();
+					jarContextUrl = new URL("jar:file:context.jar!/");
+					URLConnection connection = jarContextUrl.openConnection();
+					if (connection instanceof JarURLConnection) {
+						jarContextUrl = null;
+					}
+				}
+				catch (Exception ex) {
+				}
+			}
+			finally {
+				if (handlers == null) {
+					System.clearProperty(PROTOCOL_HANDLER);
+				}
+				else {
+					System.setProperty(PROTOCOL_HANDLER, handlers);
+				}
+			}
+			resetCachedUrlHandlers();
+		}
+	}
+
+	private static boolean canResetCachedUrlHandlers() {
+		try {
+			resetCachedUrlHandlers();
+			return true;
+		}
+		catch (Error ex) {
+			return false;
+		}
+	}
+
+	private static void resetCachedUrlHandlers() {
+		URL.setURLStreamHandlerFactory(null);
+	}
+
+	/**
+	 * Set if a generic static exception can be thrown when a URL cannot be connected.
+	 * This optimization is used during class loading to save creating lots of exceptions
+	 * which are then swallowed.
+	 * @param useFastConnectionExceptions if fast connection exceptions can be used.
+	 */
+	public static void setUseFastConnectionExceptions(boolean useFastConnectionExceptions) {
+		JarURLConnection.setUseFastExceptions(useFastConnectionExceptions);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntry.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarEntry.java
similarity index 98%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntry.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarEntry.java
index 5b8f3bedb20b..8f54dc3070df 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntry.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarEntry.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntryCertification.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarEntryCertification.java
similarity index 97%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntryCertification.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarEntryCertification.java
index cbf66412e215..ffd629e09428 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntryCertification.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarEntryCertification.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntryFilter.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarEntryFilter.java
similarity index 95%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntryFilter.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarEntryFilter.java
index 98ed4b905e5c..6804f0ba37f9 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntryFilter.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarEntryFilter.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFile.java
similarity index 99%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFile.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFile.java
index 502c450fa738..6e548048dbf0 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFile.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFile.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java
similarity index 100%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java
similarity index 98%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java
index 0a3bf030a5e0..b65358947ad1 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java
similarity index 99%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java
index c9286b3e8b58..859ae88ab000 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/StringSequence.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/StringSequence.java
similarity index 98%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/StringSequence.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/StringSequence.java
index 9e6af077ed99..12850a4ebe3e 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/StringSequence.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/StringSequence.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java
new file mode 100644
index 000000000000..67624460ccd7
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.jar;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.zip.Inflater;
+import java.util.zip.InflaterInputStream;
+
+/**
+ * {@link InflaterInputStream} that supports the writing of an extra "dummy" byte (which
+ * is required with JDK 6) and returns accurate available() results.
+ *
+ * @author Phillip Webb
+ */
+class ZipInflaterInputStream extends InflaterInputStream {
+
+	private int available;
+
+	private boolean extraBytesWritten;
+
+	ZipInflaterInputStream(InputStream inputStream, int size) {
+		super(inputStream, new Inflater(true), getInflaterBufferSize(size));
+		this.available = size;
+	}
+
+	@Override
+	public int available() throws IOException {
+		if (this.available < 0) {
+			return super.available();
+		}
+		return this.available;
+	}
+
+	@Override
+	public int read(byte[] b, int off, int len) throws IOException {
+		int result = super.read(b, off, len);
+		if (result != -1) {
+			this.available -= result;
+		}
+		return result;
+	}
+
+	@Override
+	public void close() throws IOException {
+		super.close();
+		this.inf.end();
+	}
+
+	@Override
+	protected void fill() throws IOException {
+		try {
+			super.fill();
+		}
+		catch (EOFException ex) {
+			if (this.extraBytesWritten) {
+				throw ex;
+			}
+			this.len = 1;
+			this.buf[0] = 0x0;
+			this.extraBytesWritten = true;
+			this.inf.setInput(this.buf, 0, this.len);
+		}
+	}
+
+	private static int getInflaterBufferSize(long size) {
+		size += 2; // inflater likes some space
+		size = (size > 65536) ? 8192 : size;
+		size = (size <= 0) ? 4096 : size;
+		return (int) size;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/package-info.java
new file mode 100644
index 000000000000..638afe45f497
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Support for loading and manipulating JAR/WAR files.
+ */
+package org.springframework.boot.loader.jar;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java
new file mode 100644
index 000000000000..162e4a6a7396
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.jarmode;
+
+/**
+ * Interface registered in {@code spring.factories} to provides extended 'jarmode'
+ * support.
+ *
+ * @author Phillip Webb
+ * @since 2.3.0
+ */
+public interface JarMode {
+
+	/**
+	 * Returns if this accepts and can run the given mode.
+	 * @param mode the mode to check
+	 * @return if this instance accepts the mode
+	 */
+	boolean accepts(String mode);
+
+	/**
+	 * Run the jar in the given mode.
+	 * @param mode the mode to use
+	 * @param args any program arguments
+	 */
+	void run(String mode, String[] args);
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java
similarity index 92%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java
index 42a89a50a35b..600266a241be 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -22,7 +22,7 @@
 import org.springframework.util.ClassUtils;
 
 /**
- * Delegate class used to launch the fat jar in a specific mode.
+ * Delegate class used to launch the uber jar in a specific mode.
  *
  * @author Phillip Webb
  * @since 2.3.0
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java
new file mode 100644
index 000000000000..2e17175690a5
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.jarmode;
+
+import java.util.Arrays;
+
+/**
+ * {@link JarMode} for testing.
+ *
+ * @author Phillip Webb
+ */
+class TestJarMode implements JarMode {
+
+	@Override
+	public boolean accepts(String mode) {
+		return "test".equals(mode);
+	}
+
+	@Override
+	public void run(String mode, String[] args) {
+		System.out.println("running in " + mode + " jar mode " + Arrays.asList(args));
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/package-info.java
new file mode 100644
index 000000000000..2f3b5a74e8fd
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/package-info.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Support for launching the JAR using jarmode.
+ *
+ * @see org.springframework.boot.loader.jarmode.JarModeLauncher
+ */
+package org.springframework.boot.loader.jarmode;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/JarLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/JarLauncher.java
new file mode 100644
index 000000000000..5beb8d109640
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/JarLauncher.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+/**
+ * Repackaged {@link org.springframework.boot.loader.JarLauncher}.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public final class JarLauncher {
+
+	private JarLauncher() {
+	}
+
+	public static void main(String[] args) throws Exception {
+		org.springframework.boot.loader.JarLauncher.main(args);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/PropertiesLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/PropertiesLauncher.java
new file mode 100644
index 000000000000..d80fb0bb7105
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/PropertiesLauncher.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+/**
+ * Repackaged {@link org.springframework.boot.loader.PropertiesLauncher}.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public final class PropertiesLauncher {
+
+	private PropertiesLauncher() {
+	}
+
+	public static void main(String[] args) throws Exception {
+		org.springframework.boot.loader.PropertiesLauncher.main(args);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/WarLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/WarLauncher.java
new file mode 100644
index 000000000000..9392d3bf2b45
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/WarLauncher.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+/**
+ * Repackaged {@link org.springframework.boot.loader.WarLauncher}.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public final class WarLauncher {
+
+	private WarLauncher() {
+	}
+
+	public static void main(String[] args) throws Exception {
+		org.springframework.boot.loader.WarLauncher.main(args);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/package-info.java
new file mode 100644
index 000000000000..7968d509a2bb
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Repackaged launcher classes.
+ *
+ * @see org.springframework.boot.loader.launch.JarLauncher
+ * @see org.springframework.boot.loader.launch.WarLauncher
+ */
+package org.springframework.boot.loader.launch;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/package-info.java
new file mode 100644
index 000000000000..4b32f644f542
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/package-info.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * System that allows self-contained JAR/WAR archives to be launched using
+ * {@code java -jar}. Archives can include nested packaged dependency JARs (there is no
+ * need to create shade style jars) and are executed without unpacking. The only
+ * constraint is that nested JARs must be stored in the archive uncompressed.
+ *
+ * @see org.springframework.boot.loader.JarLauncher
+ * @see org.springframework.boot.loader.WarLauncher
+ */
+package org.springframework.boot.loader;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java
new file mode 100644
index 000000000000..df00705e9eec
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java
@@ -0,0 +1,232 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.util;
+
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Properties;
+import java.util.Set;
+
+/**
+ * Helper class for resolving placeholders in texts. Usually applied to file paths.
+ * <p>
+ * A text may contain {@code $ ...} placeholders, to be resolved as system properties:
+ * e.g. {@code $ user.dir}. Default values can be supplied using the ":" separator between
+ * key and value.
+ * <p>
+ * Adapted from Spring.
+ *
+ * @author Juergen Hoeller
+ * @author Rob Harrop
+ * @author Dave Syer
+ * @since 1.0.0
+ * @see System#getProperty(String)
+ */
+public abstract class SystemPropertyUtils {
+
+	/**
+	 * Prefix for system property placeholders: "${".
+	 */
+	public static final String PLACEHOLDER_PREFIX = "${";
+
+	/**
+	 * Suffix for system property placeholders: "}".
+	 */
+	public static final String PLACEHOLDER_SUFFIX = "}";
+
+	/**
+	 * Value separator for system property placeholders: ":".
+	 */
+	public static final String VALUE_SEPARATOR = ":";
+
+	private static final String SIMPLE_PREFIX = PLACEHOLDER_PREFIX.substring(1);
+
+	/**
+	 * Resolve ${...} placeholders in the given text, replacing them with corresponding
+	 * system property values.
+	 * @param text the String to resolve
+	 * @return the resolved String
+	 * @throws IllegalArgumentException if there is an unresolvable placeholder
+	 * @see #PLACEHOLDER_PREFIX
+	 * @see #PLACEHOLDER_SUFFIX
+	 */
+	public static String resolvePlaceholders(String text) {
+		if (text == null) {
+			return text;
+		}
+		return parseStringValue(null, text, text, new HashSet<>());
+	}
+
+	/**
+	 * Resolve ${...} placeholders in the given text, replacing them with corresponding
+	 * system property values.
+	 * @param properties a properties instance to use in addition to System
+	 * @param text the String to resolve
+	 * @return the resolved String
+	 * @throws IllegalArgumentException if there is an unresolvable placeholder
+	 * @see #PLACEHOLDER_PREFIX
+	 * @see #PLACEHOLDER_SUFFIX
+	 */
+	public static String resolvePlaceholders(Properties properties, String text) {
+		if (text == null) {
+			return text;
+		}
+		return parseStringValue(properties, text, text, new HashSet<>());
+	}
+
+	private static String parseStringValue(Properties properties, String value, String current,
+			Set<String> visitedPlaceholders) {
+
+		StringBuilder buf = new StringBuilder(current);
+
+		int startIndex = current.indexOf(PLACEHOLDER_PREFIX);
+		while (startIndex != -1) {
+			int endIndex = findPlaceholderEndIndex(buf, startIndex);
+			if (endIndex != -1) {
+				String placeholder = buf.substring(startIndex + PLACEHOLDER_PREFIX.length(), endIndex);
+				String originalPlaceholder = placeholder;
+				if (!visitedPlaceholders.add(originalPlaceholder)) {
+					throw new IllegalArgumentException(
+							"Circular placeholder reference '" + originalPlaceholder + "' in property definitions");
+				}
+				// Recursive invocation, parsing placeholders contained in the
+				// placeholder
+				// key.
+				placeholder = parseStringValue(properties, value, placeholder, visitedPlaceholders);
+				// Now obtain the value for the fully resolved key...
+				String propVal = resolvePlaceholder(properties, value, placeholder);
+				if (propVal == null) {
+					int separatorIndex = placeholder.indexOf(VALUE_SEPARATOR);
+					if (separatorIndex != -1) {
+						String actualPlaceholder = placeholder.substring(0, separatorIndex);
+						String defaultValue = placeholder.substring(separatorIndex + VALUE_SEPARATOR.length());
+						propVal = resolvePlaceholder(properties, value, actualPlaceholder);
+						if (propVal == null) {
+							propVal = defaultValue;
+						}
+					}
+				}
+				if (propVal != null) {
+					// Recursive invocation, parsing placeholders contained in the
+					// previously resolved placeholder value.
+					propVal = parseStringValue(properties, value, propVal, visitedPlaceholders);
+					buf.replace(startIndex, endIndex + PLACEHOLDER_SUFFIX.length(), propVal);
+					startIndex = buf.indexOf(PLACEHOLDER_PREFIX, startIndex + propVal.length());
+				}
+				else {
+					// Proceed with unprocessed value.
+					startIndex = buf.indexOf(PLACEHOLDER_PREFIX, endIndex + PLACEHOLDER_SUFFIX.length());
+				}
+				visitedPlaceholders.remove(originalPlaceholder);
+			}
+			else {
+				startIndex = -1;
+			}
+		}
+
+		return buf.toString();
+	}
+
+	private static String resolvePlaceholder(Properties properties, String text, String placeholderName) {
+		String propVal = getProperty(placeholderName, null, text);
+		if (propVal != null) {
+			return propVal;
+		}
+		return (properties != null) ? properties.getProperty(placeholderName) : null;
+	}
+
+	public static String getProperty(String key) {
+		return getProperty(key, null, "");
+	}
+
+	public static String getProperty(String key, String defaultValue) {
+		return getProperty(key, defaultValue, "");
+	}
+
+	/**
+	 * Search the System properties and environment variables for a value with the
+	 * provided key. Environment variables in {@code UPPER_CASE} style are allowed where
+	 * System properties would normally be {@code lower.case}.
+	 * @param key the key to resolve
+	 * @param defaultValue the default value
+	 * @param text optional extra context for an error message if the key resolution fails
+	 * (e.g. if System properties are not accessible)
+	 * @return a static property value or null of not found
+	 */
+	public static String getProperty(String key, String defaultValue, String text) {
+		try {
+			String propVal = System.getProperty(key);
+			if (propVal == null) {
+				// Fall back to searching the system environment.
+				propVal = System.getenv(key);
+			}
+			if (propVal == null) {
+				// Try with underscores.
+				String name = key.replace('.', '_');
+				propVal = System.getenv(name);
+			}
+			if (propVal == null) {
+				// Try uppercase with underscores as well.
+				String name = key.toUpperCase(Locale.ENGLISH).replace('.', '_');
+				propVal = System.getenv(name);
+			}
+			if (propVal != null) {
+				return propVal;
+			}
+		}
+		catch (Throwable ex) {
+			System.err.println("Could not resolve key '" + key + "' in '" + text
+					+ "' as system property or in environment: " + ex);
+		}
+		return defaultValue;
+	}
+
+	private static int findPlaceholderEndIndex(CharSequence buf, int startIndex) {
+		int index = startIndex + PLACEHOLDER_PREFIX.length();
+		int withinNestedPlaceholder = 0;
+		while (index < buf.length()) {
+			if (substringMatch(buf, index, PLACEHOLDER_SUFFIX)) {
+				if (withinNestedPlaceholder > 0) {
+					withinNestedPlaceholder--;
+					index = index + PLACEHOLDER_SUFFIX.length();
+				}
+				else {
+					return index;
+				}
+			}
+			else if (substringMatch(buf, index, SIMPLE_PREFIX)) {
+				withinNestedPlaceholder++;
+				index = index + SIMPLE_PREFIX.length();
+			}
+			else {
+				index++;
+			}
+		}
+		return -1;
+	}
+
+	private static boolean substringMatch(CharSequence str, int index, CharSequence substring) {
+		for (int j = 0; j < substring.length(); j++) {
+			int i = index + j;
+			if (i >= str.length() || str.charAt(i) != substring.charAt(j)) {
+				return false;
+			}
+		}
+		return true;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/util/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/util/package-info.java
new file mode 100644
index 000000000000..d3d7eef2d9db
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/util/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Utilities used by Spring Boot's JAR loading.
+ */
+package org.springframework.boot.loader.util;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/AbstractExecutableArchiveLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/AbstractExecutableArchiveLauncherTests.java
new file mode 100644
index 000000000000..60e3cb2765eb
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/AbstractExecutableArchiveLauncherTests.java
@@ -0,0 +1,149 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.jar.JarOutputStream;
+import java.util.jar.Manifest;
+import java.util.zip.CRC32;
+import java.util.zip.ZipEntry;
+
+import org.junit.jupiter.api.io.TempDir;
+
+import org.springframework.boot.loader.archive.Archive;
+import org.springframework.util.FileCopyUtils;
+
+/**
+ * Base class for testing {@link ExecutableArchiveLauncher} implementations.
+ *
+ * @author Andy Wilkinson
+ * @author Madhura Bhave
+ * @author Scott Frederick
+ */
+public abstract class AbstractExecutableArchiveLauncherTests {
+
+	@TempDir
+	File tempDir;
+
+	protected File createJarArchive(String name, String entryPrefix) throws IOException {
+		return createJarArchive(name, entryPrefix, false, Collections.emptyList());
+	}
+
+	@SuppressWarnings("resource")
+	protected File createJarArchive(String name, String entryPrefix, boolean indexed, List<String> extraLibs)
+			throws IOException {
+		return createJarArchive(name, null, entryPrefix, indexed, extraLibs);
+	}
+
+	@SuppressWarnings("resource")
+	protected File createJarArchive(String name, Manifest manifest, String entryPrefix, boolean indexed,
+			List<String> extraLibs) throws IOException {
+		File archive = new File(this.tempDir, name);
+		JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(archive));
+		if (manifest != null) {
+			jarOutputStream.putNextEntry(new JarEntry("META-INF/"));
+			jarOutputStream.putNextEntry(new JarEntry("META-INF/MANIFEST.MF"));
+			manifest.write(jarOutputStream);
+			jarOutputStream.closeEntry();
+		}
+		jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/"));
+		jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/classes/"));
+		jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/lib/"));
+		if (indexed) {
+			jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/classpath.idx"));
+			Writer writer = new OutputStreamWriter(jarOutputStream, StandardCharsets.UTF_8);
+			writer.write("- \"" + entryPrefix + "/lib/foo.jar\"\n");
+			writer.write("- \"" + entryPrefix + "/lib/bar.jar\"\n");
+			writer.write("- \"" + entryPrefix + "/lib/baz.jar\"\n");
+			writer.flush();
+			jarOutputStream.closeEntry();
+		}
+		addNestedJars(entryPrefix, "/lib/foo.jar", jarOutputStream);
+		addNestedJars(entryPrefix, "/lib/bar.jar", jarOutputStream);
+		addNestedJars(entryPrefix, "/lib/baz.jar", jarOutputStream);
+		for (String lib : extraLibs) {
+			addNestedJars(entryPrefix, "/lib/" + lib, jarOutputStream);
+		}
+		jarOutputStream.close();
+		return archive;
+	}
+
+	private void addNestedJars(String entryPrefix, String lib, JarOutputStream jarOutputStream) throws IOException {
+		JarEntry libFoo = new JarEntry(entryPrefix + lib);
+		libFoo.setMethod(ZipEntry.STORED);
+		ByteArrayOutputStream fooJarStream = new ByteArrayOutputStream();
+		new JarOutputStream(fooJarStream).close();
+		libFoo.setSize(fooJarStream.size());
+		CRC32 crc32 = new CRC32();
+		crc32.update(fooJarStream.toByteArray());
+		libFoo.setCrc(crc32.getValue());
+		jarOutputStream.putNextEntry(libFoo);
+		jarOutputStream.write(fooJarStream.toByteArray());
+	}
+
+	protected File explode(File archive) throws IOException {
+		File exploded = new File(this.tempDir, "exploded");
+		exploded.mkdirs();
+		JarFile jarFile = new JarFile(archive);
+		Enumeration<JarEntry> entries = jarFile.entries();
+		while (entries.hasMoreElements()) {
+			JarEntry entry = entries.nextElement();
+			File entryFile = new File(exploded, entry.getName());
+			if (entry.isDirectory()) {
+				entryFile.mkdirs();
+			}
+			else {
+				FileCopyUtils.copy(jarFile.getInputStream(entry), new FileOutputStream(entryFile));
+			}
+		}
+		jarFile.close();
+		return exploded;
+	}
+
+	protected Set<URL> getUrls(List<Archive> archives) throws MalformedURLException {
+		Set<URL> urls = new LinkedHashSet<>(archives.size());
+		for (Archive archive : archives) {
+			urls.add(archive.getUrl());
+		}
+		return urls;
+	}
+
+	protected final URL toUrl(File file) {
+		try {
+			return file.toURI().toURL();
+		}
+		catch (MalformedURLException ex) {
+			throw new IllegalStateException(ex);
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/ClassPathIndexFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/ClassPathIndexFileTests.java
similarity index 100%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/ClassPathIndexFileTests.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/ClassPathIndexFileTests.java
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/JarLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/JarLauncherTests.java
new file mode 100644
index 000000000000..afa32a7c4f18
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/JarLauncherTests.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.jar.Attributes;
+import java.util.jar.Attributes.Name;
+import java.util.jar.Manifest;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.loader.archive.Archive;
+import org.springframework.boot.loader.archive.ExplodedArchive;
+import org.springframework.boot.loader.archive.JarFileArchive;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.core.test.tools.SourceFile;
+import org.springframework.core.test.tools.TestCompiler;
+import org.springframework.util.FileCopyUtils;
+import org.springframework.util.function.ThrowingConsumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link JarLauncher}.
+ *
+ * @author Andy Wilkinson
+ * @author Madhura Bhave
+ */
+class JarLauncherTests extends AbstractExecutableArchiveLauncherTests {
+
+	@Test
+	void explodedJarHasOnlyBootInfClassesAndContentsOfBootInfLibOnClasspath() throws Exception {
+		File explodedRoot = explode(createJarArchive("archive.jar", "BOOT-INF"));
+		JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot, true));
+		List<Archive> archives = new ArrayList<>();
+		launcher.getClassPathArchivesIterator().forEachRemaining(archives::add);
+		assertThat(getUrls(archives)).containsExactlyInAnyOrder(getExpectedFileUrls(explodedRoot));
+		for (Archive archive : archives) {
+			archive.close();
+		}
+	}
+
+	@Test
+	void archivedJarHasOnlyBootInfClassesAndContentsOfBootInfLibOnClasspath() throws Exception {
+		File jarRoot = createJarArchive("archive.jar", "BOOT-INF");
+		try (JarFileArchive archive = new JarFileArchive(jarRoot)) {
+			JarLauncher launcher = new JarLauncher(archive);
+			List<Archive> classPathArchives = new ArrayList<>();
+			launcher.getClassPathArchivesIterator().forEachRemaining(classPathArchives::add);
+			assertThat(classPathArchives).hasSize(4);
+			assertThat(getUrls(classPathArchives)).containsOnly(
+					new URL("jar:" + jarRoot.toURI().toURL() + "!/BOOT-INF/classes!/"),
+					new URL("jar:" + jarRoot.toURI().toURL() + "!/BOOT-INF/lib/foo.jar!/"),
+					new URL("jar:" + jarRoot.toURI().toURL() + "!/BOOT-INF/lib/bar.jar!/"),
+					new URL("jar:" + jarRoot.toURI().toURL() + "!/BOOT-INF/lib/baz.jar!/"));
+			for (Archive classPathArchive : classPathArchives) {
+				classPathArchive.close();
+			}
+		}
+	}
+
+	@Test
+	void explodedJarShouldPreserveClasspathOrderWhenIndexPresent() throws Exception {
+		File explodedRoot = explode(createJarArchive("archive.jar", "BOOT-INF", true, Collections.emptyList()));
+		JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot, true));
+		Iterator<Archive> archives = launcher.getClassPathArchivesIterator();
+		URLClassLoader classLoader = (URLClassLoader) launcher.createClassLoader(archives);
+		URL[] urls = classLoader.getURLs();
+		assertThat(urls).containsExactly(getExpectedFileUrls(explodedRoot));
+	}
+
+	@Test
+	void jarFilesPresentInBootInfLibsAndNotInClasspathIndexShouldBeAddedAfterBootInfClasses() throws Exception {
+		ArrayList<String> extraLibs = new ArrayList<>(Arrays.asList("extra-1.jar", "extra-2.jar"));
+		File explodedRoot = explode(createJarArchive("archive.jar", "BOOT-INF", true, extraLibs));
+		JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot, true));
+		Iterator<Archive> archives = launcher.getClassPathArchivesIterator();
+		URLClassLoader classLoader = (URLClassLoader) launcher.createClassLoader(archives);
+		URL[] urls = classLoader.getURLs();
+		List<File> expectedFiles = getExpectedFilesWithExtraLibs(explodedRoot);
+		URL[] expectedFileUrls = expectedFiles.stream().map(this::toUrl).toArray(URL[]::new);
+		assertThat(urls).containsExactly(expectedFileUrls);
+	}
+
+	@Test
+	void explodedJarDefinedPackagesIncludeManifestAttributes() {
+		Manifest manifest = new Manifest();
+		Attributes attributes = manifest.getMainAttributes();
+		attributes.put(Name.MANIFEST_VERSION, "1.0");
+		attributes.put(Name.IMPLEMENTATION_TITLE, "test");
+		SourceFile sourceFile = SourceFile.of("explodedsample/ExampleClass.java",
+				new ClassPathResource("explodedsample/ExampleClass.txt"));
+		TestCompiler.forSystem().compile(sourceFile, ThrowingConsumer.of((compiled) -> {
+			File explodedRoot = explode(
+					createJarArchive("archive.jar", manifest, "BOOT-INF", true, Collections.emptyList()));
+			File target = new File(explodedRoot, "BOOT-INF/classes/explodedsample/ExampleClass.class");
+			target.getParentFile().mkdirs();
+			FileCopyUtils.copy(compiled.getClassLoader().getResourceAsStream("explodedsample/ExampleClass.class"),
+					new FileOutputStream(target));
+			JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot, true));
+			Iterator<Archive> archives = launcher.getClassPathArchivesIterator();
+			URLClassLoader classLoader = (URLClassLoader) launcher.createClassLoader(archives);
+			Class<?> loaded = classLoader.loadClass("explodedsample.ExampleClass");
+			assertThat(loaded.getPackage().getImplementationTitle()).isEqualTo("test");
+		}));
+	}
+
+	protected final URL[] getExpectedFileUrls(File explodedRoot) {
+		return getExpectedFiles(explodedRoot).stream().map(this::toUrl).toArray(URL[]::new);
+	}
+
+	protected final List<File> getExpectedFiles(File parent) {
+		List<File> expected = new ArrayList<>();
+		expected.add(new File(parent, "BOOT-INF/classes"));
+		expected.add(new File(parent, "BOOT-INF/lib/foo.jar"));
+		expected.add(new File(parent, "BOOT-INF/lib/bar.jar"));
+		expected.add(new File(parent, "BOOT-INF/lib/baz.jar"));
+		return expected;
+	}
+
+	protected final List<File> getExpectedFilesWithExtraLibs(File parent) {
+		List<File> expected = new ArrayList<>();
+		expected.add(new File(parent, "BOOT-INF/classes"));
+		expected.add(new File(parent, "BOOT-INF/lib/extra-1.jar"));
+		expected.add(new File(parent, "BOOT-INF/lib/extra-2.jar"));
+		expected.add(new File(parent, "BOOT-INF/lib/foo.jar"));
+		expected.add(new File(parent, "BOOT-INF/lib/bar.jar"));
+		expected.add(new File(parent, "BOOT-INF/lib/baz.jar"));
+		return expected;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/LaunchedURLClassLoaderTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/LaunchedURLClassLoaderTests.java
similarity index 100%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/LaunchedURLClassLoaderTests.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/LaunchedURLClassLoaderTests.java
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/PropertiesLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/PropertiesLauncherTests.java
similarity index 100%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/PropertiesLauncherTests.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/PropertiesLauncherTests.java
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/TestJarCreator.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/TestJarCreator.java
similarity index 99%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/TestJarCreator.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/TestJarCreator.java
index 100e2c757e37..c5c5fd3b95c9 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/TestJarCreator.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/TestJarCreator.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/WarLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/WarLauncherTests.java
new file mode 100644
index 000000000000..fbab8d36ed0a
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/WarLauncherTests.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader;
+
+import java.io.File;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.loader.archive.Archive;
+import org.springframework.boot.loader.archive.ExplodedArchive;
+import org.springframework.boot.loader.archive.JarFileArchive;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link WarLauncher}.
+ *
+ * @author Andy Wilkinson
+ * @author Scott Frederick
+ */
+class WarLauncherTests extends AbstractExecutableArchiveLauncherTests {
+
+	@Test
+	void explodedWarHasOnlyWebInfClassesAndContentsOfWebInfLibOnClasspath() throws Exception {
+		File explodedRoot = explode(createJarArchive("archive.war", "WEB-INF"));
+		WarLauncher launcher = new WarLauncher(new ExplodedArchive(explodedRoot, true));
+		List<Archive> archives = new ArrayList<>();
+		launcher.getClassPathArchivesIterator().forEachRemaining(archives::add);
+		assertThat(getUrls(archives)).containsExactlyInAnyOrder(getExpectedFileUrls(explodedRoot));
+		for (Archive archive : archives) {
+			archive.close();
+		}
+	}
+
+	@Test
+	void archivedWarHasOnlyWebInfClassesAndContentsOfWebInfLibOnClasspath() throws Exception {
+		File jarRoot = createJarArchive("archive.war", "WEB-INF");
+		try (JarFileArchive archive = new JarFileArchive(jarRoot)) {
+			WarLauncher launcher = new WarLauncher(archive);
+			List<Archive> classPathArchives = new ArrayList<>();
+			launcher.getClassPathArchivesIterator().forEachRemaining(classPathArchives::add);
+			assertThat(getUrls(classPathArchives)).containsOnly(
+					new URL("jar:" + jarRoot.toURI().toURL() + "!/WEB-INF/classes!/"),
+					new URL("jar:" + jarRoot.toURI().toURL() + "!/WEB-INF/lib/foo.jar!/"),
+					new URL("jar:" + jarRoot.toURI().toURL() + "!/WEB-INF/lib/bar.jar!/"),
+					new URL("jar:" + jarRoot.toURI().toURL() + "!/WEB-INF/lib/baz.jar!/"));
+			for (Archive classPathArchive : classPathArchives) {
+				classPathArchive.close();
+			}
+		}
+	}
+
+	@Test
+	void explodedWarShouldPreserveClasspathOrderWhenIndexPresent() throws Exception {
+		File explodedRoot = explode(createJarArchive("archive.war", "WEB-INF", true, Collections.emptyList()));
+		WarLauncher launcher = new WarLauncher(new ExplodedArchive(explodedRoot, true));
+		Iterator<Archive> archives = launcher.getClassPathArchivesIterator();
+		URLClassLoader classLoader = (URLClassLoader) launcher.createClassLoader(archives);
+		URL[] urls = classLoader.getURLs();
+		assertThat(urls).containsExactly(getExpectedFileUrls(explodedRoot));
+	}
+
+	@Test
+	void warFilesPresentInWebInfLibsAndNotInClasspathIndexShouldBeAddedAfterWebInfClasses() throws Exception {
+		ArrayList<String> extraLibs = new ArrayList<>(Arrays.asList("extra-1.jar", "extra-2.jar"));
+		File explodedRoot = explode(createJarArchive("archive.war", "WEB-INF", true, extraLibs));
+		WarLauncher launcher = new WarLauncher(new ExplodedArchive(explodedRoot, true));
+		Iterator<Archive> archives = launcher.getClassPathArchivesIterator();
+		URLClassLoader classLoader = (URLClassLoader) launcher.createClassLoader(archives);
+		URL[] urls = classLoader.getURLs();
+		List<File> expectedFiles = getExpectedFilesWithExtraLibs(explodedRoot);
+		URL[] expectedFileUrls = expectedFiles.stream().map(this::toUrl).toArray(URL[]::new);
+		assertThat(urls).containsExactly(expectedFileUrls);
+	}
+
+	protected final URL[] getExpectedFileUrls(File explodedRoot) {
+		return getExpectedFiles(explodedRoot).stream().map(this::toUrl).toArray(URL[]::new);
+	}
+
+	protected final List<File> getExpectedFiles(File parent) {
+		List<File> expected = new ArrayList<>();
+		expected.add(new File(parent, "WEB-INF/classes"));
+		expected.add(new File(parent, "WEB-INF/lib/foo.jar"));
+		expected.add(new File(parent, "WEB-INF/lib/bar.jar"));
+		expected.add(new File(parent, "WEB-INF/lib/baz.jar"));
+		return expected;
+	}
+
+	protected final List<File> getExpectedFilesWithExtraLibs(File parent) {
+		List<File> expected = new ArrayList<>();
+		expected.add(new File(parent, "WEB-INF/classes"));
+		expected.add(new File(parent, "WEB-INF/lib/extra-1.jar"));
+		expected.add(new File(parent, "WEB-INF/lib/extra-2.jar"));
+		expected.add(new File(parent, "WEB-INF/lib/foo.jar"));
+		expected.add(new File(parent, "WEB-INF/lib/bar.jar"));
+		expected.add(new File(parent, "WEB-INF/lib/baz.jar"));
+		return expected;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/archive/ExplodedArchiveTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/archive/ExplodedArchiveTests.java
similarity index 100%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/archive/ExplodedArchiveTests.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/archive/ExplodedArchiveTests.java
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/archive/JarFileArchiveTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/archive/JarFileArchiveTests.java
similarity index 100%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/archive/JarFileArchiveTests.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/archive/JarFileArchiveTests.java
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/data/RandomAccessDataFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/data/RandomAccessDataFileTests.java
similarity index 100%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/data/RandomAccessDataFileTests.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/data/RandomAccessDataFileTests.java
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/AsciiBytesTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/AsciiBytesTests.java
similarity index 100%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/AsciiBytesTests.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/AsciiBytesTests.java
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/CentralDirectoryParserTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/CentralDirectoryParserTests.java
similarity index 100%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/CentralDirectoryParserTests.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/CentralDirectoryParserTests.java
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/HandlerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/HandlerTests.java
similarity index 100%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/HandlerTests.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/HandlerTests.java
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/JarFileTests.java
similarity index 99%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarFileTests.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/JarFileTests.java
index 1188dd0ba81c..b37a99183a72 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarFileTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/JarFileTests.java
@@ -220,7 +220,7 @@ void getName() {
 	@Test
 	void size() throws Exception {
 		try (ZipFile zip = new ZipFile(this.rootJarFile)) {
-			assertThat(this.jarFile.size()).isEqualTo(zip.size());
+			assertThat(this.jarFile).hasSize(zip.size());
 		}
 	}
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarFileWrapperTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/JarFileWrapperTests.java
similarity index 100%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarFileWrapperTests.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/JarFileWrapperTests.java
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarURLConnectionTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/JarURLConnectionTests.java
similarity index 100%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarURLConnectionTests.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/JarURLConnectionTests.java
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarUrlProtocolHandler.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/JarUrlProtocolHandler.java
similarity index 100%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarUrlProtocolHandler.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/JarUrlProtocolHandler.java
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/StringSequenceTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/StringSequenceTests.java
similarity index 100%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/StringSequenceTests.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/StringSequenceTests.java
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jarmode/LauncherJarModeTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jarmode/LauncherJarModeTests.java
similarity index 97%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jarmode/LauncherJarModeTests.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jarmode/LauncherJarModeTests.java
index 93179bad6fe2..dec587e18bb2 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jarmode/LauncherJarModeTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jarmode/LauncherJarModeTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/util/SystemPropertyUtilsTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/util/SystemPropertyUtilsTests.java
similarity index 96%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/util/SystemPropertyUtilsTests.java
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/util/SystemPropertyUtilsTests.java
index 0697b77b7bba..802a762e79dd 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/util/SystemPropertyUtilsTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/util/SystemPropertyUtilsTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/application.properties b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/application.properties
new file mode 100644
index 000000000000..85a390f4d4e0
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/application.properties
@@ -0,0 +1 @@
+loader.main: demo.Application
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/bar.properties b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/bar.properties
new file mode 100644
index 000000000000..6b37480f8b99
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/bar.properties
@@ -0,0 +1 @@
+loader.main: my.BootInfBarApplication
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/foo.properties b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/foo.properties
new file mode 100644
index 000000000000..36bd211df41b
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/foo.properties
@@ -0,0 +1,3 @@
+foo: Application
+loader.main: my.${foo}
+loader.path: etc
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/loader.properties b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/loader.properties
new file mode 100644
index 000000000000..85a390f4d4e0
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/loader.properties
@@ -0,0 +1 @@
+loader.main: demo.Application
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/META-INF/spring.factories
new file mode 100644
index 000000000000..c45c87d76f45
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/META-INF/spring.factories
@@ -0,0 +1,3 @@
+# Jar Modes
+org.springframework.boot.loader.jarmode.JarMode=\
+org.springframework.boot.loader.jarmode.TestJarMode
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/bar.properties b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/bar.properties
new file mode 100644
index 000000000000..8301c2649f3c
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/bar.properties
@@ -0,0 +1 @@
+loader.main: my.BarApplication
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/explodedsample/ExampleClass.txt b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/explodedsample/ExampleClass.txt
new file mode 100644
index 000000000000..c53100f90fa1
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/explodedsample/ExampleClass.txt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2012-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package explodedsample;
+
+/**
+ * Example class used to test class loading.
+ *
+ * @author Phillip Webb
+ */
+public class ExampleClass {
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/home/loader.properties b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/home/loader.properties
new file mode 100644
index 000000000000..7a134969b766
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/home/loader.properties
@@ -0,0 +1 @@
+loader.main: demo.HomeApplication
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/jars/app.jar b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/jars/app.jar
new file mode 100644
index 000000000000..fb02c027012d
Binary files /dev/null and b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/jars/app.jar differ
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/more-jars/app.jar b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/more-jars/app.jar
new file mode 100644
index 000000000000..3945fd020d34
Binary files /dev/null and b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/more-jars/app.jar differ
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/nested-jars/app.jar b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/nested-jars/app.jar
new file mode 100644
index 000000000000..5600ed279efb
Binary files /dev/null and b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/nested-jars/app.jar differ
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/nested-jars/nested-jar-app.jar b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/nested-jars/nested-jar-app.jar
new file mode 100644
index 000000000000..4c2254f6352b
Binary files /dev/null and b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/nested-jars/nested-jar-app.jar differ
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/org/springframework/boot/loader/classpath-index-file.idx b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/org/springframework/boot/loader/classpath-index-file.idx
similarity index 100%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/org/springframework/boot/loader/classpath-index-file.idx
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/org/springframework/boot/loader/classpath-index-file.idx
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/placeholders/META-INF/MANIFEST.MF b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/placeholders/META-INF/MANIFEST.MF
new file mode 100644
index 000000000000..d95a13c5284e
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/placeholders/META-INF/MANIFEST.MF
@@ -0,0 +1,2 @@
+Manifest-Version: 1.0
+Start-Class: ${foo.main}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/placeholders/loader.properties b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/placeholders/loader.properties
new file mode 100644
index 000000000000..32f7d00f2d01
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/placeholders/loader.properties
@@ -0,0 +1 @@
+foo.main: demo.FooApplication
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/root/META-INF/MANIFEST.MF b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/root/META-INF/MANIFEST.MF
similarity index 100%
rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/root/META-INF/MANIFEST.MF
rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/root/META-INF/MANIFEST.MF
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/root/META-INF/spring/application.xml b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/root/META-INF/spring/application.xml
new file mode 100644
index 000000000000..cf04aa4fbe43
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/root/META-INF/spring/application.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans xmlns="http://www.springframework.org/schema/beans"
+	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+	xsi:schemaLocation="http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd">
+
+</beans>
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/build.gradle
index c6b187317071..f7968f659d51 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/build.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/build.gradle
@@ -13,10 +13,25 @@ configurations {
 		extendsFrom dependencyManagement
 		transitive = false
 	}
+	loaderClassic {
+		extendsFrom dependencyManagement
+		transitive = false
+	}
 	jarmode {
 		extendsFrom dependencyManagement
 		transitive = false
 	}
+	all {
+		resolutionStrategy {
+			eachDependency { dependency ->
+				// Downgrade Spring Framework as Gradle cannot cope with 6.1.0-M1's
+				// multi-version jar files with bytecode in META-INF/versions/21
+				if (dependency.requested.group.equals("org.springframework")) {
+					dependency.useVersion("6.0.10")
+				}
+			}
+		}
+	}
 }
 
 dependencies {
@@ -26,6 +41,7 @@ dependencies {
 	compileOnly("ch.qos.logback:logback-classic")
 
 	loader(project(":spring-boot-project:spring-boot-tools:spring-boot-loader"))
+	loaderClassic(project(":spring-boot-project:spring-boot-tools:spring-boot-loader-classic"))
 
 	jarmode(project(":spring-boot-project:spring-boot-tools:spring-boot-jarmode-layertools"))
 
@@ -50,6 +66,21 @@ task reproducibleLoaderJar(type: Jar) {
 	destinationDirectory = file("${generatedResources}/META-INF/loader")
 }
 
+task reproducibleLoaderClassicJar(type: Jar) {
+	dependsOn configurations.loaderClassic
+	from {
+		zipTree(configurations.loaderClassic.incoming.files.singleFile).matching {
+			exclude "META-INF/LICENSE.txt"
+			exclude "META-INF/NOTICE.txt"
+			exclude "META-INF/spring-boot.properties"
+		}
+	}
+	reproducibleFileOrder = true
+	preserveFileTimestamps = false
+	archiveFileName = "spring-boot-loader-classic.jar"
+	destinationDirectory = file("${generatedResources}/META-INF/loader")
+}
+
 task layerToolsJar(type: Sync) {
 	dependsOn configurations.jarmode
 	from {
@@ -61,12 +92,12 @@ task layerToolsJar(type: Sync) {
 
 sourceSets {
 	main {
-		output.dir(generatedResources, builtBy: [layerToolsJar, reproducibleLoaderJar])
+		output.dir(generatedResources, builtBy: [layerToolsJar, reproducibleLoaderJar, reproducibleLoaderClassicJar])
 	}
 }
 
 compileJava {
 	if ((!project.hasProperty("toolchainVersion")) && JavaVersion.current() == JavaVersion.VERSION_1_8) {
-		options.compilerArgs += ['-Xlint:-sunapi', '-XDenableSunApiLintControl']	
-	}	
+		options.compilerArgs += ['-Xlint:-sunapi', '-XDenableSunApiLintControl']
+	}
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/AbstractJarWriter.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/AbstractJarWriter.java
index ffbdf5ec73b9..429f63c2b36f 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/AbstractJarWriter.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/AbstractJarWriter.java
@@ -51,8 +51,6 @@
  */
 public abstract class AbstractJarWriter implements LoaderClassesWriter {
 
-	private static final String NESTED_LOADER_JAR = "META-INF/loader/spring-boot-loader.jar";
-
 	private static final int BUFFER_SIZE = 32 * 1024;
 
 	private static final int UNIX_FILE_MODE = UnixStat.FILE_FLAG | UnixStat.DEFAULT_FILE_PERM;
@@ -199,13 +197,15 @@ private long getNestedLibraryTime(Library library) {
 		return library.getLastModified();
 	}
 
-	/**
-	 * Write the required spring-boot-loader classes to the JAR.
-	 * @throws IOException if the classes cannot be written
-	 */
 	@Override
 	public void writeLoaderClasses() throws IOException {
-		writeLoaderClasses(NESTED_LOADER_JAR);
+		writeLoaderClasses(LoaderImplementation.DEFAULT);
+	}
+
+	@Override
+	public void writeLoaderClasses(LoaderImplementation loaderImplementation) throws IOException {
+		writeLoaderClasses((loaderImplementation != null) ? loaderImplementation.getJarResourceName()
+				: LoaderImplementation.DEFAULT.getJarResourceName());
 	}
 
 	/**
@@ -220,7 +220,7 @@ public void writeLoaderClasses(String loaderJarResourceName) throws IOException
 		try (JarInputStream inputStream = new JarInputStream(new BufferedInputStream(loaderJar.openStream()))) {
 			JarEntry entry;
 			while ((entry = inputStream.getNextJarEntry()) != null) {
-				if (isDirectoryEntry(entry) || isClassEntry(entry)) {
+				if (isDirectoryEntry(entry) || isClassEntry(entry) || isServicesEntry(entry)) {
 					writeEntry(new JarArchiveEntry(entry), new InputStreamEntryWriter(inputStream));
 				}
 			}
@@ -235,6 +235,10 @@ private boolean isClassEntry(JarEntry entry) {
 		return entry.getName().endsWith(".class");
 	}
 
+	private boolean isServicesEntry(JarEntry entry) {
+		return !entry.isDirectory() && entry.getName().startsWith("META-INF/services/");
+	}
+
 	private void writeEntry(JarArchiveEntry entry, EntryWriter entryWriter) throws IOException {
 		writeEntry(entry, null, entryWriter, UnpackHandler.NEVER);
 	}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/FileUtils.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/FileUtils.java
index ecfded739077..0347e1cbe6f4 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/FileUtils.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/FileUtils.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -18,6 +18,9 @@
 
 import java.io.File;
 import java.io.IOException;
+import java.util.jar.Attributes;
+import java.util.jar.JarFile;
+import java.util.jar.Manifest;
 
 /**
  * Utilities for manipulating files and directories in Spring Boot tooling.
@@ -61,4 +64,31 @@ public static String sha1Hash(File file) throws IOException {
 		return Digest.sha1(InputStreamSupplier.forFile(file));
 	}
 
+	/**
+	 * Returns {@code true} if the given jar file has been signed.
+	 * @param file the file to check
+	 * @return if the file has been signed
+	 * @throws IOException on IO error
+	 */
+	public static boolean isSignedJarFile(File file) throws IOException {
+		try (JarFile jarFile = new JarFile(file)) {
+			if (hasDigestEntry(jarFile.getManifest())) {
+				return true;
+			}
+		}
+		return false;
+	}
+
+	private static boolean hasDigestEntry(Manifest manifest) {
+		return (manifest != null) && manifest.getEntries().values().stream().anyMatch(FileUtils::hasDigestName);
+	}
+
+	private static boolean hasDigestName(Attributes attributes) {
+		return attributes.keySet().stream().anyMatch(FileUtils::isDigestName);
+	}
+
+	private static boolean isDigestName(Object name) {
+		return String.valueOf(name).toUpperCase().endsWith("-DIGEST");
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Layouts.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Layouts.java
index 61586d3d1c51..e6f99282a717 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Layouts.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Layouts.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -23,7 +23,7 @@
 import java.util.Map;
 
 /**
- * Common {@link Layout}s.
+ * Common {@link Layout layouts}.
  *
  * @author Phillip Webb
  * @author Dave Syer
@@ -66,7 +66,7 @@ public static class Jar implements RepackagingLayout {
 
 		@Override
 		public String getLauncherClassName() {
-			return "org.springframework.boot.loader.JarLauncher";
+			return "org.springframework.boot.loader.launch.JarLauncher";
 		}
 
 		@Override
@@ -108,7 +108,7 @@ public static class Expanded extends Jar {
 
 		@Override
 		public String getLauncherClassName() {
-			return "org.springframework.boot.loader.PropertiesLauncher";
+			return "org.springframework.boot.loader.launch.PropertiesLauncher";
 		}
 
 	}
@@ -148,7 +148,7 @@ public static class War implements Layout {
 
 		@Override
 		public String getLauncherClassName() {
-			return "org.springframework.boot.loader.WarLauncher";
+			return "org.springframework.boot.loader.launch.WarLauncher";
 		}
 
 		@Override
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Library.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Library.java
index f2fe532223c5..213809553815 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Library.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Library.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -64,7 +64,7 @@ public Library(File file, LibraryScope scope) {
 	 * @param unpackRequired if the library needs to be unpacked before it can be used
 	 * @param local if the library is local (part of the same build) to the application
 	 * that is being packaged
-	 * @param included if the library is included in the fat jar
+	 * @param included if the library is included in the uber jar
 	 * @since 2.4.8
 	 */
 	public Library(String name, File file, LibraryScope scope, LibraryCoordinates coordinates, boolean unpackRequired,
@@ -142,7 +142,7 @@ public boolean isLocal() {
 	}
 
 	/**
-	 * Return if the library is included in the fat jar.
+	 * Return if the library is included in the uber jar.
 	 * @return if the library is included
 	 */
 	public boolean isIncluded() {
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/LoaderClassesWriter.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/LoaderClassesWriter.java
index 187ff0b90292..864992279d35 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/LoaderClassesWriter.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/LoaderClassesWriter.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -34,6 +34,14 @@ public interface LoaderClassesWriter {
 	 */
 	void writeLoaderClasses() throws IOException;
 
+	/**
+	 * Write the default required spring-boot-loader classes to the JAR.
+	 * @param loaderImplementation the specific implementation to write
+	 * @throws IOException if the classes cannot be written
+	 * @since 3.2.0
+	 */
+	void writeLoaderClasses(LoaderImplementation loaderImplementation) throws IOException;
+
 	/**
 	 * Write custom required spring-boot-loader classes to the JAR.
 	 * @param loaderJarResourceName the name of the resource containing the loader classes
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/LoaderImplementation.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/LoaderImplementation.java
new file mode 100644
index 000000000000..6414a3cfbbf8
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/LoaderImplementation.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.tools;
+
+/**
+ * Supported loader implementations.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public enum LoaderImplementation {
+
+	/**
+	 * The default recommended loader implementation.
+	 */
+	DEFAULT("META-INF/loader/spring-boot-loader.jar"),
+
+	/**
+	 * The classic loader implementation as used with Spring Boot 3.1 and earlier.
+	 */
+	CLASSIC("META-INF/loader/spring-boot-loader-classic.jar");
+
+	private final String jarResourceName;
+
+	LoaderImplementation(String jarResourceName) {
+		this.jarResourceName = jarResourceName;
+	}
+
+	/**
+	 * Return the name of the nested resource that can be loaded from the tools jar.
+	 * @return the jar resource name
+	 */
+	public String getJarResourceName() {
+		return this.jarResourceName;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Packager.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Packager.java
index 815e0ee4a935..b04ac4501543 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Packager.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Packager.java
@@ -88,6 +88,8 @@ public abstract class Packager {
 
 	private Layout layout;
 
+	private LoaderImplementation loaderImplementation;
+
 	private LayoutFactory layoutFactory;
 
 	private Layers layers;
@@ -135,6 +137,14 @@ public void setLayout(Layout layout) {
 		this.layout = layout;
 	}
 
+	/**
+	 * Sets the loader implementation to use.
+	 * @param loaderImplementation the loaderImplementation to set
+	 */
+	public void setLoaderImplementation(LoaderImplementation loaderImplementation) {
+		this.loaderImplementation = loaderImplementation;
+	}
+
 	/**
 	 * Sets the layout factory for the jar. The factory can be used when no specific
 	 * layout is specified.
@@ -207,6 +217,7 @@ private void write(JarFile sourceJar, AbstractJarWriter writer, PackagedLibrarie
 		if (isLayered()) {
 			writeLayerIndex(writer);
 		}
+		writeSignatureFileIfNecessary(writtenLibraries, writer);
 	}
 
 	private void writeLoaderClasses(AbstractJarWriter writer) throws IOException {
@@ -215,7 +226,7 @@ private void writeLoaderClasses(AbstractJarWriter writer) throws IOException {
 			customLoaderLayout.writeLoadedClasses(writer);
 		}
 		else if (layout.isExecutable()) {
-			writer.writeLoaderClasses();
+			writer.writeLoaderClasses(this.loaderImplementation);
 		}
 	}
 
@@ -253,6 +264,10 @@ private void writeLayerIndex(AbstractJarWriter writer) throws IOException {
 		}
 	}
 
+	protected void writeSignatureFileIfNecessary(Map<String, Library> writtenLibraries, AbstractJarWriter writer)
+			throws IOException {
+	}
+
 	private EntryTransformer getEntityTransformer() {
 		if (getLayout() instanceof RepackagingLayout repackagingLayout) {
 			return new RepackagingEntryTransformer(repackagingLayout);
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Repackager.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Repackager.java
index 07da873c83a4..764c84f9fde8 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Repackager.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Repackager.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -19,6 +19,7 @@
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.attribute.FileTime;
+import java.util.Map;
 import java.util.jar.JarFile;
 
 import org.springframework.util.Assert;
@@ -46,6 +47,24 @@ public Repackager(File source) {
 		super(source);
 	}
 
+	@Override
+	protected void writeSignatureFileIfNecessary(Map<String, Library> writtenLibraries, AbstractJarWriter writer)
+			throws IOException {
+		if (getSource().getName().toLowerCase().endsWith(".jar") && hasSignedLibrary(writtenLibraries)) {
+			writer.writeEntry("META-INF/BOOT.SF", (entryWriter) -> {
+			});
+		}
+	}
+
+	private boolean hasSignedLibrary(Map<String, Library> writtenLibraries) throws IOException {
+		for (Library library : writtenLibraries.values()) {
+			if (!(library instanceof JarModeLibrary) && FileUtils.isSignedJarFile(library.getFile())) {
+				return true;
+			}
+		}
+		return false;
+	}
+
 	/**
 	 * Sets if source files should be backed up when they would be overwritten.
 	 * @param backupSource if source files should be backed up
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/StandardLayers.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/StandardLayers.java
index 544c213f3ad9..58254591eec8 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/StandardLayers.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/StandardLayers.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -27,7 +27,7 @@
  * <ol>
  * <li>"dependencies" - For non snapshot dependencies</li>
  * <li>"spring-boot-loader" - For classes from {@code spring-boot-loader} used to launch a
- * fat jar</li>
+ * uber jar</li>
  * <li>"snapshot-dependencies" - For snapshot dependencies</li>
  * <li>"application" - For application classes and resources</li>
  * </ol>
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/AbstractPackagerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/AbstractPackagerTests.java
index 3429e4af90ea..1193ce35959b 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/AbstractPackagerTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/AbstractPackagerTests.java
@@ -105,7 +105,7 @@ void specificMainClass() throws Exception {
 		execute(packager, NO_LIBRARIES);
 		Manifest actualManifest = getPackagedManifest();
 		assertThat(actualManifest.getMainAttributes().getValue("Main-Class"))
-			.isEqualTo("org.springframework.boot.loader.JarLauncher");
+			.isEqualTo("org.springframework.boot.loader.launch.JarLauncher");
 		assertThat(actualManifest.getMainAttributes().getValue("Start-Class")).isEqualTo("a.b.C");
 		assertThat(hasPackagedLauncherClasses()).isTrue();
 	}
@@ -121,7 +121,7 @@ void mainClassFromManifest() throws Exception {
 		execute(packager, NO_LIBRARIES);
 		Manifest actualManifest = getPackagedManifest();
 		assertThat(actualManifest.getMainAttributes().getValue("Main-Class"))
-			.isEqualTo("org.springframework.boot.loader.JarLauncher");
+			.isEqualTo("org.springframework.boot.loader.launch.JarLauncher");
 		assertThat(actualManifest.getMainAttributes().getValue("Start-Class")).isEqualTo("a.b.C");
 		assertThat(hasPackagedLauncherClasses()).isTrue();
 	}
@@ -133,7 +133,7 @@ void mainClassFound() throws Exception {
 		execute(packager, NO_LIBRARIES);
 		Manifest actualManifest = getPackagedManifest();
 		assertThat(actualManifest.getMainAttributes().getValue("Main-Class"))
-			.isEqualTo("org.springframework.boot.loader.JarLauncher");
+			.isEqualTo("org.springframework.boot.loader.launch.JarLauncher");
 		assertThat(actualManifest.getMainAttributes().getValue("Start-Class")).isEqualTo("a.b.C");
 		assertThat(hasPackagedLauncherClasses()).isTrue();
 	}
@@ -660,7 +660,7 @@ private File createLibraryJar() throws IOException {
 		return library.getFile();
 	}
 
-	private Library newLibrary(File file, LibraryScope scope, boolean unpackRequired) {
+	protected Library newLibrary(File file, LibraryScope scope, boolean unpackRequired) {
 		return new Library(null, file, scope, null, unpackRequired, false, true);
 	}
 
@@ -684,10 +684,10 @@ protected Collection<String> getPackagedEntryNames() throws IOException {
 
 	protected boolean hasPackagedLauncherClasses() throws IOException {
 		return hasPackagedEntry("org/springframework/boot/")
-				&& hasPackagedEntry("org/springframework/boot/loader/JarLauncher.class");
+				&& hasPackagedEntry("org/springframework/boot/loader/launch/JarLauncher.class");
 	}
 
-	private boolean hasPackagedEntry(String name) throws IOException {
+	protected boolean hasPackagedEntry(String name) throws IOException {
 		return getPackagedEntry(name) != null;
 	}
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/FileUtilsTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/FileUtilsTests.java
index edf8b38b889a..e6e084bc4d6f 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/FileUtilsTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/FileUtilsTests.java
@@ -17,9 +17,13 @@
 package org.springframework.boot.loader.tools;
 
 import java.io.File;
+import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.OutputStream;
+import java.util.jar.JarOutputStream;
+import java.util.jar.Manifest;
+import java.util.zip.ZipEntry;
 
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -99,4 +103,28 @@ void hash() throws Exception {
 		assertThat(FileUtils.sha1Hash(file)).isEqualTo("7037807198c22a7d2b0807371d763779a84fdfcf");
 	}
 
+	@Test
+	void isSignedJarFileWhenSignedReturnsTrue() throws IOException {
+		Manifest manifest = new Manifest(getClass().getResourceAsStream("signed-manifest.mf"));
+		File jarFile = new File(this.tempDir, "test.jar");
+		writeTestJar(manifest, jarFile);
+		assertThat(FileUtils.isSignedJarFile(jarFile)).isTrue();
+	}
+
+	@Test
+	void isSignedJarFileWhenNotSignedReturnsFalse() throws IOException {
+		Manifest manifest = new Manifest();
+		File jarFile = new File(this.tempDir, "test.jar");
+		writeTestJar(manifest, jarFile);
+		assertThat(FileUtils.isSignedJarFile(jarFile)).isFalse();
+	}
+
+	private void writeTestJar(Manifest manifest, File jarFile) throws IOException, FileNotFoundException {
+		try (JarOutputStream out = new JarOutputStream(new FileOutputStream(jarFile))) {
+			out.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF"));
+			manifest.write(out);
+			out.closeEntry();
+		}
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/RepackagerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/RepackagerTests.java
index a4a648c34fb9..239c0cc381e4 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/RepackagerTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/RepackagerTests.java
@@ -28,6 +28,7 @@
 import java.util.Collection;
 import java.util.Enumeration;
 import java.util.List;
+import java.util.jar.Attributes;
 import java.util.jar.JarEntry;
 import java.util.jar.JarFile;
 import java.util.jar.Manifest;
@@ -79,7 +80,7 @@ void jarIsOnlyRepackagedOnce() throws Exception {
 		repackager.repackage(NO_LIBRARIES);
 		Manifest actualManifest = getPackagedManifest();
 		assertThat(actualManifest.getMainAttributes().getValue("Main-Class"))
-			.isEqualTo("org.springframework.boot.loader.JarLauncher");
+			.isEqualTo("org.springframework.boot.loader.launch.JarLauncher");
 		assertThat(actualManifest.getMainAttributes().getValue("Start-Class")).isEqualTo("a.b.C");
 		assertThat(hasPackagedLauncherClasses()).isTrue();
 	}
@@ -218,9 +219,23 @@ void repackagingDeeplyNestedPackageIsNotProhibitivelySlow() throws IOException {
 		assertThat(stopWatch.getTotalTimeMillis()).isLessThan(5000);
 	}
 
+	@Test
+	void signedJar() throws Exception {
+		Repackager packager = createPackager();
+		packager.setMainClass("a.b.C");
+		Manifest manifest = new Manifest();
+		Attributes attributes = new Attributes();
+		attributes.putValue("SHA1-Digest", "0000");
+		manifest.getEntries().put("a/b/C.class", attributes);
+		TestJarFile libJar = new TestJarFile(this.tempDir);
+		libJar.addManifest(manifest);
+		execute(packager, (callback) -> callback.library(newLibrary(libJar.getFile(), LibraryScope.COMPILE, false)));
+		assertThat(hasPackagedEntry("META-INF/BOOT.SF")).isTrue();
+	}
+
 	private boolean hasLauncherClasses(File file) throws IOException {
 		return hasEntry(file, "org/springframework/boot/")
-				&& hasEntry(file, "org/springframework/boot/loader/JarLauncher.class");
+				&& hasEntry(file, "org/springframework/boot/loader/launch/JarLauncher.class");
 	}
 
 	private boolean hasEntry(File file, String name) throws IOException {
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/resources/org/springframework/boot/loader/tools/signed-manifest.mf b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/resources/org/springframework/boot/loader/tools/signed-manifest.mf
new file mode 100644
index 000000000000..8316a0550d50
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/resources/org/springframework/boot/loader/tools/signed-manifest.mf
@@ -0,0 +1,9 @@
+Manifest-Version: 1.0
+Created-By: 1.5.0_08 (Sun Microsystems Inc.)
+Specification-Version: 1.1
+
+Name: org/bouncycastle/pqc/legacy/math/linearalgebra/GoppaCode.class
+SHA-256-Digest: wNhEfeTvNG9ggqKfLjQDDoFoDqeWwGUc47JiL7VqxqU=
+
+Name: org/bouncycastle/crypto/modes/gcm/Tables8kGCMMultiplier.class
+SHA-256-Digest: nqljr9DNx4nNie4sbkZajVenvd3LdMF3X5s5dmSMToM=
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ClassPathIndexFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ClassPathIndexFile.java
deleted file mode 100644
index c08407941b3a..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ClassPathIndexFile.java
+++ /dev/null
@@ -1,123 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.loader;
-
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.net.MalformedURLException;
-import java.net.URISyntaxException;
-import java.net.URL;
-import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
-/**
- * A class path index file that provides ordering information for JARs.
- *
- * @author Madhura Bhave
- * @author Phillip Webb
- */
-final class ClassPathIndexFile {
-
-	private final File root;
-
-	private final List<String> lines;
-
-	private ClassPathIndexFile(File root, List<String> lines) {
-		this.root = root;
-		this.lines = lines.stream().map(this::extractName).toList();
-	}
-
-	private String extractName(String line) {
-		if (line.startsWith("- \"") && line.endsWith("\"")) {
-			return line.substring(3, line.length() - 1);
-		}
-		throw new IllegalStateException("Malformed classpath index line [" + line + "]");
-	}
-
-	int size() {
-		return this.lines.size();
-	}
-
-	boolean containsEntry(String name) {
-		if (name == null || name.isEmpty()) {
-			return false;
-		}
-		return this.lines.contains(name);
-	}
-
-	List<URL> getUrls() {
-		return this.lines.stream().map(this::asUrl).toList();
-	}
-
-	private URL asUrl(String line) {
-		try {
-			return new File(this.root, line).toURI().toURL();
-		}
-		catch (MalformedURLException ex) {
-			throw new IllegalStateException(ex);
-		}
-	}
-
-	static ClassPathIndexFile loadIfPossible(URL root, String location) throws IOException {
-		return loadIfPossible(asFile(root), location);
-	}
-
-	private static ClassPathIndexFile loadIfPossible(File root, String location) throws IOException {
-		return loadIfPossible(root, new File(root, location));
-	}
-
-	private static ClassPathIndexFile loadIfPossible(File root, File indexFile) throws IOException {
-		if (indexFile.exists() && indexFile.isFile()) {
-			try (InputStream inputStream = new FileInputStream(indexFile)) {
-				return new ClassPathIndexFile(root, loadLines(inputStream));
-			}
-		}
-		return null;
-	}
-
-	private static List<String> loadLines(InputStream inputStream) throws IOException {
-		List<String> lines = new ArrayList<>();
-		BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
-		String line = reader.readLine();
-		while (line != null) {
-			if (!line.trim().isEmpty()) {
-				lines.add(line);
-			}
-			line = reader.readLine();
-		}
-		return Collections.unmodifiableList(lines);
-	}
-
-	private static File asFile(URL url) {
-		if (!"file".equals(url.getProtocol())) {
-			throw new IllegalArgumentException("URL does not reference a file");
-		}
-		try {
-			return new File(url.toURI());
-		}
-		catch (URISyntaxException ex) {
-			return new File(url.getPath());
-		}
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java
deleted file mode 100644
index 91b84b1140de..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java
+++ /dev/null
@@ -1,207 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.loader;
-
-import java.io.IOException;
-import java.net.URL;
-import java.util.ArrayList;
-import java.util.Iterator;
-import java.util.List;
-import java.util.jar.Attributes;
-import java.util.jar.Manifest;
-
-import org.springframework.boot.loader.archive.Archive;
-import org.springframework.boot.loader.archive.ExplodedArchive;
-
-/**
- * Base class for executable archive {@link Launcher}s.
- *
- * @author Phillip Webb
- * @author Andy Wilkinson
- * @author Madhura Bhave
- * @author Scott Frederick
- * @since 1.0.0
- */
-public abstract class ExecutableArchiveLauncher extends Launcher {
-
-	private static final String START_CLASS_ATTRIBUTE = "Start-Class";
-
-	protected static final String BOOT_CLASSPATH_INDEX_ATTRIBUTE = "Spring-Boot-Classpath-Index";
-
-	protected static final String DEFAULT_CLASSPATH_INDEX_FILE_NAME = "classpath.idx";
-
-	private final Archive archive;
-
-	private final ClassPathIndexFile classPathIndex;
-
-	public ExecutableArchiveLauncher() {
-		try {
-			this.archive = createArchive();
-			this.classPathIndex = getClassPathIndex(this.archive);
-		}
-		catch (Exception ex) {
-			throw new IllegalStateException(ex);
-		}
-	}
-
-	protected ExecutableArchiveLauncher(Archive archive) {
-		try {
-			this.archive = archive;
-			this.classPathIndex = getClassPathIndex(this.archive);
-		}
-		catch (Exception ex) {
-			throw new IllegalStateException(ex);
-		}
-	}
-
-	protected ClassPathIndexFile getClassPathIndex(Archive archive) throws IOException {
-		// Only needed for exploded archives, regular ones already have a defined order
-		if (archive instanceof ExplodedArchive) {
-			String location = getClassPathIndexFileLocation(archive);
-			return ClassPathIndexFile.loadIfPossible(archive.getUrl(), location);
-		}
-		return null;
-	}
-
-	private String getClassPathIndexFileLocation(Archive archive) throws IOException {
-		Manifest manifest = archive.getManifest();
-		Attributes attributes = (manifest != null) ? manifest.getMainAttributes() : null;
-		String location = (attributes != null) ? attributes.getValue(BOOT_CLASSPATH_INDEX_ATTRIBUTE) : null;
-		return (location != null) ? location : getArchiveEntryPathPrefix() + DEFAULT_CLASSPATH_INDEX_FILE_NAME;
-	}
-
-	@Override
-	protected String getMainClass() throws Exception {
-		Manifest manifest = this.archive.getManifest();
-		String mainClass = null;
-		if (manifest != null) {
-			mainClass = manifest.getMainAttributes().getValue(START_CLASS_ATTRIBUTE);
-		}
-		if (mainClass == null) {
-			throw new IllegalStateException("No 'Start-Class' manifest entry specified in " + this);
-		}
-		return mainClass;
-	}
-
-	@Override
-	protected ClassLoader createClassLoader(Iterator<Archive> archives) throws Exception {
-		List<URL> urls = new ArrayList<>(guessClassPathSize());
-		while (archives.hasNext()) {
-			urls.add(archives.next().getUrl());
-		}
-		if (this.classPathIndex != null) {
-			urls.addAll(this.classPathIndex.getUrls());
-		}
-		return createClassLoader(urls.toArray(new URL[0]));
-	}
-
-	private int guessClassPathSize() {
-		if (this.classPathIndex != null) {
-			return this.classPathIndex.size() + 10;
-		}
-		return 50;
-	}
-
-	@Override
-	protected Iterator<Archive> getClassPathArchivesIterator() throws Exception {
-		Archive.EntryFilter searchFilter = this::isSearchCandidate;
-		Iterator<Archive> archives = this.archive.getNestedArchives(searchFilter,
-				(entry) -> isNestedArchive(entry) && !isEntryIndexed(entry));
-		if (isPostProcessingClassPathArchives()) {
-			archives = applyClassPathArchivePostProcessing(archives);
-		}
-		return archives;
-	}
-
-	private boolean isEntryIndexed(Archive.Entry entry) {
-		if (this.classPathIndex != null) {
-			return this.classPathIndex.containsEntry(entry.getName());
-		}
-		return false;
-	}
-
-	private Iterator<Archive> applyClassPathArchivePostProcessing(Iterator<Archive> archives) throws Exception {
-		List<Archive> list = new ArrayList<>();
-		while (archives.hasNext()) {
-			list.add(archives.next());
-		}
-		postProcessClassPathArchives(list);
-		return list.iterator();
-	}
-
-	/**
-	 * Determine if the specified entry is a candidate for further searching.
-	 * @param entry the entry to check
-	 * @return {@code true} if the entry is a candidate for further searching
-	 * @since 2.3.0
-	 */
-	protected boolean isSearchCandidate(Archive.Entry entry) {
-		if (getArchiveEntryPathPrefix() == null) {
-			return true;
-		}
-		return entry.getName().startsWith(getArchiveEntryPathPrefix());
-	}
-
-	/**
-	 * Determine if the specified entry is a nested item that should be added to the
-	 * classpath.
-	 * @param entry the entry to check
-	 * @return {@code true} if the entry is a nested item (jar or directory)
-	 */
-	protected abstract boolean isNestedArchive(Archive.Entry entry);
-
-	/**
-	 * Return if post-processing needs to be applied to the archives. For back
-	 * compatibility this method returns {@code true}, but subclasses that don't override
-	 * {@link #postProcessClassPathArchives(List)} should provide an implementation that
-	 * returns {@code false}.
-	 * @return if the {@link #postProcessClassPathArchives(List)} method is implemented
-	 * @since 2.3.0
-	 */
-	protected boolean isPostProcessingClassPathArchives() {
-		return true;
-	}
-
-	/**
-	 * Called to post-process archive entries before they are used. Implementations can
-	 * add and remove entries.
-	 * @param archives the archives
-	 * @throws Exception if the post-processing fails
-	 * @see #isPostProcessingClassPathArchives()
-	 */
-	protected void postProcessClassPathArchives(List<Archive> archives) throws Exception {
-	}
-
-	/**
-	 * Return the path prefix for entries in the archive.
-	 * @return the path prefix
-	 */
-	protected String getArchiveEntryPathPrefix() {
-		return null;
-	}
-
-	@Override
-	protected boolean isExploded() {
-		return this.archive.isExploded();
-	}
-
-	@Override
-	protected final Archive getArchive() {
-		return this.archive;
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/JarLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/JarLauncher.java
deleted file mode 100644
index 2c86b3d41f43..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/JarLauncher.java
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- * Copyright 2012-2021 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.loader;
-
-import org.springframework.boot.loader.archive.Archive;
-import org.springframework.boot.loader.archive.Archive.EntryFilter;
-
-/**
- * {@link Launcher} for JAR based archives. This launcher assumes that dependency jars are
- * included inside a {@code /BOOT-INF/lib} directory and that application classes are
- * included inside a {@code /BOOT-INF/classes} directory.
- *
- * @author Phillip Webb
- * @author Andy Wilkinson
- * @author Madhura Bhave
- * @author Scott Frederick
- * @since 1.0.0
- */
-public class JarLauncher extends ExecutableArchiveLauncher {
-
-	static final EntryFilter NESTED_ARCHIVE_ENTRY_FILTER = (entry) -> {
-		if (entry.isDirectory()) {
-			return entry.getName().equals("BOOT-INF/classes/");
-		}
-		return entry.getName().startsWith("BOOT-INF/lib/");
-	};
-
-	public JarLauncher() {
-	}
-
-	protected JarLauncher(Archive archive) {
-		super(archive);
-	}
-
-	@Override
-	protected boolean isPostProcessingClassPathArchives() {
-		return false;
-	}
-
-	@Override
-	protected boolean isNestedArchive(Archive.Entry entry) {
-		return NESTED_ARCHIVE_ENTRY_FILTER.matches(entry);
-	}
-
-	@Override
-	protected String getArchiveEntryPathPrefix() {
-		return "BOOT-INF/";
-	}
-
-	public static void main(String[] args) throws Exception {
-		new JarLauncher().launch(args);
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/Launcher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/Launcher.java
deleted file mode 100644
index f83f685d24f7..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/Launcher.java
+++ /dev/null
@@ -1,159 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.loader;
-
-import java.io.File;
-import java.net.URI;
-import java.net.URL;
-import java.security.CodeSource;
-import java.security.ProtectionDomain;
-import java.util.ArrayList;
-import java.util.Iterator;
-import java.util.List;
-
-import org.springframework.boot.loader.archive.Archive;
-import org.springframework.boot.loader.archive.ExplodedArchive;
-import org.springframework.boot.loader.archive.JarFileArchive;
-import org.springframework.boot.loader.jar.JarFile;
-
-/**
- * Base class for launchers that can start an application with a fully configured
- * classpath backed by one or more {@link Archive}s.
- *
- * @author Phillip Webb
- * @author Dave Syer
- * @since 1.0.0
- */
-public abstract class Launcher {
-
-	private static final String JAR_MODE_LAUNCHER = "org.springframework.boot.loader.jarmode.JarModeLauncher";
-
-	/**
-	 * Launch the application. This method is the initial entry point that should be
-	 * called by a subclass {@code public static void main(String[] args)} method.
-	 * @param args the incoming arguments
-	 * @throws Exception if the application fails to launch
-	 */
-	protected void launch(String[] args) throws Exception {
-		if (!isExploded()) {
-			JarFile.registerUrlProtocolHandler();
-		}
-		ClassLoader classLoader = createClassLoader(getClassPathArchivesIterator());
-		String jarMode = System.getProperty("jarmode");
-		String launchClass = (jarMode != null && !jarMode.isEmpty()) ? JAR_MODE_LAUNCHER : getMainClass();
-		launch(args, launchClass, classLoader);
-	}
-
-	/**
-	 * Create a classloader for the specified archives.
-	 * @param archives the archives
-	 * @return the classloader
-	 * @throws Exception if the classloader cannot be created
-	 * @since 2.3.0
-	 */
-	protected ClassLoader createClassLoader(Iterator<Archive> archives) throws Exception {
-		List<URL> urls = new ArrayList<>(50);
-		while (archives.hasNext()) {
-			urls.add(archives.next().getUrl());
-		}
-		return createClassLoader(urls.toArray(new URL[0]));
-	}
-
-	/**
-	 * Create a classloader for the specified URLs.
-	 * @param urls the URLs
-	 * @return the classloader
-	 * @throws Exception if the classloader cannot be created
-	 */
-	protected ClassLoader createClassLoader(URL[] urls) throws Exception {
-		return new LaunchedURLClassLoader(isExploded(), getArchive(), urls, getClass().getClassLoader());
-	}
-
-	/**
-	 * Launch the application given the archive file and a fully configured classloader.
-	 * @param args the incoming arguments
-	 * @param launchClass the launch class to run
-	 * @param classLoader the classloader
-	 * @throws Exception if the launch fails
-	 */
-	protected void launch(String[] args, String launchClass, ClassLoader classLoader) throws Exception {
-		Thread.currentThread().setContextClassLoader(classLoader);
-		createMainMethodRunner(launchClass, args, classLoader).run();
-	}
-
-	/**
-	 * Create the {@code MainMethodRunner} used to launch the application.
-	 * @param mainClass the main class
-	 * @param args the incoming arguments
-	 * @param classLoader the classloader
-	 * @return the main method runner
-	 */
-	protected MainMethodRunner createMainMethodRunner(String mainClass, String[] args, ClassLoader classLoader) {
-		return new MainMethodRunner(mainClass, args);
-	}
-
-	/**
-	 * Returns the main class that should be launched.
-	 * @return the name of the main class
-	 * @throws Exception if the main class cannot be obtained
-	 */
-	protected abstract String getMainClass() throws Exception;
-
-	/**
-	 * Returns the archives that will be used to construct the class path.
-	 * @return the class path archives
-	 * @throws Exception if the class path archives cannot be obtained
-	 * @since 2.3.0
-	 */
-	protected abstract Iterator<Archive> getClassPathArchivesIterator() throws Exception;
-
-	protected final Archive createArchive() throws Exception {
-		ProtectionDomain protectionDomain = getClass().getProtectionDomain();
-		CodeSource codeSource = protectionDomain.getCodeSource();
-		URI location = (codeSource != null) ? codeSource.getLocation().toURI() : null;
-		String path = (location != null) ? location.getSchemeSpecificPart() : null;
-		if (path == null) {
-			throw new IllegalStateException("Unable to determine code source archive");
-		}
-		File root = new File(path);
-		if (!root.exists()) {
-			throw new IllegalStateException("Unable to determine code source archive from " + root);
-		}
-		return (root.isDirectory() ? new ExplodedArchive(root) : new JarFileArchive(root));
-	}
-
-	/**
-	 * Returns if the launcher is running in an exploded mode. If this method returns
-	 * {@code true} then only regular JARs are supported and the additional URL and
-	 * ClassLoader support infrastructure can be optimized.
-	 * @return if the jar is exploded.
-	 * @since 2.3.0
-	 */
-	protected boolean isExploded() {
-		return false;
-	}
-
-	/**
-	 * Return the root archive.
-	 * @return the root archive
-	 * @since 2.3.1
-	 */
-	protected Archive getArchive() {
-		return null;
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/WarLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/WarLauncher.java
deleted file mode 100644
index 81e0a744144c..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/WarLauncher.java
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
- * Copyright 2012-2021 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.loader;
-
-import org.springframework.boot.loader.archive.Archive;
-
-/**
- * {@link Launcher} for WAR based archives. This launcher for standard WAR archives.
- * Supports dependencies in {@code WEB-INF/lib} as well as {@code WEB-INF/lib-provided},
- * classes are loaded from {@code WEB-INF/classes}.
- *
- * @author Phillip Webb
- * @author Andy Wilkinson
- * @author Scott Frederick
- * @since 1.0.0
- */
-public class WarLauncher extends ExecutableArchiveLauncher {
-
-	public WarLauncher() {
-	}
-
-	protected WarLauncher(Archive archive) {
-		super(archive);
-	}
-
-	@Override
-	protected boolean isPostProcessingClassPathArchives() {
-		return false;
-	}
-
-	@Override
-	public boolean isNestedArchive(Archive.Entry entry) {
-		if (entry.isDirectory()) {
-			return entry.getName().equals("WEB-INF/classes/");
-		}
-		return entry.getName().startsWith("WEB-INF/lib/") || entry.getName().startsWith("WEB-INF/lib-provided/");
-	}
-
-	@Override
-	protected String getArchiveEntryPathPrefix() {
-		return "WEB-INF/";
-	}
-
-	public static void main(String[] args) throws Exception {
-		new WarLauncher().launch(args);
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/Archive.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/Archive.java
deleted file mode 100644
index a99c1c2c229b..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/Archive.java
+++ /dev/null
@@ -1,115 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.loader.archive;
-
-import java.io.IOException;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.util.Iterator;
-import java.util.jar.Manifest;
-
-import org.springframework.boot.loader.Launcher;
-
-/**
- * An archive that can be launched by the {@link Launcher}.
- *
- * @author Phillip Webb
- * @since 1.0.0
- * @see JarFileArchive
- */
-public interface Archive extends Iterable<Archive.Entry>, AutoCloseable {
-
-	/**
-	 * Returns a URL that can be used to load the archive.
-	 * @return the archive URL
-	 * @throws MalformedURLException if the URL is malformed
-	 */
-	URL getUrl() throws MalformedURLException;
-
-	/**
-	 * Returns the manifest of the archive.
-	 * @return the manifest
-	 * @throws IOException if the manifest cannot be read
-	 */
-	Manifest getManifest() throws IOException;
-
-	/**
-	 * Returns nested {@link Archive}s for entries that match the specified filters.
-	 * @param searchFilter filter used to limit when additional sub-entry searching is
-	 * required or {@code null} if all entries should be considered.
-	 * @param includeFilter filter used to determine which entries should be included in
-	 * the result or {@code null} if all entries should be included
-	 * @return the nested archives
-	 * @throws IOException on IO error
-	 * @since 2.3.0
-	 */
-	Iterator<Archive> getNestedArchives(EntryFilter searchFilter, EntryFilter includeFilter) throws IOException;
-
-	/**
-	 * Return if the archive is exploded (already unpacked).
-	 * @return if the archive is exploded
-	 * @since 2.3.0
-	 */
-	default boolean isExploded() {
-		return false;
-	}
-
-	/**
-	 * Closes the {@code Archive}, releasing any open resources.
-	 * @throws Exception if an error occurs during close processing
-	 * @since 2.2.0
-	 */
-	@Override
-	default void close() throws Exception {
-
-	}
-
-	/**
-	 * Represents a single entry in the archive.
-	 */
-	interface Entry {
-
-		/**
-		 * Returns {@code true} if the entry represents a directory.
-		 * @return if the entry is a directory
-		 */
-		boolean isDirectory();
-
-		/**
-		 * Returns the name of the entry.
-		 * @return the name of the entry
-		 */
-		String getName();
-
-	}
-
-	/**
-	 * Strategy interface to filter {@link Entry Entries}.
-	 */
-	@FunctionalInterface
-	interface EntryFilter {
-
-		/**
-		 * Apply the jar entry filter.
-		 * @param entry the entry to filter
-		 * @return {@code true} if the filter matches
-		 */
-		boolean matches(Entry entry);
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/ExplodedArchive.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/ExplodedArchive.java
deleted file mode 100644
index 08734078520c..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/ExplodedArchive.java
+++ /dev/null
@@ -1,342 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.loader.archive;
-
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.IOException;
-import java.net.MalformedURLException;
-import java.net.URI;
-import java.net.URL;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.Deque;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.LinkedList;
-import java.util.NoSuchElementException;
-import java.util.Set;
-import java.util.jar.Manifest;
-
-/**
- * {@link Archive} implementation backed by an exploded archive directory.
- *
- * @author Phillip Webb
- * @author Andy Wilkinson
- * @author Madhura Bhave
- * @since 1.0.0
- */
-public class ExplodedArchive implements Archive {
-
-	private static final Set<String> SKIPPED_NAMES = new HashSet<>(Arrays.asList(".", ".."));
-
-	private final File root;
-
-	private final boolean recursive;
-
-	private final File manifestFile;
-
-	private Manifest manifest;
-
-	/**
-	 * Create a new {@link ExplodedArchive} instance.
-	 * @param root the root directory
-	 */
-	public ExplodedArchive(File root) {
-		this(root, true);
-	}
-
-	/**
-	 * Create a new {@link ExplodedArchive} instance.
-	 * @param root the root directory
-	 * @param recursive if recursive searching should be used to locate the manifest.
-	 * Defaults to {@code true}, directories with a large tree might want to set this to
-	 * {@code false}.
-	 */
-	public ExplodedArchive(File root, boolean recursive) {
-		if (!root.exists() || !root.isDirectory()) {
-			throw new IllegalArgumentException("Invalid source directory " + root);
-		}
-		this.root = root;
-		this.recursive = recursive;
-		this.manifestFile = getManifestFile(root);
-	}
-
-	private File getManifestFile(File root) {
-		File metaInf = new File(root, "META-INF");
-		return new File(metaInf, "MANIFEST.MF");
-	}
-
-	@Override
-	public URL getUrl() throws MalformedURLException {
-		return this.root.toURI().toURL();
-	}
-
-	@Override
-	public Manifest getManifest() throws IOException {
-		if (this.manifest == null && this.manifestFile.exists()) {
-			try (FileInputStream inputStream = new FileInputStream(this.manifestFile)) {
-				this.manifest = new Manifest(inputStream);
-			}
-		}
-		return this.manifest;
-	}
-
-	@Override
-	public Iterator<Archive> getNestedArchives(EntryFilter searchFilter, EntryFilter includeFilter) throws IOException {
-		return new ArchiveIterator(this.root, this.recursive, searchFilter, includeFilter);
-	}
-
-	@Override
-	@Deprecated(since = "2.3.10", forRemoval = false)
-	public Iterator<Entry> iterator() {
-		return new EntryIterator(this.root, this.recursive, null, null);
-	}
-
-	protected Archive getNestedArchive(Entry entry) {
-		File file = ((FileEntry) entry).getFile();
-		return (file.isDirectory() ? new ExplodedArchive(file) : new SimpleJarFileArchive((FileEntry) entry));
-	}
-
-	@Override
-	public boolean isExploded() {
-		return true;
-	}
-
-	@Override
-	public String toString() {
-		try {
-			return getUrl().toString();
-		}
-		catch (Exception ex) {
-			return "exploded archive";
-		}
-	}
-
-	/**
-	 * File based {@link Entry} {@link Iterator}.
-	 */
-	private abstract static class AbstractIterator<T> implements Iterator<T> {
-
-		private static final Comparator<File> entryComparator = Comparator.comparing(File::getAbsolutePath);
-
-		private final File root;
-
-		private final boolean recursive;
-
-		private final EntryFilter searchFilter;
-
-		private final EntryFilter includeFilter;
-
-		private final Deque<Iterator<File>> stack = new LinkedList<>();
-
-		private FileEntry current;
-
-		private final String rootUrl;
-
-		AbstractIterator(File root, boolean recursive, EntryFilter searchFilter, EntryFilter includeFilter) {
-			this.root = root;
-			this.rootUrl = this.root.toURI().getPath();
-			this.recursive = recursive;
-			this.searchFilter = searchFilter;
-			this.includeFilter = includeFilter;
-			this.stack.add(listFiles(root));
-			this.current = poll();
-		}
-
-		@Override
-		public boolean hasNext() {
-			return this.current != null;
-		}
-
-		@Override
-		public T next() {
-			FileEntry entry = this.current;
-			if (entry == null) {
-				throw new NoSuchElementException();
-			}
-			this.current = poll();
-			return adapt(entry);
-		}
-
-		private FileEntry poll() {
-			while (!this.stack.isEmpty()) {
-				while (this.stack.peek().hasNext()) {
-					File file = this.stack.peek().next();
-					if (SKIPPED_NAMES.contains(file.getName())) {
-						continue;
-					}
-					FileEntry entry = getFileEntry(file);
-					if (isListable(entry)) {
-						this.stack.addFirst(listFiles(file));
-					}
-					if (this.includeFilter == null || this.includeFilter.matches(entry)) {
-						return entry;
-					}
-				}
-				this.stack.poll();
-			}
-			return null;
-		}
-
-		private FileEntry getFileEntry(File file) {
-			URI uri = file.toURI();
-			String name = uri.getPath().substring(this.rootUrl.length());
-			try {
-				return new FileEntry(name, file, uri.toURL());
-			}
-			catch (MalformedURLException ex) {
-				throw new IllegalStateException(ex);
-			}
-		}
-
-		private boolean isListable(FileEntry entry) {
-			return entry.isDirectory() && (this.recursive || entry.getFile().getParentFile().equals(this.root))
-					&& (this.searchFilter == null || this.searchFilter.matches(entry))
-					&& (this.includeFilter == null || !this.includeFilter.matches(entry));
-		}
-
-		private Iterator<File> listFiles(File file) {
-			File[] files = file.listFiles();
-			if (files == null) {
-				return Collections.emptyIterator();
-			}
-			Arrays.sort(files, entryComparator);
-			return Arrays.asList(files).iterator();
-		}
-
-		@Override
-		public void remove() {
-			throw new UnsupportedOperationException("remove");
-		}
-
-		protected abstract T adapt(FileEntry entry);
-
-	}
-
-	private static class EntryIterator extends AbstractIterator<Entry> {
-
-		EntryIterator(File root, boolean recursive, EntryFilter searchFilter, EntryFilter includeFilter) {
-			super(root, recursive, searchFilter, includeFilter);
-		}
-
-		@Override
-		protected Entry adapt(FileEntry entry) {
-			return entry;
-		}
-
-	}
-
-	private static class ArchiveIterator extends AbstractIterator<Archive> {
-
-		ArchiveIterator(File root, boolean recursive, EntryFilter searchFilter, EntryFilter includeFilter) {
-			super(root, recursive, searchFilter, includeFilter);
-		}
-
-		@Override
-		protected Archive adapt(FileEntry entry) {
-			File file = entry.getFile();
-			return (file.isDirectory() ? new ExplodedArchive(file) : new SimpleJarFileArchive(entry));
-		}
-
-	}
-
-	/**
-	 * {@link Entry} backed by a File.
-	 */
-	private static class FileEntry implements Entry {
-
-		private final String name;
-
-		private final File file;
-
-		private final URL url;
-
-		FileEntry(String name, File file, URL url) {
-			this.name = name;
-			this.file = file;
-			this.url = url;
-		}
-
-		File getFile() {
-			return this.file;
-		}
-
-		@Override
-		public boolean isDirectory() {
-			return this.file.isDirectory();
-		}
-
-		@Override
-		public String getName() {
-			return this.name;
-		}
-
-		URL getUrl() {
-			return this.url;
-		}
-
-	}
-
-	/**
-	 * {@link Archive} implementation backed by a simple JAR file that doesn't itself
-	 * contain nested archives.
-	 */
-	private static class SimpleJarFileArchive implements Archive {
-
-		private final URL url;
-
-		SimpleJarFileArchive(FileEntry file) {
-			this.url = file.getUrl();
-		}
-
-		@Override
-		public URL getUrl() throws MalformedURLException {
-			return this.url;
-		}
-
-		@Override
-		public Manifest getManifest() throws IOException {
-			return null;
-		}
-
-		@Override
-		public Iterator<Archive> getNestedArchives(EntryFilter searchFilter, EntryFilter includeFilter)
-				throws IOException {
-			return Collections.emptyIterator();
-		}
-
-		@Override
-		@Deprecated(since = "2.3.10", forRemoval = false)
-		public Iterator<Entry> iterator() {
-			return Collections.emptyIterator();
-		}
-
-		@Override
-		public String toString() {
-			try {
-				return getUrl().toString();
-			}
-			catch (Exception ex) {
-				return "jar archive";
-			}
-		}
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/JarFileArchive.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/JarFileArchive.java
deleted file mode 100755
index b30c8bb37a52..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/JarFileArchive.java
+++ /dev/null
@@ -1,310 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.loader.archive;
-
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.nio.file.FileSystem;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.nio.file.StandardOpenOption;
-import java.nio.file.attribute.FileAttribute;
-import java.nio.file.attribute.PosixFilePermission;
-import java.nio.file.attribute.PosixFilePermissions;
-import java.util.EnumSet;
-import java.util.Iterator;
-import java.util.UUID;
-import java.util.jar.JarEntry;
-import java.util.jar.Manifest;
-
-import org.springframework.boot.loader.jar.JarFile;
-
-/**
- * {@link Archive} implementation backed by a {@link JarFile}.
- *
- * @author Phillip Webb
- * @author Andy Wilkinson
- * @since 1.0.0
- */
-public class JarFileArchive implements Archive {
-
-	private static final String UNPACK_MARKER = "UNPACK:";
-
-	private static final int BUFFER_SIZE = 32 * 1024;
-
-	private static final FileAttribute<?>[] NO_FILE_ATTRIBUTES = {};
-
-	private static final EnumSet<PosixFilePermission> DIRECTORY_PERMISSIONS = EnumSet.of(PosixFilePermission.OWNER_READ,
-			PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE);
-
-	private static final EnumSet<PosixFilePermission> FILE_PERMISSIONS = EnumSet.of(PosixFilePermission.OWNER_READ,
-			PosixFilePermission.OWNER_WRITE);
-
-	private final JarFile jarFile;
-
-	private URL url;
-
-	private Path tempUnpackDirectory;
-
-	public JarFileArchive(File file) throws IOException {
-		this(file, file.toURI().toURL());
-	}
-
-	public JarFileArchive(File file, URL url) throws IOException {
-		this(new JarFile(file));
-		this.url = url;
-	}
-
-	public JarFileArchive(JarFile jarFile) {
-		this.jarFile = jarFile;
-	}
-
-	@Override
-	public URL getUrl() throws MalformedURLException {
-		if (this.url != null) {
-			return this.url;
-		}
-		return this.jarFile.getUrl();
-	}
-
-	@Override
-	public Manifest getManifest() throws IOException {
-		return this.jarFile.getManifest();
-	}
-
-	@Override
-	public Iterator<Archive> getNestedArchives(EntryFilter searchFilter, EntryFilter includeFilter) throws IOException {
-		return new NestedArchiveIterator(this.jarFile.iterator(), searchFilter, includeFilter);
-	}
-
-	@Override
-	@Deprecated(since = "2.3.10", forRemoval = false)
-	public Iterator<Entry> iterator() {
-		return new EntryIterator(this.jarFile.iterator(), null, null);
-	}
-
-	@Override
-	public void close() throws IOException {
-		this.jarFile.close();
-	}
-
-	protected Archive getNestedArchive(Entry entry) throws IOException {
-		JarEntry jarEntry = ((JarFileEntry) entry).getJarEntry();
-		if (jarEntry.getComment().startsWith(UNPACK_MARKER)) {
-			return getUnpackedNestedArchive(jarEntry);
-		}
-		try {
-			JarFile jarFile = this.jarFile.getNestedJarFile(jarEntry);
-			return new JarFileArchive(jarFile);
-		}
-		catch (Exception ex) {
-			throw new IllegalStateException("Failed to get nested archive for entry " + entry.getName(), ex);
-		}
-	}
-
-	private Archive getUnpackedNestedArchive(JarEntry jarEntry) throws IOException {
-		String name = jarEntry.getName();
-		if (name.lastIndexOf('/') != -1) {
-			name = name.substring(name.lastIndexOf('/') + 1);
-		}
-		Path path = getTempUnpackDirectory().resolve(name);
-		if (!Files.exists(path) || Files.size(path) != jarEntry.getSize()) {
-			unpack(jarEntry, path);
-		}
-		return new JarFileArchive(path.toFile(), path.toUri().toURL());
-	}
-
-	private Path getTempUnpackDirectory() {
-		if (this.tempUnpackDirectory == null) {
-			Path tempDirectory = Paths.get(System.getProperty("java.io.tmpdir"));
-			this.tempUnpackDirectory = createUnpackDirectory(tempDirectory);
-		}
-		return this.tempUnpackDirectory;
-	}
-
-	private Path createUnpackDirectory(Path parent) {
-		int attempts = 0;
-		while (attempts++ < 1000) {
-			String fileName = Paths.get(this.jarFile.getName()).getFileName().toString();
-			Path unpackDirectory = parent.resolve(fileName + "-spring-boot-libs-" + UUID.randomUUID());
-			try {
-				createDirectory(unpackDirectory);
-				return unpackDirectory;
-			}
-			catch (IOException ex) {
-			}
-		}
-		throw new IllegalStateException("Failed to create unpack directory in directory '" + parent + "'");
-	}
-
-	private void unpack(JarEntry entry, Path path) throws IOException {
-		createFile(path);
-		path.toFile().deleteOnExit();
-		try (InputStream inputStream = this.jarFile.getInputStream(entry);
-				OutputStream outputStream = Files.newOutputStream(path, StandardOpenOption.WRITE,
-						StandardOpenOption.TRUNCATE_EXISTING)) {
-			byte[] buffer = new byte[BUFFER_SIZE];
-			int bytesRead;
-			while ((bytesRead = inputStream.read(buffer)) != -1) {
-				outputStream.write(buffer, 0, bytesRead);
-			}
-			outputStream.flush();
-		}
-	}
-
-	private void createDirectory(Path path) throws IOException {
-		Files.createDirectory(path, getFileAttributes(path.getFileSystem(), DIRECTORY_PERMISSIONS));
-	}
-
-	private void createFile(Path path) throws IOException {
-		Files.createFile(path, getFileAttributes(path.getFileSystem(), FILE_PERMISSIONS));
-	}
-
-	private FileAttribute<?>[] getFileAttributes(FileSystem fileSystem, EnumSet<PosixFilePermission> ownerReadWrite) {
-		if (!fileSystem.supportedFileAttributeViews().contains("posix")) {
-			return NO_FILE_ATTRIBUTES;
-		}
-		return new FileAttribute<?>[] { PosixFilePermissions.asFileAttribute(ownerReadWrite) };
-	}
-
-	@Override
-	public String toString() {
-		try {
-			return getUrl().toString();
-		}
-		catch (Exception ex) {
-			return "jar archive";
-		}
-	}
-
-	/**
-	 * Abstract base class for iterator implementations.
-	 */
-	private abstract static class AbstractIterator<T> implements Iterator<T> {
-
-		private final Iterator<JarEntry> iterator;
-
-		private final EntryFilter searchFilter;
-
-		private final EntryFilter includeFilter;
-
-		private Entry current;
-
-		AbstractIterator(Iterator<JarEntry> iterator, EntryFilter searchFilter, EntryFilter includeFilter) {
-			this.iterator = iterator;
-			this.searchFilter = searchFilter;
-			this.includeFilter = includeFilter;
-			this.current = poll();
-		}
-
-		@Override
-		public boolean hasNext() {
-			return this.current != null;
-		}
-
-		@Override
-		public T next() {
-			T result = adapt(this.current);
-			this.current = poll();
-			return result;
-		}
-
-		private Entry poll() {
-			while (this.iterator.hasNext()) {
-				JarFileEntry candidate = new JarFileEntry(this.iterator.next());
-				if ((this.searchFilter == null || this.searchFilter.matches(candidate))
-						&& (this.includeFilter == null || this.includeFilter.matches(candidate))) {
-					return candidate;
-				}
-			}
-			return null;
-		}
-
-		protected abstract T adapt(Entry entry);
-
-	}
-
-	/**
-	 * {@link Archive.Entry} iterator implementation backed by {@link JarEntry}.
-	 */
-	private static class EntryIterator extends AbstractIterator<Entry> {
-
-		EntryIterator(Iterator<JarEntry> iterator, EntryFilter searchFilter, EntryFilter includeFilter) {
-			super(iterator, searchFilter, includeFilter);
-		}
-
-		@Override
-		protected Entry adapt(Entry entry) {
-			return entry;
-		}
-
-	}
-
-	/**
-	 * Nested {@link Archive} iterator implementation backed by {@link JarEntry}.
-	 */
-	private class NestedArchiveIterator extends AbstractIterator<Archive> {
-
-		NestedArchiveIterator(Iterator<JarEntry> iterator, EntryFilter searchFilter, EntryFilter includeFilter) {
-			super(iterator, searchFilter, includeFilter);
-		}
-
-		@Override
-		protected Archive adapt(Entry entry) {
-			try {
-				return getNestedArchive(entry);
-			}
-			catch (IOException ex) {
-				throw new IllegalStateException(ex);
-			}
-		}
-
-	}
-
-	/**
-	 * {@link Archive.Entry} implementation backed by a {@link JarEntry}.
-	 */
-	private static class JarFileEntry implements Entry {
-
-		private final JarEntry jarEntry;
-
-		JarFileEntry(JarEntry jarEntry) {
-			this.jarEntry = jarEntry;
-		}
-
-		JarEntry getJarEntry() {
-			return this.jarEntry;
-		}
-
-		@Override
-		public boolean isDirectory() {
-			return this.jarEntry.isDirectory();
-		}
-
-		@Override
-		public String getName() {
-			return this.jarEntry.getName();
-		}
-
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/package-info.java
deleted file mode 100644
index ebaca84bb95d..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/package-info.java
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
- * Copyright 2012-2020 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * Abstraction over logical Archives be they backed by a JAR file or unpacked into a
- * directory.
- *
- * @see org.springframework.boot.loader.archive.Archive
- */
-package org.springframework.boot.loader.archive;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/package-info.java
deleted file mode 100644
index 8f456bd685dc..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/package-info.java
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
- * Copyright 2012-2021 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * Classes and interfaces to allow random access to a block of data.
- *
- * @see org.springframework.boot.loader.data.RandomAccessData
- */
-package org.springframework.boot.loader.data;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/Handler.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/Handler.java
deleted file mode 100644
index 67bf8048f046..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/Handler.java
+++ /dev/null
@@ -1,466 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.loader.jar;
-
-import java.io.File;
-import java.io.IOException;
-import java.lang.ref.SoftReference;
-import java.net.MalformedURLException;
-import java.net.URI;
-import java.net.URL;
-import java.net.URLConnection;
-import java.net.URLStreamHandler;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-import java.util.regex.Pattern;
-
-/**
- * {@link URLStreamHandler} for Spring Boot loader {@link JarFile}s.
- *
- * @author Phillip Webb
- * @author Andy Wilkinson
- * @since 1.0.0
- * @see JarFile#registerUrlProtocolHandler()
- */
-public class Handler extends URLStreamHandler {
-
-	// NOTE: in order to be found as a URL protocol handler, this class must be public,
-	// must be named Handler and must be in a package ending '.jar'
-
-	private static final String JAR_PROTOCOL = "jar:";
-
-	private static final String FILE_PROTOCOL = "file:";
-
-	private static final String TOMCAT_WARFILE_PROTOCOL = "war:file:";
-
-	private static final String SEPARATOR = "!/";
-
-	private static final Pattern SEPARATOR_PATTERN = Pattern.compile(SEPARATOR, Pattern.LITERAL);
-
-	private static final String CURRENT_DIR = "/./";
-
-	private static final Pattern CURRENT_DIR_PATTERN = Pattern.compile(CURRENT_DIR, Pattern.LITERAL);
-
-	private static final String PARENT_DIR = "/../";
-
-	private static final String PROTOCOL_HANDLER = "java.protocol.handler.pkgs";
-
-	private static final String[] FALLBACK_HANDLERS = { "sun.net.www.protocol.jar.Handler" };
-
-	private static URL jarContextUrl;
-
-	private static SoftReference<Map<File, JarFile>> rootFileCache;
-
-	static {
-		rootFileCache = new SoftReference<>(null);
-	}
-
-	private final JarFile jarFile;
-
-	private URLStreamHandler fallbackHandler;
-
-	public Handler() {
-		this(null);
-	}
-
-	public Handler(JarFile jarFile) {
-		this.jarFile = jarFile;
-	}
-
-	@Override
-	protected URLConnection openConnection(URL url) throws IOException {
-		if (this.jarFile != null && isUrlInJarFile(url, this.jarFile)) {
-			return JarURLConnection.get(url, this.jarFile);
-		}
-		try {
-			return JarURLConnection.get(url, getRootJarFileFromUrl(url));
-		}
-		catch (Exception ex) {
-			return openFallbackConnection(url, ex);
-		}
-	}
-
-	private boolean isUrlInJarFile(URL url, JarFile jarFile) throws MalformedURLException {
-		// Try the path first to save building a new url string each time
-		return url.getPath().startsWith(jarFile.getUrl().getPath())
-				&& url.toString().startsWith(jarFile.getUrlString());
-	}
-
-	private URLConnection openFallbackConnection(URL url, Exception reason) throws IOException {
-		try {
-			URLConnection connection = openFallbackTomcatConnection(url);
-			connection = (connection != null) ? connection : openFallbackContextConnection(url);
-			return (connection != null) ? connection : openFallbackHandlerConnection(url);
-		}
-		catch (Exception ex) {
-			if (reason instanceof IOException ioException) {
-				log(false, "Unable to open fallback handler", ex);
-				throw ioException;
-			}
-			log(true, "Unable to open fallback handler", ex);
-			if (reason instanceof RuntimeException runtimeException) {
-				throw runtimeException;
-			}
-			throw new IllegalStateException(reason);
-		}
-	}
-
-	/**
-	 * Attempt to open a Tomcat formatted 'jar:war:file:...' URL. This method allows us to
-	 * use our own nested JAR support to open the content rather than the logic in
-	 * {@code sun.net.www.protocol.jar.URLJarFile} which will extract the nested jar to
-	 * the temp folder to that its content can be accessed.
-	 * @param url the URL to open
-	 * @return a {@link URLConnection} or {@code null}
-	 */
-	private URLConnection openFallbackTomcatConnection(URL url) {
-		String file = url.getFile();
-		if (isTomcatWarUrl(file)) {
-			file = file.substring(TOMCAT_WARFILE_PROTOCOL.length());
-			file = file.replaceFirst("\\*/", "!/");
-			try {
-				URLConnection connection = openConnection(new URL("jar:file:" + file));
-				connection.getInputStream().close();
-				return connection;
-			}
-			catch (IOException ex) {
-			}
-		}
-		return null;
-	}
-
-	private boolean isTomcatWarUrl(String file) {
-		if (file.startsWith(TOMCAT_WARFILE_PROTOCOL) || !file.contains("*/")) {
-			try {
-				URLConnection connection = new URL(file).openConnection();
-				if (connection.getClass().getName().startsWith("org.apache.catalina")) {
-					return true;
-				}
-			}
-			catch (Exception ex) {
-			}
-		}
-		return false;
-	}
-
-	/**
-	 * Attempt to open a fallback connection by using a context URL captured before the
-	 * jar handler was replaced with our own version. Since this method doesn't use
-	 * reflection it won't trigger "illegal reflective access operation has occurred"
-	 * warnings on Java 13+.
-	 * @param url the URL to open
-	 * @return a {@link URLConnection} or {@code null}
-	 */
-	private URLConnection openFallbackContextConnection(URL url) {
-		try {
-			if (jarContextUrl != null) {
-				return new URL(jarContextUrl, url.toExternalForm()).openConnection();
-			}
-		}
-		catch (Exception ex) {
-		}
-		return null;
-	}
-
-	/**
-	 * Attempt to open a fallback connection by using reflection to access Java's default
-	 * jar {@link URLStreamHandler}.
-	 * @param url the URL to open
-	 * @return the {@link URLConnection}
-	 * @throws Exception if not connection could be opened
-	 */
-	private URLConnection openFallbackHandlerConnection(URL url) throws Exception {
-		URLStreamHandler fallbackHandler = getFallbackHandler();
-		return new URL(null, url.toExternalForm(), fallbackHandler).openConnection();
-	}
-
-	private URLStreamHandler getFallbackHandler() {
-		if (this.fallbackHandler != null) {
-			return this.fallbackHandler;
-		}
-		for (String handlerClassName : FALLBACK_HANDLERS) {
-			try {
-				Class<?> handlerClass = Class.forName(handlerClassName);
-				this.fallbackHandler = (URLStreamHandler) handlerClass.getDeclaredConstructor().newInstance();
-				return this.fallbackHandler;
-			}
-			catch (Exception ex) {
-				// Ignore
-			}
-		}
-		throw new IllegalStateException("Unable to find fallback handler");
-	}
-
-	private void log(boolean warning, String message, Exception cause) {
-		try {
-			Level level = warning ? Level.WARNING : Level.FINEST;
-			Logger.getLogger(getClass().getName()).log(level, message, cause);
-		}
-		catch (Exception ex) {
-			if (warning) {
-				System.err.println("WARNING: " + message);
-			}
-		}
-	}
-
-	@Override
-	protected void parseURL(URL context, String spec, int start, int limit) {
-		if (spec.regionMatches(true, 0, JAR_PROTOCOL, 0, JAR_PROTOCOL.length())) {
-			setFile(context, getFileFromSpec(spec.substring(start, limit)));
-		}
-		else {
-			setFile(context, getFileFromContext(context, spec.substring(start, limit)));
-		}
-	}
-
-	private String getFileFromSpec(String spec) {
-		int separatorIndex = spec.lastIndexOf("!/");
-		if (separatorIndex == -1) {
-			throw new IllegalArgumentException("No !/ in spec '" + spec + "'");
-		}
-		try {
-			new URL(spec.substring(0, separatorIndex));
-			return spec;
-		}
-		catch (MalformedURLException ex) {
-			throw new IllegalArgumentException("Invalid spec URL '" + spec + "'", ex);
-		}
-	}
-
-	private String getFileFromContext(URL context, String spec) {
-		String file = context.getFile();
-		if (spec.startsWith("/")) {
-			return trimToJarRoot(file) + SEPARATOR + spec.substring(1);
-		}
-		if (file.endsWith("/")) {
-			return file + spec;
-		}
-		int lastSlashIndex = file.lastIndexOf('/');
-		if (lastSlashIndex == -1) {
-			throw new IllegalArgumentException("No / found in context URL's file '" + file + "'");
-		}
-		return file.substring(0, lastSlashIndex + 1) + spec;
-	}
-
-	private String trimToJarRoot(String file) {
-		int lastSeparatorIndex = file.lastIndexOf(SEPARATOR);
-		if (lastSeparatorIndex == -1) {
-			throw new IllegalArgumentException("No !/ found in context URL's file '" + file + "'");
-		}
-		return file.substring(0, lastSeparatorIndex);
-	}
-
-	private void setFile(URL context, String file) {
-		String path = normalize(file);
-		String query = null;
-		int queryIndex = path.lastIndexOf('?');
-		if (queryIndex != -1) {
-			query = path.substring(queryIndex + 1);
-			path = path.substring(0, queryIndex);
-		}
-		setURL(context, JAR_PROTOCOL, null, -1, null, null, path, query, context.getRef());
-	}
-
-	private String normalize(String file) {
-		if (!file.contains(CURRENT_DIR) && !file.contains(PARENT_DIR)) {
-			return file;
-		}
-		int afterLastSeparatorIndex = file.lastIndexOf(SEPARATOR) + SEPARATOR.length();
-		String afterSeparator = file.substring(afterLastSeparatorIndex);
-		afterSeparator = replaceParentDir(afterSeparator);
-		afterSeparator = replaceCurrentDir(afterSeparator);
-		return file.substring(0, afterLastSeparatorIndex) + afterSeparator;
-	}
-
-	private String replaceParentDir(String file) {
-		int parentDirIndex;
-		while ((parentDirIndex = file.indexOf(PARENT_DIR)) >= 0) {
-			int precedingSlashIndex = file.lastIndexOf('/', parentDirIndex - 1);
-			if (precedingSlashIndex >= 0) {
-				file = file.substring(0, precedingSlashIndex) + file.substring(parentDirIndex + 3);
-			}
-			else {
-				file = file.substring(parentDirIndex + 4);
-			}
-		}
-		return file;
-	}
-
-	private String replaceCurrentDir(String file) {
-		return CURRENT_DIR_PATTERN.matcher(file).replaceAll("/");
-	}
-
-	@Override
-	protected int hashCode(URL u) {
-		return hashCode(u.getProtocol(), u.getFile());
-	}
-
-	private int hashCode(String protocol, String file) {
-		int result = (protocol != null) ? protocol.hashCode() : 0;
-		int separatorIndex = file.indexOf(SEPARATOR);
-		if (separatorIndex == -1) {
-			return result + file.hashCode();
-		}
-		String source = file.substring(0, separatorIndex);
-		String entry = canonicalize(file.substring(separatorIndex + 2));
-		try {
-			result += new URL(source).hashCode();
-		}
-		catch (MalformedURLException ex) {
-			result += source.hashCode();
-		}
-		result += entry.hashCode();
-		return result;
-	}
-
-	@Override
-	protected boolean sameFile(URL u1, URL u2) {
-		if (!u1.getProtocol().equals("jar") || !u2.getProtocol().equals("jar")) {
-			return false;
-		}
-		int separator1 = u1.getFile().indexOf(SEPARATOR);
-		int separator2 = u2.getFile().indexOf(SEPARATOR);
-		if (separator1 == -1 || separator2 == -1) {
-			return super.sameFile(u1, u2);
-		}
-		String nested1 = u1.getFile().substring(separator1 + SEPARATOR.length());
-		String nested2 = u2.getFile().substring(separator2 + SEPARATOR.length());
-		if (!nested1.equals(nested2)) {
-			String canonical1 = canonicalize(nested1);
-			String canonical2 = canonicalize(nested2);
-			if (!canonical1.equals(canonical2)) {
-				return false;
-			}
-		}
-		String root1 = u1.getFile().substring(0, separator1);
-		String root2 = u2.getFile().substring(0, separator2);
-		try {
-			return super.sameFile(new URL(root1), new URL(root2));
-		}
-		catch (MalformedURLException ex) {
-			// Continue
-		}
-		return super.sameFile(u1, u2);
-	}
-
-	private String canonicalize(String path) {
-		return SEPARATOR_PATTERN.matcher(path).replaceAll("/");
-	}
-
-	public JarFile getRootJarFileFromUrl(URL url) throws IOException {
-		String spec = url.getFile();
-		int separatorIndex = spec.indexOf(SEPARATOR);
-		if (separatorIndex == -1) {
-			throw new MalformedURLException("Jar URL does not contain !/ separator");
-		}
-		String name = spec.substring(0, separatorIndex);
-		return getRootJarFile(name);
-	}
-
-	private JarFile getRootJarFile(String name) throws IOException {
-		try {
-			if (!name.startsWith(FILE_PROTOCOL)) {
-				throw new IllegalStateException("Not a file URL");
-			}
-			File file = new File(URI.create(name));
-			Map<File, JarFile> cache = rootFileCache.get();
-			JarFile result = (cache != null) ? cache.get(file) : null;
-			if (result == null) {
-				result = new JarFile(file);
-				addToRootFileCache(file, result);
-			}
-			return result;
-		}
-		catch (Exception ex) {
-			throw new IOException("Unable to open root Jar file '" + name + "'", ex);
-		}
-	}
-
-	/**
-	 * Add the given {@link JarFile} to the root file cache.
-	 * @param sourceFile the source file to add
-	 * @param jarFile the jar file.
-	 */
-	static void addToRootFileCache(File sourceFile, JarFile jarFile) {
-		Map<File, JarFile> cache = rootFileCache.get();
-		if (cache == null) {
-			cache = new ConcurrentHashMap<>();
-			rootFileCache = new SoftReference<>(cache);
-		}
-		cache.put(sourceFile, jarFile);
-	}
-
-	/**
-	 * If possible, capture a URL that is configured with the original jar handler so that
-	 * we can use it as a fallback context later. We can only do this if we know that we
-	 * can reset the handlers after.
-	 */
-	static void captureJarContextUrl() {
-		if (canResetCachedUrlHandlers()) {
-			String handlers = System.getProperty(PROTOCOL_HANDLER);
-			try {
-				System.clearProperty(PROTOCOL_HANDLER);
-				try {
-					resetCachedUrlHandlers();
-					jarContextUrl = new URL("jar:file:context.jar!/");
-					URLConnection connection = jarContextUrl.openConnection();
-					if (connection instanceof JarURLConnection) {
-						jarContextUrl = null;
-					}
-				}
-				catch (Exception ex) {
-				}
-			}
-			finally {
-				if (handlers == null) {
-					System.clearProperty(PROTOCOL_HANDLER);
-				}
-				else {
-					System.setProperty(PROTOCOL_HANDLER, handlers);
-				}
-			}
-			resetCachedUrlHandlers();
-		}
-	}
-
-	private static boolean canResetCachedUrlHandlers() {
-		try {
-			resetCachedUrlHandlers();
-			return true;
-		}
-		catch (Error ex) {
-			return false;
-		}
-	}
-
-	private static void resetCachedUrlHandlers() {
-		URL.setURLStreamHandlerFactory(null);
-	}
-
-	/**
-	 * Set if a generic static exception can be thrown when a URL cannot be connected.
-	 * This optimization is used during class loading to save creating lots of exceptions
-	 * which are then swallowed.
-	 * @param useFastConnectionExceptions if fast connection exceptions can be used.
-	 */
-	public static void setUseFastConnectionExceptions(boolean useFastConnectionExceptions) {
-		JarURLConnection.setUseFastExceptions(useFastConnectionExceptions);
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ManifestInfo.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ManifestInfo.java
new file mode 100644
index 000000000000..1a6b592f3287
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ManifestInfo.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.jar;
+
+import java.util.jar.Attributes;
+import java.util.jar.Attributes.Name;
+import java.util.jar.Manifest;
+
+import org.springframework.boot.loader.zip.ZipContent;
+
+/**
+ * Info obtained from a {@link ZipContent} instance relating to the {@link Manifest}.
+ *
+ * @author Phillip Webb
+ */
+class ManifestInfo {
+
+	private static final Name MULTI_RELEASE = new Name("Multi-Release");
+
+	static final ManifestInfo NONE = new ManifestInfo(null, false);
+
+	private final Manifest manifest;
+
+	private volatile Boolean multiRelease;
+
+	/**
+	 * Create a new {@link ManifestInfo} instance.
+	 * @param manifest the jar manifest
+	 */
+	ManifestInfo(Manifest manifest) {
+		this(manifest, null);
+	}
+
+	private ManifestInfo(Manifest manifest, Boolean multiRelease) {
+		this.manifest = manifest;
+		this.multiRelease = multiRelease;
+	}
+
+	/**
+	 * Return the manifest, if any.
+	 * @return the manifest or {@code null}
+	 */
+	Manifest getManifest() {
+		return this.manifest;
+	}
+
+	/**
+	 * Return if this is a multi-release jar.
+	 * @return if the jar is multi-release
+	 */
+	boolean isMultiRelease() {
+		if (this.manifest == null) {
+			return false;
+		}
+		Boolean multiRelease = this.multiRelease;
+		if (multiRelease != null) {
+			return multiRelease;
+		}
+		Attributes attributes = this.manifest.getMainAttributes();
+		multiRelease = attributes.containsKey(MULTI_RELEASE);
+		this.multiRelease = multiRelease;
+		return multiRelease;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/MetaInfVersionsInfo.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/MetaInfVersionsInfo.java
new file mode 100644
index 000000000000..caf76a2b96f6
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/MetaInfVersionsInfo.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.jar;
+
+import java.util.Collections;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.function.IntFunction;
+
+import org.springframework.boot.loader.zip.ZipContent;
+
+/**
+ * Info obtained from a {@link ZipContent} instance relating to the directories listed
+ * under {@code META-INF/versions/}.
+ *
+ * @author Phillip Webb
+ */
+final class MetaInfVersionsInfo {
+
+	static final MetaInfVersionsInfo NONE = new MetaInfVersionsInfo(Collections.emptySet());
+
+	private static final String META_INF_VERSIONS = NestedJarFile.META_INF_VERSIONS;
+
+	private final int[] versions;
+
+	private final String[] directories;
+
+	private MetaInfVersionsInfo(Set<Integer> versions) {
+		this.versions = versions.stream().mapToInt(Integer::intValue).toArray();
+		this.directories = versions.stream().map((version) -> META_INF_VERSIONS + version + "/").toArray(String[]::new);
+	}
+
+	/**
+	 * Return the versions listed under {@code META-INF/versions/} in ascending order.
+	 * @return the versions
+	 */
+	int[] versions() {
+		return this.versions;
+	}
+
+	/**
+	 * Return the version directories in the same order as {@link #versions()}.
+	 * @return the version directories
+	 */
+	String[] directories() {
+		return this.directories;
+	}
+
+	/**
+	 * Get {@link MetaInfVersionsInfo} for the given {@link ZipContent}.
+	 * @param zipContent the zip content
+	 * @return the {@link MetaInfVersionsInfo}.
+	 */
+	static MetaInfVersionsInfo get(ZipContent zipContent) {
+		return get(zipContent.size(), zipContent::getEntry);
+	}
+
+	/**
+	 * Get {@link MetaInfVersionsInfo} for the given details.
+	 * @param size the number of entries
+	 * @param entries a function to get an entry from an index
+	 * @return the {@link MetaInfVersionsInfo}.
+	 */
+	static MetaInfVersionsInfo get(int size, IntFunction<ZipContent.Entry> entries) {
+		Set<Integer> versions = new TreeSet<>();
+		for (int i = 0; i < size; i++) {
+			ZipContent.Entry contentEntry = entries.apply(i);
+			if (contentEntry.hasNameStartingWith(META_INF_VERSIONS) && !contentEntry.isDirectory()) {
+				String name = contentEntry.getName();
+				int slash = name.indexOf('/', META_INF_VERSIONS.length());
+				String version = name.substring(META_INF_VERSIONS.length(), slash);
+				try {
+					int versionNumber = Integer.parseInt(version);
+					if (versionNumber >= NestedJarFile.BASE_VERSION) {
+						versions.add(versionNumber);
+					}
+				}
+				catch (NumberFormatException ex) {
+					// Ignore
+				}
+			}
+		}
+		return (!versions.isEmpty()) ? new MetaInfVersionsInfo(versions) : NONE;
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/NestedJarFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/NestedJarFile.java
new file mode 100644
index 000000000000..401157b17d95
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/NestedJarFile.java
@@ -0,0 +1,833 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.jar;
+
+import java.io.File;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.lang.ref.Cleaner.Cleanable;
+import java.nio.ByteBuffer;
+import java.nio.file.attribute.FileTime;
+import java.security.CodeSigner;
+import java.security.cert.Certificate;
+import java.time.LocalDateTime;
+import java.util.Enumeration;
+import java.util.NoSuchElementException;
+import java.util.Objects;
+import java.util.Spliterator;
+import java.util.Spliterators.AbstractSpliterator;
+import java.util.function.Consumer;
+import java.util.jar.Attributes;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.jar.Manifest;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+import java.util.zip.Inflater;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipException;
+
+import org.springframework.boot.loader.log.DebugLogger;
+import org.springframework.boot.loader.ref.Cleaner;
+import org.springframework.boot.loader.zip.CloseableDataBlock;
+import org.springframework.boot.loader.zip.ZipContent;
+import org.springframework.boot.loader.zip.ZipContent.Entry;
+
+/**
+ * Extended variant of {@link JarFile} that behaves in the same way but can open nested
+ * jars.
+ *
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ * @since 3.2.0
+ */
+public class NestedJarFile extends JarFile {
+
+	private static final int DECIMAL = 10;
+
+	private static final String META_INF = "META-INF/";
+
+	static final String META_INF_VERSIONS = META_INF + "versions/";
+
+	static final int BASE_VERSION = baseVersion().feature();
+
+	private static final DebugLogger debug = DebugLogger.get(NestedJarFile.class);
+
+	private final Cleaner cleaner;
+
+	private final NestedJarFileResources resources;
+
+	private final Cleanable cleanup;
+
+	private final String name;
+
+	private final int version;
+
+	private volatile NestedJarEntry lastEntry;
+
+	private volatile boolean closed;
+
+	private volatile ManifestInfo manifestInfo;
+
+	private volatile MetaInfVersionsInfo metaInfVersionsInfo;
+
+	/**
+	 * Creates a new {@link NestedJarFile} instance to read from the specific
+	 * {@code File}.
+	 * @param file the jar file to be opened for reading
+	 * @throws IOException on I/O error
+	 */
+	NestedJarFile(File file) throws IOException {
+		this(file, null, null, false, Cleaner.instance);
+	}
+
+	/**
+	 * Creates a new {@link NestedJarFile} instance to read from the specific
+	 * {@code File}.
+	 * @param file the jar file to be opened for reading
+	 * @param nestedEntryName the nested entry name to open or {@code null}
+	 * @throws IOException on I/O error
+	 */
+	public NestedJarFile(File file, String nestedEntryName) throws IOException {
+		this(file, nestedEntryName, null, true, Cleaner.instance);
+	}
+
+	/**
+	 * Creates a new {@link NestedJarFile} instance to read from the specific
+	 * {@code File}.
+	 * @param file the jar file to be opened for reading
+	 * @param nestedEntryName the nested entry name to open or {@code null}
+	 * @param version the release version to use when opening a multi-release jar
+	 * @throws IOException on I/O error
+	 */
+	public NestedJarFile(File file, String nestedEntryName, Runtime.Version version) throws IOException {
+		this(file, nestedEntryName, version, true, Cleaner.instance);
+	}
+
+	/**
+	 * Creates a new {@link NestedJarFile} instance to read from the specific
+	 * {@code File}.
+	 * @param file the jar file to be opened for reading
+	 * @param nestedEntryName the nested entry name to open or {@code null}
+	 * @param version the release version to use when opening a multi-release jar
+	 * @param onlyNestedJars if <em>only</em> nested jars should be opened
+	 * @param cleaner the cleaner used to release resources
+	 * @throws IOException on I/O error
+	 */
+	NestedJarFile(File file, String nestedEntryName, Runtime.Version version, boolean onlyNestedJars, Cleaner cleaner)
+			throws IOException {
+		super(file);
+		if (onlyNestedJars && (nestedEntryName == null || nestedEntryName.isEmpty())) {
+			throw new IllegalArgumentException("nestedEntryName must not be empty");
+		}
+		debug.log("Created nested jar file (%s, %s, %s)", file, nestedEntryName, version);
+		this.cleaner = cleaner;
+		this.resources = new NestedJarFileResources(file, nestedEntryName);
+		this.cleanup = cleaner.register(this, this.resources);
+		this.name = file.getPath() + ((nestedEntryName != null) ? "!/" + nestedEntryName : "");
+		this.version = (version != null) ? version.feature() : baseVersion().feature();
+	}
+
+	public InputStream getRawZipDataInputStream() throws IOException {
+		RawZipDataInputStream inputStream = new RawZipDataInputStream(
+				this.resources.zipContent().openRawZipData().asInputStream());
+		this.resources.addInputStream(inputStream);
+		return inputStream;
+	}
+
+	@Override
+	public Manifest getManifest() throws IOException {
+		try {
+			return this.resources.zipContent().getInfo(ManifestInfo.class, this::getManifestInfo).getManifest();
+		}
+		catch (UncheckedIOException ex) {
+			throw ex.getCause();
+		}
+	}
+
+	@Override
+	public Enumeration<JarEntry> entries() {
+		synchronized (this) {
+			ensureOpen();
+			return new JarEntriesEnumeration(this.resources.zipContent());
+		}
+	}
+
+	@Override
+	public Stream<JarEntry> stream() {
+		synchronized (this) {
+			ensureOpen();
+			return streamContentEntries().map(NestedJarEntry::new);
+		}
+	}
+
+	@Override
+	public Stream<JarEntry> versionedStream() {
+		synchronized (this) {
+			ensureOpen();
+			return streamContentEntries().map(this::getBaseName)
+				.filter(Objects::nonNull)
+				.distinct()
+				.map(this::getJarEntry)
+				.filter(Objects::nonNull);
+		}
+	}
+
+	private Stream<ZipContent.Entry> streamContentEntries() {
+		ZipContentEntriesSpliterator spliterator = new ZipContentEntriesSpliterator(this.resources.zipContent());
+		return StreamSupport.stream(spliterator, false);
+	}
+
+	private String getBaseName(ZipContent.Entry contentEntry) {
+		String name = contentEntry.getName();
+		if (!name.startsWith(META_INF_VERSIONS)) {
+			return name;
+		}
+		int versionNumberStartIndex = META_INF_VERSIONS.length();
+		int versionNumberEndIndex = (versionNumberStartIndex != -1) ? name.indexOf('/', versionNumberStartIndex) : -1;
+		if (versionNumberEndIndex == -1 || versionNumberEndIndex == (name.length() - 1)) {
+			return null;
+		}
+		try {
+			int versionNumber = Integer.parseInt(name, versionNumberStartIndex, versionNumberEndIndex, DECIMAL);
+			if (versionNumber > this.version) {
+				return null;
+			}
+		}
+		catch (NumberFormatException ex) {
+			return null;
+		}
+		return name.substring(versionNumberEndIndex + 1);
+	}
+
+	@Override
+	public JarEntry getJarEntry(String name) {
+		return getNestedJarEntry(name);
+	}
+
+	@Override
+	public JarEntry getEntry(String name) {
+		return getNestedJarEntry(name);
+	}
+
+	/**
+	 * Return if an entry with the given name exists.
+	 * @param name the name to check
+	 * @return if the entry exists
+	 */
+	public boolean hasEntry(String name) {
+		NestedJarEntry lastEntry = this.lastEntry;
+		if (lastEntry != null && name.equals(lastEntry.getName())) {
+			return true;
+		}
+		ZipContent.Entry entry = getVersionedContentEntry(name);
+		if (entry != null) {
+			return true;
+		}
+		synchronized (this) {
+			ensureOpen();
+			return this.resources.zipContent().hasEntry(null, name);
+		}
+	}
+
+	private NestedJarEntry getNestedJarEntry(String name) {
+		Objects.requireNonNull(name, "name");
+		NestedJarEntry lastEntry = this.lastEntry;
+		if (lastEntry != null && name.equals(lastEntry.getName())) {
+			return lastEntry;
+		}
+		ZipContent.Entry entry = getVersionedContentEntry(name);
+		entry = (entry != null) ? entry : getContentEntry(null, name);
+		if (entry == null) {
+			return null;
+		}
+		NestedJarEntry nestedJarEntry = new NestedJarEntry(entry, name);
+		this.lastEntry = nestedJarEntry;
+		return nestedJarEntry;
+	}
+
+	private ZipContent.Entry getVersionedContentEntry(String name) {
+		// NOTE: we can't call isMultiRelease() directly because it's a final method and
+		// it inspects the container jar. We use ManifestInfo instead.
+		if (BASE_VERSION >= this.version || name.startsWith(META_INF) || !getManifestInfo().isMultiRelease()) {
+			return null;
+		}
+		MetaInfVersionsInfo metaInfVersionsInfo = getMetaInfVersionsInfo();
+		int[] versions = metaInfVersionsInfo.versions();
+		String[] directories = metaInfVersionsInfo.directories();
+		for (int i = versions.length - 1; i >= 0; i--) {
+			if (versions[i] <= this.version) {
+				ZipContent.Entry entry = getContentEntry(directories[i], name);
+				if (entry != null) {
+					return entry;
+				}
+			}
+		}
+		return null;
+	}
+
+	private ZipContent.Entry getContentEntry(String namePrefix, String name) {
+		synchronized (this) {
+			ensureOpen();
+			return this.resources.zipContent().getEntry(namePrefix, name);
+		}
+	}
+
+	private ManifestInfo getManifestInfo() {
+		ManifestInfo manifestInfo = this.manifestInfo;
+		if (manifestInfo != null) {
+			return manifestInfo;
+		}
+		synchronized (this) {
+			ensureOpen();
+			manifestInfo = this.resources.zipContent().getInfo(ManifestInfo.class, this::getManifestInfo);
+		}
+		this.manifestInfo = manifestInfo;
+		return manifestInfo;
+	}
+
+	private ManifestInfo getManifestInfo(ZipContent zipContent) {
+		ZipContent.Entry contentEntry = zipContent.getEntry(MANIFEST_NAME);
+		if (contentEntry == null) {
+			return ManifestInfo.NONE;
+		}
+		try {
+			try (InputStream inputStream = getInputStream(contentEntry)) {
+				Manifest manifest = new Manifest(inputStream);
+				return new ManifestInfo(manifest);
+			}
+		}
+		catch (IOException ex) {
+			throw new UncheckedIOException(ex);
+		}
+	}
+
+	private MetaInfVersionsInfo getMetaInfVersionsInfo() {
+		MetaInfVersionsInfo metaInfVersionsInfo = this.metaInfVersionsInfo;
+		if (metaInfVersionsInfo != null) {
+			return metaInfVersionsInfo;
+		}
+		synchronized (this) {
+			ensureOpen();
+			metaInfVersionsInfo = this.resources.zipContent()
+				.getInfo(MetaInfVersionsInfo.class, MetaInfVersionsInfo::get);
+		}
+		this.metaInfVersionsInfo = metaInfVersionsInfo;
+		return metaInfVersionsInfo;
+	}
+
+	@Override
+	public InputStream getInputStream(ZipEntry entry) throws IOException {
+		Objects.requireNonNull(entry, "entry");
+		if (entry instanceof NestedJarEntry nestedJarEntry && nestedJarEntry.isOwnedBy(this)) {
+			return getInputStream(nestedJarEntry.contentEntry());
+		}
+		return getInputStream(getNestedJarEntry(entry.getName()).contentEntry());
+	}
+
+	private InputStream getInputStream(ZipContent.Entry contentEntry) throws IOException {
+		int compression = contentEntry.getCompressionMethod();
+		if (compression != ZipEntry.STORED && compression != ZipEntry.DEFLATED) {
+			throw new ZipException("invalid compression method");
+		}
+		synchronized (this) {
+			ensureOpen();
+			InputStream inputStream = new JarEntryInputStream(contentEntry);
+			try {
+				if (compression == ZipEntry.DEFLATED) {
+					inputStream = new JarEntryInflaterInputStream((JarEntryInputStream) inputStream, this.resources);
+				}
+				this.resources.addInputStream(inputStream);
+				return inputStream;
+			}
+			catch (RuntimeException ex) {
+				inputStream.close();
+				throw ex;
+			}
+		}
+	}
+
+	@Override
+	public String getComment() {
+		synchronized (this) {
+			ensureOpen();
+			return this.resources.zipContent().getComment();
+		}
+	}
+
+	@Override
+	public int size() {
+		synchronized (this) {
+			ensureOpen();
+			return this.resources.zipContent().size();
+		}
+	}
+
+	@Override
+	public void close() throws IOException {
+		super.close();
+		if (this.closed) {
+			return;
+		}
+		this.closed = true;
+		synchronized (this) {
+			try {
+				this.cleanup.clean();
+			}
+			catch (UncheckedIOException ex) {
+				throw ex.getCause();
+			}
+		}
+	}
+
+	@Override
+	public String getName() {
+		return this.name;
+	}
+
+	private void ensureOpen() {
+		if (this.closed) {
+			throw new IllegalStateException("Zip file closed");
+		}
+		if (this.resources.zipContent() == null) {
+			throw new IllegalStateException("The object is not initialized.");
+		}
+	}
+
+	/**
+	 * Clear any internal caches.
+	 */
+	public void clearCache() {
+		synchronized (this) {
+			this.lastEntry = null;
+		}
+	}
+
+	/**
+	 * An individual entry from a {@link NestedJarFile}.
+	 */
+	private class NestedJarEntry extends java.util.jar.JarEntry {
+
+		private static final IllegalStateException CANNOT_BE_MODIFIED_EXCEPTION = new IllegalStateException(
+				"Neste jar entries cannot be modified");
+
+		private final ZipContent.Entry contentEntry;
+
+		private final String name;
+
+		private volatile boolean populated;
+
+		NestedJarEntry(Entry contentEntry) {
+			this(contentEntry, contentEntry.getName());
+		}
+
+		NestedJarEntry(ZipContent.Entry contentEntry, String name) {
+			super(contentEntry.getName());
+			this.contentEntry = contentEntry;
+			this.name = name;
+		}
+
+		@Override
+		public long getTime() {
+			populate();
+			return super.getTime();
+		}
+
+		@Override
+		public LocalDateTime getTimeLocal() {
+			populate();
+			return super.getTimeLocal();
+		}
+
+		@Override
+		public void setTime(long time) {
+			throw CANNOT_BE_MODIFIED_EXCEPTION;
+		}
+
+		@Override
+		public void setTimeLocal(LocalDateTime time) {
+			throw CANNOT_BE_MODIFIED_EXCEPTION;
+		}
+
+		@Override
+		public FileTime getLastModifiedTime() {
+			populate();
+			return super.getLastModifiedTime();
+		}
+
+		@Override
+		public ZipEntry setLastModifiedTime(FileTime time) {
+			throw CANNOT_BE_MODIFIED_EXCEPTION;
+		}
+
+		@Override
+		public FileTime getLastAccessTime() {
+			populate();
+			return super.getLastAccessTime();
+		}
+
+		@Override
+		public ZipEntry setLastAccessTime(FileTime time) {
+			throw CANNOT_BE_MODIFIED_EXCEPTION;
+		}
+
+		@Override
+		public FileTime getCreationTime() {
+			populate();
+			return super.getCreationTime();
+		}
+
+		@Override
+		public ZipEntry setCreationTime(FileTime time) {
+			throw CANNOT_BE_MODIFIED_EXCEPTION;
+		}
+
+		@Override
+		public long getSize() {
+			return this.contentEntry.getUncompressedSize() & 0xFFFFFFFFL;
+		}
+
+		@Override
+		public void setSize(long size) {
+			throw CANNOT_BE_MODIFIED_EXCEPTION;
+		}
+
+		@Override
+		public long getCompressedSize() {
+			populate();
+			return super.getCompressedSize();
+		}
+
+		@Override
+		public void setCompressedSize(long csize) {
+			throw CANNOT_BE_MODIFIED_EXCEPTION;
+		}
+
+		@Override
+		public long getCrc() {
+			populate();
+			return super.getCrc();
+		}
+
+		@Override
+		public void setCrc(long crc) {
+			throw CANNOT_BE_MODIFIED_EXCEPTION;
+		}
+
+		@Override
+		public int getMethod() {
+			populate();
+			return super.getMethod();
+		}
+
+		@Override
+		public void setMethod(int method) {
+			throw CANNOT_BE_MODIFIED_EXCEPTION;
+		}
+
+		@Override
+		public byte[] getExtra() {
+			populate();
+			return super.getExtra();
+		}
+
+		@Override
+		public void setExtra(byte[] extra) {
+			throw CANNOT_BE_MODIFIED_EXCEPTION;
+		}
+
+		@Override
+		public String getComment() {
+			populate();
+			return super.getComment();
+		}
+
+		@Override
+		public void setComment(String comment) {
+			throw CANNOT_BE_MODIFIED_EXCEPTION;
+		}
+
+		boolean isOwnedBy(NestedJarFile nestedJarFile) {
+			return NestedJarFile.this == nestedJarFile;
+		}
+
+		@Override
+		public String getRealName() {
+			return super.getName();
+		}
+
+		@Override
+		public String getName() {
+			return this.name;
+		}
+
+		@Override
+		public Attributes getAttributes() throws IOException {
+			Manifest manifest = getManifest();
+			return (manifest != null) ? manifest.getAttributes(getName()) : null;
+		}
+
+		@Override
+		public Certificate[] getCertificates() {
+			return getSecurityInfo().getCertificates(contentEntry());
+		}
+
+		@Override
+		public CodeSigner[] getCodeSigners() {
+			return getSecurityInfo().getCodeSigners(contentEntry());
+		}
+
+		private SecurityInfo getSecurityInfo() {
+			return NestedJarFile.this.resources.zipContent().getInfo(SecurityInfo.class, SecurityInfo::get);
+		}
+
+		ZipContent.Entry contentEntry() {
+			return this.contentEntry;
+		}
+
+		private void populate() {
+			boolean populated = this.populated;
+			if (!populated) {
+				ZipEntry entry = this.contentEntry.as(ZipEntry::new);
+				super.setMethod(entry.getMethod());
+				super.setTime(entry.getTime());
+				super.setCrc(entry.getCrc());
+				super.setCompressedSize(entry.getCompressedSize());
+				super.setSize(entry.getSize());
+				super.setExtra(entry.getExtra());
+				super.setComment(entry.getComment());
+				this.populated = true;
+			}
+		}
+
+	}
+
+	/**
+	 * {@link Enumeration} of {@link NestedJarEntry} instances.
+	 */
+	private class JarEntriesEnumeration implements Enumeration<JarEntry> {
+
+		private final ZipContent zipContent;
+
+		private int cursor;
+
+		JarEntriesEnumeration(ZipContent zipContent) {
+			this.zipContent = zipContent;
+		}
+
+		@Override
+		public boolean hasMoreElements() {
+			return this.cursor < this.zipContent.size();
+		}
+
+		@Override
+		public NestedJarEntry nextElement() {
+			if (!hasMoreElements()) {
+				throw new NoSuchElementException();
+			}
+			synchronized (NestedJarFile.this) {
+				ensureOpen();
+				return new NestedJarEntry(this.zipContent.getEntry(this.cursor++));
+			}
+		}
+
+	}
+
+	/**
+	 * {@link Spliterator} for {@link ZipContent.Entry} instances.
+	 */
+	private class ZipContentEntriesSpliterator extends AbstractSpliterator<ZipContent.Entry> {
+
+		private static final int ADDITIONAL_CHARACTERISTICS = Spliterator.ORDERED | Spliterator.DISTINCT
+				| Spliterator.IMMUTABLE | Spliterator.NONNULL;
+
+		private final ZipContent zipContent;
+
+		private int cursor;
+
+		ZipContentEntriesSpliterator(ZipContent zipContent) {
+			super(zipContent.size(), ADDITIONAL_CHARACTERISTICS);
+			this.zipContent = zipContent;
+		}
+
+		@Override
+		public boolean tryAdvance(Consumer<? super ZipContent.Entry> action) {
+			if (this.cursor < this.zipContent.size()) {
+				synchronized (NestedJarFile.this) {
+					ensureOpen();
+					action.accept(this.zipContent.getEntry(this.cursor++));
+				}
+				return true;
+			}
+			return false;
+		}
+
+	}
+
+	/**
+	 * {@link InputStream} to read jar entry content.
+	 */
+	private class JarEntryInputStream extends InputStream {
+
+		private final int uncompressedSize;
+
+		private final CloseableDataBlock content;
+
+		private long pos;
+
+		private long remaining;
+
+		private volatile boolean closed;
+
+		JarEntryInputStream(ZipContent.Entry entry) throws IOException {
+			this.uncompressedSize = entry.getUncompressedSize();
+			this.content = entry.openContent();
+		}
+
+		@Override
+		public int read() throws IOException {
+			byte[] b = new byte[1];
+			return (read(b, 0, 1) == 1) ? b[0] & 0xFF : -1;
+		}
+
+		@Override
+		public int read(byte[] b, int off, int len) throws IOException {
+			int result;
+			synchronized (NestedJarFile.this) {
+				ensureOpen();
+				ByteBuffer dst = ByteBuffer.wrap(b, off, len);
+				int count = this.content.read(dst, this.pos);
+				if (count > 0) {
+					this.pos += count;
+					this.remaining -= count;
+				}
+				result = count;
+			}
+			if (this.remaining == 0) {
+				close();
+			}
+			return result;
+		}
+
+		@Override
+		public long skip(long n) throws IOException {
+			long result;
+			synchronized (NestedJarFile.this) {
+				result = (n > 0) ? maxForwardSkip(n) : maxBackwardSkip(n);
+				this.pos += result;
+				this.remaining -= result;
+			}
+			if (this.remaining == 0) {
+				close();
+			}
+			return result;
+		}
+
+		private long maxForwardSkip(long n) {
+			boolean willCauseOverflow = (this.pos + n) < 0;
+			return (willCauseOverflow || n > this.remaining) ? this.remaining : n;
+		}
+
+		private long maxBackwardSkip(long n) {
+			return Math.max(-this.pos, n);
+		}
+
+		@Override
+		public int available() {
+			return (this.remaining < Integer.MAX_VALUE) ? (int) this.remaining : Integer.MAX_VALUE;
+		}
+
+		private void ensureOpen() throws ZipException {
+			if (NestedJarFile.this.closed || this.closed) {
+				throw new ZipException("ZipFile closed");
+			}
+		}
+
+		@Override
+		public void close() throws IOException {
+			if (this.closed) {
+				return;
+			}
+			this.closed = true;
+			this.content.close();
+			NestedJarFile.this.resources.removeInputStream(this);
+		}
+
+		int getUncompressedSize() {
+			return this.uncompressedSize;
+		}
+
+	}
+
+	/**
+	 * {@link ZipInflaterInputStream} to read and inflate jar entry content.
+	 */
+	private class JarEntryInflaterInputStream extends ZipInflaterInputStream {
+
+		private final Cleanable cleanup;
+
+		private volatile boolean closed;
+
+		JarEntryInflaterInputStream(JarEntryInputStream inputStream, NestedJarFileResources resources) {
+			this(inputStream, resources, resources.getOrCreateInflater());
+		}
+
+		private JarEntryInflaterInputStream(JarEntryInputStream inputStream, NestedJarFileResources resources,
+				Inflater inflater) {
+			super(inputStream, inflater, inputStream.getUncompressedSize());
+			this.cleanup = NestedJarFile.this.cleaner.register(this, resources.createInflatorCleanupAction(inflater));
+		}
+
+		@Override
+		public void close() throws IOException {
+			if (this.closed) {
+				return;
+			}
+			this.closed = true;
+			super.close();
+			NestedJarFile.this.resources.removeInputStream(this);
+			this.cleanup.clean();
+		}
+
+	}
+
+	/**
+	 * {@link InputStream} for raw zip data.
+	 */
+	private class RawZipDataInputStream extends FilterInputStream {
+
+		private volatile boolean closed;
+
+		RawZipDataInputStream(InputStream in) {
+			super(in);
+		}
+
+		@Override
+		public void close() throws IOException {
+			if (this.closed) {
+				return;
+			}
+			this.closed = true;
+			super.close();
+			NestedJarFile.this.resources.removeInputStream(this);
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/NestedJarFileResources.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/NestedJarFileResources.java
new file mode 100644
index 000000000000..4f57e03497f8
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/NestedJarFileResources.java
@@ -0,0 +1,206 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.jar;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.util.ArrayDeque;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.List;
+import java.util.Set;
+import java.util.WeakHashMap;
+import java.util.zip.Inflater;
+
+import org.springframework.boot.loader.ref.Cleaner;
+import org.springframework.boot.loader.zip.ZipContent;
+
+/**
+ * Resources created managed and cleaned by a {@link NestedJarFile} instance and suitable
+ * for registration with a {@link Cleaner}.
+ *
+ * @author Phillip Webb
+ */
+class NestedJarFileResources implements Runnable {
+
+	private static final int INFLATER_CACHE_LIMIT = 20;
+
+	private ZipContent zipContent;
+
+	private final Set<InputStream> inputStreams = Collections.newSetFromMap(new WeakHashMap<>());
+
+	private Deque<Inflater> inflaterCache = new ArrayDeque<>();
+
+	/**
+	 * Create a new {@link NestedJarFileResources} instance.
+	 * @param file the source zip file
+	 * @param nestedEntryName the nested entry or {@code null}
+	 * @throws IOException on I/O error
+	 */
+	NestedJarFileResources(File file, String nestedEntryName) throws IOException {
+		this.zipContent = ZipContent.open(file.toPath(), nestedEntryName);
+	}
+
+	/**
+	 * Return the underling {@link ZipContent}.
+	 * @return the zip content
+	 */
+	ZipContent zipContent() {
+		return this.zipContent;
+	}
+
+	/**
+	 * Add a managed input stream resource.
+	 * @param inputStream the input stream
+	 */
+	void addInputStream(InputStream inputStream) {
+		synchronized (this.inputStreams) {
+			this.inputStreams.add(inputStream);
+		}
+	}
+
+	/**
+	 * Remove a managed input stream resource.
+	 * @param inputStream the input stream
+	 */
+	void removeInputStream(InputStream inputStream) {
+		synchronized (this.inputStreams) {
+			this.inputStreams.remove(inputStream);
+		}
+	}
+
+	/**
+	 * Create a {@link Runnable} action to cleanup the given inflater.
+	 * @param inflater the inflater to cleanup
+	 * @return the cleanup action
+	 */
+	Runnable createInflatorCleanupAction(Inflater inflater) {
+		return () -> endOrCacheInflater(inflater);
+	}
+
+	/**
+	 * Get previously used {@link Inflater} from the cache, or create a new one.
+	 * @return a usable {@link Inflater}
+	 */
+	Inflater getOrCreateInflater() {
+		Deque<Inflater> inflaterCache = this.inflaterCache;
+		if (inflaterCache != null) {
+			synchronized (inflaterCache) {
+				Inflater inflater = this.inflaterCache.poll();
+				if (inflater != null) {
+					return inflater;
+				}
+			}
+		}
+		return new Inflater(true);
+	}
+
+	/**
+	 * Either release the given {@link Inflater} by calling {@link Inflater#end()} or add
+	 * it to the cache for later reuse.
+	 * @param inflater the inflater to end or cache
+	 */
+	private void endOrCacheInflater(Inflater inflater) {
+		Deque<Inflater> inflaterCache = this.inflaterCache;
+		if (inflaterCache != null) {
+			synchronized (inflaterCache) {
+				if (this.inflaterCache == inflaterCache && inflaterCache.size() < INFLATER_CACHE_LIMIT) {
+					inflater.reset();
+					this.inflaterCache.add(inflater);
+					return;
+				}
+			}
+		}
+		inflater.end();
+	}
+
+	/**
+	 * Called by the {@link Cleaner} to free resources.
+	 * @see java.lang.Runnable#run()
+	 */
+	@Override
+	public void run() {
+		releaseAll();
+	}
+
+	private void releaseAll() {
+		IOException exceptionChain = null;
+		exceptionChain = releaseInflators(exceptionChain);
+		exceptionChain = releaseInputStreams(exceptionChain);
+		exceptionChain = releaseZipContent(exceptionChain);
+		if (exceptionChain != null) {
+			throw new UncheckedIOException(exceptionChain);
+		}
+	}
+
+	private IOException releaseInflators(IOException exceptionChain) {
+		Deque<Inflater> inflaterCache = this.inflaterCache;
+		if (inflaterCache != null) {
+			try {
+				synchronized (inflaterCache) {
+					inflaterCache.forEach(Inflater::end);
+				}
+			}
+			finally {
+				this.inflaterCache = null;
+			}
+		}
+		return exceptionChain;
+	}
+
+	private IOException releaseInputStreams(IOException exceptionChain) {
+		synchronized (this.inputStreams) {
+			for (InputStream inputStream : List.copyOf(this.inputStreams)) {
+				try {
+					inputStream.close();
+				}
+				catch (IOException ex) {
+					exceptionChain = addToExceptionChain(exceptionChain, ex);
+				}
+			}
+			this.inputStreams.clear();
+		}
+		return exceptionChain;
+	}
+
+	private IOException releaseZipContent(IOException exceptionChain) {
+		ZipContent zipContent = this.zipContent;
+		if (zipContent != null) {
+			try {
+				zipContent.close();
+			}
+			catch (IOException ex) {
+				exceptionChain = addToExceptionChain(exceptionChain, ex);
+			}
+			finally {
+				this.zipContent = null;
+			}
+		}
+		return exceptionChain;
+	}
+
+	private IOException addToExceptionChain(IOException exceptionChain, IOException ex) {
+		if (exceptionChain != null) {
+			exceptionChain.addSuppressed(ex);
+			return exceptionChain;
+		}
+		return ex;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/SecurityInfo.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/SecurityInfo.java
new file mode 100644
index 000000000000..3b20bebdbe4d
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/SecurityInfo.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.jar;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.security.CodeSigner;
+import java.security.cert.Certificate;
+import java.util.jar.JarEntry;
+import java.util.jar.JarInputStream;
+
+import org.springframework.boot.loader.zip.ZipContent;
+
+/**
+ * Security information ({@link Certificate} and {@link CodeSigner} details) for entries
+ * in the jar.
+ *
+ * @author Phillip Webb
+ */
+final class SecurityInfo {
+
+	static final SecurityInfo NONE = new SecurityInfo(null, null);
+
+	private final Certificate[][] certificateLookups;
+
+	private final CodeSigner[][] codeSignerLookups;
+
+	private SecurityInfo(Certificate[][] entryCertificates, CodeSigner[][] entryCodeSigners) {
+		this.certificateLookups = entryCertificates;
+		this.codeSignerLookups = entryCodeSigners;
+	}
+
+	Certificate[] getCertificates(ZipContent.Entry contentEntry) {
+		return (this.certificateLookups != null) ? clone(this.certificateLookups[contentEntry.getLookupIndex()]) : null;
+	}
+
+	CodeSigner[] getCodeSigners(ZipContent.Entry contentEntry) {
+		return (this.codeSignerLookups != null) ? clone(this.codeSignerLookups[contentEntry.getLookupIndex()]) : null;
+	}
+
+	private <T> T[] clone(T[] array) {
+		return (array != null) ? array.clone() : null;
+	}
+
+	/**
+	 * Get the {@link SecurityInfo} for the given {@link ZipContent}.
+	 * @param content the zip content
+	 * @return the security info
+	 */
+	static SecurityInfo get(ZipContent content) {
+		if (!content.hasJarSignatureFile()) {
+			return NONE;
+		}
+		try {
+			return load(content);
+		}
+		catch (IOException ex) {
+			throw new UncheckedIOException(ex);
+		}
+	}
+
+	/**
+	 * Load security info from the jar file. We need to use {@link JarInputStream} to
+	 * obtain the security info since we don't have an actual real file to read. This
+	 * isn't that fast, but hopefully doesn't happen too often and the result is cached.
+	 * @param content the zip content
+	 * @return the security info
+	 * @throws IOException on I/O error
+	 */
+	private static SecurityInfo load(ZipContent content) throws IOException {
+		int size = content.size();
+		boolean hasSecurityInfo = false;
+		Certificate[][] entryCertificates = new Certificate[size][];
+		CodeSigner[][] entryCodeSigners = new CodeSigner[size][];
+		try (JarInputStream in = new JarInputStream(content.openRawZipData().asInputStream())) {
+			JarEntry jarEntry = in.getNextJarEntry();
+			while (jarEntry != null) {
+				in.closeEntry(); // Close to trigger a read and set certs/signers
+				Certificate[] certificates = jarEntry.getCertificates();
+				CodeSigner[] codeSigners = jarEntry.getCodeSigners();
+				if (certificates != null || codeSigners != null) {
+					ZipContent.Entry contentEntry = content.getEntry(jarEntry.getName());
+					if (contentEntry != null) {
+						hasSecurityInfo = true;
+						entryCertificates[contentEntry.getLookupIndex()] = certificates;
+						entryCodeSigners[contentEntry.getLookupIndex()] = codeSigners;
+					}
+				}
+				jarEntry = in.getNextJarEntry();
+			}
+			return (!hasSecurityInfo) ? NONE : new SecurityInfo(entryCertificates, entryCodeSigners);
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java
index 87587bed3ff1..1528f0b9c507 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -24,27 +24,32 @@
 
 /**
  * {@link InflaterInputStream} that supports the writing of an extra "dummy" byte (which
- * is required with JDK 6) and returns accurate available() results.
+ * is required when using an {@link Inflater} with {@code nowrap}) and returns accurate
+ * available() results.
  *
  * @author Phillip Webb
  */
-class ZipInflaterInputStream extends InflaterInputStream {
+abstract class ZipInflaterInputStream extends InflaterInputStream {
 
 	private int available;
 
 	private boolean extraBytesWritten;
 
-	ZipInflaterInputStream(InputStream inputStream, int size) {
-		super(inputStream, new Inflater(true), getInflaterBufferSize(size));
+	ZipInflaterInputStream(InputStream inputStream, Inflater inflater, int size) {
+		super(inputStream, inflater, getInflaterBufferSize(size));
 		this.available = size;
 	}
 
+	private static int getInflaterBufferSize(long size) {
+		size += 2; // inflater likes some space
+		size = (size > 65536) ? 8192 : size;
+		size = (size <= 0) ? 4096 : size;
+		return (int) size;
+	}
+
 	@Override
 	public int available() throws IOException {
-		if (this.available < 0) {
-			return super.available();
-		}
-		return this.available;
+		return (this.available >= 0) ? this.available : super.available();
 	}
 
 	@Override
@@ -56,12 +61,6 @@ public int read(byte[] b, int off, int len) throws IOException {
 		return result;
 	}
 
-	@Override
-	public void close() throws IOException {
-		super.close();
-		this.inf.end();
-	}
-
 	@Override
 	protected void fill() throws IOException {
 		try {
@@ -78,11 +77,4 @@ protected void fill() throws IOException {
 		}
 	}
 
-	private static int getInflaterBufferSize(long size) {
-		size += 2; // inflater likes some space
-		size = (size > 65536) ? 8192 : size;
-		size = (size <= 0) ? 4096 : size;
-		return (int) size;
-	}
-
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/package-info.java
index e232261ff47e..ae1ba30639e2 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/package-info.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/package-info.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -15,6 +15,7 @@
  */
 
 /**
- * Support for loading and manipulating JAR/WAR files.
+ * Alternative {@link java.util.jar.JarFile} implementation with support for nested jars.
+ * @see org.springframework.boot.loader.jar.NestedJarFile
  */
 package org.springframework.boot.loader.jar;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java
index c711e206f5da..162e4a6a7396 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java
deleted file mode 100644
index 6a6e83ff23c4..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * Copyright 2012-2020 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.loader.jarmode;
-
-import java.util.Arrays;
-
-/**
- * {@link JarMode} for testing.
- *
- * @author Phillip Webb
- */
-class TestJarMode implements JarMode {
-
-	@Override
-	public boolean accepts(String mode) {
-		return "test".equals(mode);
-	}
-
-	@Override
-	public void run(String mode, String[] args) {
-		System.out.println("running in " + mode + " jar mode " + Arrays.asList(args));
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/package-info.java
index 315cb5696b83..d68ef83474eb 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/package-info.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/package-info.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,7 +16,5 @@
 
 /**
  * Support for launching the JAR using jarmode.
- *
- * @see org.springframework.boot.loader.jarmode.JarModeLauncher
  */
 package org.springframework.boot.loader.jarmode;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/Archive.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/Archive.java
new file mode 100644
index 000000000000..933a630ffb30
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/Archive.java
@@ -0,0 +1,150 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URL;
+import java.security.CodeSource;
+import java.security.ProtectionDomain;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.jar.Manifest;
+
+/**
+ * An archive that can be launched by the {@link Launcher}.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public interface Archive extends AutoCloseable {
+
+	/**
+	 * Predicate that accepts all entries.
+	 */
+	Predicate<Entry> ALL_ENTRIES = (entry) -> true;
+
+	/**
+	 * Returns the manifest of the archive.
+	 * @return the manifest or {@code null}
+	 * @throws IOException if the manifest cannot be read
+	 */
+	Manifest getManifest() throws IOException;
+
+	/**
+	 * Returns classpath URLs for the archive that match the specified filter.
+	 * @param includeFilter filter used to determine which entries should be included.
+	 * @return the classpath URLs
+	 * @throws IOException on IO error
+	 */
+	default Set<URL> getClassPathUrls(Predicate<Entry> includeFilter) throws IOException {
+		return getClassPathUrls(includeFilter, ALL_ENTRIES);
+
+	}
+
+	/**
+	 * Returns classpath URLs for the archive that match the specified filters.
+	 * @param includeFilter filter used to determine which entries should be included
+	 * @param directorySearchFilter filter used to optimize tree walking for exploded
+	 * archives by determining if a directory needs to be searched or not
+	 * @return the classpath URLs
+	 * @throws IOException on IO error
+	 */
+	Set<URL> getClassPathUrls(Predicate<Entry> includeFilter, Predicate<Entry> directorySearchFilter)
+			throws IOException;
+
+	/**
+	 * Returns if this archive is backed by an exploded archive directory.
+	 * @return if the archive is exploded
+	 */
+	default boolean isExploded() {
+		return getRootDirectory() != null;
+	}
+
+	/**
+	 * Returns the root directory of this archive or {@code null} if the archive is not
+	 * backed by a directory.
+	 * @return the root directory
+	 */
+	default File getRootDirectory() {
+		return null;
+	}
+
+	/**
+	 * Closes the {@code Archive}, releasing any open resources.
+	 * @throws Exception if an error occurs during close processing
+	 */
+	@Override
+	default void close() throws Exception {
+	}
+
+	/**
+	 * Factory method to create an appropriate {@link Archive} from the given
+	 * {@link Class} target.
+	 * @param target a target class that will be used to find the archive code source
+	 * @return an new {@link Archive} instance
+	 * @throws Exception if the archive cannot be created
+	 */
+	static Archive create(Class<?> target) throws Exception {
+		return create(target.getProtectionDomain());
+	}
+
+	static Archive create(ProtectionDomain protectionDomain) throws Exception {
+		CodeSource codeSource = protectionDomain.getCodeSource();
+		URI location = (codeSource != null) ? codeSource.getLocation().toURI() : null;
+		String path = (location != null) ? location.getSchemeSpecificPart() : null;
+		if (path == null) {
+			throw new IllegalStateException("Unable to determine code source archive");
+		}
+		return create(new File(path));
+	}
+
+	/**
+	 * Factory method to create an {@link Archive} from the given {@link File} target.
+	 * @param target a target {@link File} used to create the archive. May be a directory
+	 * or a jar file.
+	 * @return a new {@link Archive} instance.
+	 * @throws Exception if the archive cannot be created
+	 */
+	static Archive create(File target) throws Exception {
+		if (!target.exists()) {
+			throw new IllegalStateException("Unable to determine code source archive from " + target);
+		}
+		return (target.isDirectory() ? new ExplodedArchive(target) : new JarFileArchive(target));
+	}
+
+	/**
+	 * Represents a single entry in the archive.
+	 */
+	interface Entry {
+
+		/**
+		 * Returns the name of the entry.
+		 * @return the name of the entry
+		 */
+		String name();
+
+		/**
+		 * Returns {@code true} if the entry represents a directory.
+		 * @return if the entry is a directory
+		 */
+		boolean isDirectory();
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ClassPathIndexFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ClassPathIndexFile.java
new file mode 100644
index 000000000000..dcc4384099a7
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ClassPathIndexFile.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.file.Files;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * A class path index file that provides an ordered classpath for exploded JARs.
+ *
+ * @author Madhura Bhave
+ * @author Phillip Webb
+ */
+final class ClassPathIndexFile {
+
+	private final File root;
+
+	private final Set<String> lines;
+
+	private ClassPathIndexFile(File root, List<String> lines) {
+		this.root = root;
+		this.lines = lines.stream().map(this::extractName).collect(Collectors.toCollection(LinkedHashSet::new));
+	}
+
+	private String extractName(String line) {
+		if (line.startsWith("- \"") && line.endsWith("\"")) {
+			return line.substring(3, line.length() - 1);
+		}
+		throw new IllegalStateException("Malformed classpath index line [" + line + "]");
+	}
+
+	int size() {
+		return this.lines.size();
+	}
+
+	boolean containsEntry(String name) {
+		if (name == null || name.isEmpty()) {
+			return false;
+		}
+		return this.lines.contains(name);
+	}
+
+	List<URL> getUrls() {
+		return this.lines.stream().map(this::asUrl).toList();
+	}
+
+	private URL asUrl(String line) {
+		try {
+			return new File(this.root, line).toURI().toURL();
+		}
+		catch (MalformedURLException ex) {
+			throw new IllegalStateException(ex);
+		}
+	}
+
+	static ClassPathIndexFile loadIfPossible(File root, String location) throws IOException {
+		return loadIfPossible(root, new File(root, location));
+	}
+
+	private static ClassPathIndexFile loadIfPossible(File root, File indexFile) throws IOException {
+		if (indexFile.exists() && indexFile.isFile()) {
+			List<String> lines = Files.readAllLines(indexFile.toPath())
+				.stream()
+				.filter(ClassPathIndexFile::lineHasText)
+				.toList();
+			return new ClassPathIndexFile(root, lines);
+		}
+		return null;
+	}
+
+	private static boolean lineHasText(String line) {
+		return !line.trim().isEmpty();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ExecutableArchiveLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ExecutableArchiveLauncher.java
new file mode 100644
index 000000000000..fd6bd8cf527c
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ExecutableArchiveLauncher.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+import java.io.IOException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Set;
+import java.util.jar.Attributes;
+import java.util.jar.Manifest;
+
+import org.springframework.boot.loader.launch.Archive.Entry;
+
+/**
+ * Base class for a {@link Launcher} backed by an executable archive.
+ *
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ * @author Madhura Bhave
+ * @author Scott Frederick
+ * @since 3.2.0
+ * @see JarLauncher
+ * @see WarLauncher
+ */
+public abstract class ExecutableArchiveLauncher extends Launcher {
+
+	private static final String START_CLASS_ATTRIBUTE = "Start-Class";
+
+	protected static final String BOOT_CLASSPATH_INDEX_ATTRIBUTE = "Spring-Boot-Classpath-Index";
+
+	protected static final String DEFAULT_CLASSPATH_INDEX_FILE_NAME = "classpath.idx";
+
+	private final Archive archive;
+
+	private final ClassPathIndexFile classPathIndex;
+
+	public ExecutableArchiveLauncher() throws Exception {
+		this(Archive.create(Launcher.class));
+	}
+
+	protected ExecutableArchiveLauncher(Archive archive) throws Exception {
+		this.archive = archive;
+		this.classPathIndex = getClassPathIndex(this.archive);
+	}
+
+	ClassPathIndexFile getClassPathIndex(Archive archive) throws IOException {
+		if (!archive.isExploded()) {
+			return null; // Regular archives already have a defined order
+		}
+		String location = getClassPathIndexFileLocation(archive);
+		return ClassPathIndexFile.loadIfPossible(archive.getRootDirectory(), location);
+	}
+
+	private String getClassPathIndexFileLocation(Archive archive) throws IOException {
+		Manifest manifest = archive.getManifest();
+		Attributes attributes = (manifest != null) ? manifest.getMainAttributes() : null;
+		String location = (attributes != null) ? attributes.getValue(BOOT_CLASSPATH_INDEX_ATTRIBUTE) : null;
+		return (location != null) ? location : getEntryPathPrefix() + DEFAULT_CLASSPATH_INDEX_FILE_NAME;
+	}
+
+	@Override
+	protected ClassLoader createClassLoader(Collection<URL> urls) throws Exception {
+		if (this.classPathIndex != null) {
+			urls = new ArrayList<>(urls);
+			urls.addAll(this.classPathIndex.getUrls());
+		}
+		return super.createClassLoader(urls);
+	}
+
+	@Override
+	protected final Archive getArchive() {
+		return this.archive;
+	}
+
+	@Override
+	protected String getMainClass() throws Exception {
+		Manifest manifest = this.archive.getManifest();
+		String mainClass = (manifest != null) ? manifest.getMainAttributes().getValue(START_CLASS_ATTRIBUTE) : null;
+		if (mainClass == null) {
+			throw new IllegalStateException("No 'Start-Class' manifest entry specified in " + this);
+		}
+		return mainClass;
+	}
+
+	@Override
+	protected Set<URL> getClassPathUrls() throws Exception {
+		return this.archive.getClassPathUrls(this::isIncludedOnClassPathAndNotIndexed, this::isSearchedDirectory);
+	}
+
+	private boolean isIncludedOnClassPathAndNotIndexed(Entry entry) {
+		if (!isIncludedOnClassPath(entry)) {
+			return false;
+		}
+		return (this.classPathIndex == null) || !this.classPathIndex.containsEntry(entry.name());
+	}
+
+	/**
+	 * Determine if the specified directory entry is a candidate for further searching.
+	 * @param entry the entry to check
+	 * @return {@code true} if the entry is a candidate for further searching
+	 */
+	protected boolean isSearchedDirectory(Archive.Entry entry) {
+		return ((getEntryPathPrefix() == null) || entry.name().startsWith(getEntryPathPrefix()))
+				&& !isIncludedOnClassPath(entry);
+	}
+
+	/**
+	 * Determine if the specified entry is a nested item that should be added to the
+	 * classpath.
+	 * @param entry the entry to check
+	 * @return {@code true} if the entry is a nested item (jar or directory)
+	 */
+	protected abstract boolean isIncludedOnClassPath(Archive.Entry entry);
+
+	/**
+	 * Return the path prefix for relevant entries in the archive.
+	 * @return the entry path prefix
+	 */
+	protected abstract String getEntryPathPrefix();
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ExplodedArchive.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ExplodedArchive.java
new file mode 100644
index 000000000000..79cb729f60f7
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ExplodedArchive.java
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.net.URL;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.jar.Manifest;
+
+/**
+ * {@link Archive} implementation backed by an exploded archive directory.
+ *
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ * @author Madhura Bhave
+ */
+class ExplodedArchive implements Archive {
+
+	private static final Object NO_MANIFEST = new Object();
+
+	private static final Set<String> SKIPPED_NAMES = Set.of(".", "..");
+
+	private static final Comparator<File> entryComparator = Comparator.comparing(File::getAbsolutePath);
+
+	private final File rootDirectory;
+
+	private final String rootUriPath;
+
+	private volatile Object manifest;
+
+	/**
+	 * Create a new {@link ExplodedArchive} instance.
+	 * @param rootDirectory the root directory
+	 */
+	ExplodedArchive(File rootDirectory) {
+		if (!rootDirectory.exists() || !rootDirectory.isDirectory()) {
+			throw new IllegalArgumentException("Invalid source directory " + rootDirectory);
+		}
+		this.rootDirectory = rootDirectory;
+		this.rootUriPath = ExplodedArchive.this.rootDirectory.toURI().getPath();
+	}
+
+	@Override
+	public Manifest getManifest() throws IOException {
+		Object manifest = this.manifest;
+		if (manifest == null) {
+			manifest = loadManifest();
+			this.manifest = manifest;
+		}
+		return (manifest != NO_MANIFEST) ? (Manifest) manifest : null;
+	}
+
+	private Object loadManifest() throws IOException {
+		File file = new File(this.rootDirectory, "META-INF/MANIFEST.MF");
+		if (!file.exists()) {
+			return NO_MANIFEST;
+		}
+		try (FileInputStream inputStream = new FileInputStream(file)) {
+			return new Manifest(inputStream);
+		}
+	}
+
+	@Override
+	public Set<URL> getClassPathUrls(Predicate<Entry> includeFilter, Predicate<Entry> directorySearchFilter)
+			throws IOException {
+		Set<URL> urls = new LinkedHashSet<>();
+		LinkedList<File> files = new LinkedList<>(listFiles(this.rootDirectory));
+		while (!files.isEmpty()) {
+			File file = files.poll();
+			if (SKIPPED_NAMES.contains(file.getName())) {
+				continue;
+			}
+			String entryName = file.toURI().getPath().substring(this.rootUriPath.length());
+			Entry entry = new FileArchiveEntry(entryName, file);
+			if (entry.isDirectory() && directorySearchFilter.test(entry)) {
+				files.addAll(0, listFiles(file));
+			}
+			if (includeFilter.test(entry)) {
+				urls.add(file.toURI().toURL());
+			}
+		}
+		return urls;
+	}
+
+	private List<File> listFiles(File file) {
+		File[] files = file.listFiles();
+		if (files == null) {
+			return Collections.emptyList();
+		}
+		Arrays.sort(files, entryComparator);
+		return Arrays.asList(files);
+	}
+
+	@Override
+	public File getRootDirectory() {
+		return this.rootDirectory;
+	}
+
+	@Override
+	public String toString() {
+		return this.rootDirectory.toString();
+	}
+
+	/**
+	 * {@link Entry} backed by a File.
+	 */
+	private record FileArchiveEntry(String name, File file) implements Entry {
+
+		@Override
+		public boolean isDirectory() {
+			return this.file.isDirectory();
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarFileArchive.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarFileArchive.java
new file mode 100755
index 000000000000..3ccb32009fb3
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarFileArchive.java
@@ -0,0 +1,205 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.net.URL;
+import java.nio.file.FileSystem;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.attribute.FileAttribute;
+import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.UUID;
+import java.util.function.Predicate;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.jar.Manifest;
+import java.util.stream.Collectors;
+
+import org.springframework.boot.loader.net.protocol.jar.JarUrl;
+
+/**
+ * {@link Archive} implementation backed by a {@link JarFile}.
+ *
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ */
+class JarFileArchive implements Archive {
+
+	private static final String UNPACK_MARKER = "UNPACK:";
+
+	private static final FileAttribute<?>[] NO_FILE_ATTRIBUTES = {};
+
+	private static final FileAttribute<?>[] DIRECTORY_PERMISSION_ATTRIBUTES = asFileAttributes(
+			PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE);
+
+	private static final FileAttribute<?>[] FILE_PERMISSION_ATTRIBUTES = asFileAttributes(
+			PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE);
+
+	private static final Path TEMP = Paths.get(System.getProperty("java.io.tmpdir"));
+
+	private final File file;
+
+	private final JarFile jarFile;
+
+	private volatile Path tempUnpackDirectory;
+
+	JarFileArchive(File file) throws IOException {
+		this(file, new JarFile(file));
+	}
+
+	private JarFileArchive(File file, JarFile jarFile) {
+		this.file = file;
+		this.jarFile = jarFile;
+	}
+
+	@Override
+	public Manifest getManifest() throws IOException {
+		return this.jarFile.getManifest();
+	}
+
+	@Override
+	public Set<URL> getClassPathUrls(Predicate<Entry> includeFilter, Predicate<Entry> directorySearchFilter)
+			throws IOException {
+		return this.jarFile.stream()
+			.map(JarArchiveEntry::new)
+			.filter(includeFilter)
+			.map(this::getNestedJarUrl)
+			.collect(Collectors.toCollection(LinkedHashSet::new));
+	}
+
+	private URL getNestedJarUrl(JarArchiveEntry archiveEntry) {
+		try {
+			JarEntry jarEntry = archiveEntry.jarEntry();
+			String comment = jarEntry.getComment();
+			if (comment != null && comment.startsWith(UNPACK_MARKER)) {
+				return getUnpackedNestedJarUrl(jarEntry);
+			}
+			return JarUrl.create(this.file, jarEntry);
+		}
+		catch (IOException ex) {
+			throw new UncheckedIOException(ex);
+		}
+	}
+
+	private URL getUnpackedNestedJarUrl(JarEntry jarEntry) throws IOException {
+		String name = jarEntry.getName();
+		if (name.lastIndexOf('/') != -1) {
+			name = name.substring(name.lastIndexOf('/') + 1);
+		}
+		Path path = getTempUnpackDirectory().resolve(name);
+		if (!Files.exists(path) || Files.size(path) != jarEntry.getSize()) {
+			unpack(jarEntry, path);
+		}
+		return JarUrl.create(path.toFile());
+	}
+
+	private Path getTempUnpackDirectory() {
+		Path tempUnpackDirectory = this.tempUnpackDirectory;
+		if (tempUnpackDirectory != null) {
+			return tempUnpackDirectory;
+		}
+		synchronized (TEMP) {
+			tempUnpackDirectory = this.tempUnpackDirectory;
+			if (tempUnpackDirectory == null) {
+				tempUnpackDirectory = createUnpackDirectory(TEMP);
+				this.tempUnpackDirectory = tempUnpackDirectory;
+			}
+		}
+		return tempUnpackDirectory;
+	}
+
+	private Path createUnpackDirectory(Path parent) {
+		int attempts = 0;
+		String fileName = Paths.get(this.jarFile.getName()).getFileName().toString();
+		while (attempts++ < 100) {
+			Path unpackDirectory = parent.resolve(fileName + "-spring-boot-libs-" + UUID.randomUUID());
+			try {
+				createDirectory(unpackDirectory);
+				return unpackDirectory;
+			}
+			catch (IOException ex) {
+				// Ignore
+			}
+		}
+		throw new IllegalStateException("Failed to create unpack directory in directory '" + parent + "'");
+	}
+
+	private void createDirectory(Path path) throws IOException {
+		Files.createDirectory(path, getFileAttributes(path, DIRECTORY_PERMISSION_ATTRIBUTES));
+	}
+
+	private void unpack(JarEntry entry, Path path) throws IOException {
+		createFile(path);
+		path.toFile().deleteOnExit();
+		try (InputStream in = this.jarFile.getInputStream(entry)) {
+			Files.copy(in, path, StandardCopyOption.REPLACE_EXISTING);
+		}
+	}
+
+	private void createFile(Path path) throws IOException {
+		Files.createFile(path, getFileAttributes(path, FILE_PERMISSION_ATTRIBUTES));
+	}
+
+	private FileAttribute<?>[] getFileAttributes(Path path, FileAttribute<?>[] permissionAttributes) {
+		return (!supportsPosix(path.getFileSystem())) ? NO_FILE_ATTRIBUTES : permissionAttributes;
+	}
+
+	private boolean supportsPosix(FileSystem fileSystem) {
+		return fileSystem.supportedFileAttributeViews().contains("posix");
+	}
+
+	@Override
+	public void close() throws IOException {
+		this.jarFile.close();
+	}
+
+	@Override
+	public String toString() {
+		return this.file.toString();
+	}
+
+	private static FileAttribute<?>[] asFileAttributes(PosixFilePermission... permissions) {
+		return new FileAttribute<?>[] { PosixFilePermissions.asFileAttribute(Set.of(permissions)) };
+	}
+
+	/**
+	 * {@link Entry} implementation backed by a {@link JarEntry}.
+	 */
+	private record JarArchiveEntry(JarEntry jarEntry) implements Entry {
+
+		@Override
+		public String name() {
+			return this.jarEntry.getName();
+		}
+
+		@Override
+		public boolean isDirectory() {
+			return this.jarEntry.isDirectory();
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarLauncher.java
new file mode 100644
index 000000000000..3a6d1339ca11
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarLauncher.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+/**
+ * {@link Launcher} for JAR based archives. This launcher assumes that dependency jars are
+ * included inside a {@code /BOOT-INF/lib} directory and that application classes are
+ * included inside a {@code /BOOT-INF/classes} directory.
+ *
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ * @author Madhura Bhave
+ * @author Scott Frederick
+ * @since 3.2.0
+ */
+public class JarLauncher extends ExecutableArchiveLauncher {
+
+	public JarLauncher() throws Exception {
+	}
+
+	protected JarLauncher(Archive archive) throws Exception {
+		super(archive);
+	}
+
+	@Override
+	protected boolean isIncludedOnClassPath(Archive.Entry entry) {
+		return isLibraryFileOrClassesDirectory(entry);
+	}
+
+	@Override
+	protected String getEntryPathPrefix() {
+		return "BOOT-INF/";
+	}
+
+	static boolean isLibraryFileOrClassesDirectory(Archive.Entry entry) {
+		String name = entry.name();
+		if (entry.isDirectory()) {
+			return name.equals("BOOT-INF/classes/");
+		}
+		return name.startsWith("BOOT-INF/lib/");
+	}
+
+	public static void main(String[] args) throws Exception {
+		new JarLauncher().launch(args);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarModeRunner.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarModeRunner.java
new file mode 100644
index 000000000000..4805a633d48c
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarModeRunner.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+import java.util.List;
+
+import org.springframework.boot.loader.jarmode.JarMode;
+import org.springframework.core.io.support.SpringFactoriesLoader;
+import org.springframework.util.ClassUtils;
+
+/**
+ * Delegate class used to run the nested jar in a specific mode.
+ *
+ * @author Phillip Webb
+ */
+final class JarModeRunner {
+
+	static final String DISABLE_SYSTEM_EXIT = JarModeRunner.class.getName() + ".DISABLE_SYSTEM_EXIT";
+
+	private JarModeRunner() {
+	}
+
+	static void main(String[] args) {
+		String mode = System.getProperty("jarmode");
+		List<JarMode> candidates = SpringFactoriesLoader.loadFactories(JarMode.class,
+				ClassUtils.getDefaultClassLoader());
+		for (JarMode candidate : candidates) {
+			if (candidate.accepts(mode)) {
+				candidate.run(mode, args);
+				return;
+			}
+		}
+		System.err.println("Unsupported jarmode '" + mode + "'");
+		if (!Boolean.getBoolean(DISABLE_SYSTEM_EXIT)) {
+			System.exit(1);
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/LaunchedClassLoader.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/LaunchedClassLoader.java
new file mode 100644
index 000000000000..c604df0487a4
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/LaunchedClassLoader.java
@@ -0,0 +1,189 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.function.Supplier;
+import java.util.jar.Manifest;
+
+import org.springframework.boot.loader.net.protocol.jar.JarUrlClassLoader;
+
+/**
+ * {@link ClassLoader} used by the {@link Launcher}.
+ *
+ * @author Phillip Webb
+ * @author Dave Syer
+ * @author Andy Wilkinson
+ * @since 3.2.0
+ */
+public class LaunchedClassLoader extends JarUrlClassLoader {
+
+	private static final String JAR_MODE_PACKAGE_PREFIX = "org.springframework.boot.loader.jarmode.";
+
+	private static final String JAR_MODE_RUNNER_CLASS_NAME = JarModeRunner.class.getName();
+
+	static {
+		ClassLoader.registerAsParallelCapable();
+	}
+
+	private final boolean exploded;
+
+	private final Archive rootArchive;
+
+	private final Object definePackageLock = new Object();
+
+	private volatile DefinePackageCallType definePackageCallType;
+
+	/**
+	 * Create a new {@link LaunchedClassLoader} instance.
+	 * @param exploded if the underlying archive is exploded
+	 * @param urls the URLs from which to load classes and resources
+	 * @param parent the parent class loader for delegation
+	 */
+	public LaunchedClassLoader(boolean exploded, URL[] urls, ClassLoader parent) {
+		this(exploded, null, urls, parent);
+	}
+
+	/**
+	 * Create a new {@link LaunchedClassLoader} instance.
+	 * @param exploded if the underlying archive is exploded
+	 * @param rootArchive the root archive or {@code null}
+	 * @param urls the URLs from which to load classes and resources
+	 * @param parent the parent class loader for delegation
+	 */
+	public LaunchedClassLoader(boolean exploded, Archive rootArchive, URL[] urls, ClassLoader parent) {
+		super(urls, parent);
+		this.exploded = exploded;
+		this.rootArchive = rootArchive;
+	}
+
+	@Override
+	protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
+		if (name.startsWith(JAR_MODE_PACKAGE_PREFIX) || name.equals(JAR_MODE_RUNNER_CLASS_NAME)) {
+			try {
+				Class<?> result = loadClassInLaunchedClassLoader(name);
+				if (resolve) {
+					resolveClass(result);
+				}
+				return result;
+			}
+			catch (ClassNotFoundException ex) {
+				// Ignore
+			}
+		}
+		return super.loadClass(name, resolve);
+	}
+
+	private Class<?> loadClassInLaunchedClassLoader(String name) throws ClassNotFoundException {
+		try {
+			String internalName = name.replace('.', '/') + ".class";
+			try (InputStream inputStream = getParent().getResourceAsStream(internalName);
+					ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
+				if (inputStream == null) {
+					throw new ClassNotFoundException(name);
+				}
+				inputStream.transferTo(outputStream);
+				byte[] bytes = outputStream.toByteArray();
+				Class<?> definedClass = defineClass(name, bytes, 0, bytes.length);
+				definePackageIfNecessary(name);
+				return definedClass;
+			}
+		}
+		catch (IOException ex) {
+			throw new ClassNotFoundException("Cannot load resource for class [" + name + "]", ex);
+		}
+	}
+
+	@Override
+	protected Package definePackage(String name, Manifest man, URL url) throws IllegalArgumentException {
+		return (!this.exploded) ? super.definePackage(name, man, url) : definePackageForExploded(name, man, url);
+	}
+
+	private Package definePackageForExploded(String name, Manifest man, URL url) {
+		synchronized (this.definePackageLock) {
+			return definePackage(DefinePackageCallType.MANIFEST, () -> super.definePackage(name, man, url));
+		}
+	}
+
+	@Override
+	protected Package definePackage(String name, String specTitle, String specVersion, String specVendor,
+			String implTitle, String implVersion, String implVendor, URL sealBase) throws IllegalArgumentException {
+		if (!this.exploded) {
+			return super.definePackage(name, specTitle, specVersion, specVendor, implTitle, implVersion, implVendor,
+					sealBase);
+		}
+		return definePackageForExploded(name, sealBase, () -> super.definePackage(name, specTitle, specVersion,
+				specVendor, implTitle, implVersion, implVendor, sealBase));
+	}
+
+	private Package definePackageForExploded(String name, URL sealBase, Supplier<Package> call) {
+		synchronized (this.definePackageLock) {
+			if (this.definePackageCallType == null) {
+				// We're not part of a call chain which means that the URLClassLoader
+				// is trying to define a package for our exploded JAR. We use the
+				// manifest version to ensure package attributes are set
+				Manifest manifest = getManifest(this.rootArchive);
+				if (manifest != null) {
+					return definePackage(name, manifest, sealBase);
+				}
+			}
+			return definePackage(DefinePackageCallType.ATTRIBUTES, call);
+		}
+	}
+
+	private <T> T definePackage(DefinePackageCallType type, Supplier<T> call) {
+		DefinePackageCallType existingType = this.definePackageCallType;
+		try {
+			this.definePackageCallType = type;
+			return call.get();
+		}
+		finally {
+			this.definePackageCallType = existingType;
+		}
+	}
+
+	private Manifest getManifest(Archive archive) {
+		try {
+			return (archive != null) ? archive.getManifest() : null;
+		}
+		catch (IOException ex) {
+			return null;
+		}
+	}
+
+	/**
+	 * The different types of call made to define a package. We track these for exploded
+	 * jars so that we can detect packages that should have manifest attributes applied.
+	 */
+	private enum DefinePackageCallType {
+
+		/**
+		 * A define package call from a resource that has a manifest.
+		 */
+		MANIFEST,
+
+		/**
+		 * A define package call with a direct set of attributes.
+		 */
+		ATTRIBUTES
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/Launcher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/Launcher.java
new file mode 100644
index 000000000000..2cae9b06b916
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/Launcher.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+import java.io.UncheckedIOException;
+import java.lang.reflect.Method;
+import java.net.URL;
+import java.util.Collection;
+import java.util.Set;
+
+import org.springframework.boot.loader.net.protocol.Handlers;
+
+/**
+ * Base class for launchers that can start an application with a fully configured
+ * classpath.
+ *
+ * @author Phillip Webb
+ * @author Dave Syer
+ * @since 3.2.0
+ */
+public abstract class Launcher {
+
+	private static final String JAR_MODE_RUNNER_CLASS_NAME = JarModeRunner.class.getName();
+
+	/**
+	 * Launch the application. This method is the initial entry point that should be
+	 * called by a subclass {@code public static void main(String[] args)} method.
+	 * @param args the incoming arguments
+	 * @throws Exception if the application fails to launch
+	 */
+	protected void launch(String[] args) throws Exception {
+		if (!isExploded()) {
+			Handlers.register();
+		}
+		try {
+			ClassLoader classLoader = createClassLoader(getClassPathUrls());
+			String jarMode = System.getProperty("jarmode");
+			String mainClassName = hasLength(jarMode) ? JAR_MODE_RUNNER_CLASS_NAME : getMainClass();
+			launch(classLoader, mainClassName, args);
+		}
+		catch (UncheckedIOException ex) {
+			throw ex.getCause();
+		}
+	}
+
+	private boolean hasLength(String jarMode) {
+		return (jarMode != null) && !jarMode.isEmpty();
+	}
+
+	/**
+	 * Create a classloader for the specified archives.
+	 * @param urls the classpath URLs
+	 * @return the classloader
+	 * @throws Exception if the classloader cannot be created
+	 */
+	protected ClassLoader createClassLoader(Collection<URL> urls) throws Exception {
+		return createClassLoader(urls.toArray(new URL[0]));
+	}
+
+	private ClassLoader createClassLoader(URL[] urls) {
+		ClassLoader parent = getClass().getClassLoader();
+		return new LaunchedClassLoader(isExploded(), getArchive(), urls, parent);
+	}
+
+	/**
+	 * Launch the application given the archive file and a fully configured classloader.
+	 * @param classLoader the classloader
+	 * @param mainClassName the main class to run
+	 * @param args the incoming arguments
+	 * @throws Exception if the launch fails
+	 */
+	protected void launch(ClassLoader classLoader, String mainClassName, String[] args) throws Exception {
+		Thread.currentThread().setContextClassLoader(classLoader);
+		Class<?> mainClass = Class.forName(mainClassName, false, classLoader);
+		Method mainMethod = mainClass.getDeclaredMethod("main", String[].class);
+		mainMethod.setAccessible(true);
+		mainMethod.invoke(null, new Object[] { args });
+	}
+
+	/**
+	 * Returns if the launcher is running in an exploded mode. If this method returns
+	 * {@code true} then only regular JARs are supported and the additional URL and
+	 * ClassLoader support infrastructure can be optimized.
+	 * @return if the jar is exploded.
+	 */
+	protected boolean isExploded() {
+		Archive archive = getArchive();
+		return (archive != null) && archive.isExploded();
+	}
+
+	/**
+	 * Return the archive being launched or {@code null} if there is no archive.
+	 * @return the launched archive
+	 */
+	protected abstract Archive getArchive();
+
+	/**
+	 * Returns the main class that should be launched.
+	 * @return the name of the main class
+	 * @throws Exception if the main class cannot be obtained
+	 */
+	protected abstract String getMainClass() throws Exception;
+
+	/**
+	 * Returns the archives that will be used to construct the class path.
+	 * @return the class path archives
+	 * @throws Exception if the class path archives cannot be obtained
+	 */
+	protected abstract Set<URL> getClassPathUrls() throws Exception;
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/PropertiesLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/PropertiesLauncher.java
new file mode 100644
index 000000000000..efa9d80c0b3f
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/PropertiesLauncher.java
@@ -0,0 +1,611 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Constructor;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLConnection;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Properties;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.jar.Manifest;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.springframework.boot.loader.launch.Archive.Entry;
+import org.springframework.boot.loader.log.DebugLogger;
+import org.springframework.boot.loader.net.protocol.jar.JarUrl;
+
+/**
+ * {@link Launcher} for archives with user-configured classpath and main class through a
+ * properties file.
+ * <p>
+ * Looks in various places for a properties file to extract loader settings, defaulting to
+ * {@code loader.properties} either on the current classpath or in the current working
+ * directory. The name of the properties file can be changed by setting a System property
+ * {@code loader.config.name} (e.g. {@code -Dloader.config.name=my} will look for
+ * {@code my.properties}. If that file doesn't exist then tries
+ * {@code loader.config.location} (with allowed prefixes {@code classpath:} and
+ * {@code file:} or any valid URL). Once that file is located turns it into Properties and
+ * extracts optional values (which can also be provided overridden as System properties in
+ * case the file doesn't exist):
+ * <ul>
+ * <li>{@code loader.path}: a comma-separated list of directories (containing file
+ * resources and/or nested archives in *.jar or *.zip or archives) or archives to append
+ * to the classpath. {@code BOOT-INF/classes,BOOT-INF/lib} in the application archive are
+ * always used</li>
+ * <li>{@code loader.main}: the main method to delegate execution to once the class loader
+ * is set up. No default, but will fall back to looking for a {@code Start-Class} in a
+ * {@code MANIFEST.MF}, if there is one in <code>${loader.home}/META-INF</code>.</li>
+ * </ul>
+ *
+ * @author Dave Syer
+ * @author Janne Valkealahti
+ * @author Andy Wilkinson
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public class PropertiesLauncher extends Launcher {
+
+	/**
+	 * Properties key for main class. As a manifest entry can also be specified as
+	 * {@code Start-Class}.
+	 */
+	public static final String MAIN = "loader.main";
+
+	/**
+	 * Properties key for classpath entries (directories possibly containing jars or
+	 * jars). Multiple entries can be specified using a comma-separated list. {@code
+	 * BOOT-INF/classes,BOOT-INF/lib} in the application archive are always used.
+	 */
+	public static final String PATH = "loader.path";
+
+	/**
+	 * Properties key for home directory. This is the location of external configuration
+	 * if not on classpath, and also the base path for any relative paths in the
+	 * {@link #PATH loader path}. Defaults to current working directory (
+	 * <code>${user.dir}</code>).
+	 */
+	public static final String HOME = "loader.home";
+
+	/**
+	 * Properties key for default command line arguments. These arguments (if present) are
+	 * prepended to the main method arguments before launching.
+	 */
+	public static final String ARGS = "loader.args";
+
+	/**
+	 * Properties key for name of external configuration file (excluding suffix). Defaults
+	 * to "application". Ignored if {@link #CONFIG_LOCATION loader config location} is
+	 * provided instead.
+	 */
+	public static final String CONFIG_NAME = "loader.config.name";
+
+	/**
+	 * Properties key for config file location (including optional classpath:, file: or
+	 * URL prefix).
+	 */
+	public static final String CONFIG_LOCATION = "loader.config.location";
+
+	/**
+	 * Properties key for boolean flag (default false) which, if set, will cause the
+	 * external configuration properties to be copied to System properties (assuming that
+	 * is allowed by Java security).
+	 */
+	public static final String SET_SYSTEM_PROPERTIES = "loader.system";
+
+	private static final URL[] NO_URLS = new URL[0];
+
+	private static final Pattern WORD_SEPARATOR = Pattern.compile("\\W+");
+
+	private static final String NESTED_ARCHIVE_SEPARATOR = "!" + File.separator;
+
+	private static final String JAR_FILE_PREFIX = "jar:file:";
+
+	private static final DebugLogger debug = DebugLogger.get(PropertiesLauncher.class);
+
+	private final Archive archive;
+
+	private final File homeDirectory;
+
+	private final List<String> paths;
+
+	private final Properties properties = new Properties();
+
+	public PropertiesLauncher() throws Exception {
+		this(Archive.create(Launcher.class));
+	}
+
+	PropertiesLauncher(Archive archive) throws Exception {
+		this.archive = archive;
+		this.homeDirectory = getHomeDirectory();
+		initializeProperties();
+		this.paths = getPaths();
+	}
+
+	protected File getHomeDirectory() throws Exception {
+		return new File(getPropertyWithDefault(HOME, "${user.dir}"));
+	}
+
+	private void initializeProperties() throws Exception {
+		List<String> configs = new ArrayList<>();
+		if (getProperty(CONFIG_LOCATION) != null) {
+			configs.add(getProperty(CONFIG_LOCATION));
+		}
+		else {
+			String[] names = getPropertyWithDefault(CONFIG_NAME, "loader").split(",");
+			for (String name : names) {
+				String propertiesFile = name + ".properties";
+				configs.add("file:" + this.homeDirectory + "/" + propertiesFile);
+				configs.add("classpath:" + propertiesFile);
+				configs.add("classpath:BOOT-INF/classes/" + propertiesFile);
+			}
+		}
+		for (String config : configs) {
+			try (InputStream resource = getResource(config)) {
+				if (resource == null) {
+					debug.log("Not found: %s", config);
+					continue;
+				}
+				debug.log("Found: %s", config);
+				loadResource(resource);
+				return; // Load the first one we find
+			}
+		}
+	}
+
+	private InputStream getResource(String config) throws Exception {
+		if (config.startsWith("classpath:")) {
+			return getClasspathResource(config.substring("classpath:".length()));
+		}
+		config = handleUrl(config);
+		if (isUrl(config)) {
+			return getURLResource(config);
+		}
+		return getFileResource(config);
+	}
+
+	private InputStream getClasspathResource(String config) {
+		config = stripLeadingSlashes(config);
+		config = "/" + config;
+		debug.log("Trying classpath: %s", config);
+		return getClass().getResourceAsStream(config);
+	}
+
+	private String handleUrl(String path) {
+		if (path.startsWith("jar:file:") || path.startsWith("file:")) {
+			path = URLDecoder.decode(path, StandardCharsets.UTF_8);
+			if (path.startsWith("file:")) {
+				path = path.substring("file:".length());
+				if (path.startsWith("//")) {
+					path = path.substring(2);
+				}
+			}
+		}
+		return path;
+	}
+
+	private boolean isUrl(String config) {
+		return config.contains("://");
+	}
+
+	private InputStream getURLResource(String config) throws Exception {
+		URL url = new URL(config);
+		if (exists(url)) {
+			URLConnection connection = url.openConnection();
+			try {
+				return connection.getInputStream();
+			}
+			catch (IOException ex) {
+				disconnect(connection);
+				throw ex;
+			}
+		}
+		return null;
+	}
+
+	private boolean exists(URL url) throws IOException {
+		URLConnection connection = url.openConnection();
+		try {
+			connection.setUseCaches(connection.getClass().getSimpleName().startsWith("JNLP"));
+			if (connection instanceof HttpURLConnection httpConnection) {
+				httpConnection.setRequestMethod("HEAD");
+				int responseCode = httpConnection.getResponseCode();
+				if (responseCode == HttpURLConnection.HTTP_OK) {
+					return true;
+				}
+				if (responseCode == HttpURLConnection.HTTP_NOT_FOUND) {
+					return false;
+				}
+			}
+			return (connection.getContentLength() >= 0);
+		}
+		finally {
+			disconnect(connection);
+		}
+	}
+
+	private void disconnect(URLConnection connection) {
+		if (connection instanceof HttpURLConnection httpConnection) {
+			httpConnection.disconnect();
+		}
+	}
+
+	private InputStream getFileResource(String config) throws Exception {
+		File file = new File(config);
+		debug.log("Trying file: %s", config);
+		return (!file.canRead()) ? null : new FileInputStream(file);
+	}
+
+	private void loadResource(InputStream resource) throws Exception {
+		this.properties.load(resource);
+		resolvePropertyPlaceholders();
+		if ("true".equalsIgnoreCase(getProperty(SET_SYSTEM_PROPERTIES))) {
+			addToSystemProperties();
+		}
+	}
+
+	private void resolvePropertyPlaceholders() {
+		for (String name : this.properties.stringPropertyNames()) {
+			String value = this.properties.getProperty(name);
+			String resolved = SystemPropertyUtils.resolvePlaceholders(this.properties, value);
+			if (resolved != null) {
+				this.properties.put(name, resolved);
+			}
+		}
+	}
+
+	private void addToSystemProperties() {
+		debug.log("Adding resolved properties to System properties");
+		for (String name : this.properties.stringPropertyNames()) {
+			String value = this.properties.getProperty(name);
+			System.setProperty(name, value);
+		}
+	}
+
+	private List<String> getPaths() throws Exception {
+		String path = getProperty(PATH);
+		List<String> paths = (path != null) ? parsePathsProperty(path) : Collections.emptyList();
+		debug.log("Nested archive paths: %s", this.paths);
+		return paths;
+	}
+
+	private List<String> parsePathsProperty(String commaSeparatedPaths) {
+		List<String> paths = new ArrayList<>();
+		for (String path : commaSeparatedPaths.split(",")) {
+			path = cleanupPath(path);
+			// "" means the user wants root of archive but not current directory
+			path = (path.isEmpty()) ? "/" : path;
+			paths.add(path);
+		}
+		if (paths.isEmpty()) {
+			paths.add("lib");
+		}
+		return paths;
+	}
+
+	private String cleanupPath(String path) {
+		path = path.trim();
+		// No need for current dir path
+		if (path.startsWith("./")) {
+			path = path.substring(2);
+		}
+		if (isArchive(path)) {
+			return path;
+		}
+		if (path.endsWith("/*")) {
+			return path.substring(0, path.length() - 1);
+		}
+		// It's a directory
+		return (!path.endsWith("/") && !path.equals(".")) ? path + "/" : path;
+	}
+
+	@Override
+	protected ClassLoader createClassLoader(Collection<URL> urls) throws Exception {
+		String loaderClassName = getProperty("loader.classLoader");
+		if (loaderClassName == null) {
+			return super.createClassLoader(urls);
+		}
+		ClassLoader parent = getClass().getClassLoader();
+		ClassLoader classLoader = new LaunchedClassLoader(false, urls.toArray(new URL[0]), parent);
+		debug.log("Classpath for custom loader: %s", urls);
+		classLoader = wrapWithCustomClassLoader(classLoader, loaderClassName);
+		debug.log("Using custom class loader: %s", loaderClassName);
+		return classLoader;
+	}
+
+	private ClassLoader wrapWithCustomClassLoader(ClassLoader parent, String loaderClassName) throws Exception {
+		Instantiator<ClassLoader> instantiator = new Instantiator<>(parent, loaderClassName);
+		ClassLoader loader = instantiator.declaredConstructor(ClassLoader.class).newInstance(parent);
+		loader = (loader != null) ? loader
+				: instantiator.declaredConstructor(URL[].class, ClassLoader.class).newInstance(NO_URLS, parent);
+		loader = (loader != null) ? loader : instantiator.constructWithoutParameters();
+		if (loader != null) {
+			return loader;
+		}
+		throw new IllegalStateException("Unable to create class loader for " + loaderClassName);
+	}
+
+	@Override
+	protected Archive getArchive() {
+		return null; // We don't have a single archive and are not exploded.
+	}
+
+	@Override
+	protected String getMainClass() throws Exception {
+		String mainClass = getProperty(MAIN, "Start-Class");
+		if (mainClass == null) {
+			throw new IllegalStateException("No '%s' or 'Start-Class' specified".formatted(MAIN));
+		}
+		return mainClass;
+	}
+
+	protected String[] getArgs(String... args) throws Exception {
+		String loaderArgs = getProperty(ARGS);
+		return (loaderArgs != null) ? merge(loaderArgs.split("\\s+"), args) : args;
+	}
+
+	private String[] merge(String[] a1, String[] a2) {
+		String[] result = new String[a1.length + a2.length];
+		System.arraycopy(a1, 0, result, 0, a1.length);
+		System.arraycopy(a2, 0, result, a1.length, a2.length);
+		return result;
+	}
+
+	private String getProperty(String name) throws Exception {
+		return getProperty(name, null, null);
+	}
+
+	private String getProperty(String name, String manifestKey) throws Exception {
+		return getProperty(name, manifestKey, null);
+	}
+
+	private String getPropertyWithDefault(String name, String defaultValue) throws Exception {
+		return getProperty(name, null, defaultValue);
+	}
+
+	private String getProperty(String name, String manifestKey, String defaultValue) throws Exception {
+		manifestKey = (manifestKey != null) ? manifestKey : toCamelCase(name.replace('.', '-'));
+		String value = SystemPropertyUtils.getProperty(name);
+		if (value != null) {
+			return getResolvedProperty(name, manifestKey, value, "environment");
+		}
+		if (this.properties.containsKey(name)) {
+			value = this.properties.getProperty(name);
+			return getResolvedProperty(name, manifestKey, value, "properties");
+		}
+		// Prefer home dir for MANIFEST if there is one
+		if (this.homeDirectory != null) {
+			try {
+				try (ExplodedArchive explodedArchive = new ExplodedArchive(this.homeDirectory)) {
+					value = getManifestValue(explodedArchive, manifestKey);
+					if (value != null) {
+						return getResolvedProperty(name, manifestKey, value, "home directory manifest");
+					}
+				}
+			}
+			catch (IllegalStateException ex) {
+				// Ignore
+			}
+		}
+		// Otherwise try the root archive
+		value = getManifestValue(this.archive, manifestKey);
+		if (value != null) {
+			return getResolvedProperty(name, manifestKey, value, "manifest");
+		}
+		return SystemPropertyUtils.resolvePlaceholders(this.properties, defaultValue);
+	}
+
+	String getManifestValue(Archive archive, String manifestKey) throws Exception {
+		Manifest manifest = archive.getManifest();
+		return (manifest != null) ? manifest.getMainAttributes().getValue(manifestKey) : null;
+	}
+
+	private String getResolvedProperty(String name, String manifestKey, String value, String from) {
+		value = SystemPropertyUtils.resolvePlaceholders(this.properties, value);
+		String altName = (manifestKey != null && !manifestKey.equals(name)) ? "[%s] ".formatted(manifestKey) : "";
+		debug.log("Property '%s'%s from %s: %s", name, altName, from, value);
+		return value;
+
+	}
+
+	void close() throws Exception {
+		if (this.archive != null) {
+			this.archive.close();
+		}
+	}
+
+	public static String toCamelCase(CharSequence string) {
+		if (string == null) {
+			return null;
+		}
+		StringBuilder result = new StringBuilder();
+		Matcher matcher = WORD_SEPARATOR.matcher(string);
+		int pos = 0;
+		while (matcher.find()) {
+			result.append(capitalize(string.subSequence(pos, matcher.end()).toString()));
+			pos = matcher.end();
+		}
+		result.append(capitalize(string.subSequence(pos, string.length()).toString()));
+		return result.toString();
+	}
+
+	private static String capitalize(String str) {
+		return Character.toUpperCase(str.charAt(0)) + str.substring(1);
+	}
+
+	@Override
+	protected Set<URL> getClassPathUrls() throws Exception {
+		Set<URL> urls = new LinkedHashSet<>();
+		for (String path : getPaths()) {
+			path = cleanupPath(handleUrl(path));
+			urls.addAll(getClassPathUrlsForPath(path));
+		}
+		urls.addAll(getClassPathUrlsForRoot());
+		debug.log("Using class path URLs %s", urls);
+		return urls;
+	}
+
+	private Set<URL> getClassPathUrlsForPath(String path) throws Exception {
+		File file = (!isAbsolutePath(path)) ? new File(this.homeDirectory, path) : new File(path);
+		Set<URL> urls = new LinkedHashSet<>();
+		if (!"/".equals(path)) {
+			if (file.isDirectory()) {
+				try (ExplodedArchive explodedArchive = new ExplodedArchive(file)) {
+					debug.log("Adding classpath entries from directory %s", file);
+					urls.add(file.toURI().toURL());
+					urls.addAll(explodedArchive.getClassPathUrls(this::isArchive));
+				}
+			}
+		}
+		if (!file.getPath().contains(NESTED_ARCHIVE_SEPARATOR) && isArchive(file.getName())) {
+			debug.log("Adding classpath entries from jar/zip archive %s", path);
+			urls.add(file.toURI().toURL());
+		}
+		Set<URL> nested = getClassPathUrlsForNested(path);
+		if (!nested.isEmpty()) {
+			debug.log("Adding classpath entries from nested %s", path);
+			urls.addAll(nested);
+		}
+		return urls;
+	}
+
+	private Set<URL> getClassPathUrlsForNested(String path) throws Exception {
+		boolean isJustArchive = isArchive(path);
+		if (!path.equals("/") && path.startsWith("/")
+				|| (this.archive.isExploded() && this.archive.getRootDirectory().equals(this.homeDirectory))) {
+			return Collections.emptySet();
+		}
+		File file = null;
+		if (isJustArchive) {
+			File candidate = new File(this.homeDirectory, path);
+			if (candidate.exists()) {
+				file = candidate;
+				path = "";
+			}
+		}
+		int separatorIndex = path.indexOf('!');
+		if (separatorIndex != -1) {
+			file = (!path.startsWith(JAR_FILE_PREFIX)) ? new File(this.homeDirectory, path.substring(0, separatorIndex))
+					: new File(path.substring(JAR_FILE_PREFIX.length(), separatorIndex));
+			path = path.substring(separatorIndex + 1);
+			path = stripLeadingSlashes(path);
+		}
+		if (path.equals("/") || path.equals("./") || path.equals(".")) {
+			// The prefix for nested jars is actually empty if it's at the root
+			path = "";
+		}
+		Archive archive = (file != null) ? new JarFileArchive(file) : this.archive;
+		try {
+			Set<URL> urls = new LinkedHashSet<>(archive.getClassPathUrls(includeByPrefix(path)));
+			if (!isJustArchive && file != null && path.isEmpty()) {
+				urls.add(JarUrl.create(file));
+			}
+			return urls;
+		}
+		finally {
+			if (archive != this.archive) {
+				archive.close();
+			}
+		}
+	}
+
+	private Set<URL> getClassPathUrlsForRoot() throws IOException {
+		debug.log("Adding classpath entries from root archive %s", this.archive);
+		return this.archive.getClassPathUrls(JarLauncher::isLibraryFileOrClassesDirectory);
+	}
+
+	private Predicate<Entry> includeByPrefix(String prefix) {
+		return (entry) -> (entry.isDirectory() && entry.name().equals(prefix))
+				|| (isArchive(entry) && entry.name().startsWith(prefix));
+	}
+
+	private boolean isArchive(Entry entry) {
+		return isArchive(entry.name());
+	}
+
+	private boolean isArchive(String name) {
+		name = name.toLowerCase(Locale.ENGLISH);
+		return name.endsWith(".jar") || name.endsWith(".zip");
+	}
+
+	private boolean isAbsolutePath(String root) {
+		// Windows contains ":" others start with "/"
+		return root.contains(":") || root.startsWith("/");
+	}
+
+	private String stripLeadingSlashes(String string) {
+		while (string.startsWith("/")) {
+			string = string.substring(1);
+		}
+		return string;
+	}
+
+	public static void main(String[] args) throws Exception {
+		PropertiesLauncher launcher = new PropertiesLauncher();
+		args = launcher.getArgs(args);
+		launcher.launch(args);
+	}
+
+	/**
+	 * Utility to help instantiate objects.
+	 */
+	private record Instantiator<T>(ClassLoader parent, Class<?> type) {
+
+		Instantiator(ClassLoader parent, String className) throws ClassNotFoundException {
+			this(parent, Class.forName(className, true, parent));
+		}
+
+		T constructWithoutParameters() throws Exception {
+			return declaredConstructor().newInstance();
+		}
+
+		Using<T> declaredConstructor(Class<?>... parameterTypes) {
+			return new Using<>(this, parameterTypes);
+		}
+
+		private record Using<T>(Instantiator<T> instantiator, Class<?>... parameterTypes) {
+
+			@SuppressWarnings("unchecked")
+			T newInstance(Object... initargs) throws Exception {
+				try {
+					Constructor<?> constructor = this.instantiator.type().getDeclaredConstructor(this.parameterTypes);
+					constructor.setAccessible(true);
+					return (T) constructor.newInstance(initargs);
+				}
+				catch (NoSuchMethodException ex) {
+					return null;
+				}
+			}
+
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/SystemPropertyUtils.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/SystemPropertyUtils.java
new file mode 100644
index 000000000000..5efb96f3540c
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/SystemPropertyUtils.java
@@ -0,0 +1,151 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Properties;
+import java.util.Set;
+
+/**
+ * Internal helper class adapted from Spring Framework for resolving placeholders in
+ * texts.
+ *
+ * @author Juergen Hoeller
+ * @author Rob Harrop
+ * @author Dave Syer
+ * @author Phillip Webb
+ */
+final class SystemPropertyUtils {
+
+	private static final String PLACEHOLDER_PREFIX = "${";
+
+	private static final String PLACEHOLDER_SUFFIX = "}";
+
+	private static final String VALUE_SEPARATOR = ":";
+
+	private static final String SIMPLE_PREFIX = PLACEHOLDER_PREFIX.substring(1);
+
+	private SystemPropertyUtils() {
+	}
+
+	static String resolvePlaceholders(Properties properties, String text) {
+		return (text != null) ? parseStringValue(properties, text, text, new HashSet<>()) : null;
+	}
+
+	private static String parseStringValue(Properties properties, String value, String current,
+			Set<String> visitedPlaceholders) {
+		StringBuilder result = new StringBuilder(current);
+		int startIndex = current.indexOf(PLACEHOLDER_PREFIX);
+		while (startIndex != -1) {
+			int endIndex = findPlaceholderEndIndex(result, startIndex);
+			if (endIndex == -1) {
+				startIndex = -1;
+				continue;
+			}
+			String placeholder = result.substring(startIndex + PLACEHOLDER_PREFIX.length(), endIndex);
+			String originalPlaceholder = placeholder;
+			if (!visitedPlaceholders.add(originalPlaceholder)) {
+				throw new IllegalArgumentException(
+						"Circular placeholder reference '" + originalPlaceholder + "' in property definitions");
+			}
+			placeholder = parseStringValue(properties, value, placeholder, visitedPlaceholders);
+			String propertyValue = resolvePlaceholder(properties, value, placeholder);
+			if (propertyValue == null) {
+				int separatorIndex = placeholder.indexOf(VALUE_SEPARATOR);
+				if (separatorIndex != -1) {
+					String actualPlaceholder = placeholder.substring(0, separatorIndex);
+					String defaultValue = placeholder.substring(separatorIndex + VALUE_SEPARATOR.length());
+					propertyValue = resolvePlaceholder(properties, value, actualPlaceholder);
+					propertyValue = (propertyValue != null) ? propertyValue : defaultValue;
+				}
+			}
+			if (propertyValue != null) {
+				propertyValue = parseStringValue(properties, value, propertyValue, visitedPlaceholders);
+				result.replace(startIndex, endIndex + PLACEHOLDER_SUFFIX.length(), propertyValue);
+				startIndex = result.indexOf(PLACEHOLDER_PREFIX, startIndex + propertyValue.length());
+			}
+			else {
+				startIndex = result.indexOf(PLACEHOLDER_PREFIX, endIndex + PLACEHOLDER_SUFFIX.length());
+			}
+			visitedPlaceholders.remove(originalPlaceholder);
+		}
+		return result.toString();
+	}
+
+	private static String resolvePlaceholder(Properties properties, String text, String placeholderName) {
+		String propertyValue = getProperty(placeholderName, null, text);
+		if (propertyValue != null) {
+			return propertyValue;
+		}
+		return (properties != null) ? properties.getProperty(placeholderName) : null;
+	}
+
+	static String getProperty(String key) {
+		return getProperty(key, null, "");
+	}
+
+	private static String getProperty(String key, String defaultValue, String text) {
+		try {
+			String value = System.getProperty(key);
+			value = (value != null) ? value : System.getenv(key);
+			value = (value != null) ? value : System.getenv(key.replace('.', '_'));
+			value = (value != null) ? value : System.getenv(key.toUpperCase(Locale.ENGLISH).replace('.', '_'));
+			return (value != null) ? value : defaultValue;
+		}
+		catch (Throwable ex) {
+			System.err.println("Could not resolve key '" + key + "' in '" + text
+					+ "' as system property or in environment: " + ex);
+			return defaultValue;
+		}
+	}
+
+	private static int findPlaceholderEndIndex(CharSequence buf, int startIndex) {
+		int index = startIndex + PLACEHOLDER_PREFIX.length();
+		int withinNestedPlaceholder = 0;
+		while (index < buf.length()) {
+			if (substringMatch(buf, index, PLACEHOLDER_SUFFIX)) {
+				if (withinNestedPlaceholder > 0) {
+					withinNestedPlaceholder--;
+					index = index + PLACEHOLDER_SUFFIX.length();
+				}
+				else {
+					return index;
+				}
+			}
+			else if (substringMatch(buf, index, SIMPLE_PREFIX)) {
+				withinNestedPlaceholder++;
+				index = index + SIMPLE_PREFIX.length();
+			}
+			else {
+				index++;
+			}
+		}
+		return -1;
+	}
+
+	private static boolean substringMatch(CharSequence str, int index, CharSequence substring) {
+		for (int j = 0; j < substring.length(); j++) {
+			int i = index + j;
+			if (i >= str.length() || str.charAt(i) != substring.charAt(j)) {
+				return false;
+			}
+		}
+		return true;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/WarLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/WarLauncher.java
new file mode 100644
index 000000000000..38318ba222c8
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/WarLauncher.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+/**
+ * {@link Launcher} for WAR based archives. This launcher for standard WAR archives.
+ * Supports dependencies in {@code WEB-INF/lib} as well as {@code WEB-INF/lib-provided},
+ * classes are loaded from {@code WEB-INF/classes}.
+ *
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ * @author Scott Frederick
+ * @since 3.2.0
+ */
+public class WarLauncher extends ExecutableArchiveLauncher {
+
+	public WarLauncher() throws Exception {
+	}
+
+	protected WarLauncher(Archive archive) throws Exception {
+		super(archive);
+	}
+
+	@Override
+	public boolean isIncludedOnClassPath(Archive.Entry entry) {
+		return isLibraryFileOrClassesDirectory(entry);
+	}
+
+	@Override
+	protected String getEntryPathPrefix() {
+		return "WEB-INF/";
+	}
+
+	static boolean isLibraryFileOrClassesDirectory(Archive.Entry entry) {
+		String name = entry.name();
+		if (entry.isDirectory()) {
+			return name.equals("WEB-INF/classes/");
+		}
+		return name.startsWith("WEB-INF/lib/") || name.startsWith("WEB-INF/lib-provided/");
+	}
+
+	public static void main(String[] args) throws Exception {
+		new WarLauncher().launch(args);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/package-info.java
new file mode 100644
index 000000000000..5c5115bf0e34
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/package-info.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * System that allows self-contained JAR/WAR archives to be launched using
+ * {@code java -jar}. Archives can include nested packaged dependency JARs (there is no
+ * need to create shade style jars) and are executed without unpacking. The only
+ * constraint is that nested JARs must be stored in the archive uncompressed.
+ *
+ * @see org.springframework.boot.loader.launch.JarLauncher
+ * @see org.springframework.boot.loader.launch.WarLauncher
+ */
+package org.springframework.boot.loader.launch;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/log/DebugLogger.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/log/DebugLogger.java
new file mode 100644
index 000000000000..417a9c5a4b7d
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/log/DebugLogger.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.log;
+
+/**
+ * Simple logger class used for {@link System#err} debugging.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public abstract sealed class DebugLogger {
+
+	private static final String ENABLED_PROPERTY = "loader.debug";
+
+	private static final DebugLogger disabled;
+	static {
+		disabled = Boolean.getBoolean(ENABLED_PROPERTY) ? null : new DisabledDebugLogger();
+	}
+
+	/**
+	 * Log a message.
+	 * @param message the message to log
+	 */
+	public abstract void log(String message);
+
+	/**
+	 * Log a formatted message.
+	 * @param message the message to log
+	 * @param arg1 the first format argument
+	 */
+	public abstract void log(String message, Object arg1);
+
+	/**
+	 * Log a formatted message.
+	 * @param message the message to log
+	 * @param arg1 the first format argument
+	 * @param arg2 the second format argument
+	 */
+	public abstract void log(String message, Object arg1, Object arg2);
+
+	/**
+	 * Log a formatted message.
+	 * @param message the message to log
+	 * @param arg1 the first format argument
+	 * @param arg2 the second format argument
+	 * @param arg3 the third format argument
+	 */
+	public abstract void log(String message, Object arg1, Object arg2, Object arg3);
+
+	/**
+	 * Log a formatted message.
+	 * @param message the message to log
+	 * @param arg1 the first format argument
+	 * @param arg2 the second format argument
+	 * @param arg3 the third format argument
+	 * @param arg4 the fourth format argument
+	 */
+	public abstract void log(String message, Object arg1, Object arg2, Object arg3, Object arg4);
+
+	/**
+	 * Get a {@link DebugLogger} to log messages for the given source class.
+	 * @param sourceClass the source class
+	 * @return a {@link DebugLogger} instance
+	 */
+	public static DebugLogger get(Class<?> sourceClass) {
+		return (disabled != null) ? disabled : new SystemErrDebugLogger(sourceClass);
+	}
+
+	/**
+	 * {@link DebugLogger} used for disabled logging that does nothing.
+	 */
+	private static final class DisabledDebugLogger extends DebugLogger {
+
+		@Override
+		public void log(String message) {
+		}
+
+		@Override
+		public void log(String message, Object arg1) {
+		}
+
+		@Override
+		public void log(String message, Object arg1, Object arg2) {
+		}
+
+		@Override
+		public void log(String message, Object arg1, Object arg2, Object arg3) {
+		}
+
+		@Override
+		public void log(String message, Object arg1, Object arg2, Object arg3, Object arg4) {
+		}
+
+	}
+
+	/**
+	 * {@link DebugLogger} that prints messages to {@link System#err}.
+	 */
+	private static final class SystemErrDebugLogger extends DebugLogger {
+
+		private final String prefix;
+
+		SystemErrDebugLogger(Class<?> sourceClass) {
+			this.prefix = "LOADER: " + sourceClass + " : ";
+		}
+
+		@Override
+		public void log(String message) {
+			print(message);
+		}
+
+		@Override
+		public void log(String message, Object arg1) {
+			print(message.formatted(arg1));
+		}
+
+		@Override
+		public void log(String message, Object arg1, Object arg2) {
+			print(message.formatted(arg1, arg2));
+		}
+
+		@Override
+		public void log(String message, Object arg1, Object arg2, Object arg3) {
+			print(message.formatted(arg1, arg2, arg3));
+		}
+
+		@Override
+		public void log(String message, Object arg1, Object arg2, Object arg3, Object arg4) {
+			print(message.formatted(arg1, arg2, arg3, arg4));
+		}
+
+		private void print(String message) {
+			System.err.println(this.prefix + message);
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/log/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/log/package-info.java
new file mode 100644
index 000000000000..c94baf14b30a
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/log/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Debug {@link java.lang.System#err} logging support.
+ */
+package org.springframework.boot.loader.log;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/Handlers.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/Handlers.java
new file mode 100644
index 000000000000..781daeaf0f0a
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/Handlers.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol;
+
+import java.net.URL;
+import java.net.URLStreamHandler;
+import java.net.URLStreamHandlerFactory;
+
+/**
+ * Utility used to register loader {@link URLStreamHandler URL handlers}.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public final class Handlers {
+
+	private static final String PROTOCOL_HANDLER_PACKAGES = "java.protocol.handler.pkgs";
+
+	private static final String PACKAGE = Handlers.class.getPackageName();
+
+	private Handlers() {
+	}
+
+	/**
+	 * Register a {@literal 'java.protocol.handler.pkgs'} property so that a
+	 * {@link URLStreamHandler} will be located to deal with jar URLs.
+	 */
+	public static void register() {
+		String packages = System.getProperty(PROTOCOL_HANDLER_PACKAGES, "");
+		packages = (!packages.isEmpty() && !packages.contains(PACKAGE)) ? packages + "|" + PACKAGE : PACKAGE;
+		System.setProperty(PROTOCOL_HANDLER_PACKAGES, packages);
+		resetCachedUrlHandlers();
+	}
+
+	/**
+	 * Reset any cached handlers just in case a jar protocol has already been used. We
+	 * reset the handler by trying to set a null {@link URLStreamHandlerFactory} which
+	 * should have no effect other than clearing the handlers cache.
+	 */
+	private static void resetCachedUrlHandlers() {
+		try {
+			URL.setURLStreamHandlerFactory(null);
+		}
+		catch (Error ex) {
+			// Ignore
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Canonicalizer.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Canonicalizer.java
new file mode 100644
index 000000000000..209160c3e7ed
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Canonicalizer.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+/**
+ * Internal utility used by the {@link Handler} to canonicalize paths. This implementation
+ * should behave the same as the canonicalization functions in
+ * {@code sun.net.www.protocol.jar.Handler}.
+ *
+ * @author Phillip Webb
+ */
+final class Canonicalizer {
+
+	private Canonicalizer() {
+	}
+
+	static String canonicalizeAfter(String path, int pos) {
+		int pathLength = path.length();
+		boolean noDotSlash = path.indexOf("./", pos) == -1;
+		if (pos >= pathLength || (noDotSlash && path.charAt(pathLength - 1) != '.')) {
+			return path;
+		}
+		String before = path.substring(0, pos);
+		String after = path.substring(pos);
+		return before + canonicalize(after);
+	}
+
+	static String canonicalize(String path) {
+		path = removeEmbeddedSlashDotDotSlash(path);
+		path = removeEmbeddedSlashDotSlash(path);
+		path = removeTrailingSlashDotDot(path);
+		path = removeTrailingSlashDot(path);
+		return path;
+	}
+
+	private static String removeEmbeddedSlashDotDotSlash(String path) {
+		int index;
+		while ((index = path.indexOf("/../")) >= 0) {
+			int priorSlash = path.lastIndexOf('/', index - 1);
+			String after = path.substring(index + 3);
+			path = (priorSlash >= 0) ? path.substring(0, priorSlash) + after : after;
+		}
+		return path;
+	}
+
+	private static String removeEmbeddedSlashDotSlash(String path) {
+		int index;
+		while ((index = path.indexOf("/./")) >= 0) {
+			String before = path.substring(0, index);
+			String after = path.substring(index + 2);
+			path = before + after;
+		}
+		return path;
+	}
+
+	private static String removeTrailingSlashDot(String path) {
+		return (!path.endsWith("/.")) ? path : path.substring(0, path.length() - 1);
+	}
+
+	private static String removeTrailingSlashDotDot(String path) {
+		int index;
+		while (path.endsWith("/..")) {
+			index = path.indexOf("/..");
+			int priorSlash = path.lastIndexOf('/', index - 1);
+			path = (priorSlash >= 0) ? path.substring(0, priorSlash + 1) : path.substring(0, index);
+		}
+		return path;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Handler.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Handler.java
new file mode 100644
index 000000000000..2778beeccaf3
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Handler.java
@@ -0,0 +1,190 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLConnection;
+import java.net.URLStreamHandler;
+
+/**
+ * {@link URLStreamHandler} alternative to {@code sun.net.www.protocol.jar.Handler} with
+ * optimized support for nested jars.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ * @see org.springframework.boot.loader.net.protocol.Handlers
+ */
+public class Handler extends URLStreamHandler {
+
+	// NOTE: in order to be found as a URL protocol handler, this class must be public,
+	// must be named Handler and must be in a package ending '.jar'
+
+	private static final String PROTOCOL = "jar";
+
+	private static final String SEPARATOR = "!/";
+
+	static final Handler INSTANCE = new Handler();
+
+	@Override
+	protected URLConnection openConnection(URL url) throws IOException {
+		return JarUrlConnection.open(url);
+	}
+
+	@Override
+	protected void parseURL(URL url, String spec, int start, int limit) {
+		if (spec.regionMatches(true, start, "jar:", 0, 4)) {
+			throw new IllegalStateException("Nested JAR URLs are not supported");
+		}
+		int anchorIndex = spec.indexOf('#', limit);
+		String path = extractPath(url, spec, start, limit, anchorIndex);
+		String ref = (anchorIndex != -1) ? spec.substring(anchorIndex + 1) : null;
+		setURL(url, PROTOCOL, "", -1, null, null, path, null, ref);
+	}
+
+	private String extractPath(URL url, String spec, int start, int limit, int anchorIndex) {
+		if (anchorIndex == start) {
+			return extractAnchorOnlyPath(url);
+		}
+		if (spec.length() >= 4 && spec.regionMatches(true, 0, "jar:", 0, 4)) {
+			return extractAbsolutePath(spec, start, limit);
+		}
+		return extractRelativePath(url, spec, start, limit);
+	}
+
+	private String extractAnchorOnlyPath(URL url) {
+		return url.getPath();
+	}
+
+	private String extractAbsolutePath(String spec, int start, int limit) {
+		int indexOfSeparator = indexOfSeparator(spec, start, limit);
+		if (indexOfSeparator == -1) {
+			throw new IllegalStateException("no !/ in spec");
+		}
+		String innerUrl = spec.substring(start, indexOfSeparator);
+		assertInnerUrlIsNotMalformed(spec, innerUrl);
+		return spec.substring(start, limit);
+	}
+
+	private String extractRelativePath(URL url, String spec, int start, int limit) {
+		String contextPath = extractContextPath(url, spec, start);
+		String path = contextPath + spec.substring(start, limit);
+		return Canonicalizer.canonicalizeAfter(path, indexOfSeparator(path) + 1);
+	}
+
+	private String extractContextPath(URL url, String spec, int start) {
+		String contextPath = url.getPath();
+		if (spec.charAt(start) == '/') {
+			int indexOfContextPathSeparator = indexOfSeparator(contextPath);
+			if (indexOfContextPathSeparator == -1) {
+				throw new IllegalStateException("malformed context url:%s: no !/".formatted(url));
+			}
+			return contextPath.substring(0, indexOfContextPathSeparator + 1);
+		}
+		int lastSlash = contextPath.lastIndexOf('/');
+		if (lastSlash == -1) {
+			throw new IllegalStateException("malformed context url:%s".formatted(url));
+		}
+		return contextPath.substring(0, lastSlash + 1);
+	}
+
+	private void assertInnerUrlIsNotMalformed(String spec, String innerUrl) {
+		if (innerUrl.startsWith("nested:")) {
+			org.springframework.boot.loader.net.protocol.nested.Handler.assertUrlIsNotMalformed(innerUrl);
+			return;
+		}
+		try {
+			new URL(innerUrl);
+		}
+		catch (MalformedURLException ex) {
+			throw new IllegalStateException("invalid url: %s (%s)".formatted(spec, ex));
+		}
+	}
+
+	@Override
+	protected int hashCode(URL url) {
+		String protocol = url.getProtocol();
+		int hash = (protocol != null) ? protocol.hashCode() : 0;
+		String file = url.getFile();
+		int indexOfSeparator = file.indexOf(SEPARATOR);
+		if (indexOfSeparator == -1) {
+			return hash + file.hashCode();
+		}
+		String fileWithoutEntry = file.substring(0, indexOfSeparator);
+		try {
+			hash += new URL(fileWithoutEntry).hashCode();
+		}
+		catch (MalformedURLException ex) {
+			hash += fileWithoutEntry.hashCode();
+		}
+		String entry = file.substring(indexOfSeparator + 2);
+		return hash + entry.hashCode();
+	}
+
+	@Override
+	protected boolean sameFile(URL url1, URL url2) {
+		if (!url1.getProtocol().equals(PROTOCOL) || !url2.getProtocol().equals(PROTOCOL)) {
+			return false;
+		}
+		String file1 = url1.getFile();
+		String file2 = url2.getFile();
+		int indexOfSeparator1 = file1.indexOf(SEPARATOR);
+		int indexOfSeparator2 = file2.indexOf(SEPARATOR);
+		if (indexOfSeparator1 == -1 || indexOfSeparator2 == -1) {
+			return super.sameFile(url1, url2);
+		}
+		String entry1 = file1.substring(indexOfSeparator1 + 2);
+		String entry2 = file2.substring(indexOfSeparator2 + 2);
+		if (!entry1.equals(entry2)) {
+			return false;
+		}
+		try {
+			URL innerUrl1 = new URL(file1.substring(0, indexOfSeparator1));
+			URL innerUrl2 = new URL(file2.substring(0, indexOfSeparator2));
+			if (!super.sameFile(innerUrl1, innerUrl2)) {
+				return false;
+			}
+		}
+		catch (MalformedURLException unused) {
+			return super.sameFile(url1, url2);
+		}
+		return true;
+	}
+
+	static int indexOfSeparator(String spec) {
+		return indexOfSeparator(spec, 0, spec.length());
+	}
+
+	static int indexOfSeparator(String spec, int start, int limit) {
+		for (int i = limit - 1; i >= start; i--) {
+			if (spec.charAt(i) == '!' && (i + 1) < limit && spec.charAt(i + 1) == '/') {
+				return i;
+			}
+		}
+		return -1;
+	}
+
+	/**
+	 * Clear any internal caches.
+	 */
+	public static void clearCache() {
+		JarFileUrlKey.clearCache();
+		JarUrlConnection.clearCache();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarFileUrlKey.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarFileUrlKey.java
new file mode 100644
index 000000000000..e8ce0f503db1
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarFileUrlKey.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.lang.ref.SoftReference;
+import java.net.URL;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Utility to generate a string key from a jar file {@link URL} that can be used as a
+ * cache key.
+ *
+ * @author Phillip Webb
+ */
+final class JarFileUrlKey {
+
+	private static volatile SoftReference<Map<URL, String>> cache;
+
+	private JarFileUrlKey() {
+	}
+
+	/**
+	 * Get the {@link JarFileUrlKey} for the given URL.
+	 * @param url the source URL
+	 * @return a {@link JarFileUrlKey} instance
+	 */
+	static String get(URL url) {
+		Map<URL, String> cache = (JarFileUrlKey.cache != null) ? JarFileUrlKey.cache.get() : null;
+		if (cache == null) {
+			cache = new ConcurrentHashMap<>();
+			JarFileUrlKey.cache = new SoftReference<>(cache);
+		}
+		return cache.computeIfAbsent(url, JarFileUrlKey::create);
+	}
+
+	private static String create(URL url) {
+		StringBuilder value = new StringBuilder();
+		String protocol = url.getProtocol();
+		String host = url.getHost();
+		int port = (url.getPort() != -1) ? url.getPort() : url.getDefaultPort();
+		String file = url.getFile();
+		value.append(protocol.toLowerCase());
+		value.append(":");
+		if (host != null && !host.isEmpty()) {
+			value.append(host.toLowerCase());
+			value.append((port != -1) ? ":" + port : "");
+		}
+		value.append((file != null) ? file : "");
+		if ("runtime".equals(url.getRef())) {
+			value.append("#runtime");
+		}
+		return value.toString();
+	}
+
+	static void clearCache() {
+		cache = null;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrl.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrl.java
new file mode 100644
index 000000000000..1e40ced32f1f
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrl.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.io.File;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.jar.JarEntry;
+
+/**
+ * Utility class with factory methods that can be used to create JAR URLs.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public final class JarUrl {
+
+	private JarUrl() {
+	}
+
+	/**
+	 * Create a new jar URL.
+	 * @param file the jar file
+	 * @return a jar file URL
+	 */
+	public static URL create(File file) {
+		return create(file, (String) null);
+	}
+
+	/**
+	 * Create a new jar URL.
+	 * @param file the jar file
+	 * @param nestedEntry the nested entry or {@code null}
+	 * @return a jar file URL
+	 */
+	public static URL create(File file, JarEntry nestedEntry) {
+		return create(file, (nestedEntry != null) ? nestedEntry.getName() : null);
+	}
+
+	/**
+	 * Create a new jar URL.
+	 * @param file the jar file
+	 * @param nestedEntryName the nested entry name or {@code null}
+	 * @return a jar file URL
+	 */
+	public static URL create(File file, String nestedEntryName) {
+		return create(file, nestedEntryName, null);
+	}
+
+	/**
+	 * Create a new jar URL.
+	 * @param file the jar file
+	 * @param nestedEntryName the nested entry name or {@code null}
+	 * @param path the path within the jar or nested jar
+	 * @return a jar file URL
+	 */
+	public static URL create(File file, String nestedEntryName, String path) {
+		try {
+			path = (path != null) ? path : "";
+			return new URL(null, "jar:" + getJarReference(file, nestedEntryName) + "!/" + path, Handler.INSTANCE);
+		}
+		catch (MalformedURLException ex) {
+			throw new IllegalStateException("Unable to create JarFileArchive URL", ex);
+		}
+	}
+
+	private static String getJarReference(File file, String nestedEntryName) {
+		String jarFilePath = file.toURI().getPath();
+		return (nestedEntryName != null) ? "nested:" + jarFilePath + "/!" + nestedEntryName : "file:" + jarFilePath;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlClassLoader.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlClassLoader.java
new file mode 100644
index 000000000000..bf2aadc218e9
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlClassLoader.java
@@ -0,0 +1,290 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.io.IOException;
+import java.net.JarURLConnection;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.net.URLConnection;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.jar.JarFile;
+
+import org.springframework.boot.loader.jar.NestedJarFile;
+import org.springframework.boot.loader.launch.LaunchedClassLoader;
+
+/**
+ * {@link URLClassLoader} with optimized support for Jar URLs.
+ *
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ * @since 3.2.0
+ */
+public abstract class JarUrlClassLoader extends URLClassLoader {
+
+	private final URL[] urls;
+
+	private final boolean hasJarUrls;
+
+	private final Map<URL, JarFile> jarFiles = new ConcurrentHashMap<>();
+
+	private final Set<String> undefinablePackages = Collections.newSetFromMap(new ConcurrentHashMap<>());
+
+	/**
+	 * Create a new {@link LaunchedClassLoader} instance.
+	 * @param urls the URLs from which to load classes and resources
+	 * @param parent the parent class loader for delegation
+	 */
+	public JarUrlClassLoader(URL[] urls, ClassLoader parent) {
+		super(urls, parent);
+		this.urls = urls;
+		this.hasJarUrls = Arrays.stream(urls).anyMatch(this::isJarUrl);
+	}
+
+	@Override
+	public URL findResource(String name) {
+		if (!this.hasJarUrls) {
+			return super.findResource(name);
+		}
+		Optimizations.enable(false);
+		try {
+			return super.findResource(name);
+		}
+		finally {
+			Optimizations.disable();
+		}
+	}
+
+	@Override
+	public Enumeration<URL> findResources(String name) throws IOException {
+		if (!this.hasJarUrls) {
+			return super.findResources(name);
+		}
+		Optimizations.enable(false);
+		try {
+			return new OptimizedEnumeration(super.findResources(name));
+		}
+		finally {
+			Optimizations.disable();
+		}
+	}
+
+	@Override
+	protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
+		if (!this.hasJarUrls) {
+			return super.loadClass(name, resolve);
+		}
+		Optimizations.enable(true);
+		try {
+			try {
+				definePackageIfNecessary(name);
+			}
+			catch (IllegalArgumentException ex) {
+				tolerateRaceConditionDueToBeingParallelCapable(ex, name);
+			}
+			return super.loadClass(name, resolve);
+		}
+		finally {
+			Optimizations.disable();
+		}
+	}
+
+	/**
+	 * Define a package before a {@code findClass} call is made. This is necessary to
+	 * ensure that the appropriate manifest for nested JARs is associated with the
+	 * package.
+	 * @param className the class name being found
+	 */
+	protected final void definePackageIfNecessary(String className) {
+		if (className.startsWith("java.")) {
+			return;
+		}
+		int lastDot = className.lastIndexOf('.');
+		if (lastDot >= 0) {
+			String packageName = className.substring(0, lastDot);
+			if (getDefinedPackage(packageName) == null) {
+				try {
+					definePackage(className, packageName);
+				}
+				catch (IllegalArgumentException ex) {
+					tolerateRaceConditionDueToBeingParallelCapable(ex, packageName);
+				}
+			}
+		}
+	}
+
+	private void definePackage(String className, String packageName) {
+		if (this.undefinablePackages.contains(packageName)) {
+			return;
+		}
+		String packageEntryName = packageName.replace('.', '/') + "/";
+		String classEntryName = className.replace('.', '/') + ".class";
+		for (URL url : this.urls) {
+			try {
+				JarFile jarFile = getJarFile(url);
+				if (jarFile != null) {
+					if (hasEntry(jarFile, classEntryName) && hasEntry(jarFile, packageEntryName)
+							&& jarFile.getManifest() != null) {
+						definePackage(packageName, jarFile.getManifest(), url);
+						return;
+					}
+				}
+			}
+			catch (IOException ex) {
+				// Ignore
+			}
+		}
+		this.undefinablePackages.add(packageName);
+	}
+
+	private void tolerateRaceConditionDueToBeingParallelCapable(IllegalArgumentException ex, String packageName)
+			throws AssertionError {
+		if (getDefinedPackage(packageName) == null) {
+			// This should never happen as the IllegalArgumentException indicates that the
+			// package has already been defined and, therefore, getDefinedPackage(name)
+			// should not have returned null.
+			throw new AssertionError(
+					"Package %s has already been defined but it could not be found".formatted(packageName), ex);
+		}
+	}
+
+	private boolean hasEntry(JarFile jarFile, String name) {
+		return (jarFile instanceof NestedJarFile nestedJarFile) ? nestedJarFile.hasEntry(name)
+				: jarFile.getEntry(name) != null;
+	}
+
+	private JarFile getJarFile(URL url) throws IOException {
+		JarFile jarFile = this.jarFiles.get(url);
+		if (jarFile != null) {
+			return jarFile;
+		}
+		URLConnection connection = url.openConnection();
+		if (!(connection instanceof JarURLConnection)) {
+			return null;
+		}
+		connection.setUseCaches(false);
+		jarFile = ((JarURLConnection) connection).getJarFile();
+		synchronized (this.jarFiles) {
+			JarFile previous = this.jarFiles.putIfAbsent(url, jarFile);
+			if (previous != null) {
+				jarFile.close();
+				jarFile = previous;
+			}
+		}
+		return jarFile;
+	}
+
+	/**
+	 * Clear any caches. This method is called reflectively by
+	 * {@code ClearCachesApplicationListener}.
+	 */
+	public void clearCache() {
+		Handler.clearCache();
+		org.springframework.boot.loader.net.protocol.nested.Handler.clearCache();
+		try {
+			clearJarFiles();
+		}
+		catch (IOException ex) {
+			// Ignore
+		}
+		for (URL url : this.urls) {
+			if (isJarUrl(url)) {
+				clearCache(url);
+			}
+		}
+	}
+
+	private void clearCache(URL url) {
+		try {
+			URLConnection connection = url.openConnection();
+			if (connection instanceof JarURLConnection jarUrlConnection) {
+				clearCache(jarUrlConnection);
+			}
+		}
+		catch (IOException ex) {
+			// Ignore
+		}
+	}
+
+	private void clearCache(JarURLConnection connection) throws IOException {
+		JarFile jarFile = connection.getJarFile();
+		if (jarFile instanceof NestedJarFile nestedJarFile) {
+			nestedJarFile.clearCache();
+		}
+	}
+
+	private boolean isJarUrl(URL url) {
+		return "jar".equals(url.getProtocol());
+	}
+
+	@Override
+	public void close() throws IOException {
+		super.close();
+		clearJarFiles();
+	}
+
+	private void clearJarFiles() throws IOException {
+		synchronized (this.jarFiles) {
+			for (JarFile jarFile : this.jarFiles.values()) {
+				jarFile.close();
+			}
+			this.jarFiles.clear();
+		}
+	}
+
+	/**
+	 * {@link Enumeration} that uses fast connections.
+	 */
+	private static class OptimizedEnumeration implements Enumeration<URL> {
+
+		private final Enumeration<URL> delegate;
+
+		OptimizedEnumeration(Enumeration<URL> delegate) {
+			this.delegate = delegate;
+		}
+
+		@Override
+		public boolean hasMoreElements() {
+			Optimizations.enable(false);
+			try {
+				return this.delegate.hasMoreElements();
+			}
+			finally {
+				Optimizations.disable();
+			}
+
+		}
+
+		@Override
+		public URL nextElement() {
+			Optimizations.enable(false);
+			try {
+				return this.delegate.nextElement();
+			}
+			finally {
+				Optimizations.disable();
+			}
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnection.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnection.java
new file mode 100644
index 000000000000..513bc5cab7ae
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnection.java
@@ -0,0 +1,411 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.FileNotFoundException;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.net.URLConnection;
+import java.net.URLStreamHandler;
+import java.security.Permission;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Supplier;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+import org.springframework.boot.loader.jar.NestedJarFile;
+import org.springframework.boot.loader.net.util.UrlDecoder;
+
+/**
+ * {@link java.net.JarURLConnection} alternative to
+ * {@code sun.net.www.protocol.jar.JarURLConnection} with optimized support for nested
+ * jars.
+ *
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ * @author Rostyslav Dudka
+ */
+final class JarUrlConnection extends java.net.JarURLConnection {
+
+	static final UrlJarFiles jarFiles = new UrlJarFiles();
+
+	static final InputStream emptyInputStream = new ByteArrayInputStream(new byte[0]);
+
+	static final FileNotFoundException FILE_NOT_FOUND_EXCEPTION = new FileNotFoundException(
+			"Jar file or entry not found");
+
+	private static final URL NOT_FOUND_URL;
+
+	static final JarUrlConnection NOT_FOUND_CONNECTION;
+	static {
+		try {
+			NOT_FOUND_URL = new URL("jar:", null, 0, "nested:!/", new EmptyUrlStreamHandler());
+			NOT_FOUND_CONNECTION = new JarUrlConnection(() -> FILE_NOT_FOUND_EXCEPTION);
+		}
+		catch (IOException ex) {
+			throw new IllegalStateException(ex);
+		}
+	}
+
+	private final String entryName;
+
+	private final Supplier<FileNotFoundException> notFound;
+
+	private JarFile jarFile;
+
+	private URLConnection jarFileConnection;
+
+	private JarEntry jarEntry;
+
+	private String contentType;
+
+	private JarUrlConnection(URL url) throws IOException {
+		super(url);
+		this.entryName = getEntryName();
+		this.notFound = null;
+		this.jarFileConnection = getJarFileURL().openConnection();
+		this.jarFileConnection.setUseCaches(this.useCaches);
+	}
+
+	private JarUrlConnection(Supplier<FileNotFoundException> notFound) throws IOException {
+		super(NOT_FOUND_URL);
+		this.entryName = null;
+		this.notFound = notFound;
+	}
+
+	@Override
+	public JarFile getJarFile() throws IOException {
+		connect();
+		return this.jarFile;
+	}
+
+	@Override
+	public JarEntry getJarEntry() throws IOException {
+		connect();
+		return this.jarEntry;
+	}
+
+	@Override
+	public int getContentLength() {
+		long contentLength = getContentLengthLong();
+		return (contentLength <= Integer.MAX_VALUE) ? (int) contentLength : -1;
+	}
+
+	@Override
+	public long getContentLengthLong() {
+		try {
+			connect();
+			return (this.jarEntry != null) ? this.jarEntry.getSize() : this.jarFileConnection.getContentLengthLong();
+		}
+		catch (IOException ex) {
+			return -1;
+		}
+	}
+
+	@Override
+	public String getContentType() {
+		if (this.contentType == null) {
+			this.contentType = deduceContentType();
+		}
+		return this.contentType;
+	}
+
+	private String deduceContentType() {
+		String type = (this.entryName != null) ? null : "x-java/jar";
+		type = (type != null) ? type : deduceContentTypeFromStream();
+		type = (type != null) ? type : deduceContentTypeFromEntryName();
+		return (type != null) ? type : "content/unknown";
+	}
+
+	private String deduceContentTypeFromStream() {
+		try {
+			connect();
+			try (InputStream in = this.jarFile.getInputStream(this.jarEntry)) {
+				return guessContentTypeFromStream(new BufferedInputStream(in));
+			}
+		}
+		catch (IOException ex) {
+			return null;
+		}
+	}
+
+	private String deduceContentTypeFromEntryName() {
+		return guessContentTypeFromName(this.entryName);
+	}
+
+	@Override
+	public long getLastModified() {
+		return (this.jarFileConnection != null) ? this.jarFileConnection.getLastModified() : super.getLastModified();
+	}
+
+	@Override
+	public String getHeaderField(String name) {
+		return (this.jarFileConnection != null) ? this.jarFileConnection.getHeaderField(name) : null;
+	}
+
+	@Override
+	public Object getContent() throws IOException {
+		connect();
+		return (this.entryName != null) ? super.getContent() : this.jarFile;
+	}
+
+	@Override
+	public Permission getPermission() throws IOException {
+		return this.jarFileConnection.getPermission();
+	}
+
+	@Override
+	public InputStream getInputStream() throws IOException {
+		if (this.notFound != null) {
+			throwFileNotFound();
+		}
+		URL jarFileURL = getJarFileURL();
+		if (this.entryName == null && !UrlJarFileFactory.isNestedUrl(jarFileURL)) {
+			throw new IOException("no entry name specified");
+		}
+		if (!getUseCaches() && Optimizations.isEnabled(false) && this.entryName != null) {
+			JarFile cached = jarFiles.getCached(jarFileURL);
+			if (cached != null) {
+				if (cached.getEntry(this.entryName) != null) {
+					return emptyInputStream;
+				}
+			}
+		}
+		connect();
+		if (this.jarEntry == null) {
+			if (this.jarFile instanceof NestedJarFile nestedJarFile) {
+				// In order to work with Tomcat's TLD scanning and WarURLConnection we
+				// return the raw zip data rather than failing because there is no entry.
+				// See gh-38047 for details.
+				return nestedJarFile.getRawZipDataInputStream();
+			}
+			throwFileNotFound();
+		}
+		return new ConnectionInputStream();
+	}
+
+	@Override
+	public boolean getAllowUserInteraction() {
+		return (this.jarFileConnection != null) ? this.jarFileConnection.getAllowUserInteraction() : false;
+	}
+
+	@Override
+	public void setAllowUserInteraction(boolean allowuserinteraction) {
+		if (this.jarFileConnection != null) {
+			this.jarFileConnection.setAllowUserInteraction(allowuserinteraction);
+		}
+	}
+
+	@Override
+	public boolean getUseCaches() {
+		return (this.jarFileConnection != null) ? this.jarFileConnection.getUseCaches() : true;
+	}
+
+	@Override
+	public void setUseCaches(boolean usecaches) {
+		if (this.jarFileConnection != null) {
+			this.jarFileConnection.setUseCaches(usecaches);
+		}
+	}
+
+	@Override
+	public boolean getDefaultUseCaches() {
+		return (this.jarFileConnection != null) ? this.jarFileConnection.getDefaultUseCaches() : true;
+	}
+
+	@Override
+	public void setDefaultUseCaches(boolean defaultusecaches) {
+		if (this.jarFileConnection != null) {
+			this.jarFileConnection.setDefaultUseCaches(defaultusecaches);
+		}
+	}
+
+	@Override
+	public void setIfModifiedSince(long ifModifiedSince) {
+		if (this.jarFileConnection != null) {
+			this.jarFileConnection.setIfModifiedSince(ifModifiedSince);
+		}
+	}
+
+	@Override
+	public String getRequestProperty(String key) {
+		return (this.jarFileConnection != null) ? this.jarFileConnection.getRequestProperty(key) : null;
+	}
+
+	@Override
+	public void setRequestProperty(String key, String value) {
+		if (this.jarFileConnection != null) {
+			this.jarFileConnection.setRequestProperty(key, value);
+		}
+	}
+
+	@Override
+	public void addRequestProperty(String key, String value) {
+		if (this.jarFileConnection != null) {
+			this.jarFileConnection.addRequestProperty(key, value);
+		}
+	}
+
+	@Override
+	public Map<String, List<String>> getRequestProperties() {
+		return (this.jarFileConnection != null) ? this.jarFileConnection.getRequestProperties()
+				: Collections.emptyMap();
+	}
+
+	@Override
+	public void connect() throws IOException {
+		if (this.connected) {
+			return;
+		}
+		if (this.notFound != null) {
+			throwFileNotFound();
+		}
+		boolean useCaches = getUseCaches();
+		URL jarFileURL = getJarFileURL();
+		if (this.entryName != null && Optimizations.isEnabled()) {
+			assertCachedJarFileHasEntry(jarFileURL, this.entryName);
+		}
+		this.jarFile = jarFiles.getOrCreate(useCaches, jarFileURL);
+		this.jarEntry = getJarEntry(jarFileURL);
+		boolean addedToCache = jarFiles.cacheIfAbsent(useCaches, jarFileURL, this.jarFile);
+		if (addedToCache) {
+			this.jarFileConnection = jarFiles.reconnect(this.jarFile, this.jarFileConnection);
+		}
+		this.connected = true;
+	}
+
+	/**
+	 * The {@link URLClassLoader} connects often to check if a resource exists, we can
+	 * save some object allocations by using the cached copy if we have one.
+	 * @param jarFileURL the jar file to check
+	 * @param entryName the entry name to check
+	 * @throws FileNotFoundException on a missing entry
+	 */
+	private void assertCachedJarFileHasEntry(URL jarFileURL, String entryName) throws FileNotFoundException {
+		JarFile cachedJarFile = jarFiles.getCached(jarFileURL);
+		if (cachedJarFile != null && cachedJarFile.getJarEntry(entryName) == null) {
+			throw FILE_NOT_FOUND_EXCEPTION;
+		}
+	}
+
+	private JarEntry getJarEntry(URL jarFileUrl) throws IOException {
+		if (this.entryName == null) {
+			return null;
+		}
+		JarEntry jarEntry = this.jarFile.getJarEntry(this.entryName);
+		if (jarEntry == null) {
+			jarFiles.closeIfNotCached(jarFileUrl, this.jarFile);
+			throwFileNotFound();
+		}
+		return jarEntry;
+	}
+
+	private void throwFileNotFound() throws FileNotFoundException {
+		if (Optimizations.isEnabled()) {
+			throw FILE_NOT_FOUND_EXCEPTION;
+		}
+		if (this.notFound != null) {
+			throw this.notFound.get();
+		}
+		throw new FileNotFoundException("JAR entry " + this.entryName + " not found in " + this.jarFile.getName());
+	}
+
+	static JarUrlConnection open(URL url) throws IOException {
+		String spec = url.getFile();
+		if (spec.startsWith("nested:")) {
+			int separator = spec.indexOf("!/");
+			boolean specHasEntry = (separator != -1) && (separator + 2 != spec.length());
+			if (specHasEntry) {
+				URL jarFileUrl = new URL(spec.substring(0, separator));
+				if ("runtime".equals(url.getRef())) {
+					jarFileUrl = new URL(jarFileUrl, "#runtime");
+				}
+				String entryName = UrlDecoder.decode(spec.substring(separator + 2));
+				JarFile jarFile = jarFiles.getOrCreate(true, jarFileUrl);
+				jarFiles.cacheIfAbsent(true, jarFileUrl, jarFile);
+				if (!hasEntry(jarFile, entryName)) {
+					return notFoundConnection(jarFile.getName(), entryName);
+				}
+			}
+		}
+		return new JarUrlConnection(url);
+	}
+
+	private static boolean hasEntry(JarFile jarFile, String name) {
+		return (jarFile instanceof NestedJarFile nestedJarFile) ? nestedJarFile.hasEntry(name)
+				: jarFile.getEntry(name) != null;
+	}
+
+	private static JarUrlConnection notFoundConnection(String jarFileName, String entryName) throws IOException {
+		if (Optimizations.isEnabled()) {
+			return NOT_FOUND_CONNECTION;
+		}
+		return new JarUrlConnection(
+				() -> new FileNotFoundException("JAR entry " + entryName + " not found in " + jarFileName));
+	}
+
+	static void clearCache() {
+		jarFiles.clearCache();
+	}
+
+	/**
+	 * Connection {@link InputStream}. This is not a {@link FilterInputStream} since
+	 * {@link URLClassLoader} often creates streams that it doesn't call and we want to be
+	 * lazy about getting the underlying {@link InputStream}.
+	 */
+	class ConnectionInputStream extends LazyDelegatingInputStream {
+
+		@Override
+		public void close() throws IOException {
+			try {
+				super.close();
+			}
+			finally {
+				if (!getUseCaches()) {
+					JarUrlConnection.this.jarFile.close();
+				}
+			}
+		}
+
+		@Override
+		protected InputStream getDelegateInputStream() throws IOException {
+			return JarUrlConnection.this.jarFile.getInputStream(JarUrlConnection.this.jarEntry);
+		}
+
+	}
+
+	/**
+	 * Empty {@link URLStreamHandler} used to prevent the wrong JAR Handler from being
+	 * Instantiated and cached.
+	 */
+	private static class EmptyUrlStreamHandler extends URLStreamHandler {
+
+		@Override
+		protected URLConnection openConnection(URL url) {
+			return null;
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/LazyDelegatingInputStream.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/LazyDelegatingInputStream.java
new file mode 100644
index 000000000000..95e5cc3c14a7
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/LazyDelegatingInputStream.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * {@link InputStream} that delegates lazily to another {@link InputStream}.
+ *
+ * @author Phillip Webb
+ */
+abstract class LazyDelegatingInputStream extends InputStream {
+
+	private volatile InputStream in;
+
+	@Override
+	public int read() throws IOException {
+		return in().read();
+	}
+
+	@Override
+	public int read(byte[] b) throws IOException {
+		return in().read(b);
+	}
+
+	@Override
+	public int read(byte[] b, int off, int len) throws IOException {
+		return in().read(b, off, len);
+	}
+
+	@Override
+	public long skip(long n) throws IOException {
+		return in().skip(n);
+	}
+
+	@Override
+	public int available() throws IOException {
+		return in().available();
+	}
+
+	@Override
+	public boolean markSupported() {
+		try {
+			return in().markSupported();
+		}
+		catch (IOException ex) {
+			return false;
+		}
+	}
+
+	@Override
+	public synchronized void mark(int readlimit) {
+		try {
+			in().mark(readlimit);
+		}
+		catch (IOException ex) {
+			// Ignore
+		}
+	}
+
+	@Override
+	public synchronized void reset() throws IOException {
+		in().reset();
+	}
+
+	private InputStream in() throws IOException {
+		InputStream in = this.in;
+		if (in == null) {
+			synchronized (this) {
+				in = this.in;
+				if (in == null) {
+					in = getDelegateInputStream();
+					this.in = in;
+				}
+			}
+		}
+		return in;
+	}
+
+	@Override
+	public void close() throws IOException {
+		InputStream in = this.in;
+		if (in != null) {
+			synchronized (this) {
+				in = this.in;
+				if (in != null) {
+					in.close();
+				}
+			}
+		}
+	}
+
+	protected abstract InputStream getDelegateInputStream() throws IOException;
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Optimizations.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Optimizations.java
new file mode 100644
index 000000000000..138e8e45e086
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Optimizations.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+/**
+ * {@link ThreadLocal} state for {@link Handler} optimizations.
+ *
+ * @author Phillip Webb
+ */
+final class Optimizations {
+
+	private static final ThreadLocal<Boolean> status = new ThreadLocal<>();
+
+	private Optimizations() {
+	}
+
+	static void enable(boolean readContents) {
+		status.set(readContents);
+	}
+
+	static void disable() {
+		status.remove();
+	}
+
+	static boolean isEnabled() {
+		return status.get() != null;
+	}
+
+	static boolean isEnabled(boolean readContents) {
+		return Boolean.valueOf(readContents).equals(status.get());
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarEntry.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarEntry.java
new file mode 100644
index 000000000000..5c2b100cf201
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarEntry.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.io.IOException;
+import java.util.jar.Attributes;
+import java.util.jar.JarEntry;
+import java.util.zip.ZipEntry;
+
+/**
+ * A {@link JarEntry} returned from a {@link UrlJarFile} or {@link UrlNestedJarFile}.
+ *
+ * @author Phillip Webb
+ */
+final class UrlJarEntry extends JarEntry {
+
+	private final UrlJarManifest manifest;
+
+	private UrlJarEntry(JarEntry entry, UrlJarManifest manifest) {
+		super(entry);
+		this.manifest = manifest;
+	}
+
+	@Override
+	public Attributes getAttributes() throws IOException {
+		return this.manifest.getEntryAttributes(this);
+	}
+
+	static UrlJarEntry of(ZipEntry entry, UrlJarManifest manifest) {
+		return (entry != null) ? new UrlJarEntry((JarEntry) entry, manifest) : null;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFile.java
new file mode 100644
index 000000000000..e70af3081a90
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFile.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.function.Consumer;
+import java.util.jar.JarFile;
+import java.util.jar.Manifest;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+import org.springframework.boot.loader.ref.Cleaner;
+
+/**
+ * A {@link JarFile} subclass returned from a {@link JarUrlConnection}.
+ *
+ * @author Phillip Webb
+ */
+class UrlJarFile extends JarFile {
+
+	private final UrlJarManifest manifest;
+
+	private final Consumer<JarFile> closeAction;
+
+	UrlJarFile(File file, Runtime.Version version, Consumer<JarFile> closeAction) throws IOException {
+		super(file, true, ZipFile.OPEN_READ, version);
+		// Registered only for test cleanup since parent class is JarFile
+		Cleaner.instance.register(this, null);
+		this.manifest = new UrlJarManifest(super::getManifest);
+		this.closeAction = closeAction;
+	}
+
+	@Override
+	public ZipEntry getEntry(String name) {
+		return UrlJarEntry.of(super.getEntry(name), this.manifest);
+	}
+
+	@Override
+	public Manifest getManifest() throws IOException {
+		return this.manifest.get();
+	}
+
+	@Override
+	public void close() throws IOException {
+		if (this.closeAction != null) {
+			this.closeAction.accept(this);
+		}
+		super.close();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactory.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactory.java
new file mode 100644
index 000000000000..208c5e9478fa
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactory.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.Runtime.Version;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.function.Consumer;
+import java.util.jar.JarFile;
+
+import org.springframework.boot.loader.net.protocol.nested.NestedLocation;
+import org.springframework.boot.loader.net.util.UrlDecoder;
+
+/**
+ * Factory used by {@link UrlJarFiles} to create {@link JarFile} instances.
+ *
+ * @author Phillip Webb
+ * @see UrlJarFile
+ * @see UrlNestedJarFile
+ */
+class UrlJarFileFactory {
+
+	/**
+	 * Create a new {@link UrlJarFile} or {@link UrlNestedJarFile} instance.
+	 * @param jarFileUrl the jar file URL
+	 * @param closeAction the action to call when the file is closed
+	 * @return a new {@link JarFile} instance
+	 * @throws IOException on I/O error
+	 */
+	JarFile createJarFile(URL jarFileUrl, Consumer<JarFile> closeAction) throws IOException {
+		Runtime.Version version = getVersion(jarFileUrl);
+		if (isLocalFileUrl(jarFileUrl)) {
+			return createJarFileForLocalFile(jarFileUrl, version, closeAction);
+		}
+		if (isNestedUrl(jarFileUrl)) {
+			return createJarFileForNested(jarFileUrl, version, closeAction);
+		}
+		return createJarFileForStream(jarFileUrl, version, closeAction);
+	}
+
+	private Runtime.Version getVersion(URL url) {
+		// The standard JDK handler uses #runtime to indicate that the runtime version
+		// should be used. This unfortunately doesn't work for us as
+		// jdk.internal.loader.URLClassPath only adds the runtime fragment when the URL
+		// is using the internal JDK handler. We need to flip the default to use
+		// the runtime version. See gh-38050
+		return "base".equals(url.getRef()) ? JarFile.baseVersion() : JarFile.runtimeVersion();
+	}
+
+	private boolean isLocalFileUrl(URL url) {
+		return url.getProtocol().equalsIgnoreCase("file") && isLocal(url.getHost());
+	}
+
+	private boolean isLocal(String host) {
+		return host == null || host.isEmpty() || host.equals("~") || host.equalsIgnoreCase("localhost");
+	}
+
+	private JarFile createJarFileForLocalFile(URL url, Runtime.Version version, Consumer<JarFile> closeAction)
+			throws IOException {
+		String path = UrlDecoder.decode(url.getPath());
+		return new UrlJarFile(new File(path), version, closeAction);
+	}
+
+	private JarFile createJarFileForNested(URL url, Runtime.Version version, Consumer<JarFile> closeAction)
+			throws IOException {
+		NestedLocation location = NestedLocation.fromUrl(url);
+		return new UrlNestedJarFile(location.path().toFile(), location.nestedEntryName(), version, closeAction);
+	}
+
+	private JarFile createJarFileForStream(URL url, Version version, Consumer<JarFile> closeAction) throws IOException {
+		try (InputStream in = url.openStream()) {
+			return createJarFileForStream(in, version, closeAction);
+		}
+	}
+
+	private JarFile createJarFileForStream(InputStream in, Version version, Consumer<JarFile> closeAction)
+			throws IOException {
+		Path local = Files.createTempFile("jar_cache", null);
+		try {
+			Files.copy(in, local, StandardCopyOption.REPLACE_EXISTING);
+			JarFile jarFile = new UrlJarFile(local.toFile(), version, closeAction);
+			local.toFile().deleteOnExit();
+			return jarFile;
+		}
+		catch (Throwable ex) {
+			deleteIfPossible(local, ex);
+			throw ex;
+		}
+	}
+
+	private void deleteIfPossible(Path local, Throwable cause) {
+		try {
+			Files.delete(local);
+		}
+		catch (IOException ex) {
+			cause.addSuppressed(ex);
+		}
+	}
+
+	static boolean isNestedUrl(URL url) {
+		return url.getProtocol().equalsIgnoreCase("nested");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFiles.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFiles.java
new file mode 100644
index 000000000000..145a51496054
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFiles.java
@@ -0,0 +1,217 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.io.IOException;
+import java.net.URL;
+import java.net.URLConnection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.jar.JarFile;
+
+/**
+ * Provides access to {@link UrlJarFile} and {@link UrlNestedJarFile} instances taking
+ * care of caching concerns when necessary.
+ * <p>
+ * This class is thread-safe and designed to be shared by all {@link JarUrlConnection}
+ * instances.
+ *
+ * @author Phillip Webb
+ */
+class UrlJarFiles {
+
+	private final UrlJarFileFactory factory;
+
+	private final Cache cache = new Cache();
+
+	/**
+	 * Create a new {@link UrlJarFiles} instance.
+	 */
+	UrlJarFiles() {
+		this(new UrlJarFileFactory());
+	}
+
+	/**
+	 * Create a new {@link UrlJarFiles} instance.
+	 * @param factory the {@link UrlJarFileFactory} to use.
+	 */
+	UrlJarFiles(UrlJarFileFactory factory) {
+		this.factory = factory;
+	}
+
+	/**
+	 * Get an existing {@link JarFile} instance from the cache, or create a new
+	 * {@link JarFile} instance that can be {@link #cacheIfAbsent(boolean, URL, JarFile)
+	 * cached later}.
+	 * @param useCaches if caches can be used
+	 * @param jarFileUrl the jar file URL
+	 * @return a new or existing {@link JarFile} instance
+	 * @throws IOException on I/O error
+	 */
+	JarFile getOrCreate(boolean useCaches, URL jarFileUrl) throws IOException {
+		if (useCaches) {
+			JarFile cached = getCached(jarFileUrl);
+			if (cached != null) {
+				return cached;
+			}
+		}
+		return this.factory.createJarFile(jarFileUrl, this::onClose);
+	}
+
+	/**
+	 * Return the cached {@link JarFile} if available.
+	 * @param jarFileUrl the jar file URL
+	 * @return the cached jar or {@code null}
+	 */
+	JarFile getCached(URL jarFileUrl) {
+		return this.cache.get(jarFileUrl);
+	}
+
+	/**
+	 * Cache the given {@link JarFile} if caching can be used and there is no existing
+	 * entry.
+	 * @param useCaches if caches can be used
+	 * @param jarFileUrl the jar file URL
+	 * @param jarFile the jar file
+	 * @return {@code true} if that file was added to the cache
+	 */
+	boolean cacheIfAbsent(boolean useCaches, URL jarFileUrl, JarFile jarFile) {
+		if (!useCaches) {
+			return false;
+		}
+		return this.cache.putIfAbsent(jarFileUrl, jarFile);
+	}
+
+	/**
+	 * Close the given {@link JarFile} only if it is not contained in the cache.
+	 * @param jarFileUrl the jar file URL
+	 * @param jarFile the jar file
+	 * @throws IOException on I/O error
+	 */
+	void closeIfNotCached(URL jarFileUrl, JarFile jarFile) throws IOException {
+		JarFile cached = getCached(jarFileUrl);
+		if (cached != jarFile) {
+			jarFile.close();
+		}
+	}
+
+	/**
+	 * Reconnect to the {@link JarFile}, returning a replacement {@link URLConnection}.
+	 * @param jarFile the jar file
+	 * @param existingConnection the existing connection
+	 * @return a newly opened connection inhering the same {@code useCaches} value as the
+	 * existing connection
+	 * @throws IOException on I/O error
+	 */
+	URLConnection reconnect(JarFile jarFile, URLConnection existingConnection) throws IOException {
+		Boolean useCaches = (existingConnection != null) ? existingConnection.getUseCaches() : null;
+		URLConnection connection = openConnection(jarFile);
+		if (useCaches != null && connection != null) {
+			connection.setUseCaches(useCaches);
+		}
+		return connection;
+	}
+
+	private URLConnection openConnection(JarFile jarFile) throws IOException {
+		URL url = this.cache.get(jarFile);
+		return (url != null) ? url.openConnection() : null;
+	}
+
+	private void onClose(JarFile jarFile) {
+		this.cache.remove(jarFile);
+	}
+
+	void clearCache() {
+		this.cache.clear();
+	}
+
+	/**
+	 * Internal cache.
+	 */
+	private static class Cache {
+
+		private final Map<String, JarFile> jarFileUrlToJarFile = new HashMap<>();
+
+		private final Map<JarFile, URL> jarFileToJarFileUrl = new HashMap<>();
+
+		/**
+		 * Get a {@link JarFile} from the cache given a jar file URL.
+		 * @param jarFileUrl the jar file URL
+		 * @return the cached {@link JarFile} or {@code null}
+		 */
+		JarFile get(URL jarFileUrl) {
+			String urlKey = JarFileUrlKey.get(jarFileUrl);
+			synchronized (this) {
+				return this.jarFileUrlToJarFile.get(urlKey);
+			}
+		}
+
+		/**
+		 * Get a jar file URL from the cache given a jar file.
+		 * @param jarFile the jar file
+		 * @return the cached {@link URL} or {@code null}
+		 */
+		URL get(JarFile jarFile) {
+			synchronized (this) {
+				return this.jarFileToJarFileUrl.get(jarFile);
+			}
+		}
+
+		/**
+		 * Put the given jar file URL and jar file into the cache if they aren't already
+		 * there.
+		 * @param jarFileUrl the jar file URL
+		 * @param jarFile the jar file
+		 * @return {@code true} if the items were added to the cache or {@code false} if
+		 * they were already there
+		 */
+		boolean putIfAbsent(URL jarFileUrl, JarFile jarFile) {
+			String urlKey = JarFileUrlKey.get(jarFileUrl);
+			synchronized (this) {
+				JarFile cached = this.jarFileUrlToJarFile.get(urlKey);
+				if (cached == null) {
+					this.jarFileUrlToJarFile.put(urlKey, jarFile);
+					this.jarFileToJarFileUrl.put(jarFile, jarFileUrl);
+					return true;
+				}
+				return false;
+			}
+		}
+
+		/**
+		 * Remove the given jar and any related URL file from the cache.
+		 * @param jarFile the jar file to remove
+		 */
+		void remove(JarFile jarFile) {
+			synchronized (this) {
+				URL removedUrl = this.jarFileToJarFileUrl.remove(jarFile);
+				if (removedUrl != null) {
+					this.jarFileUrlToJarFile.remove(JarFileUrlKey.get(removedUrl));
+				}
+			}
+		}
+
+		void clear() {
+			synchronized (this) {
+				this.jarFileToJarFileUrl.clear();
+				this.jarFileUrlToJarFile.clear();
+			}
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarManifest.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarManifest.java
new file mode 100644
index 000000000000..70c372855d50
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarManifest.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.jar.Attributes;
+import java.util.jar.JarEntry;
+import java.util.jar.Manifest;
+
+/**
+ * Provides access {@link Manifest} content that can be safely returned from
+ * {@link UrlJarFile} or {@link UrlNestedJarFile}.
+ *
+ * @author Phillip Webb
+ */
+class UrlJarManifest {
+
+	private static final Object NONE = new Object();
+
+	private final ManifestSupplier supplier;
+
+	private volatile Object supplied;
+
+	UrlJarManifest(ManifestSupplier supplier) {
+		this.supplier = supplier;
+	}
+
+	Manifest get() throws IOException {
+		Manifest manifest = supply();
+		if (manifest == null) {
+			return null;
+		}
+		Manifest copy = new Manifest();
+		copy.getMainAttributes().putAll((Map<?, ?>) manifest.getMainAttributes().clone());
+		manifest.getEntries().forEach((key, value) -> copy.getEntries().put(key, cloneAttributes(value)));
+		return copy;
+	}
+
+	Attributes getEntryAttributes(JarEntry entry) throws IOException {
+		Manifest manifest = supply();
+		if (manifest == null) {
+			return null;
+		}
+		Attributes attributes = manifest.getEntries().get(entry.getName());
+		return cloneAttributes(attributes);
+	}
+
+	private Attributes cloneAttributes(Attributes attributes) {
+		return (attributes != null) ? (Attributes) attributes.clone() : null;
+	}
+
+	private Manifest supply() throws IOException {
+		Object supplied = this.supplied;
+		if (supplied == null) {
+			supplied = this.supplier.getManifest();
+			this.supplied = (supplied != null) ? supplied : NONE;
+		}
+		return (supplied != NONE) ? (Manifest) supplied : null;
+	}
+
+	/**
+	 * Interface used to supply the actual manifest.
+	 */
+	@FunctionalInterface
+	interface ManifestSupplier {
+
+		Manifest getManifest() throws IOException;
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlNestedJarFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlNestedJarFile.java
new file mode 100644
index 000000000000..1f9f62b2a32c
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlNestedJarFile.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.Runtime.Version;
+import java.util.function.Consumer;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.jar.Manifest;
+
+import org.springframework.boot.loader.jar.NestedJarFile;
+
+/**
+ * {@link NestedJarFile} subclass returned from a {@link JarUrlConnection}.
+ *
+ * @author Phillip Webb
+ */
+class UrlNestedJarFile extends NestedJarFile {
+
+	private final UrlJarManifest manifest;
+
+	private final Consumer<JarFile> closeAction;
+
+	UrlNestedJarFile(File file, String nestedEntryName, Version version, Consumer<JarFile> closeAction)
+			throws IOException {
+		super(file, nestedEntryName, version);
+		this.manifest = new UrlJarManifest(super::getManifest);
+		this.closeAction = closeAction;
+	}
+
+	@Override
+	public Manifest getManifest() throws IOException {
+		return this.manifest.get();
+	}
+
+	@Override
+	public JarEntry getEntry(String name) {
+		return UrlJarEntry.of(super.getEntry(name), this.manifest);
+	}
+
+	@Override
+	public void close() throws IOException {
+		if (this.closeAction != null) {
+			this.closeAction.accept(this);
+		}
+		super.close();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/package-info.java
new file mode 100644
index 000000000000..980f4230226f
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * JAR URL support, including support for nested jars.
+ *
+ * @see org.springframework.boot.loader.net.protocol.jar.JarUrl
+ * @see org.springframework.boot.loader.net.protocol.jar.Handler
+ */
+package org.springframework.boot.loader.net.protocol.jar;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/Handler.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/Handler.java
new file mode 100644
index 000000000000..0a05596e2831
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/Handler.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.nested;
+
+import java.io.IOException;
+import java.net.URL;
+import java.net.URLConnection;
+import java.net.URLStreamHandler;
+
+/**
+ * {@link URLStreamHandler} to support {@code nested:} URLs. See {@link NestedLocation}
+ * for details of the URL format.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public class Handler extends URLStreamHandler {
+
+	// NOTE: in order to be found as a URL protocol handler, this class must be public,
+	// must be named Handler and must be in a package ending '.nested'
+
+	private static final String PREFIX = "nested:";
+
+	@Override
+	protected URLConnection openConnection(URL url) throws IOException {
+		return new NestedUrlConnection(url);
+	}
+
+	/**
+	 * Assert that the specified URL is a valid "nested" URL.
+	 * @param url the URL to check
+	 */
+	public static void assertUrlIsNotMalformed(String url) {
+		if (url == null || !url.startsWith(PREFIX)) {
+			throw new IllegalArgumentException("'url' must not be null and must use 'nested' protocol");
+		}
+		NestedLocation.parse(url.substring(PREFIX.length()));
+	}
+
+	/**
+	 * Clear any internal caches.
+	 */
+	public static void clearCache() {
+		NestedLocation.clearCache();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedLocation.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedLocation.java
new file mode 100644
index 000000000000..c350a34fdbf7
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedLocation.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.nested;
+
+import java.io.File;
+import java.net.URI;
+import java.net.URL;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.springframework.boot.loader.net.util.UrlDecoder;
+
+/**
+ * A location obtained from a {@code nested:} {@link URL} consisting of a jar file and a
+ * nested entry.
+ * <p>
+ * The syntax of a nested JAR URL is: <pre>
+ * nestedjar:&lt;path&gt;/!{entry}
+ * </pre>
+ * <p>
+ * for example:
+ * <p>
+ * {@code nested:/home/example/my.jar/!BOOT-INF/lib/my-nested.jar}
+ * <p>
+ * or:
+ * <p>
+ * {@code nested:/home/example/my.jar/!BOOT-INF/classes/}
+ * <p>
+ * The path must refer to a jar file on the file system. The entry refers to either an
+ * uncompressed entry that contains the nested jar, or a directory entry. The entry must
+ * not start with a {@code '/'}.
+ *
+ * @param path the path to the zip that contains the nested entry
+ * @param nestedEntryName the nested entry name
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public record NestedLocation(Path path, String nestedEntryName) {
+
+	private static final Map<String, NestedLocation> cache = new ConcurrentHashMap<>();
+
+	public NestedLocation {
+		if (path == null) {
+			throw new IllegalArgumentException("'path' must not be null");
+		}
+		if (nestedEntryName == null || nestedEntryName.trim().isEmpty()) {
+			throw new IllegalArgumentException("'nestedEntryName' must not be empty");
+		}
+	}
+
+	/**
+	 * Create a new {@link NestedLocation} from the given URL.
+	 * @param url the nested URL
+	 * @return a new {@link NestedLocation} instance
+	 * @throws IllegalArgumentException if the URL is not valid
+	 */
+	public static NestedLocation fromUrl(URL url) {
+		if (url == null || !"nested".equalsIgnoreCase(url.getProtocol())) {
+			throw new IllegalArgumentException("'url' must not be null and must use 'nested' protocol");
+		}
+		return parse(UrlDecoder.decode(url.getPath()));
+	}
+
+	/**
+	 * Create a new {@link NestedLocation} from the given URI.
+	 * @param uri the nested URI
+	 * @return a new {@link NestedLocation} instance
+	 * @throws IllegalArgumentException if the URI is not valid
+	 */
+	public static NestedLocation fromUri(URI uri) {
+		if (uri == null || !"nested".equalsIgnoreCase(uri.getScheme())) {
+			throw new IllegalArgumentException("'uri' must not be null and must use 'nested' scheme");
+		}
+		return parse(uri.getSchemeSpecificPart());
+	}
+
+	static NestedLocation parse(String path) {
+		if (path == null || path.isEmpty()) {
+			throw new IllegalArgumentException("'path' must not be empty");
+		}
+		int index = path.lastIndexOf("/!");
+		if (index == -1) {
+			throw new IllegalArgumentException("'path' must contain '/!'");
+		}
+		return cache.computeIfAbsent(path, (l) -> create(index, l));
+	}
+
+	private static NestedLocation create(int index, String location) {
+		String locationPath = location.substring(0, index);
+		if (isWindows()) {
+			while (locationPath.startsWith("/")) {
+				locationPath = locationPath.substring(1, locationPath.length());
+			}
+		}
+		String nestedEntryName = location.substring(index + 2);
+		return new NestedLocation((!locationPath.isEmpty()) ? Path.of(locationPath) : null, nestedEntryName);
+	}
+
+	private static boolean isWindows() {
+		return File.separatorChar == '\\';
+	}
+
+	static void clearCache() {
+		cache.clear();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnection.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnection.java
new file mode 100644
index 000000000000..0409fe6e3d99
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnection.java
@@ -0,0 +1,231 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.nested;
+
+import java.io.File;
+import java.io.FilePermission;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.lang.ref.Cleaner.Cleanable;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLConnection;
+import java.nio.file.Files;
+import java.security.Permission;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import org.springframework.boot.loader.ref.Cleaner;
+
+/**
+ * {@link URLConnection} to support {@code nested:} URLs. See {@link NestedLocation} for
+ * details of the URL format.
+ *
+ * @author Phillip Webb
+ */
+class NestedUrlConnection extends URLConnection {
+
+	private static final DateTimeFormatter RFC_1123_DATE_TIME = DateTimeFormatter.RFC_1123_DATE_TIME
+		.withZone(ZoneId.of("GMT"));
+
+	private static final String CONTENT_TYPE = "x-java/jar";
+
+	private final NestedUrlConnectionResources resources;
+
+	private final Cleanable cleanup;
+
+	private long lastModified = -1;
+
+	private FilePermission permission;
+
+	private Map<String, List<String>> headerFields;
+
+	NestedUrlConnection(URL url) throws MalformedURLException {
+		this(url, Cleaner.instance);
+	}
+
+	NestedUrlConnection(URL url, Cleaner cleaner) throws MalformedURLException {
+		super(url);
+		NestedLocation location = parseNestedLocation(url);
+		this.resources = new NestedUrlConnectionResources(location);
+		this.cleanup = cleaner.register(this, this.resources);
+	}
+
+	private NestedLocation parseNestedLocation(URL url) throws MalformedURLException {
+		try {
+			return NestedLocation.parse(url.getPath());
+		}
+		catch (IllegalArgumentException ex) {
+			throw new MalformedURLException(ex.getMessage());
+		}
+	}
+
+	@Override
+	public String getHeaderField(String name) {
+		List<String> values = getHeaderFields().get(name);
+		return (values != null && !values.isEmpty()) ? values.get(0) : null;
+	}
+
+	@Override
+	public String getHeaderField(int n) {
+		Entry<String, List<String>> entry = getHeaderEntry(n);
+		List<String> values = (entry != null) ? entry.getValue() : null;
+		return (values != null && !values.isEmpty()) ? values.get(0) : null;
+	}
+
+	@Override
+	public String getHeaderFieldKey(int n) {
+		Entry<String, List<String>> entry = getHeaderEntry(n);
+		return (entry != null) ? entry.getKey() : null;
+	}
+
+	private Entry<String, List<String>> getHeaderEntry(int n) {
+		Iterator<Entry<String, List<String>>> iterator = getHeaderFields().entrySet().iterator();
+		Entry<String, List<String>> entry = null;
+		for (int i = 0; i < n; i++) {
+			entry = (!iterator.hasNext()) ? null : iterator.next();
+		}
+		return entry;
+	}
+
+	@Override
+	public Map<String, List<String>> getHeaderFields() {
+		try {
+			connect();
+		}
+		catch (IOException ex) {
+			return Collections.emptyMap();
+		}
+		Map<String, List<String>> headerFields = this.headerFields;
+		if (headerFields == null) {
+			headerFields = new LinkedHashMap<>();
+			long contentLength = getContentLengthLong();
+			long lastModified = getLastModified();
+			if (contentLength > 0) {
+				headerFields.put("content-length", List.of(String.valueOf(contentLength)));
+			}
+			if (getLastModified() > 0) {
+				headerFields.put("last-modified",
+						List.of(RFC_1123_DATE_TIME.format(Instant.ofEpochMilli(lastModified))));
+			}
+			headerFields = Collections.unmodifiableMap(headerFields);
+			this.headerFields = headerFields;
+		}
+		return headerFields;
+	}
+
+	@Override
+	public int getContentLength() {
+		long contentLength = getContentLengthLong();
+		return (contentLength <= Integer.MAX_VALUE) ? (int) contentLength : -1;
+	}
+
+	@Override
+	public long getContentLengthLong() {
+		try {
+			connect();
+			return this.resources.getContentLength();
+		}
+		catch (IOException ex) {
+			return -1;
+		}
+	}
+
+	@Override
+	public String getContentType() {
+		return CONTENT_TYPE;
+	}
+
+	@Override
+	public long getLastModified() {
+		if (this.lastModified == -1) {
+			try {
+				this.lastModified = Files.getLastModifiedTime(this.resources.getLocation().path()).toMillis();
+			}
+			catch (IOException ex) {
+				this.lastModified = 0;
+			}
+		}
+		return this.lastModified;
+	}
+
+	@Override
+	public Permission getPermission() throws IOException {
+		if (this.permission == null) {
+			File file = this.resources.getLocation().path().toFile();
+			this.permission = new FilePermission(file.getCanonicalPath(), "read");
+		}
+		return this.permission;
+	}
+
+	@Override
+	public InputStream getInputStream() throws IOException {
+		connect();
+		return new ConnectionInputStream(this.resources.getInputStream());
+	}
+
+	@Override
+	public void connect() throws IOException {
+		if (this.connected) {
+			return;
+		}
+		this.resources.connect();
+		this.connected = true;
+	}
+
+	/**
+	 * Connection {@link InputStream}.
+	 */
+	class ConnectionInputStream extends FilterInputStream {
+
+		private volatile boolean closing;
+
+		ConnectionInputStream(InputStream in) {
+			super(in);
+		}
+
+		@Override
+		public void close() throws IOException {
+			if (this.closing) {
+				return;
+			}
+			this.closing = true;
+			try {
+				super.close();
+			}
+			finally {
+				try {
+					NestedUrlConnection.this.cleanup.clean();
+				}
+				catch (UncheckedIOException ex) {
+					throw ex.getCause();
+				}
+			}
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnectionResources.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnectionResources.java
new file mode 100644
index 000000000000..5806c2392882
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnectionResources.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.nested;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+
+import org.springframework.boot.loader.ref.Cleaner;
+import org.springframework.boot.loader.zip.CloseableDataBlock;
+import org.springframework.boot.loader.zip.ZipContent;
+
+/**
+ * Resources created managed and cleaned by a {@link NestedUrlConnection} instance and
+ * suitable for registration with a {@link Cleaner}.
+ *
+ * @author Phillip Webb
+ */
+class NestedUrlConnectionResources implements Runnable {
+
+	private final NestedLocation location;
+
+	private volatile ZipContent zipContent;
+
+	private volatile long size = -1;
+
+	private volatile InputStream inputStream;
+
+	NestedUrlConnectionResources(NestedLocation location) {
+		this.location = location;
+	}
+
+	NestedLocation getLocation() {
+		return this.location;
+	}
+
+	void connect() throws IOException {
+		synchronized (this) {
+			if (this.zipContent == null) {
+				this.zipContent = ZipContent.open(this.location.path(), this.location.nestedEntryName());
+				try {
+					connectData();
+				}
+				catch (IOException | RuntimeException ex) {
+					this.zipContent.close();
+					this.zipContent = null;
+					throw ex;
+				}
+			}
+		}
+	}
+
+	private void connectData() throws IOException {
+		CloseableDataBlock data = this.zipContent.openRawZipData();
+		try {
+			this.size = data.size();
+			this.inputStream = data.asInputStream();
+		}
+		catch (IOException | RuntimeException ex) {
+			data.close();
+		}
+	}
+
+	InputStream getInputStream() throws IOException {
+		synchronized (this) {
+			if (this.inputStream == null) {
+				throw new IOException("Nested location not found " + this.location);
+			}
+			return this.inputStream;
+		}
+	}
+
+	long getContentLength() {
+		return this.size;
+	}
+
+	@Override
+	public void run() {
+		releaseAll();
+	}
+
+	private void releaseAll() {
+		synchronized (this) {
+			if (this.zipContent != null) {
+				IOException exceptionChain = null;
+				try {
+					this.inputStream.close();
+				}
+				catch (IOException ex) {
+					exceptionChain = addToExceptionChain(exceptionChain, ex);
+				}
+				try {
+					this.zipContent.close();
+				}
+				catch (IOException ex) {
+					exceptionChain = addToExceptionChain(exceptionChain, ex);
+				}
+				this.size = -1;
+				if (exceptionChain != null) {
+					throw new UncheckedIOException(exceptionChain);
+				}
+			}
+		}
+	}
+
+	private IOException addToExceptionChain(IOException exceptionChain, IOException ex) {
+		if (exceptionChain != null) {
+			exceptionChain.addSuppressed(ex);
+			return exceptionChain;
+		}
+		return ex;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/package-info.java
new file mode 100644
index 000000000000..1e0426e2a976
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Nested URL support.
+ *
+ * @see org.springframework.boot.loader.net.protocol.nested.NestedLocation
+ * @see org.springframework.boot.loader.net.protocol.nested.Handler
+ */
+package org.springframework.boot.loader.net.protocol.nested;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/package-info.java
new file mode 100644
index 000000000000..fa1a2cfb7a4c
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * {@link java.net.URL} protocol support.
+ */
+package org.springframework.boot.loader.net.protocol;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/UrlDecoder.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/UrlDecoder.java
new file mode 100644
index 000000000000..999c55140e04
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/UrlDecoder.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.util;
+
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.CharsetDecoder;
+import java.nio.charset.CoderResult;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Utility to decode URL strings.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public final class UrlDecoder {
+
+	private UrlDecoder() {
+	}
+
+	/**
+	 * Decode the given string by decoding URL {@code '%'} escapes. This method should be
+	 * identical in behavior to the {@code decode} method in the internal
+	 * {@code sun.net.www.ParseUtil} JDK class.
+	 * @param string the string to decode
+	 * @return the decoded string
+	 */
+	public static String decode(String string) {
+		int length = string.length();
+		if ((length == 0) || (string.indexOf('%') < 0)) {
+			return string;
+		}
+		StringBuilder result = new StringBuilder(length);
+		ByteBuffer byteBuffer = ByteBuffer.allocate(length);
+		CharBuffer charBuffer = CharBuffer.allocate(length);
+		CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
+			.onMalformedInput(CodingErrorAction.REPORT)
+			.onUnmappableCharacter(CodingErrorAction.REPORT);
+		int index = 0;
+		while (index < length) {
+			char ch = string.charAt(index);
+			if (ch != '%') {
+				result.append(ch);
+				if (index + 1 >= length) {
+					return result.toString();
+				}
+				index++;
+				continue;
+			}
+			index = fillByteBuffer(byteBuffer, string, index, length);
+			decodeToCharBuffer(byteBuffer, charBuffer, decoder);
+			result.append(charBuffer.flip());
+
+		}
+		return result.toString();
+	}
+
+	private static int fillByteBuffer(ByteBuffer byteBuffer, String string, int index, int length) {
+		byteBuffer.clear();
+		while (true) {
+			byteBuffer.put(unescape(string, index));
+			index += 3;
+			if (index >= length || string.charAt(index) != '%') {
+				break;
+			}
+		}
+		byteBuffer.flip();
+		return index;
+	}
+
+	private static byte unescape(String string, int index) {
+		try {
+			return (byte) Integer.parseInt(string, index + 1, index + 3, 16);
+		}
+		catch (NumberFormatException ex) {
+			throw new IllegalArgumentException();
+		}
+	}
+
+	private static void decodeToCharBuffer(ByteBuffer byteBuffer, CharBuffer charBuffer, CharsetDecoder decoder) {
+		decoder.reset();
+		charBuffer.clear();
+		assertNoError(decoder.decode(byteBuffer, charBuffer, true));
+		assertNoError(decoder.flush(charBuffer));
+	}
+
+	private static void assertNoError(CoderResult result) {
+		if (result.isError()) {
+			throw new IllegalArgumentException("Error decoding percent encoded characters");
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/package-info.java
new file mode 100644
index 000000000000..231571bee07a
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Net utilities.
+ */
+package org.springframework.boot.loader.net.util;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedByteChannel.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedByteChannel.java
new file mode 100644
index 000000000000..e41b27fd655d
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedByteChannel.java
@@ -0,0 +1,176 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.nio.file;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.lang.ref.Cleaner.Cleanable;
+import java.nio.ByteBuffer;
+import java.nio.channels.ClosedChannelException;
+import java.nio.channels.NonWritableChannelException;
+import java.nio.channels.SeekableByteChannel;
+import java.nio.file.Path;
+
+import org.springframework.boot.loader.net.protocol.nested.NestedLocation;
+import org.springframework.boot.loader.ref.Cleaner;
+import org.springframework.boot.loader.zip.CloseableDataBlock;
+import org.springframework.boot.loader.zip.DataBlock;
+import org.springframework.boot.loader.zip.ZipContent;
+
+/**
+ * {@link SeekableByteChannel} implementation for {@link NestedLocation nested} jar files.
+ *
+ * @author Phillip Webb
+ * @see NestedFileSystemProvider
+ */
+class NestedByteChannel implements SeekableByteChannel {
+
+	private long position;
+
+	private final Resources resources;
+
+	private final Cleanable cleanup;
+
+	private final long size;
+
+	private volatile boolean closed;
+
+	NestedByteChannel(Path path, String nestedEntryName) throws IOException {
+		this(path, nestedEntryName, Cleaner.instance);
+	}
+
+	NestedByteChannel(Path path, String nestedEntryName, Cleaner cleaner) throws IOException {
+		this.resources = new Resources(path, nestedEntryName);
+		this.cleanup = cleaner.register(this, this.resources);
+		this.size = this.resources.getData().size();
+	}
+
+	@Override
+	public boolean isOpen() {
+		return !this.closed;
+	}
+
+	@Override
+	public void close() throws IOException {
+		if (this.closed) {
+			return;
+		}
+		this.closed = true;
+		try {
+			this.cleanup.clean();
+		}
+		catch (UncheckedIOException ex) {
+			throw ex.getCause();
+		}
+	}
+
+	@Override
+	public int read(ByteBuffer dst) throws IOException {
+		assertNotClosed();
+		int count = this.resources.getData().read(dst, this.position);
+		if (count > 0) {
+			this.position += count;
+		}
+		return count;
+	}
+
+	@Override
+	public int write(ByteBuffer src) throws IOException {
+		throw new NonWritableChannelException();
+	}
+
+	@Override
+	public long position() throws IOException {
+		assertNotClosed();
+		return this.position;
+	}
+
+	@Override
+	public SeekableByteChannel position(long position) throws IOException {
+		assertNotClosed();
+		if (position < 0 || position >= this.size) {
+			throw new IllegalArgumentException("Position must be in bounds");
+		}
+		this.position = position;
+		return this;
+	}
+
+	@Override
+	public long size() throws IOException {
+		assertNotClosed();
+		return this.size;
+	}
+
+	@Override
+	public SeekableByteChannel truncate(long size) throws IOException {
+		throw new NonWritableChannelException();
+	}
+
+	private void assertNotClosed() throws ClosedChannelException {
+		if (this.closed) {
+			throw new ClosedChannelException();
+		}
+	}
+
+	/**
+	 * Resources used by the channel and suitable for registration with a {@link Cleaner}.
+	 */
+	static class Resources implements Runnable {
+
+		private final ZipContent zipContent;
+
+		private final CloseableDataBlock data;
+
+		Resources(Path path, String nestedEntryName) throws IOException {
+			this.zipContent = ZipContent.open(path, nestedEntryName);
+			this.data = this.zipContent.openRawZipData();
+		}
+
+		DataBlock getData() {
+			return this.data;
+		}
+
+		@Override
+		public void run() {
+			releaseAll();
+		}
+
+		private void releaseAll() {
+			IOException exception = null;
+			try {
+				this.data.close();
+			}
+			catch (IOException ex) {
+				exception = ex;
+			}
+			try {
+				this.zipContent.close();
+			}
+			catch (IOException ex) {
+				if (exception != null) {
+					ex.addSuppressed(exception);
+				}
+				exception = ex;
+			}
+			if (exception != null) {
+				throw new UncheckedIOException(exception);
+			}
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedFileStore.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedFileStore.java
new file mode 100644
index 000000000000..c5a7edb559eb
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedFileStore.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.nio.file;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.FileStore;
+import java.nio.file.Files;
+import java.nio.file.attribute.FileAttributeView;
+import java.nio.file.attribute.FileStoreAttributeView;
+
+import org.springframework.boot.loader.net.protocol.nested.NestedLocation;
+
+/**
+ * {@link FileStore} implementation for {@link NestedLocation nested} jar files.
+ *
+ * @author Phillip Webb
+ * @see NestedFileSystemProvider
+ */
+class NestedFileStore extends FileStore {
+
+	private final NestedFileSystem fileSystem;
+
+	NestedFileStore(NestedFileSystem fileSystem) {
+		this.fileSystem = fileSystem;
+	}
+
+	@Override
+	public String name() {
+		return this.fileSystem.toString();
+	}
+
+	@Override
+	public String type() {
+		return "nestedfs";
+	}
+
+	@Override
+	public boolean isReadOnly() {
+		return this.fileSystem.isReadOnly();
+	}
+
+	@Override
+	public long getTotalSpace() throws IOException {
+		return 0;
+	}
+
+	@Override
+	public long getUsableSpace() throws IOException {
+		return 0;
+	}
+
+	@Override
+	public long getUnallocatedSpace() throws IOException {
+		return 0;
+	}
+
+	@Override
+	public boolean supportsFileAttributeView(Class<? extends FileAttributeView> type) {
+		return getJarPathFileStore().supportsFileAttributeView(type);
+	}
+
+	@Override
+	public boolean supportsFileAttributeView(String name) {
+		return getJarPathFileStore().supportsFileAttributeView(name);
+	}
+
+	@Override
+	public <V extends FileStoreAttributeView> V getFileStoreAttributeView(Class<V> type) {
+		return getJarPathFileStore().getFileStoreAttributeView(type);
+	}
+
+	@Override
+	public Object getAttribute(String attribute) throws IOException {
+		try {
+			return getJarPathFileStore().getAttribute(attribute);
+		}
+		catch (UncheckedIOException ex) {
+			throw ex.getCause();
+		}
+	}
+
+	protected FileStore getJarPathFileStore() {
+		try {
+			return Files.getFileStore(this.fileSystem.getJarPath());
+		}
+		catch (IOException ex) {
+			throw new UncheckedIOException(ex);
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedFileSystem.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedFileSystem.java
new file mode 100644
index 000000000000..be38b10cd020
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedFileSystem.java
@@ -0,0 +1,229 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.nio.file;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.file.ClosedFileSystemException;
+import java.nio.file.FileStore;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystemNotFoundException;
+import java.nio.file.FileSystems;
+import java.nio.file.Path;
+import java.nio.file.PathMatcher;
+import java.nio.file.WatchService;
+import java.nio.file.attribute.UserPrincipalLookupService;
+import java.nio.file.spi.FileSystemProvider;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import org.springframework.boot.loader.net.protocol.nested.NestedLocation;
+
+/**
+ * {@link FileSystem} implementation for {@link NestedLocation nested} jar files.
+ *
+ * @author Phillip Webb
+ * @see NestedFileSystemProvider
+ */
+class NestedFileSystem extends FileSystem {
+
+	private static final Set<String> SUPPORTED_FILE_ATTRIBUTE_VIEWS = Set.of("basic");
+
+	private static final String FILE_SYSTEMS_CLASS_NAME = FileSystems.class.getName();
+
+	private static final Object EXISTING_FILE_SYSTEM = new Object();
+
+	private final NestedFileSystemProvider provider;
+
+	private final Path jarPath;
+
+	private volatile boolean closed;
+
+	private final Map<String, Object> zipFileSystems = new HashMap<>();
+
+	NestedFileSystem(NestedFileSystemProvider provider, Path jarPath) {
+		if (provider == null || jarPath == null) {
+			throw new IllegalArgumentException("Provider and JarPath must not be null");
+		}
+		this.provider = provider;
+		this.jarPath = jarPath;
+	}
+
+	void installZipFileSystemIfNecessary(String nestedEntryName) {
+		try {
+			boolean seen;
+			synchronized (this.zipFileSystems) {
+				seen = this.zipFileSystems.putIfAbsent(nestedEntryName, EXISTING_FILE_SYSTEM) != null;
+			}
+			if (!seen) {
+				URI uri = new URI("jar:nested:" + this.jarPath.toUri().getPath() + "/!" + nestedEntryName);
+				if (!hasFileSystem(uri)) {
+					FileSystem zipFileSystem = FileSystems.newFileSystem(uri, Collections.emptyMap());
+					synchronized (this.zipFileSystems) {
+						this.zipFileSystems.put(nestedEntryName, zipFileSystem);
+					}
+				}
+			}
+		}
+		catch (Exception ex) {
+			// Ignore
+		}
+	}
+
+	private boolean hasFileSystem(URI uri) {
+		try {
+			FileSystems.getFileSystem(uri);
+			return true;
+		}
+		catch (FileSystemNotFoundException ex) {
+			return isCreatingNewFileSystem();
+		}
+	}
+
+	private boolean isCreatingNewFileSystem() {
+		StackTraceElement[] stack = Thread.currentThread().getStackTrace();
+		if (stack != null) {
+			for (StackTraceElement element : stack) {
+				if (FILE_SYSTEMS_CLASS_NAME.equals(element.getClassName())) {
+					return "newFileSystem".equals(element.getMethodName());
+				}
+			}
+		}
+		return false;
+	}
+
+	@Override
+	public FileSystemProvider provider() {
+		return this.provider;
+	}
+
+	Path getJarPath() {
+		return this.jarPath;
+	}
+
+	@Override
+	public void close() throws IOException {
+		if (this.closed) {
+			return;
+		}
+		this.closed = true;
+		synchronized (this.zipFileSystems) {
+			this.zipFileSystems.values()
+				.stream()
+				.filter(FileSystem.class::isInstance)
+				.map(FileSystem.class::cast)
+				.forEach(this::closeZipFileSystem);
+		}
+		this.provider.removeFileSystem(this);
+	}
+
+	private void closeZipFileSystem(FileSystem zipFileSystem) {
+		try {
+			zipFileSystem.close();
+		}
+		catch (Exception ex) {
+		}
+	}
+
+	@Override
+	public boolean isOpen() {
+		return !this.closed;
+	}
+
+	@Override
+	public boolean isReadOnly() {
+		return true;
+	}
+
+	@Override
+	public String getSeparator() {
+		return "/!";
+	}
+
+	@Override
+	public Iterable<Path> getRootDirectories() {
+		assertNotClosed();
+		return Collections.emptySet();
+	}
+
+	@Override
+	public Iterable<FileStore> getFileStores() {
+		assertNotClosed();
+		return Collections.emptySet();
+	}
+
+	@Override
+	public Set<String> supportedFileAttributeViews() {
+		assertNotClosed();
+		return SUPPORTED_FILE_ATTRIBUTE_VIEWS;
+	}
+
+	@Override
+	public Path getPath(String first, String... more) {
+		assertNotClosed();
+		if (first == null || first.isBlank() || more.length != 0) {
+			throw new IllegalArgumentException("Nested paths must contain a single element");
+		}
+		return new NestedPath(this, first);
+	}
+
+	@Override
+	public PathMatcher getPathMatcher(String syntaxAndPattern) {
+		throw new UnsupportedOperationException("Nested paths do not support path matchers");
+	}
+
+	@Override
+	public UserPrincipalLookupService getUserPrincipalLookupService() {
+		throw new UnsupportedOperationException("Nested paths do not have a user principal lookup service");
+	}
+
+	@Override
+	public WatchService newWatchService() throws IOException {
+		throw new UnsupportedOperationException("Nested paths do not support the WacherService");
+	}
+
+	@Override
+	public boolean equals(Object obj) {
+		if (this == obj) {
+			return true;
+		}
+		if (obj == null || getClass() != obj.getClass()) {
+			return false;
+		}
+		NestedFileSystem other = (NestedFileSystem) obj;
+		return this.jarPath.equals(other.jarPath);
+	}
+
+	@Override
+	public int hashCode() {
+		return this.jarPath.hashCode();
+	}
+
+	@Override
+	public String toString() {
+		return this.jarPath.toAbsolutePath().toString();
+	}
+
+	private void assertNotClosed() {
+		if (this.closed) {
+			throw new ClosedFileSystemException();
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedFileSystemProvider.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedFileSystemProvider.java
new file mode 100644
index 000000000000..ca136748df8c
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedFileSystemProvider.java
@@ -0,0 +1,186 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.nio.file;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.channels.SeekableByteChannel;
+import java.nio.file.AccessMode;
+import java.nio.file.CopyOption;
+import java.nio.file.DirectoryStream;
+import java.nio.file.DirectoryStream.Filter;
+import java.nio.file.FileStore;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystemAlreadyExistsException;
+import java.nio.file.FileSystemNotFoundException;
+import java.nio.file.LinkOption;
+import java.nio.file.NotDirectoryException;
+import java.nio.file.OpenOption;
+import java.nio.file.Path;
+import java.nio.file.ReadOnlyFileSystemException;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.FileAttribute;
+import java.nio.file.attribute.FileAttributeView;
+import java.nio.file.spi.FileSystemProvider;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import org.springframework.boot.loader.net.protocol.nested.NestedLocation;
+
+/**
+ * {@link FileSystemProvider} implementation for {@link NestedLocation nested} jar files.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public class NestedFileSystemProvider extends FileSystemProvider {
+
+	private Map<Path, NestedFileSystem> fileSystems = new HashMap<>();
+
+	@Override
+	public String getScheme() {
+		return "nested";
+	}
+
+	@Override
+	public FileSystem newFileSystem(URI uri, Map<String, ?> env) throws IOException {
+		NestedLocation location = NestedLocation.fromUri(uri);
+		Path jarPath = location.path();
+		synchronized (this.fileSystems) {
+			if (this.fileSystems.containsKey(jarPath)) {
+				throw new FileSystemAlreadyExistsException();
+			}
+			NestedFileSystem fileSystem = new NestedFileSystem(this, location.path());
+			this.fileSystems.put(location.path(), fileSystem);
+			return fileSystem;
+		}
+	}
+
+	@Override
+	public FileSystem getFileSystem(URI uri) {
+		NestedLocation location = NestedLocation.fromUri(uri);
+		synchronized (this.fileSystems) {
+			NestedFileSystem fileSystem = this.fileSystems.get(location.path());
+			if (fileSystem == null) {
+				throw new FileSystemNotFoundException();
+			}
+			return fileSystem;
+		}
+	}
+
+	@Override
+	public Path getPath(URI uri) {
+		NestedLocation location = NestedLocation.fromUri(uri);
+		synchronized (this.fileSystems) {
+			NestedFileSystem fileSystem = this.fileSystems.computeIfAbsent(location.path(),
+					(path) -> new NestedFileSystem(this, path));
+			fileSystem.installZipFileSystemIfNecessary(location.nestedEntryName());
+			return fileSystem.getPath(location.nestedEntryName());
+		}
+	}
+
+	void removeFileSystem(NestedFileSystem fileSystem) {
+		synchronized (this.fileSystems) {
+			this.fileSystems.remove(fileSystem.getJarPath());
+		}
+	}
+
+	@Override
+	public SeekableByteChannel newByteChannel(Path path, Set<? extends OpenOption> options, FileAttribute<?>... attrs)
+			throws IOException {
+		NestedPath nestedPath = NestedPath.cast(path);
+		return new NestedByteChannel(nestedPath.getJarPath(), nestedPath.getNestedEntryName());
+	}
+
+	@Override
+	public DirectoryStream<Path> newDirectoryStream(Path dir, Filter<? super Path> filter) throws IOException {
+		throw new NotDirectoryException(NestedPath.cast(dir).toString());
+	}
+
+	@Override
+	public void createDirectory(Path dir, FileAttribute<?>... attrs) throws IOException {
+		throw new ReadOnlyFileSystemException();
+	}
+
+	@Override
+	public void delete(Path path) throws IOException {
+		throw new ReadOnlyFileSystemException();
+	}
+
+	@Override
+	public void copy(Path source, Path target, CopyOption... options) throws IOException {
+		throw new ReadOnlyFileSystemException();
+	}
+
+	@Override
+	public void move(Path source, Path target, CopyOption... options) throws IOException {
+		throw new ReadOnlyFileSystemException();
+	}
+
+	@Override
+	public boolean isSameFile(Path path, Path path2) throws IOException {
+		return path.equals(path2);
+	}
+
+	@Override
+	public boolean isHidden(Path path) throws IOException {
+		return false;
+	}
+
+	@Override
+	public FileStore getFileStore(Path path) throws IOException {
+		NestedPath nestedPath = NestedPath.cast(path);
+		nestedPath.assertExists();
+		return new NestedFileStore(nestedPath.getFileSystem());
+	}
+
+	@Override
+	public void checkAccess(Path path, AccessMode... modes) throws IOException {
+		Path jarPath = getJarPath(path);
+		jarPath.getFileSystem().provider().checkAccess(jarPath, modes);
+	}
+
+	@Override
+	public <V extends FileAttributeView> V getFileAttributeView(Path path, Class<V> type, LinkOption... options) {
+		Path jarPath = getJarPath(path);
+		return jarPath.getFileSystem().provider().getFileAttributeView(jarPath, type, options);
+	}
+
+	@Override
+	public <A extends BasicFileAttributes> A readAttributes(Path path, Class<A> type, LinkOption... options)
+			throws IOException {
+		Path jarPath = getJarPath(path);
+		return jarPath.getFileSystem().provider().readAttributes(jarPath, type, options);
+	}
+
+	@Override
+	public Map<String, Object> readAttributes(Path path, String attributes, LinkOption... options) throws IOException {
+		Path jarPath = getJarPath(path);
+		return jarPath.getFileSystem().provider().readAttributes(jarPath, attributes, options);
+	}
+
+	protected Path getJarPath(Path path) {
+		return NestedPath.cast(path).getJarPath();
+	}
+
+	@Override
+	public void setAttribute(Path path, String attribute, Object value, LinkOption... options) throws IOException {
+		throw new ReadOnlyFileSystemException();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedPath.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedPath.java
new file mode 100644
index 000000000000..163af41784a9
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedPath.java
@@ -0,0 +1,221 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.nio.file;
+
+import java.io.IOError;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.ProviderMismatchException;
+import java.nio.file.WatchEvent.Kind;
+import java.nio.file.WatchEvent.Modifier;
+import java.nio.file.WatchKey;
+import java.nio.file.WatchService;
+import java.util.Objects;
+
+import org.springframework.boot.loader.net.protocol.nested.NestedLocation;
+import org.springframework.boot.loader.zip.ZipContent;
+
+/**
+ * {@link Path} implementation for {@link NestedLocation nested} jar files.
+ *
+ * @author Phillip Webb
+ * @see NestedFileSystemProvider
+ */
+final class NestedPath implements Path {
+
+	private final NestedFileSystem fileSystem;
+
+	private final String nestedEntryName;
+
+	private volatile Boolean entryExists;
+
+	NestedPath(NestedFileSystem fileSystem, String nestedEntryName) {
+		if (fileSystem == null || nestedEntryName == null || nestedEntryName.isBlank()) {
+			throw new IllegalArgumentException("'filesSystem' and 'nestedEntryName' are required");
+		}
+		this.fileSystem = fileSystem;
+		this.nestedEntryName = nestedEntryName;
+	}
+
+	Path getJarPath() {
+		return this.fileSystem.getJarPath();
+	}
+
+	String getNestedEntryName() {
+		return this.nestedEntryName;
+	}
+
+	@Override
+	public NestedFileSystem getFileSystem() {
+		return this.fileSystem;
+	}
+
+	@Override
+	public boolean isAbsolute() {
+		return true;
+	}
+
+	@Override
+	public Path getRoot() {
+		return null;
+	}
+
+	@Override
+	public Path getFileName() {
+		return this;
+	}
+
+	@Override
+	public Path getParent() {
+		return null;
+	}
+
+	@Override
+	public int getNameCount() {
+		return 1;
+	}
+
+	@Override
+	public Path getName(int index) {
+		if (index != 0) {
+			throw new IllegalArgumentException("Nested paths only have a single element");
+		}
+		return this;
+	}
+
+	@Override
+	public Path subpath(int beginIndex, int endIndex) {
+		if (beginIndex != 0 || endIndex != 1) {
+			throw new IllegalArgumentException("Nested paths only have a single element");
+		}
+		return this;
+	}
+
+	@Override
+	public boolean startsWith(Path other) {
+		return equals(other);
+	}
+
+	@Override
+	public boolean endsWith(Path other) {
+		return equals(other);
+	}
+
+	@Override
+	public Path normalize() {
+		return this;
+	}
+
+	@Override
+	public Path resolve(Path other) {
+		throw new UnsupportedOperationException("Unable to resolve nested path");
+	}
+
+	@Override
+	public Path relativize(Path other) {
+		throw new UnsupportedOperationException("Unable to relativize nested path");
+	}
+
+	@Override
+	public URI toUri() {
+		try {
+			String jarFilePath = this.fileSystem.getJarPath().toUri().getPath();
+			return new URI("nested:" + jarFilePath + "/!" + this.nestedEntryName);
+		}
+		catch (URISyntaxException ex) {
+			throw new IOError(ex);
+		}
+	}
+
+	@Override
+	public Path toAbsolutePath() {
+		return this;
+	}
+
+	@Override
+	public Path toRealPath(LinkOption... options) throws IOException {
+		return this;
+	}
+
+	@Override
+	public WatchKey register(WatchService watcher, Kind<?>[] events, Modifier... modifiers) throws IOException {
+		throw new UnsupportedOperationException("Nested paths cannot be watched");
+	}
+
+	@Override
+	public int compareTo(Path other) {
+		NestedPath otherNestedPath = cast(other);
+		return this.nestedEntryName.compareTo(otherNestedPath.nestedEntryName);
+	}
+
+	@Override
+	public boolean equals(Object obj) {
+		if (this == obj) {
+			return true;
+		}
+		if (obj == null || getClass() != obj.getClass()) {
+			return false;
+		}
+		NestedPath other = (NestedPath) obj;
+		return Objects.equals(this.fileSystem, other.fileSystem)
+				&& Objects.equals(this.nestedEntryName, other.nestedEntryName);
+	}
+
+	@Override
+	public int hashCode() {
+		return Objects.hash(this.fileSystem, this.nestedEntryName);
+	}
+
+	@Override
+	public String toString() {
+		return this.fileSystem.getJarPath() + this.fileSystem.getSeparator() + this.nestedEntryName;
+	}
+
+	void assertExists() throws NoSuchFileException {
+		if (!Files.isRegularFile(getJarPath())) {
+			throw new NoSuchFileException(toString());
+		}
+		Boolean entryExists = this.entryExists;
+		if (entryExists == null) {
+			try {
+				try (ZipContent content = ZipContent.open(getJarPath(), this.nestedEntryName)) {
+					entryExists = true;
+				}
+			}
+			catch (IOException ex) {
+				entryExists = false;
+			}
+			this.entryExists = entryExists;
+		}
+		if (!entryExists) {
+			throw new NoSuchFileException(toString());
+		}
+	}
+
+	static NestedPath cast(Path path) {
+		if (path instanceof NestedPath nestedPath) {
+			return nestedPath;
+		}
+		throw new ProviderMismatchException();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/package-info.java
new file mode 100644
index 000000000000..6431f845345c
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Non-blocking IO {@link java.nio.file.FileSystem} implementation for nested suppoprt.
+ */
+package org.springframework.boot.loader.nio.file;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/package-info.java
deleted file mode 100644
index c2114c2d83bb..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/package-info.java
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * System that allows self-contained JAR/WAR archives to be launched using
- * {@code java -jar}. Archives can include nested packaged dependency JARs (there is no
- * need to create shade style jars) and are executed without unpacking. The only
- * constraint is that nested JARs must be stored in the archive uncompressed.
- *
- * @see org.springframework.boot.loader.JarLauncher
- * @see org.springframework.boot.loader.WarLauncher
- */
-package org.springframework.boot.loader;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/Cleaner.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/Cleaner.java
new file mode 100644
index 000000000000..4b053b78d9fb
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/Cleaner.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.ref;
+
+import java.lang.ref.Cleaner.Cleanable;
+
+/**
+ * Wrapper for {@link java.lang.ref.Cleaner} providing registration support.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public interface Cleaner {
+
+	/**
+	 * Provides access to the default clean instance which delegates to
+	 * {@link java.lang.ref.Cleaner}.
+	 */
+	Cleaner instance = DefaultCleaner.instance;
+
+	/**
+	 * Registers an object and the clean action to run when the object becomes phantom
+	 * reachable.
+	 * @param obj the object to monitor
+	 * @param action the cleanup action to run
+	 * @return a {@link Cleanable} instance
+	 * @see java.lang.ref.Cleaner#register(Object, Runnable)
+	 */
+	Cleanable register(Object obj, Runnable action);
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/DefaultCleaner.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/DefaultCleaner.java
new file mode 100644
index 000000000000..01c6817a38a0
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/DefaultCleaner.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.ref;
+
+import java.lang.ref.Cleaner.Cleanable;
+import java.util.function.BiConsumer;
+
+/**
+ * Default {@link Cleaner} implementation that delegates to {@link java.lang.ref.Cleaner}.
+ *
+ * @author Phillip Webb
+ */
+class DefaultCleaner implements Cleaner {
+
+	static final DefaultCleaner instance = new DefaultCleaner();
+
+	static BiConsumer<Object, Cleanable> tracker;
+
+	private final java.lang.ref.Cleaner cleaner = java.lang.ref.Cleaner.create();
+
+	@Override
+	public Cleanable register(Object obj, Runnable action) {
+		Cleanable cleanable = (action != null) ? this.cleaner.register(obj, action) : null;
+		if (tracker != null) {
+			tracker.accept(obj, cleanable);
+		}
+		return cleanable;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/package-info.java
new file mode 100644
index 000000000000..4cb63bb4a64d
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Support for {@link java.lang.ref.Cleaner}.
+ */
+package org.springframework.boot.loader.ref;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java
deleted file mode 100644
index b6f0e3a3a7fb..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java
+++ /dev/null
@@ -1,232 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.loader.util;
-
-import java.util.HashSet;
-import java.util.Locale;
-import java.util.Properties;
-import java.util.Set;
-
-/**
- * Helper class for resolving placeholders in texts. Usually applied to file paths.
- * <p>
- * A text may contain {@code $ ...} placeholders, to be resolved as system properties:
- * e.g. {@code $ user.dir}. Default values can be supplied using the ":" separator between
- * key and value.
- * <p>
- * Adapted from Spring.
- *
- * @author Juergen Hoeller
- * @author Rob Harrop
- * @author Dave Syer
- * @since 1.0.0
- * @see System#getProperty(String)
- */
-public abstract class SystemPropertyUtils {
-
-	/**
-	 * Prefix for system property placeholders: "${".
-	 */
-	public static final String PLACEHOLDER_PREFIX = "${";
-
-	/**
-	 * Suffix for system property placeholders: "}".
-	 */
-	public static final String PLACEHOLDER_SUFFIX = "}";
-
-	/**
-	 * Value separator for system property placeholders: ":".
-	 */
-	public static final String VALUE_SEPARATOR = ":";
-
-	private static final String SIMPLE_PREFIX = PLACEHOLDER_PREFIX.substring(1);
-
-	/**
-	 * Resolve ${...} placeholders in the given text, replacing them with corresponding
-	 * system property values.
-	 * @param text the String to resolve
-	 * @return the resolved String
-	 * @throws IllegalArgumentException if there is an unresolvable placeholder
-	 * @see #PLACEHOLDER_PREFIX
-	 * @see #PLACEHOLDER_SUFFIX
-	 */
-	public static String resolvePlaceholders(String text) {
-		if (text == null) {
-			return text;
-		}
-		return parseStringValue(null, text, text, new HashSet<>());
-	}
-
-	/**
-	 * Resolve ${...} placeholders in the given text, replacing them with corresponding
-	 * system property values.
-	 * @param properties a properties instance to use in addition to System
-	 * @param text the String to resolve
-	 * @return the resolved String
-	 * @throws IllegalArgumentException if there is an unresolvable placeholder
-	 * @see #PLACEHOLDER_PREFIX
-	 * @see #PLACEHOLDER_SUFFIX
-	 */
-	public static String resolvePlaceholders(Properties properties, String text) {
-		if (text == null) {
-			return text;
-		}
-		return parseStringValue(properties, text, text, new HashSet<>());
-	}
-
-	private static String parseStringValue(Properties properties, String value, String current,
-			Set<String> visitedPlaceholders) {
-
-		StringBuilder buf = new StringBuilder(current);
-
-		int startIndex = current.indexOf(PLACEHOLDER_PREFIX);
-		while (startIndex != -1) {
-			int endIndex = findPlaceholderEndIndex(buf, startIndex);
-			if (endIndex != -1) {
-				String placeholder = buf.substring(startIndex + PLACEHOLDER_PREFIX.length(), endIndex);
-				String originalPlaceholder = placeholder;
-				if (!visitedPlaceholders.add(originalPlaceholder)) {
-					throw new IllegalArgumentException(
-							"Circular placeholder reference '" + originalPlaceholder + "' in property definitions");
-				}
-				// Recursive invocation, parsing placeholders contained in the
-				// placeholder
-				// key.
-				placeholder = parseStringValue(properties, value, placeholder, visitedPlaceholders);
-				// Now obtain the value for the fully resolved key...
-				String propVal = resolvePlaceholder(properties, value, placeholder);
-				if (propVal == null) {
-					int separatorIndex = placeholder.indexOf(VALUE_SEPARATOR);
-					if (separatorIndex != -1) {
-						String actualPlaceholder = placeholder.substring(0, separatorIndex);
-						String defaultValue = placeholder.substring(separatorIndex + VALUE_SEPARATOR.length());
-						propVal = resolvePlaceholder(properties, value, actualPlaceholder);
-						if (propVal == null) {
-							propVal = defaultValue;
-						}
-					}
-				}
-				if (propVal != null) {
-					// Recursive invocation, parsing placeholders contained in the
-					// previously resolved placeholder value.
-					propVal = parseStringValue(properties, value, propVal, visitedPlaceholders);
-					buf.replace(startIndex, endIndex + PLACEHOLDER_SUFFIX.length(), propVal);
-					startIndex = buf.indexOf(PLACEHOLDER_PREFIX, startIndex + propVal.length());
-				}
-				else {
-					// Proceed with unprocessed value.
-					startIndex = buf.indexOf(PLACEHOLDER_PREFIX, endIndex + PLACEHOLDER_SUFFIX.length());
-				}
-				visitedPlaceholders.remove(originalPlaceholder);
-			}
-			else {
-				startIndex = -1;
-			}
-		}
-
-		return buf.toString();
-	}
-
-	private static String resolvePlaceholder(Properties properties, String text, String placeholderName) {
-		String propVal = getProperty(placeholderName, null, text);
-		if (propVal != null) {
-			return propVal;
-		}
-		return (properties != null) ? properties.getProperty(placeholderName) : null;
-	}
-
-	public static String getProperty(String key) {
-		return getProperty(key, null, "");
-	}
-
-	public static String getProperty(String key, String defaultValue) {
-		return getProperty(key, defaultValue, "");
-	}
-
-	/**
-	 * Search the System properties and environment variables for a value with the
-	 * provided key. Environment variables in {@code UPPER_CASE} style are allowed where
-	 * System properties would normally be {@code lower.case}.
-	 * @param key the key to resolve
-	 * @param defaultValue the default value
-	 * @param text optional extra context for an error message if the key resolution fails
-	 * (e.g. if System properties are not accessible)
-	 * @return a static property value or null of not found
-	 */
-	public static String getProperty(String key, String defaultValue, String text) {
-		try {
-			String propVal = System.getProperty(key);
-			if (propVal == null) {
-				// Fall back to searching the system environment.
-				propVal = System.getenv(key);
-			}
-			if (propVal == null) {
-				// Try with underscores.
-				String name = key.replace('.', '_');
-				propVal = System.getenv(name);
-			}
-			if (propVal == null) {
-				// Try uppercase with underscores as well.
-				String name = key.toUpperCase(Locale.ENGLISH).replace('.', '_');
-				propVal = System.getenv(name);
-			}
-			if (propVal != null) {
-				return propVal;
-			}
-		}
-		catch (Throwable ex) {
-			System.err.println("Could not resolve key '" + key + "' in '" + text
-					+ "' as system property or in environment: " + ex);
-		}
-		return defaultValue;
-	}
-
-	private static int findPlaceholderEndIndex(CharSequence buf, int startIndex) {
-		int index = startIndex + PLACEHOLDER_PREFIX.length();
-		int withinNestedPlaceholder = 0;
-		while (index < buf.length()) {
-			if (substringMatch(buf, index, PLACEHOLDER_SUFFIX)) {
-				if (withinNestedPlaceholder > 0) {
-					withinNestedPlaceholder--;
-					index = index + PLACEHOLDER_SUFFIX.length();
-				}
-				else {
-					return index;
-				}
-			}
-			else if (substringMatch(buf, index, SIMPLE_PREFIX)) {
-				withinNestedPlaceholder++;
-				index = index + SIMPLE_PREFIX.length();
-			}
-			else {
-				index++;
-			}
-		}
-		return -1;
-	}
-
-	private static boolean substringMatch(CharSequence str, int index, CharSequence substring) {
-		for (int j = 0; j < substring.length(); j++) {
-			int i = index + j;
-			if (i >= str.length() || str.charAt(i) != substring.charAt(j)) {
-				return false;
-			}
-		}
-		return true;
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/util/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/util/package-info.java
deleted file mode 100644
index af0aa2d1a7dc..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/util/package-info.java
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * Copyright 2012-2019 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * Utilities used by Spring Boot's JAR loading.
- */
-package org.springframework.boot.loader.util;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ByteArrayDataBlock.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ByteArrayDataBlock.java
new file mode 100644
index 000000000000..d1a4f7fcf982
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ByteArrayDataBlock.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+/**
+ * {@link DataBlock} backed by a byte array .
+ *
+ * @author Phillip Webb
+ */
+class ByteArrayDataBlock implements CloseableDataBlock {
+
+	private final byte[] bytes;
+
+	/**
+	 * Create a new {@link ByteArrayDataBlock} backed by the given bytes.
+	 * @param bytes the bytes to use
+	 */
+	ByteArrayDataBlock(byte... bytes) {
+		this.bytes = bytes;
+	}
+
+	@Override
+	public long size() throws IOException {
+		return this.bytes.length;
+	}
+
+	@Override
+	public int read(ByteBuffer dst, long pos) throws IOException {
+		return read(dst, (int) pos);
+	}
+
+	private int read(ByteBuffer dst, int pos) {
+		int remaining = dst.remaining();
+		int length = Math.min(this.bytes.length - pos, remaining);
+		dst.put(this.bytes, pos, length);
+		return length;
+	}
+
+	@Override
+	public void close() throws IOException {
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/CloseableDataBlock.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/CloseableDataBlock.java
new file mode 100644
index 000000000000..6303daf4dcd6
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/CloseableDataBlock.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.Closeable;
+
+/**
+ * A {@link Closeable} {@link DataBlock}.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public interface CloseableDataBlock extends DataBlock, Closeable {
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/DataBlock.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/DataBlock.java
new file mode 100644
index 000000000000..b37cad6a82d1
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/DataBlock.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+
+/**
+ * Provides read access to a block of data contained somewhere in a zip file.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public interface DataBlock {
+
+	/**
+	 * Return the size of this block.
+	 * @return the block size
+	 * @throws IOException on I/O error
+	 */
+	long size() throws IOException;
+
+	/**
+	 * Read a sequence of bytes from this channel into the given buffer, starting at the
+	 * given block position.
+	 * @param dst the buffer into which bytes are to be transferred
+	 * @param pos the position within the block at which the transfer is to begin
+	 * @return the number of bytes read, possibly zero, or {@code -1} if the given
+	 * position is greater than or equal to the block size
+	 * @throws IOException on I/O error
+	 * @see #readFully(ByteBuffer, long)
+	 * @see FileChannel#read(ByteBuffer, long)
+	 */
+	int read(ByteBuffer dst, long pos) throws IOException;
+
+	/**
+	 * Fully read a sequence of bytes from this channel into the given buffer, starting at
+	 * the given block position and filling {@link ByteBuffer#remaining() remaining} bytes
+	 * in the buffer.
+	 * @param dst the buffer into which bytes are to be transferred
+	 * @param pos the position within the block at which the transfer is to begin
+	 * @throws EOFException if an attempt is made to read past the end of the block
+	 * @throws IOException on I/O error
+	 */
+	default void readFully(ByteBuffer dst, long pos) throws IOException {
+		do {
+			int count = read(dst, pos);
+			if (count <= 0) {
+				throw new EOFException();
+			}
+			pos += count;
+		}
+		while (dst.hasRemaining());
+	}
+
+	/**
+	 * Return this {@link DataBlock} as an {@link InputStream}.
+	 * @return an {@link InputStream} to read the data block content
+	 * @throws IOException on IO error
+	 */
+	default InputStream asInputStream() throws IOException {
+		return new DataBlockInputStream(this);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/DataBlockInputStream.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/DataBlockInputStream.java
new file mode 100644
index 000000000000..3f9b0275bed4
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/DataBlockInputStream.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+
+/**
+ * {@link InputStream} backed by a {@link DataBlock}.
+ *
+ * @author Phillip Webb
+ */
+class DataBlockInputStream extends InputStream {
+
+	private final DataBlock dataBlock;
+
+	private long pos;
+
+	private long remaining;
+
+	private volatile boolean closed;
+
+	DataBlockInputStream(DataBlock dataBlock) throws IOException {
+		this.dataBlock = dataBlock;
+		this.remaining = dataBlock.size();
+	}
+
+	@Override
+	public int read() throws IOException {
+		byte[] b = new byte[1];
+		return (read(b, 0, 1) == 1) ? b[0] & 0xFF : -1;
+	}
+
+	@Override
+	public int read(byte[] b, int off, int len) throws IOException {
+		ensureOpen();
+		ByteBuffer dst = ByteBuffer.wrap(b, off, len);
+		int count = this.dataBlock.read(dst, this.pos);
+		if (count > 0) {
+			this.pos += count;
+			this.remaining -= count;
+		}
+		return count;
+	}
+
+	@Override
+	public long skip(long n) throws IOException {
+		long count = (n > 0) ? maxForwardSkip(n) : maxBackwardSkip(n);
+		this.pos += count;
+		this.remaining -= count;
+		return count;
+	}
+
+	private long maxForwardSkip(long n) {
+		boolean willCauseOverflow = (this.pos + n) < 0;
+		return (willCauseOverflow || n > this.remaining) ? this.remaining : n;
+	}
+
+	private long maxBackwardSkip(long n) {
+		return Math.max(-this.pos, n);
+	}
+
+	@Override
+	public int available() {
+		if (this.closed) {
+			return 0;
+		}
+		return (this.remaining < Integer.MAX_VALUE) ? (int) this.remaining : Integer.MAX_VALUE;
+	}
+
+	private void ensureOpen() throws IOException {
+		if (this.closed) {
+			throw new IOException("InputStream closed");
+		}
+	}
+
+	@Override
+	public void close() throws IOException {
+		if (this.closed) {
+			return;
+		}
+		this.closed = true;
+		if (this.dataBlock instanceof Closeable closeable) {
+			closeable.close();
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/FileChannelDataBlock.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/FileChannelDataBlock.java
new file mode 100644
index 000000000000..0346a87d4d0e
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/FileChannelDataBlock.java
@@ -0,0 +1,275 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.ClosedByInterruptException;
+import java.nio.channels.ClosedChannelException;
+import java.nio.channels.FileChannel;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.function.Supplier;
+
+import org.springframework.boot.loader.log.DebugLogger;
+
+/**
+ * Reference counted {@link DataBlock} implementation backed by a {@link FileChannel} with
+ * support for slicing.
+ *
+ * @author Phillip Webb
+ */
+class FileChannelDataBlock implements CloseableDataBlock {
+
+	private static final DebugLogger debug = DebugLogger.get(FileChannelDataBlock.class);
+
+	static Tracker tracker;
+
+	private final ManagedFileChannel channel;
+
+	private final long offset;
+
+	private final long size;
+
+	FileChannelDataBlock(Path path) throws IOException {
+		this.channel = new ManagedFileChannel(path);
+		this.offset = 0;
+		this.size = Files.size(path);
+	}
+
+	FileChannelDataBlock(ManagedFileChannel channel, long offset, long size) {
+		this.channel = channel;
+		this.offset = offset;
+		this.size = size;
+	}
+
+	@Override
+	public long size() throws IOException {
+		return this.size;
+	}
+
+	@Override
+	public int read(ByteBuffer dst, long pos) throws IOException {
+		if (pos < 0) {
+			throw new IllegalArgumentException("Position must not be negative");
+		}
+		ensureOpen(ClosedChannelException::new);
+		int remaining = (int) (this.size - pos);
+		if (remaining <= 0) {
+			return -1;
+		}
+		int originalDestinationLimit = -1;
+		if (dst.remaining() > remaining) {
+			originalDestinationLimit = dst.limit();
+			dst.limit(dst.position() + remaining);
+		}
+		int result = this.channel.read(dst, this.offset + pos);
+		if (originalDestinationLimit != -1) {
+			dst.limit(originalDestinationLimit);
+		}
+		return result;
+	}
+
+	/**
+	 * Open a connection to this block, increasing the reference count and re-opening the
+	 * underlying file channel if necessary.
+	 * @throws IOException on I/O error
+	 */
+	void open() throws IOException {
+		this.channel.open();
+	}
+
+	/**
+	 * Close a connection to this block, decreasing the reference count and closing the
+	 * underlying file channel if necessary.
+	 * @throws IOException on I/O error
+	 */
+	@Override
+	public void close() throws IOException {
+		this.channel.close();
+	}
+
+	/**
+	 * Ensure that the underlying file channel is currently open.
+	 * @param exceptionSupplier a supplier providing the exception to throw
+	 * @param <E> the exception type
+	 * @throws E if the channel is closed
+	 */
+	<E extends Exception> void ensureOpen(Supplier<E> exceptionSupplier) throws E {
+		this.channel.ensureOpen(exceptionSupplier);
+	}
+
+	/**
+	 * Return a new {@link FileChannelDataBlock} slice providing access to a subset of the
+	 * data. The caller is responsible for calling {@link #open()} and {@link #close()} on
+	 * the returned block.
+	 * @param offset the start offset for the slice relative to this block
+	 * @return a new {@link FileChannelDataBlock} instance
+	 * @throws IOException on I/O error
+	 */
+	FileChannelDataBlock slice(long offset) throws IOException {
+		return slice(offset, this.size - offset);
+	}
+
+	/**
+	 * Return a new {@link FileChannelDataBlock} slice providing access to a subset of the
+	 * data. The caller is responsible for calling {@link #open()} and {@link #close()} on
+	 * the returned block.
+	 * @param offset the start offset for the slice relative to this block
+	 * @param size the size of the new slice
+	 * @return a new {@link FileChannelDataBlock} instance
+	 */
+	FileChannelDataBlock slice(long offset, long size) {
+		if (offset == 0 && size == this.size) {
+			return this;
+		}
+		if (offset < 0) {
+			throw new IllegalArgumentException("Offset must not be negative");
+		}
+		if (size < 0 || offset + size > this.size) {
+			throw new IllegalArgumentException("Size must not be negative and must be within bounds");
+		}
+		debug.log("Slicing %s at %s with size %s", this.channel, offset, size);
+		return new FileChannelDataBlock(this.channel, this.offset + offset, size);
+	}
+
+	/**
+	 * Manages access to underlying {@link FileChannel}.
+	 */
+	static class ManagedFileChannel {
+
+		static final int BUFFER_SIZE = 1024 * 10;
+
+		private final Path path;
+
+		private int referenceCount;
+
+		private FileChannel fileChannel;
+
+		private ByteBuffer buffer;
+
+		private long bufferPosition = -1;
+
+		private int bufferSize;
+
+		private final Object lock = new Object();
+
+		ManagedFileChannel(Path path) {
+			if (!Files.isRegularFile(path)) {
+				throw new IllegalArgumentException(path + " must be a regular file");
+			}
+			this.path = path;
+		}
+
+		int read(ByteBuffer dst, long position) throws IOException {
+			synchronized (this.lock) {
+				if (position < this.bufferPosition || position >= this.bufferPosition + this.bufferSize) {
+					this.buffer.clear();
+					try {
+						this.bufferSize = this.fileChannel.read(this.buffer, position);
+					}
+					catch (ClosedByInterruptException ex) {
+						repairFileChannel();
+						throw ex;
+					}
+					this.bufferPosition = position;
+				}
+				if (this.bufferSize <= 0) {
+					return this.bufferSize;
+				}
+				int offset = (int) (position - this.bufferPosition);
+				int length = Math.min(this.bufferSize - offset, dst.remaining());
+				dst.put(dst.position(), this.buffer, offset, length);
+				dst.position(dst.position() + length);
+				return length;
+			}
+		}
+
+		private void repairFileChannel() throws IOException {
+			if (tracker != null) {
+				tracker.closedFileChannel(this.path, this.fileChannel);
+			}
+			this.fileChannel = FileChannel.open(this.path, StandardOpenOption.READ);
+			if (tracker != null) {
+				tracker.openedFileChannel(this.path, this.fileChannel);
+			}
+		}
+
+		void open() throws IOException {
+			synchronized (this.lock) {
+				if (this.referenceCount == 0) {
+					debug.log("Opening '%s'", this.path);
+					this.fileChannel = FileChannel.open(this.path, StandardOpenOption.READ);
+					this.buffer = ByteBuffer.allocateDirect(BUFFER_SIZE);
+					if (tracker != null) {
+						tracker.openedFileChannel(this.path, this.fileChannel);
+					}
+				}
+				this.referenceCount++;
+				debug.log("Reference count for '%s' incremented to %s", this.path, this.referenceCount);
+			}
+		}
+
+		void close() throws IOException {
+			synchronized (this.lock) {
+				if (this.referenceCount == 0) {
+					return;
+				}
+				this.referenceCount--;
+				if (this.referenceCount == 0) {
+					debug.log("Closing '%s'", this.path);
+					this.buffer = null;
+					this.bufferPosition = -1;
+					this.bufferSize = 0;
+					this.fileChannel.close();
+					if (tracker != null) {
+						tracker.closedFileChannel(this.path, this.fileChannel);
+					}
+					this.fileChannel = null;
+				}
+				debug.log("Reference count for '%s' decremented to %s", this.path, this.referenceCount);
+			}
+		}
+
+		<E extends Exception> void ensureOpen(Supplier<E> exceptionSupplier) throws E {
+			synchronized (this.lock) {
+				if (this.referenceCount == 0 || !this.fileChannel.isOpen()) {
+					throw exceptionSupplier.get();
+				}
+			}
+		}
+
+		@Override
+		public String toString() {
+			return this.path.toString();
+		}
+
+	}
+
+	/**
+	 * Internal tracker used to check open and closing of files in tests.
+	 */
+	interface Tracker {
+
+		void openedFileChannel(Path path, FileChannel fileChannel);
+
+		void closedFileChannel(Path path, FileChannel fileChannel);
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/NameOffsetLookups.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/NameOffsetLookups.java
new file mode 100644
index 000000000000..d3014448c570
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/NameOffsetLookups.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.util.BitSet;
+
+/**
+ * Tracks entries that have a name that should be offset by a specific amount. This class
+ * is used with nested directory zip files so that entries under the directory are offset
+ * correctly. META-INF entries are copied directly and have no offset.
+ *
+ * @author Phillip Webb
+ */
+class NameOffsetLookups {
+
+	public static final NameOffsetLookups NONE = new NameOffsetLookups(0, 0);
+
+	private final int offset;
+
+	private final BitSet enabled;
+
+	NameOffsetLookups(int offset, int size) {
+		this.offset = offset;
+		this.enabled = (size != 0) ? new BitSet(size) : null;
+	}
+
+	void swap(int i, int j) {
+		if (this.enabled != null) {
+			boolean temp = this.enabled.get(i);
+			this.enabled.set(i, this.enabled.get(j));
+			this.enabled.set(j, temp);
+		}
+	}
+
+	int get(int index) {
+		return isEnabled(index) ? this.offset : 0;
+	}
+
+	int enable(int index, boolean enable) {
+		if (this.enabled != null) {
+			this.enabled.set(index, enable);
+		}
+		return (!enable) ? 0 : this.offset;
+	}
+
+	boolean isEnabled(int index) {
+		return (this.enabled != null && this.enabled.get(index));
+	}
+
+	boolean hasAnyEnabled() {
+		return this.enabled != null && this.enabled.cardinality() > 0;
+	}
+
+	NameOffsetLookups emptyCopy() {
+		return new NameOffsetLookups(this.offset, this.enabled.size());
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/VirtualDataBlock.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/VirtualDataBlock.java
new file mode 100644
index 000000000000..e8d8838700ca
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/VirtualDataBlock.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * A virtual {@link DataBlock} build from a collection of other {@link DataBlock}
+ * instances.
+ *
+ * @author Phillip Webb
+ */
+class VirtualDataBlock implements DataBlock {
+
+	private List<DataBlock> parts;
+
+	private long size;
+
+	/**
+	 * Create a new {@link VirtualDataBlock} instance. The {@link #setParts(Collection)}
+	 * method must be called before the data block can be used.
+	 */
+	protected VirtualDataBlock() {
+	}
+
+	/**
+	 * Create a new {@link VirtualDataBlock} backed by the given parts.
+	 * @param parts the parts that make up the virtual data block
+	 * @throws IOException in I/O error
+	 */
+	VirtualDataBlock(Collection<? extends DataBlock> parts) throws IOException {
+		setParts(parts);
+	}
+
+	/**
+	 * Set the parts that make up the virtual data block.
+	 * @param parts the data block parts
+	 * @throws IOException on I/O error
+	 */
+	protected void setParts(Collection<? extends DataBlock> parts) throws IOException {
+		this.parts = List.copyOf(parts);
+		long size = 0;
+		for (DataBlock part : parts) {
+			size += part.size();
+		}
+		this.size = size;
+	}
+
+	@Override
+	public long size() throws IOException {
+		return this.size;
+	}
+
+	@Override
+	public int read(ByteBuffer dst, long pos) throws IOException {
+		if (pos < 0 || pos >= this.size) {
+			return -1;
+		}
+		long offset = 0;
+		int result = 0;
+		for (DataBlock part : this.parts) {
+			while (pos >= offset && pos < offset + part.size()) {
+				int count = part.read(dst, pos - offset);
+				result += Math.max(count, 0);
+				if (count <= 0 || !dst.hasRemaining()) {
+					return result;
+				}
+				pos += count;
+			}
+			offset += part.size();
+		}
+		return result;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/VirtualZipDataBlock.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/VirtualZipDataBlock.java
new file mode 100644
index 000000000000..6ba095c74193
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/VirtualZipDataBlock.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.file.FileSystem;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * {@link DataBlock} that creates a virtual zip. This class allows us to create virtual
+ * zip files that can be parsed by regular JDK classes such as the zip {@link FileSystem}.
+ *
+ * @author Phillip Webb
+ */
+class VirtualZipDataBlock extends VirtualDataBlock implements CloseableDataBlock {
+
+	private final CloseableDataBlock data;
+
+	/**
+	 * Create a new {@link VirtualZipDataBlock} for the given entries.
+	 * @param data the source zip data
+	 * @param nameOffsetLookups the name offsets to apply
+	 * @param centralRecords the records that should be copied to the virtual zip
+	 * @param centralRecordPositions the record positions in the data block.
+	 * @throws IOException on I/O error
+	 */
+	VirtualZipDataBlock(CloseableDataBlock data, NameOffsetLookups nameOffsetLookups,
+			ZipCentralDirectoryFileHeaderRecord[] centralRecords, long[] centralRecordPositions) throws IOException {
+		this.data = data;
+		List<DataBlock> parts = new ArrayList<>();
+		List<DataBlock> centralParts = new ArrayList<>();
+		long offset = 0;
+		long sizeOfCentralDirectory = 0;
+		for (int i = 0; i < centralRecords.length; i++) {
+			ZipCentralDirectoryFileHeaderRecord centralRecord = centralRecords[i];
+			int nameOffset = nameOffsetLookups.get(i);
+			long centralRecordPos = centralRecordPositions[i];
+			DataBlock name = new DataPart(
+					centralRecordPos + ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET + nameOffset,
+					(centralRecord.fileNameLength() & 0xFFFF) - nameOffset);
+			long localRecordPos = centralRecord.offsetToLocalHeader() & 0xFFFFFFFF;
+			ZipLocalFileHeaderRecord localRecord = ZipLocalFileHeaderRecord.load(this.data, localRecordPos);
+			DataBlock content = new DataPart(localRecordPos + localRecord.size(), centralRecord.compressedSize());
+			boolean hasDescriptorRecord = ZipDataDescriptorRecord.isPresentBasedOnFlag(centralRecord);
+			ZipDataDescriptorRecord dataDescriptorRecord = (!hasDescriptorRecord) ? null
+					: ZipDataDescriptorRecord.load(data, localRecordPos + localRecord.size() + content.size());
+			sizeOfCentralDirectory += addToCentral(centralParts, centralRecord, centralRecordPos, name, (int) offset);
+			offset += addToLocal(parts, centralRecord, localRecord, dataDescriptorRecord, name, content);
+		}
+		parts.addAll(centralParts);
+		ZipEndOfCentralDirectoryRecord eocd = new ZipEndOfCentralDirectoryRecord((short) centralRecords.length,
+				(int) sizeOfCentralDirectory, (int) offset);
+		parts.add(new ByteArrayDataBlock(eocd.asByteArray()));
+		setParts(parts);
+	}
+
+	private long addToCentral(List<DataBlock> parts, ZipCentralDirectoryFileHeaderRecord originalRecord,
+			long originalRecordPos, DataBlock name, int offsetToLocalHeader) throws IOException {
+		ZipCentralDirectoryFileHeaderRecord record = originalRecord.withFileNameLength((short) (name.size() & 0xFFFF))
+			.withOffsetToLocalHeader(offsetToLocalHeader);
+		int originalExtraFieldLength = originalRecord.extraFieldLength() & 0xFFFF;
+		int originalFileCommentLength = originalRecord.fileCommentLength() & 0xFFFF;
+		DataBlock extraFieldAndComment = new DataPart(
+				originalRecordPos + originalRecord.size() - originalExtraFieldLength - originalFileCommentLength,
+				originalExtraFieldLength + originalFileCommentLength);
+		parts.add(new ByteArrayDataBlock(record.asByteArray()));
+		parts.add(name);
+		parts.add(extraFieldAndComment);
+		return record.size();
+	}
+
+	private long addToLocal(List<DataBlock> parts, ZipCentralDirectoryFileHeaderRecord centralRecord,
+			ZipLocalFileHeaderRecord originalRecord, ZipDataDescriptorRecord dataDescriptorRecord, DataBlock name,
+			DataBlock content) throws IOException {
+		ZipLocalFileHeaderRecord record = originalRecord.withFileNameLength((short) (name.size() & 0xFFFF));
+		long originalRecordPos = centralRecord.offsetToLocalHeader() & 0xFFFFFFFF;
+		int extraFieldLength = originalRecord.extraFieldLength() & 0xFFFF;
+		parts.add(new ByteArrayDataBlock(record.asByteArray()));
+		parts.add(name);
+		parts.add(new DataPart(originalRecordPos + originalRecord.size() - extraFieldLength, extraFieldLength));
+		parts.add(content);
+		if (dataDescriptorRecord != null) {
+			parts.add(new ByteArrayDataBlock(dataDescriptorRecord.asByteArray()));
+		}
+		return record.size() + content.size() + ((dataDescriptorRecord != null) ? dataDescriptorRecord.size() : 0);
+	}
+
+	@Override
+	public void close() throws IOException {
+		this.data.close();
+	}
+
+	/**
+	 * {@link DataBlock} that points to part of the original data block.
+	 */
+	final class DataPart implements DataBlock {
+
+		private final long offset;
+
+		private final long size;
+
+		DataPart(long offset, long size) {
+			this.offset = offset;
+			this.size = size;
+		}
+
+		@Override
+		public long size() throws IOException {
+			return this.size;
+		}
+
+		@Override
+		public int read(ByteBuffer dst, long pos) throws IOException {
+			int remaining = (int) (this.size - pos);
+			if (remaining <= 0) {
+				return -1;
+			}
+			int originalLimit = -1;
+			if (dst.remaining() > remaining) {
+				originalLimit = dst.limit();
+				dst.limit(dst.position() + remaining);
+			}
+			int result = VirtualZipDataBlock.this.data.read(dst, this.offset + pos);
+			if (originalLimit != -1) {
+				dst.limit(originalLimit);
+			}
+			return result;
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryLocator.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryLocator.java
new file mode 100644
index 000000000000..078c5ad81d95
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryLocator.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+import org.springframework.boot.loader.log.DebugLogger;
+
+/**
+ * A Zip64 end of central directory locator.
+ *
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ * @param pos the position where this record begins in the source {@link DataBlock}
+ * @param numberOfThisDisk the number of the disk with the start of the zip64 end of
+ * central directory
+ * @param offsetToZip64EndOfCentralDirectoryRecord the relative offset of the zip64 end of
+ * central directory record
+ * @param totalNumberOfDisks the total number of disks
+ * @see <a href="https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT">Chapter
+ * 4.3.15 of the Zip File Format Specification</a>
+ */
+record Zip64EndOfCentralDirectoryLocator(long pos, int numberOfThisDisk, long offsetToZip64EndOfCentralDirectoryRecord,
+		int totalNumberOfDisks) {
+
+	private static final DebugLogger debug = DebugLogger.get(Zip64EndOfCentralDirectoryLocator.class);
+
+	private static final int SIGNATURE = 0x07064b50;
+
+	/**
+	 * The size of this record.
+	 */
+	static final int SIZE = 20;
+
+	/**
+	 * Return the {@link Zip64EndOfCentralDirectoryLocator} or {@code null} if this is not
+	 * a Zip64 file.
+	 * @param dataBlock the source data block
+	 * @param endOfCentralDirectoryPos the {@link ZipEndOfCentralDirectoryRecord} position
+	 * @return a {@link Zip64EndOfCentralDirectoryLocator} instance or null
+	 * @throws IOException on I/O error
+	 */
+	static Zip64EndOfCentralDirectoryLocator find(DataBlock dataBlock, long endOfCentralDirectoryPos)
+			throws IOException {
+		debug.log("Finding Zip64EndOfCentralDirectoryLocator from EOCD at %s", endOfCentralDirectoryPos);
+		long pos = endOfCentralDirectoryPos - SIZE;
+		if (pos < 0) {
+			debug.log("No Zip64EndOfCentralDirectoryLocator due to negative position %s", pos);
+			return null;
+		}
+		ByteBuffer buffer = ByteBuffer.allocate(SIZE);
+		buffer.order(ByteOrder.LITTLE_ENDIAN);
+		dataBlock.read(buffer, pos);
+		buffer.rewind();
+		int signature = buffer.getInt();
+		if (signature != SIGNATURE) {
+			debug.log("Found incorrect Zip64EndOfCentralDirectoryLocator signature %s at position %s", signature, pos);
+			return null;
+		}
+		debug.log("Found Zip64EndOfCentralDirectoryLocator at position %s", pos);
+		return new Zip64EndOfCentralDirectoryLocator(pos, buffer.getInt(), buffer.getLong(), buffer.getInt());
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryRecord.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryRecord.java
new file mode 100644
index 000000000000..c593624ff94d
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryRecord.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+import org.springframework.boot.loader.log.DebugLogger;
+
+/**
+ * A Zip64 end of central directory record.
+ *
+ * @author Phillip Webb
+ * @param size the size of this record
+ * @param sizeOfZip64EndOfCentralDirectoryRecord the size of zip64 end of central
+ * directory record
+ * @param versionMadeBy the version that made the zip
+ * @param versionNeededToExtract the version needed to extract the zip
+ * @param numberOfThisDisk the number of this disk
+ * @param diskWhereCentralDirectoryStarts the disk where central directory starts
+ * @param numberOfCentralDirectoryEntriesOnThisDisk the number of central directory
+ * entries on this disk
+ * @param totalNumberOfCentralDirectoryEntries the total number of central directory
+ * entries
+ * @param sizeOfCentralDirectory the size of central directory (bytes)
+ * @param offsetToStartOfCentralDirectory the offset of start of central directory,
+ * relative to start of archive
+ * @see <a href="https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT">Chapter
+ * 4.3.14 of the Zip File Format Specification</a>
+ */
+record Zip64EndOfCentralDirectoryRecord(long size, long sizeOfZip64EndOfCentralDirectoryRecord, short versionMadeBy,
+		short versionNeededToExtract, int numberOfThisDisk, int diskWhereCentralDirectoryStarts,
+		long numberOfCentralDirectoryEntriesOnThisDisk, long totalNumberOfCentralDirectoryEntries,
+		long sizeOfCentralDirectory, long offsetToStartOfCentralDirectory) {
+
+	private static final DebugLogger debug = DebugLogger.get(Zip64EndOfCentralDirectoryRecord.class);
+
+	private static final int SIGNATURE = 0x06064b50;
+
+	private static final int MINIMUM_SIZE = 56;
+
+	/**
+	 * Load the {@link Zip64EndOfCentralDirectoryRecord} from the given data block based
+	 * on the offset given in the locator.
+	 * @param dataBlock the source data block
+	 * @param locator the {@link Zip64EndOfCentralDirectoryLocator} or {@code null}
+	 * @return a new {@link ZipCentralDirectoryFileHeaderRecord} instance or {@code null}
+	 * if the locator is {@code null}
+	 * @throws IOException on I/O error
+	 */
+	static Zip64EndOfCentralDirectoryRecord load(DataBlock dataBlock, Zip64EndOfCentralDirectoryLocator locator)
+			throws IOException {
+		if (locator == null) {
+			return null;
+		}
+		ByteBuffer buffer = ByteBuffer.allocate(MINIMUM_SIZE);
+		buffer.order(ByteOrder.LITTLE_ENDIAN);
+		long size = locator.pos() - locator.offsetToZip64EndOfCentralDirectoryRecord();
+		long pos = locator.pos() - size;
+		debug.log("Loading Zip64EndOfCentralDirectoryRecord from position %s size %s", pos, size);
+		dataBlock.readFully(buffer, pos);
+		buffer.rewind();
+		int signature = buffer.getInt();
+		if (signature != SIGNATURE) {
+			debug.log("Found incorrect Zip64EndOfCentralDirectoryRecord signature %s at position %s", signature, pos);
+			throw new IOException("Zip64 'End Of Central Directory Record' not found at position " + pos
+					+ ". Zip file is corrupt or includes prefixed bytes which are not supported with Zip64 files");
+		}
+		return new Zip64EndOfCentralDirectoryRecord(size, buffer.getLong(), buffer.getShort(), buffer.getShort(),
+				buffer.getInt(), buffer.getInt(), buffer.getLong(), buffer.getLong(), buffer.getLong(),
+				buffer.getLong());
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipCentralDirectoryFileHeaderRecord.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipCentralDirectoryFileHeaderRecord.java
new file mode 100644
index 000000000000..27f03587ae84
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipCentralDirectoryFileHeaderRecord.java
@@ -0,0 +1,211 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoField;
+import java.time.temporal.ChronoUnit;
+import java.time.temporal.ValueRange;
+import java.util.zip.ZipEntry;
+
+import org.springframework.boot.loader.log.DebugLogger;
+
+/**
+ * A ZIP File "Central directory file header record" (CDFH).
+ *
+ * @author Phillip Webb
+ * @param versionMadeBy the version that made the zip
+ * @param versionNeededToExtract the version needed to extract the zip
+ * @param generalPurposeBitFlag the general purpose bit flag
+ * @param compressionMethod the compression method used for this entry
+ * @param lastModFileTime the last modified file time
+ * @param lastModFileDate the last modified file date
+ * @param crc32 the CRC32 checksum
+ * @param compressedSize the size of the entry when compressed
+ * @param uncompressedSize the size of the entry when uncompressed
+ * @param fileNameLength the file name length
+ * @param extraFieldLength the extra field length
+ * @param fileCommentLength the comment length
+ * @param diskNumberStart the disk number where the entry starts
+ * @param internalFileAttributes the internal file attributes
+ * @param externalFileAttributes the external file attributes
+ * @param offsetToLocalHeader the relative offset to the local file header
+ * @see <a href="https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT">Chapter
+ * 4.3.12 of the Zip File Format Specification</a>
+ */
+record ZipCentralDirectoryFileHeaderRecord(short versionMadeBy, short versionNeededToExtract,
+		short generalPurposeBitFlag, short compressionMethod, short lastModFileTime, short lastModFileDate, int crc32,
+		int compressedSize, int uncompressedSize, short fileNameLength, short extraFieldLength, short fileCommentLength,
+		short diskNumberStart, short internalFileAttributes, int externalFileAttributes, int offsetToLocalHeader) {
+
+	private static final DebugLogger debug = DebugLogger.get(ZipCentralDirectoryFileHeaderRecord.class);
+
+	private static final int SIGNATURE = 0x02014b50;
+
+	private static final int MINIMUM_SIZE = 46;
+
+	/**
+	 * The offset of the file name relative to the record start position.
+	 */
+	static final int FILE_NAME_OFFSET = MINIMUM_SIZE;
+
+	/**
+	 * Return the size of this record.
+	 * @return the record size
+	 */
+	long size() {
+		return MINIMUM_SIZE + fileNameLength() + extraFieldLength() + fileCommentLength();
+	}
+
+	/**
+	 * Copy values from this block to the given {@link ZipEntry}.
+	 * @param dataBlock the source data block
+	 * @param pos the position of this {@link ZipCentralDirectoryFileHeaderRecord}
+	 * @param zipEntry the destination zip entry
+	 * @throws IOException on I/O error
+	 */
+	void copyTo(DataBlock dataBlock, long pos, ZipEntry zipEntry) throws IOException {
+		int fileNameLength = fileNameLength() & 0xFFFF;
+		int extraLength = extraFieldLength() & 0xFFFF;
+		int commentLength = fileCommentLength() & 0xFFFF;
+		zipEntry.setMethod(compressionMethod() & 0xFFFF);
+		zipEntry.setTime(decodeMsDosFormatDateTime(lastModFileDate(), lastModFileTime()));
+		zipEntry.setCrc(crc32() & 0xFFFFFFFFL);
+		zipEntry.setCompressedSize(compressedSize() & 0xFFFFFFFFL);
+		zipEntry.setSize(uncompressedSize() & 0xFFFFFFFFL);
+		if (extraLength > 0) {
+			long extraPos = pos + MINIMUM_SIZE + fileNameLength;
+			ByteBuffer buffer = ByteBuffer.allocate(extraLength);
+			dataBlock.readFully(buffer, extraPos);
+			zipEntry.setExtra(buffer.array());
+		}
+		if ((fileCommentLength() & 0xFFFF) > 0) {
+			long commentPos = MINIMUM_SIZE + fileNameLength + extraLength;
+			zipEntry.setComment(ZipString.readString(dataBlock, commentPos, commentLength));
+		}
+	}
+
+	/**
+	 * Decode MS-DOS Date Time details. See <a href=
+	 * "https://docs.microsoft.com/en-gb/windows/desktop/api/winbase/nf-winbase-dosdatetimetofiletime">
+	 * Microsoft's documentation</a> for more details of the format.
+	 * @param date the date
+	 * @param time the time
+	 * @return the date and time as milliseconds since the epoch
+	 */
+	private long decodeMsDosFormatDateTime(short date, short time) {
+		int year = getChronoValue(((date >> 9) & 0x7f) + 1980, ChronoField.YEAR);
+		int month = getChronoValue((date >> 5) & 0x0f, ChronoField.MONTH_OF_YEAR);
+		int day = getChronoValue(date & 0x1f, ChronoField.DAY_OF_MONTH);
+		int hour = getChronoValue((time >> 11) & 0x1f, ChronoField.HOUR_OF_DAY);
+		int minute = getChronoValue((time >> 5) & 0x3f, ChronoField.MINUTE_OF_HOUR);
+		int second = getChronoValue((time << 1) & 0x3e, ChronoField.SECOND_OF_MINUTE);
+		return ZonedDateTime.of(year, month, day, hour, minute, second, 0, ZoneId.systemDefault())
+			.toInstant()
+			.truncatedTo(ChronoUnit.SECONDS)
+			.toEpochMilli();
+	}
+
+	private static int getChronoValue(long value, ChronoField field) {
+		ValueRange range = field.range();
+		return Math.toIntExact(Math.min(Math.max(value, range.getMinimum()), range.getMaximum()));
+	}
+
+	/**
+	 * Return a new {@link ZipCentralDirectoryFileHeaderRecord} with a new
+	 * {@link #fileNameLength()}.
+	 * @param fileNameLength the new file name length
+	 * @return a new {@link ZipCentralDirectoryFileHeaderRecord} instance
+	 */
+	ZipCentralDirectoryFileHeaderRecord withFileNameLength(short fileNameLength) {
+		return (this.fileNameLength != fileNameLength) ? new ZipCentralDirectoryFileHeaderRecord(this.versionMadeBy,
+				this.versionNeededToExtract, this.generalPurposeBitFlag, this.compressionMethod, this.lastModFileTime,
+				this.lastModFileDate, this.crc32, this.compressedSize, this.uncompressedSize, fileNameLength,
+				this.extraFieldLength, this.fileCommentLength, this.diskNumberStart, this.internalFileAttributes,
+				this.externalFileAttributes, this.offsetToLocalHeader) : this;
+	}
+
+	/**
+	 * Return a new {@link ZipCentralDirectoryFileHeaderRecord} with a new
+	 * {@link #offsetToLocalHeader()}.
+	 * @param offsetToLocalHeader the new offset to local header
+	 * @return a new {@link ZipCentralDirectoryFileHeaderRecord} instance
+	 */
+	ZipCentralDirectoryFileHeaderRecord withOffsetToLocalHeader(int offsetToLocalHeader) {
+		return (this.offsetToLocalHeader != offsetToLocalHeader) ? new ZipCentralDirectoryFileHeaderRecord(
+				this.versionMadeBy, this.versionNeededToExtract, this.generalPurposeBitFlag, this.compressionMethod,
+				this.lastModFileTime, this.lastModFileDate, this.crc32, this.compressedSize, this.uncompressedSize,
+				this.fileNameLength, this.extraFieldLength, this.fileCommentLength, this.diskNumberStart,
+				this.internalFileAttributes, this.externalFileAttributes, offsetToLocalHeader) : this;
+	}
+
+	/**
+	 * Return the contents of this record as a byte array suitable for writing to a zip.
+	 * @return the record as a byte array
+	 */
+	byte[] asByteArray() {
+		ByteBuffer buffer = ByteBuffer.allocate(MINIMUM_SIZE);
+		buffer.order(ByteOrder.LITTLE_ENDIAN);
+		buffer.putInt(SIGNATURE);
+		buffer.putShort(this.versionMadeBy);
+		buffer.putShort(this.versionNeededToExtract);
+		buffer.putShort(this.generalPurposeBitFlag);
+		buffer.putShort(this.compressionMethod);
+		buffer.putShort(this.lastModFileTime);
+		buffer.putShort(this.lastModFileDate);
+		buffer.putInt(this.crc32);
+		buffer.putInt(this.compressedSize);
+		buffer.putInt(this.uncompressedSize);
+		buffer.putShort(this.fileNameLength);
+		buffer.putShort(this.extraFieldLength);
+		buffer.putShort(this.fileCommentLength);
+		buffer.putShort(this.diskNumberStart);
+		buffer.putShort(this.internalFileAttributes);
+		buffer.putInt(this.externalFileAttributes);
+		buffer.putInt(this.offsetToLocalHeader);
+		return buffer.array();
+	}
+
+	/**
+	 * Load the {@link ZipCentralDirectoryFileHeaderRecord} from the given data block.
+	 * @param dataBlock the source data block
+	 * @param pos the position of the record
+	 * @return a new {@link ZipCentralDirectoryFileHeaderRecord} instance
+	 * @throws IOException on I/O error
+	 */
+	static ZipCentralDirectoryFileHeaderRecord load(DataBlock dataBlock, long pos) throws IOException {
+		debug.log("Loading CentralDirectoryFileHeaderRecord from position %s", pos);
+		ByteBuffer buffer = ByteBuffer.allocate(MINIMUM_SIZE);
+		buffer.order(ByteOrder.LITTLE_ENDIAN);
+		dataBlock.readFully(buffer, pos);
+		buffer.rewind();
+		int signature = buffer.getInt();
+		if (signature != SIGNATURE) {
+			debug.log("Found incorrect CentralDirectoryFileHeaderRecord signature %s at position %s", signature, pos);
+			throw new IOException("Zip 'Central Directory File Header Record' not found at position " + pos);
+		}
+		return new ZipCentralDirectoryFileHeaderRecord(buffer.getShort(), buffer.getShort(), buffer.getShort(),
+				buffer.getShort(), buffer.getShort(), buffer.getShort(), buffer.getInt(), buffer.getInt(),
+				buffer.getInt(), buffer.getShort(), buffer.getShort(), buffer.getShort(), buffer.getShort(),
+				buffer.getShort(), buffer.getInt(), buffer.getInt());
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipContent.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipContent.java
new file mode 100644
index 000000000000..cc980b915b3b
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipContent.java
@@ -0,0 +1,811 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.lang.ref.Cleaner.Cleanable;
+import java.lang.ref.SoftReference;
+import java.nio.ByteBuffer;
+import java.nio.channels.ClosedChannelException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import java.util.zip.ZipEntry;
+
+import org.springframework.boot.loader.log.DebugLogger;
+
+/**
+ * Provides raw access to content from a regular or nested zip file. This class performs
+ * the low level parsing of a zip file and provide access to raw entry data that it
+ * contains. Unlike {@link java.util.zip.ZipFile}, this implementation can load content
+ * from a zip file nested inside another file as long as the entry is not compressed.
+ * <p>
+ * In order to reduce memory consumption, this implementation stores only the hash of the
+ * entry names, the central directory offsets and the original positions. Entries are
+ * stored internally in {@code hashCode} order so that a binary search can be used to
+ * quickly find an entry by name or determine if the zip file doesn't have a given entry.
+ * <p>
+ * {@link ZipContent} for a typical Spring Boot application JAR will have somewhere in the
+ * region of 10,500 entries which should consume about 122K.
+ * <p>
+ * {@link ZipContent} results are cached and it is assumed that zip content will not
+ * change once loaded. Entries and Strings are not cached and will be recreated on each
+ * access which may produce a lot of garbage.
+ * <p>
+ * This implementation does not use {@link Cleanable} so care must be taken to release
+ * {@link ZipContent} resources. The {@link #close()} method should be called explicitly
+ * or by try-with-resources. Care must be take to only call close once.
+ *
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ * @since 3.2.0
+ */
+public final class ZipContent implements Closeable {
+
+	private static final String META_INF = "META-INF/";
+
+	private static final byte[] SIGNATURE_SUFFIX = ".DSA".getBytes(StandardCharsets.UTF_8);
+
+	private static final DebugLogger debug = DebugLogger.get(ZipContent.class);
+
+	private static final Map<Source, ZipContent> cache = new ConcurrentHashMap<>();
+
+	private final Source source;
+
+	private final FileChannelDataBlock data;
+
+	private final long centralDirectoryPos;
+
+	private final long commentPos;
+
+	private final long commentLength;
+
+	private final int[] lookupIndexes;
+
+	private final int[] nameHashLookups;
+
+	private final int[] relativeCentralDirectoryOffsetLookups;
+
+	private final NameOffsetLookups nameOffsetLookups;
+
+	private final boolean hasJarSignatureFile;
+
+	private SoftReference<CloseableDataBlock> virtualData;
+
+	private SoftReference<Map<Class<?>, Object>> info;
+
+	private ZipContent(Source source, FileChannelDataBlock data, long centralDirectoryPos, long commentPos,
+			long commentLength, int[] lookupIndexes, int[] nameHashLookups, int[] relativeCentralDirectoryOffsetLookups,
+			NameOffsetLookups nameOffsetLookups, boolean hasJarSignatureFile) {
+		this.source = source;
+		this.data = data;
+		this.centralDirectoryPos = centralDirectoryPos;
+		this.commentPos = commentPos;
+		this.commentLength = commentLength;
+		this.lookupIndexes = lookupIndexes;
+		this.nameHashLookups = nameHashLookups;
+		this.relativeCentralDirectoryOffsetLookups = relativeCentralDirectoryOffsetLookups;
+		this.nameOffsetLookups = nameOffsetLookups;
+		this.hasJarSignatureFile = hasJarSignatureFile;
+	}
+
+	/**
+	 * Open a {@link DataBlock} containing the raw zip data. For container zip files, this
+	 * may be smaller than the original file since additional bytes are permitted at the
+	 * front of a zip file. For nested zip files, this will be only the contents of the
+	 * nest zip.
+	 * <p>
+	 * For nested directory zip files, a virtual data block will be created containing
+	 * only the relevant content.
+	 * <p>
+	 * To release resources, the {@link #close()} method of the data block should be
+	 * called explicitly or by try-with-resources.
+	 * <p>
+	 * The returned data block should not be accessed once {@link #close()} has been
+	 * called.
+	 * @return the zip data
+	 * @throws IOException on I/O error
+	 */
+	public CloseableDataBlock openRawZipData() throws IOException {
+		this.data.open();
+		return (!this.nameOffsetLookups.hasAnyEnabled()) ? this.data : getVirtualData();
+	}
+
+	private CloseableDataBlock getVirtualData() throws IOException {
+		CloseableDataBlock virtualData = (this.virtualData != null) ? this.virtualData.get() : null;
+		if (virtualData != null) {
+			return virtualData;
+		}
+		virtualData = createVirtualData();
+		this.virtualData = new SoftReference<>(virtualData);
+		return virtualData;
+	}
+
+	private CloseableDataBlock createVirtualData() throws IOException {
+		int size = size();
+		NameOffsetLookups nameOffsetLookups = this.nameOffsetLookups.emptyCopy();
+		ZipCentralDirectoryFileHeaderRecord[] centralRecords = new ZipCentralDirectoryFileHeaderRecord[size];
+		long[] centralRecordPositions = new long[size];
+		for (int i = 0; i < size; i++) {
+			int lookupIndex = ZipContent.this.lookupIndexes[i];
+			long pos = getCentralDirectoryFileHeaderRecordPos(lookupIndex);
+			nameOffsetLookups.enable(i, this.nameOffsetLookups.isEnabled(lookupIndex));
+			centralRecords[i] = ZipCentralDirectoryFileHeaderRecord.load(this.data, pos);
+			centralRecordPositions[i] = pos;
+		}
+		return new VirtualZipDataBlock(this.data, nameOffsetLookups, centralRecords, centralRecordPositions);
+	}
+
+	/**
+	 * Returns the number of entries in the ZIP file.
+	 * @return the number of entries
+	 */
+	public int size() {
+		return this.lookupIndexes.length;
+	}
+
+	/**
+	 * Return the zip comment, if any.
+	 * @return the comment or {@code null}
+	 */
+	public String getComment() {
+		try {
+			return ZipString.readString(this.data, this.commentPos, this.commentLength);
+		}
+		catch (UncheckedIOException ex) {
+			if (ex.getCause() instanceof ClosedChannelException) {
+				throw new IllegalStateException("Zip content closed", ex);
+			}
+			throw ex;
+		}
+	}
+
+	/**
+	 * Return the entry with the given name, if any.
+	 * @param name the name of the entry to find
+	 * @return the entry or {@code null}
+	 */
+	public Entry getEntry(CharSequence name) {
+		return getEntry(null, name);
+	}
+
+	/**
+	 * Return the entry with the given name, if any.
+	 * @param namePrefix an optional prefix for the name
+	 * @param name the name of the entry to find
+	 * @return the entry or {@code null}
+	 */
+	public Entry getEntry(CharSequence namePrefix, CharSequence name) {
+		int nameHash = nameHash(namePrefix, name);
+		int lookupIndex = getFirstLookupIndex(nameHash);
+		int size = size();
+		while (lookupIndex >= 0 && lookupIndex < size && this.nameHashLookups[lookupIndex] == nameHash) {
+			long pos = getCentralDirectoryFileHeaderRecordPos(lookupIndex);
+			ZipCentralDirectoryFileHeaderRecord centralRecord = loadZipCentralDirectoryFileHeaderRecord(pos);
+			if (hasName(lookupIndex, centralRecord, pos, namePrefix, name)) {
+				return new Entry(lookupIndex, centralRecord);
+			}
+			lookupIndex++;
+		}
+		return null;
+	}
+
+	/**
+	 * Return if an entry with the given name exists.
+	 * @param namePrefix an optional prefix for the name
+	 * @param name the name of the entry to find
+	 * @return the entry or {@code null}
+	 */
+	public boolean hasEntry(CharSequence namePrefix, CharSequence name) {
+		int nameHash = nameHash(namePrefix, name);
+		int lookupIndex = getFirstLookupIndex(nameHash);
+		int size = size();
+		while (lookupIndex >= 0 && lookupIndex < size && this.nameHashLookups[lookupIndex] == nameHash) {
+			long pos = getCentralDirectoryFileHeaderRecordPos(lookupIndex);
+			ZipCentralDirectoryFileHeaderRecord centralRecord = loadZipCentralDirectoryFileHeaderRecord(pos);
+			if (hasName(lookupIndex, centralRecord, pos, namePrefix, name)) {
+				return true;
+			}
+			lookupIndex++;
+		}
+		return false;
+	}
+
+	/**
+	 * Return the entry at the specified index.
+	 * @param index the entry index
+	 * @return the entry
+	 * @throws IndexOutOfBoundsException if the index is out of bounds
+	 */
+	public Entry getEntry(int index) {
+		int lookupIndex = ZipContent.this.lookupIndexes[index];
+		long pos = getCentralDirectoryFileHeaderRecordPos(lookupIndex);
+		ZipCentralDirectoryFileHeaderRecord centralRecord = loadZipCentralDirectoryFileHeaderRecord(pos);
+		return new Entry(lookupIndex, centralRecord);
+	}
+
+	private ZipCentralDirectoryFileHeaderRecord loadZipCentralDirectoryFileHeaderRecord(long pos) {
+		try {
+			return ZipCentralDirectoryFileHeaderRecord.load(this.data, pos);
+		}
+		catch (IOException ex) {
+			if (ex instanceof ClosedChannelException) {
+				throw new IllegalStateException("Zip content closed", ex);
+			}
+			throw new UncheckedIOException(ex);
+		}
+	}
+
+	private int nameHash(CharSequence namePrefix, CharSequence name) {
+		int nameHash = 0;
+		nameHash = (namePrefix != null) ? ZipString.hash(nameHash, namePrefix, false) : nameHash;
+		nameHash = ZipString.hash(nameHash, name, true);
+		return nameHash;
+	}
+
+	private int getFirstLookupIndex(int nameHash) {
+		int lookupIndex = Arrays.binarySearch(this.nameHashLookups, 0, this.nameHashLookups.length, nameHash);
+		if (lookupIndex < 0) {
+			return -1;
+		}
+		while (lookupIndex > 0 && this.nameHashLookups[lookupIndex - 1] == nameHash) {
+			lookupIndex--;
+		}
+		return lookupIndex;
+	}
+
+	private long getCentralDirectoryFileHeaderRecordPos(int lookupIndex) {
+		return this.centralDirectoryPos + this.relativeCentralDirectoryOffsetLookups[lookupIndex];
+	}
+
+	private boolean hasName(int lookupIndex, ZipCentralDirectoryFileHeaderRecord centralRecord, long pos,
+			CharSequence namePrefix, CharSequence name) {
+		int offset = this.nameOffsetLookups.get(lookupIndex);
+		pos += ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET + offset;
+		int len = centralRecord.fileNameLength() - offset;
+		ByteBuffer buffer = ByteBuffer.allocate(ZipString.BUFFER_SIZE);
+		if (namePrefix != null) {
+			int startsWithNamePrefix = ZipString.startsWith(buffer, this.data, pos, len, namePrefix);
+			if (startsWithNamePrefix == -1) {
+				return false;
+			}
+			pos += startsWithNamePrefix;
+			len -= startsWithNamePrefix;
+		}
+		return ZipString.matches(buffer, this.data, pos, len, name, true);
+	}
+
+	/**
+	 * Get or compute information based on the {@link ZipContent}.
+	 * @param <I> the info type to get or compute
+	 * @param type the info type to get or compute
+	 * @param function the function used to compute the information
+	 * @return the computed or existing information
+	 */
+	@SuppressWarnings("unchecked")
+	public <I> I getInfo(Class<I> type, Function<ZipContent, I> function) {
+		Map<Class<?>, Object> info = (this.info != null) ? this.info.get() : null;
+		if (info == null) {
+			info = new ConcurrentHashMap<>();
+			this.info = new SoftReference<>(info);
+		}
+		return (I) info.computeIfAbsent(type, (key) -> {
+			debug.log("Getting %s info from zip '%s'", type.getName(), this);
+			return function.apply(this);
+		});
+	}
+
+	/**
+	 * Returns {@code true} if this zip contains a jar signature file
+	 * ({@code META-INF/*.DSA}).
+	 * @return if the zip contains a jar signature file
+	 */
+	public boolean hasJarSignatureFile() {
+		return this.hasJarSignatureFile;
+	}
+
+	/**
+	 * Close this jar file, releasing the underlying file if this was the last reference.
+	 * @see java.io.Closeable#close()
+	 */
+	@Override
+	public void close() throws IOException {
+		this.data.close();
+	}
+
+	@Override
+	public String toString() {
+		return this.source.toString();
+	}
+
+	/**
+	 * Open {@link ZipContent} from the specified path. The resulting {@link ZipContent}
+	 * <em>must</em> be {@link #close() closed} by the caller.
+	 * @param path the zip path
+	 * @return a {@link ZipContent} instance
+	 * @throws IOException on I/O error
+	 */
+	public static ZipContent open(Path path) throws IOException {
+		return open(new Source(path.toAbsolutePath(), null));
+	}
+
+	/**
+	 * Open nested {@link ZipContent} from the specified path. The resulting
+	 * {@link ZipContent} <em>must</em> be {@link #close() closed} by the caller.
+	 * @param path the zip path
+	 * @param nestedEntryName the nested entry name to open
+	 * @return a {@link ZipContent} instance
+	 * @throws IOException on I/O error
+	 */
+	public static ZipContent open(Path path, String nestedEntryName) throws IOException {
+		return open(new Source(path.toAbsolutePath(), nestedEntryName));
+	}
+
+	private static ZipContent open(Source source) throws IOException {
+		ZipContent zipContent = cache.get(source);
+		if (zipContent != null) {
+			debug.log("Opening existing cached zip content for %s", zipContent);
+			zipContent.data.open();
+			return zipContent;
+		}
+		debug.log("Loading zip content from %s", source);
+		zipContent = Loader.load(source);
+		ZipContent previouslyCached = cache.putIfAbsent(source, zipContent);
+		if (previouslyCached != null) {
+			debug.log("Closing zip content from %s since cache was populated from another thread", source);
+			zipContent.close();
+			previouslyCached.data.open();
+			return previouslyCached;
+		}
+		return zipContent;
+	}
+
+	/**
+	 * The source of {@link ZipContent}. Used as a cache key.
+	 *
+	 * @param path the path of the zip or container zip
+	 * @param nestedEntryName the name of the nested entry to use or {@code null}
+	 */
+	private record Source(Path path, String nestedEntryName) {
+
+		/**
+		 * Return if this is the source of a nested zip.
+		 * @return if this is for a nested zip
+		 */
+		boolean isNested() {
+			return this.nestedEntryName != null;
+		}
+
+		@Override
+		public String toString() {
+			return (!isNested()) ? path().toString() : path() + "[" + nestedEntryName() + "]";
+		}
+
+	}
+
+	/**
+	 * Internal class used to load the zip content create a new {@link ZipContent}
+	 * instance.
+	 */
+	private static final class Loader {
+
+		private final ByteBuffer buffer = ByteBuffer.allocate(ZipString.BUFFER_SIZE);
+
+		private final Source source;
+
+		private final FileChannelDataBlock data;
+
+		private final long centralDirectoryPos;
+
+		private final int[] index;
+
+		private int[] nameHashLookups;
+
+		private int[] relativeCentralDirectoryOffsetLookups;
+
+		private final NameOffsetLookups nameOffsetLookups;
+
+		private int cursor;
+
+		private Loader(Source source, Entry directoryEntry, FileChannelDataBlock data, long centralDirectoryPos,
+				int maxSize) {
+			this.source = source;
+			this.data = data;
+			this.centralDirectoryPos = centralDirectoryPos;
+			this.index = new int[maxSize];
+			this.nameHashLookups = new int[maxSize];
+			this.relativeCentralDirectoryOffsetLookups = new int[maxSize];
+			this.nameOffsetLookups = (directoryEntry != null)
+					? new NameOffsetLookups(directoryEntry.getName().length(), maxSize) : NameOffsetLookups.NONE;
+		}
+
+		private void add(ZipCentralDirectoryFileHeaderRecord centralRecord, long pos, boolean enableNameOffset)
+				throws IOException {
+			int nameOffset = this.nameOffsetLookups.enable(this.cursor, enableNameOffset);
+			int hash = ZipString.hash(this.buffer, this.data,
+					pos + ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET + nameOffset,
+					centralRecord.fileNameLength() - nameOffset, true);
+			this.nameHashLookups[this.cursor] = hash;
+			this.relativeCentralDirectoryOffsetLookups[this.cursor] = (int) ((pos - this.centralDirectoryPos));
+			this.index[this.cursor] = this.cursor;
+			this.cursor++;
+		}
+
+		private ZipContent finish(long commentPos, long commentLength, boolean hasJarSignatureFile) {
+			if (this.cursor != this.nameHashLookups.length) {
+				this.nameHashLookups = Arrays.copyOf(this.nameHashLookups, this.cursor);
+				this.relativeCentralDirectoryOffsetLookups = Arrays.copyOf(this.relativeCentralDirectoryOffsetLookups,
+						this.cursor);
+			}
+			int size = this.nameHashLookups.length;
+			sort(0, size - 1);
+			int[] lookupIndexes = new int[size];
+			for (int i = 0; i < size; i++) {
+				lookupIndexes[this.index[i]] = i;
+			}
+			return new ZipContent(this.source, this.data, this.centralDirectoryPos, commentPos, commentLength,
+					lookupIndexes, this.nameHashLookups, this.relativeCentralDirectoryOffsetLookups,
+					this.nameOffsetLookups, hasJarSignatureFile);
+		}
+
+		private void sort(int left, int right) {
+			// Quick sort algorithm, uses nameHashCode as the source but sorts all arrays
+			if (left < right) {
+				int pivot = this.nameHashLookups[left + (right - left) / 2];
+				int i = left;
+				int j = right;
+				while (i <= j) {
+					while (this.nameHashLookups[i] < pivot) {
+						i++;
+					}
+					while (this.nameHashLookups[j] > pivot) {
+						j--;
+					}
+					if (i <= j) {
+						swap(i, j);
+						i++;
+						j--;
+					}
+				}
+				if (left < j) {
+					sort(left, j);
+				}
+				if (right > i) {
+					sort(i, right);
+				}
+			}
+		}
+
+		private void swap(int i, int j) {
+			swap(this.index, i, j);
+			swap(this.nameHashLookups, i, j);
+			swap(this.relativeCentralDirectoryOffsetLookups, i, j);
+			this.nameOffsetLookups.swap(i, j);
+		}
+
+		private static void swap(int[] array, int i, int j) {
+			int temp = array[i];
+			array[i] = array[j];
+			array[j] = temp;
+		}
+
+		static ZipContent load(Source source) throws IOException {
+			if (!source.isNested()) {
+				return loadNonNested(source);
+			}
+			try (ZipContent zip = open(source.path())) {
+				Entry entry = zip.getEntry(source.nestedEntryName());
+				if (entry == null) {
+					throw new IOException("Nested entry '%s' not found in container zip '%s'"
+						.formatted(source.nestedEntryName(), source.path()));
+				}
+				return (!entry.isDirectory()) ? loadNestedZip(source, entry) : loadNestedDirectory(source, zip, entry);
+			}
+		}
+
+		private static ZipContent loadNonNested(Source source) throws IOException {
+			debug.log("Loading non-nested zip '%s'", source.path());
+			return openAndLoad(source, new FileChannelDataBlock(source.path()));
+		}
+
+		private static ZipContent loadNestedZip(Source source, Entry entry) throws IOException {
+			if (entry.centralRecord.compressionMethod() != ZipEntry.STORED) {
+				throw new IOException("Nested entry '%s' in container zip '%s' must not be compressed"
+					.formatted(source.nestedEntryName(), source.path()));
+			}
+			debug.log("Loading nested zip entry '%s' from '%s'", source.nestedEntryName(), source.path());
+			return openAndLoad(source, entry.getContent());
+		}
+
+		private static ZipContent openAndLoad(Source source, FileChannelDataBlock data) throws IOException {
+			try {
+				data.open();
+				return loadContent(source, data);
+			}
+			catch (IOException | RuntimeException ex) {
+				data.close();
+				throw ex;
+			}
+		}
+
+		private static ZipContent loadContent(Source source, FileChannelDataBlock data) throws IOException {
+			ZipEndOfCentralDirectoryRecord.Located locatedEocd = ZipEndOfCentralDirectoryRecord.load(data);
+			ZipEndOfCentralDirectoryRecord eocd = locatedEocd.endOfCentralDirectoryRecord();
+			long eocdPos = locatedEocd.pos();
+			Zip64EndOfCentralDirectoryLocator zip64Locator = Zip64EndOfCentralDirectoryLocator.find(data, eocdPos);
+			Zip64EndOfCentralDirectoryRecord zip64Eocd = Zip64EndOfCentralDirectoryRecord.load(data, zip64Locator);
+			data = data.slice(getStartOfZipContent(data, eocd, zip64Eocd));
+			long centralDirectoryPos = (zip64Eocd != null) ? zip64Eocd.offsetToStartOfCentralDirectory()
+					: eocd.offsetToStartOfCentralDirectory();
+			long numberOfEntries = (zip64Eocd != null) ? zip64Eocd.totalNumberOfCentralDirectoryEntries()
+					: eocd.totalNumberOfCentralDirectoryEntries();
+			if (numberOfEntries > 0xFFFFFFFFL) {
+				throw new IllegalStateException("Too many zip entries in " + source);
+			}
+			Loader loader = new Loader(source, null, data, centralDirectoryPos, (int) (numberOfEntries & 0xFFFFFFFFL));
+			ByteBuffer signatureNameSuffixBuffer = ByteBuffer.allocate(SIGNATURE_SUFFIX.length);
+			boolean hasJarSignatureFile = false;
+			long pos = centralDirectoryPos;
+			for (int i = 0; i < numberOfEntries; i++) {
+				ZipCentralDirectoryFileHeaderRecord centralRecord = ZipCentralDirectoryFileHeaderRecord.load(data, pos);
+				if (!hasJarSignatureFile) {
+					long filenamePos = pos + ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET;
+					if (centralRecord.fileNameLength() > SIGNATURE_SUFFIX.length && ZipString.startsWith(loader.buffer,
+							data, filenamePos, centralRecord.fileNameLength(), META_INF) >= 0) {
+						signatureNameSuffixBuffer.clear();
+						data.readFully(signatureNameSuffixBuffer,
+								filenamePos + centralRecord.fileNameLength() - SIGNATURE_SUFFIX.length);
+						hasJarSignatureFile = Arrays.equals(SIGNATURE_SUFFIX, signatureNameSuffixBuffer.array());
+					}
+				}
+				loader.add(centralRecord, pos, false);
+				pos += centralRecord.size();
+			}
+			long commentPos = locatedEocd.pos() + ZipEndOfCentralDirectoryRecord.COMMENT_OFFSET;
+			return loader.finish(commentPos, eocd.commentLength(), hasJarSignatureFile);
+		}
+
+		/**
+		 * Returns the location in the data that the archive actually starts. For most
+		 * files the archive data will start at 0, however, it is possible to have
+		 * prefixed bytes (often used for startup scripts) at the beginning of the data.
+		 * @param data the source data
+		 * @param eocd the end of central directory record
+		 * @param zip64Eocd the zip64 end of central directory record or {@code null}
+		 * @return the offset within the data where the archive begins
+		 * @throws IOException on I/O error
+		 */
+		private static long getStartOfZipContent(FileChannelDataBlock data, ZipEndOfCentralDirectoryRecord eocd,
+				Zip64EndOfCentralDirectoryRecord zip64Eocd) throws IOException {
+			long specifiedOffsetToStartOfCentralDirectory = (zip64Eocd != null)
+					? zip64Eocd.offsetToStartOfCentralDirectory() : eocd.offsetToStartOfCentralDirectory();
+			long sizeOfCentralDirectoryAndEndRecords = getSizeOfCentralDirectoryAndEndRecords(eocd, zip64Eocd);
+			long actualOffsetToStartOfCentralDirectory = data.size() - sizeOfCentralDirectoryAndEndRecords;
+			return actualOffsetToStartOfCentralDirectory - specifiedOffsetToStartOfCentralDirectory;
+		}
+
+		private static long getSizeOfCentralDirectoryAndEndRecords(ZipEndOfCentralDirectoryRecord eocd,
+				Zip64EndOfCentralDirectoryRecord zip64Eocd) {
+			long result = 0;
+			result += eocd.size();
+			if (zip64Eocd != null) {
+				result += Zip64EndOfCentralDirectoryLocator.SIZE;
+				result += zip64Eocd.size();
+			}
+			result += (zip64Eocd != null) ? zip64Eocd.sizeOfCentralDirectory() : eocd.sizeOfCentralDirectory();
+			return result;
+		}
+
+		private static ZipContent loadNestedDirectory(Source source, ZipContent zip, Entry directoryEntry)
+				throws IOException {
+			debug.log("Loading nested directry entry '%s' from '%s'", source.nestedEntryName(), source.path());
+			if (!source.nestedEntryName().endsWith("/")) {
+				throw new IllegalArgumentException("Nested entry name must end with '/'");
+			}
+			String directoryName = directoryEntry.getName();
+			zip.data.open();
+			try {
+				Loader loader = new Loader(source, directoryEntry, zip.data, zip.centralDirectoryPos, zip.size());
+				for (int cursor = 0; cursor < zip.size(); cursor++) {
+					int index = zip.lookupIndexes[cursor];
+					if (index != directoryEntry.getLookupIndex()) {
+						long pos = zip.getCentralDirectoryFileHeaderRecordPos(index);
+						ZipCentralDirectoryFileHeaderRecord centralRecord = ZipCentralDirectoryFileHeaderRecord
+							.load(zip.data, pos);
+						long namePos = pos + ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET;
+						short nameLen = centralRecord.fileNameLength();
+						if (ZipString.startsWith(loader.buffer, zip.data, namePos, nameLen, META_INF) != -1) {
+							loader.add(centralRecord, pos, false);
+						}
+						else if (ZipString.startsWith(loader.buffer, zip.data, namePos, nameLen, directoryName) != -1) {
+							loader.add(centralRecord, pos, true);
+						}
+					}
+				}
+				return loader.finish(zip.commentPos, zip.commentLength, zip.hasJarSignatureFile);
+			}
+			catch (IOException | RuntimeException ex) {
+				zip.data.close();
+				throw ex;
+			}
+		}
+
+	}
+
+	/**
+	 * A single zip content entry.
+	 */
+	public class Entry {
+
+		private final int lookupIndex;
+
+		private final ZipCentralDirectoryFileHeaderRecord centralRecord;
+
+		private volatile String name;
+
+		private volatile FileChannelDataBlock content;
+
+		/**
+		 * Create a new {@link Entry} instance.
+		 * @param lookupIndex the lookup index of the entry
+		 * @param centralRecord the {@link ZipCentralDirectoryFileHeaderRecord} for the
+		 * entry
+		 */
+		Entry(int lookupIndex, ZipCentralDirectoryFileHeaderRecord centralRecord) {
+			this.lookupIndex = lookupIndex;
+			this.centralRecord = centralRecord;
+		}
+
+		/**
+		 * Return the lookup index of the entry. Each entry has a unique lookup index but
+		 * they aren't the same as the order that the entry was loaded.
+		 * @return the entry lookup index
+		 */
+		public int getLookupIndex() {
+			return this.lookupIndex;
+		}
+
+		/**
+		 * Return {@code true} if this is a directory entry.
+		 * @return if the entry is a directory
+		 */
+		public boolean isDirectory() {
+			return getName().endsWith("/");
+		}
+
+		/**
+		 * Returns {@code true} if this entry has a name starting with the given prefix.
+		 * @param prefix the required prefix
+		 * @return if the entry name starts with the prefix
+		 */
+		public boolean hasNameStartingWith(CharSequence prefix) {
+			String name = this.name;
+			if (name != null) {
+				return name.startsWith(prefix.toString());
+			}
+			long pos = getCentralDirectoryFileHeaderRecordPos(this.lookupIndex)
+					+ ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET;
+			return ZipString.startsWith(null, ZipContent.this.data, pos, this.centralRecord.fileNameLength(),
+					prefix) != -1;
+		}
+
+		/**
+		 * Return the name of this entry.
+		 * @return the entry name
+		 */
+		public String getName() {
+			String name = this.name;
+			if (name == null) {
+				int offset = ZipContent.this.nameOffsetLookups.get(this.lookupIndex);
+				long pos = getCentralDirectoryFileHeaderRecordPos(this.lookupIndex)
+						+ ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET + offset;
+				name = ZipString.readString(ZipContent.this.data, pos, this.centralRecord.fileNameLength() - offset);
+				this.name = name;
+			}
+			return name;
+		}
+
+		/**
+		 * Return the compression method for this entry.
+		 * @return the compression method
+		 * @see ZipEntry#STORED
+		 * @see ZipEntry#DEFLATED
+		 */
+		public int getCompressionMethod() {
+			return this.centralRecord.compressionMethod();
+		}
+
+		/**
+		 * Return the uncompressed size of this entry.
+		 * @return the uncompressed size
+		 */
+		public int getUncompressedSize() {
+			return this.centralRecord.uncompressedSize();
+		}
+
+		/**
+		 * Open a {@link DataBlock} providing access to raw contents of the entry (not
+		 * including the local file header).
+		 * <p>
+		 * To release resources, the {@link #close()} method of the data block should be
+		 * called explicitly or by try-with-resources.
+		 * @return the contents of the entry
+		 * @throws IOException on I/O error
+		 */
+		public CloseableDataBlock openContent() throws IOException {
+			FileChannelDataBlock content = getContent();
+			content.open();
+			return content;
+		}
+
+		private FileChannelDataBlock getContent() throws IOException {
+			FileChannelDataBlock content = this.content;
+			if (content == null) {
+				int pos = this.centralRecord.offsetToLocalHeader();
+				checkNotZip64Extended(pos);
+				ZipLocalFileHeaderRecord localHeader = ZipLocalFileHeaderRecord.load(ZipContent.this.data, pos);
+				int size = this.centralRecord.compressedSize();
+				checkNotZip64Extended(size);
+				content = ZipContent.this.data.slice(pos + localHeader.size(), size);
+				this.content = content;
+			}
+			return content;
+		}
+
+		private void checkNotZip64Extended(int value) throws IOException {
+			if (value == 0xFFFFFFFF) {
+				throw new IOException("Zip64 extended information extra fields are not supported");
+			}
+		}
+
+		/**
+		 * Adapt the raw entry into a {@link ZipEntry} or {@link ZipEntry} subclass.
+		 * @param <E> the entry type
+		 * @param factory the factory used to create the {@link ZipEntry}
+		 * @return a fully populated zip entry
+		 */
+		public <E extends ZipEntry> E as(Function<String, E> factory) {
+			return as((entry, name) -> factory.apply(name));
+		}
+
+		/**
+		 * Adapt the raw entry into a {@link ZipEntry} or {@link ZipEntry} subclass.
+		 * @param <E> the entry type
+		 * @param factory the factory used to create the {@link ZipEntry}
+		 * @return a fully populated zip entry
+		 */
+		public <E extends ZipEntry> E as(BiFunction<Entry, String, E> factory) {
+			try {
+				E result = factory.apply(this, getName());
+				long pos = getCentralDirectoryFileHeaderRecordPos(this.lookupIndex);
+				this.centralRecord.copyTo(ZipContent.this.data, pos, result);
+				return result;
+			}
+			catch (IOException ex) {
+				throw new UncheckedIOException(ex);
+			}
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipDataDescriptorRecord.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipDataDescriptorRecord.java
new file mode 100644
index 000000000000..af3a85027ec8
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipDataDescriptorRecord.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+import org.springframework.boot.loader.log.DebugLogger;
+
+/**
+ * A ZIP File "Data Descriptor" record.
+ *
+ * @param includeSignature if the signature bytes are written or not (see note in spec)
+ * @param crc32 the CRC32 checksum
+ * @param compressedSize the size of the entry when compressed
+ * @param uncompressedSize the size of the entry when uncompressed
+ * @author Phillip Webb
+ * @see <a href="https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT">Chapter
+ * 4.3.9 of the Zip File Format Specification</a>
+ */
+record ZipDataDescriptorRecord(boolean includeSignature, int crc32, int compressedSize, int uncompressedSize) {
+
+	private static final DebugLogger debug = DebugLogger.get(ZipDataDescriptorRecord.class);
+
+	private static final int SIGNATURE = 0x08074b50;
+
+	private static final int DATA_SIZE = 12;
+
+	private static final int SIGNATURE_SIZE = 4;
+
+	long size() {
+		return (!includeSignature()) ? DATA_SIZE : DATA_SIZE + SIGNATURE_SIZE;
+	}
+
+	/**
+	 * Return the contents of this record as a byte array suitable for writing to a zip.
+	 * @return the record as a byte array
+	 */
+	byte[] asByteArray() {
+		ByteBuffer buffer = ByteBuffer.allocate((int) size());
+		buffer.order(ByteOrder.LITTLE_ENDIAN);
+		if (this.includeSignature) {
+			buffer.putInt(SIGNATURE);
+		}
+		buffer.putInt(this.crc32);
+		buffer.putInt(this.compressedSize);
+		buffer.putInt(this.uncompressedSize);
+		return buffer.array();
+	}
+
+	/**
+	 * Load the {@link ZipDataDescriptorRecord} from the given data block.
+	 * @param dataBlock the source data block
+	 * @param pos the position of the record
+	 * @return a new {@link ZipLocalFileHeaderRecord} instance
+	 * @throws IOException on I/O error
+	 */
+	static ZipDataDescriptorRecord load(DataBlock dataBlock, long pos) throws IOException {
+		debug.log("Loading ZipDataDescriptorRecord from position %s", pos);
+		ByteBuffer buffer = ByteBuffer.allocate(SIGNATURE_SIZE + DATA_SIZE);
+		buffer.order(ByteOrder.LITTLE_ENDIAN);
+		buffer.limit(SIGNATURE_SIZE);
+		dataBlock.readFully(buffer, pos);
+		buffer.rewind();
+		int signatureOrCrc = buffer.getInt();
+		boolean hasSignature = (signatureOrCrc == SIGNATURE);
+		buffer.rewind();
+		buffer.limit((!hasSignature) ? DATA_SIZE - SIGNATURE_SIZE : DATA_SIZE);
+		dataBlock.readFully(buffer, pos + SIGNATURE_SIZE);
+		buffer.rewind();
+		return new ZipDataDescriptorRecord(hasSignature, (!hasSignature) ? signatureOrCrc : buffer.getInt(),
+				buffer.getInt(), buffer.getInt());
+	}
+
+	/**
+	 * Return if the {@link ZipDataDescriptorRecord} is present based on the general
+	 * purpose bit flag in the given {@link ZipLocalFileHeaderRecord}.
+	 * @param localRecord the local record to check
+	 * @return if the bit flag is set
+	 */
+	static boolean isPresentBasedOnFlag(ZipLocalFileHeaderRecord localRecord) {
+		return isPresentBasedOnFlag(localRecord.generalPurposeBitFlag());
+	}
+
+	/**
+	 * Return if the {@link ZipDataDescriptorRecord} is present based on the general
+	 * purpose bit flag in the given {@link ZipCentralDirectoryFileHeaderRecord}.
+	 * @param centralRecord the central record to check
+	 * @return if the bit flag is set
+	 */
+	static boolean isPresentBasedOnFlag(ZipCentralDirectoryFileHeaderRecord centralRecord) {
+		return isPresentBasedOnFlag(centralRecord.generalPurposeBitFlag());
+	}
+
+	/**
+	 * Return if the {@link ZipDataDescriptorRecord} is present based on the given general
+	 * purpose bit flag.
+	 * @param generalPurposeBitFlag the general purpose bit flag to check
+	 * @return if the bit flag is set
+	 */
+	static boolean isPresentBasedOnFlag(int generalPurposeBitFlag) {
+		return (generalPurposeBitFlag & 0b0000_1000) != 0;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipEndOfCentralDirectoryRecord.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipEndOfCentralDirectoryRecord.java
new file mode 100644
index 000000000000..af2d8e57bf85
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipEndOfCentralDirectoryRecord.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+import org.springframework.boot.loader.log.DebugLogger;
+
+/**
+ * A ZIP File "End of central directory record" (EOCD).
+ *
+ * @author Phillip Webb
+ * @param numberOfThisDisk the number of this disk (or 0xffff for Zip64)
+ * @param diskWhereCentralDirectoryStarts the disk where central directory starts (or
+ * 0xffff for Zip64)
+ * @param numberOfCentralDirectoryEntriesOnThisDisk the number of central directory
+ * entries on this disk (or 0xffff for Zip64)
+ * @param totalNumberOfCentralDirectoryEntries the total number of central directory
+ * entries (or 0xffff for Zip64)
+ * @param sizeOfCentralDirectory the size of central directory (bytes) (or 0xffffffff for
+ * Zip64)
+ * @param offsetToStartOfCentralDirectory the offset of start of central directory,
+ * relative to start of archive (or 0xffffffff for Zip64)
+ * @param commentLength the length of the comment field
+ * @see <a href="https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT">Chapter
+ * 4.3.16 of the Zip File Format Specification</a>
+ */
+record ZipEndOfCentralDirectoryRecord(short numberOfThisDisk, short diskWhereCentralDirectoryStarts,
+		short numberOfCentralDirectoryEntriesOnThisDisk, short totalNumberOfCentralDirectoryEntries,
+		int sizeOfCentralDirectory, int offsetToStartOfCentralDirectory, short commentLength) {
+
+	ZipEndOfCentralDirectoryRecord(short totalNumberOfCentralDirectoryEntries, int sizeOfCentralDirectory,
+			int offsetToStartOfCentralDirectory) {
+		this((short) 0, (short) 0, totalNumberOfCentralDirectoryEntries, totalNumberOfCentralDirectoryEntries,
+				sizeOfCentralDirectory, offsetToStartOfCentralDirectory, (short) 0);
+	}
+
+	private static final DebugLogger debug = DebugLogger.get(ZipEndOfCentralDirectoryRecord.class);
+
+	private static final int SIGNATURE = 0x06054b50;
+
+	private static final int MAXIMUM_COMMENT_LENGTH = 0xFFFF;
+
+	private static final int MINIMUM_SIZE = 22;
+
+	private static final int MAXIMUM_SIZE = MINIMUM_SIZE + MAXIMUM_COMMENT_LENGTH;
+
+	static final int BUFFER_SIZE = 256;
+
+	/**
+	 * The offset of the file comment relative to the record start position.
+	 */
+	static final int COMMENT_OFFSET = MINIMUM_SIZE;
+
+	/**
+	 * Return the size of this record.
+	 * @return the record size
+	 */
+	long size() {
+		return MINIMUM_SIZE + this.commentLength;
+	}
+
+	/**
+	 * Return the contents of this record as a byte array suitable for writing to a zip.
+	 * @return the record as a byte array
+	 */
+	byte[] asByteArray() {
+		ByteBuffer buffer = ByteBuffer.allocate(MINIMUM_SIZE);
+		buffer.order(ByteOrder.LITTLE_ENDIAN);
+		buffer.putInt(SIGNATURE);
+		buffer.putShort(this.numberOfThisDisk);
+		buffer.putShort(this.diskWhereCentralDirectoryStarts);
+		buffer.putShort(this.numberOfCentralDirectoryEntriesOnThisDisk);
+		buffer.putShort(this.totalNumberOfCentralDirectoryEntries);
+		buffer.putInt(this.sizeOfCentralDirectory);
+		buffer.putInt(this.offsetToStartOfCentralDirectory);
+		buffer.putShort(this.commentLength);
+		return buffer.array();
+	}
+
+	/**
+	 * Create a new {@link ZipEndOfCentralDirectoryRecord} instance from the specified
+	 * {@link DataBlock} by searching backwards from the end until a valid record is
+	 * located.
+	 * @param dataBlock the source data block
+	 * @return the {@link Located located} {@link ZipEndOfCentralDirectoryRecord}
+	 * @throws IOException if the {@link ZipEndOfCentralDirectoryRecord} cannot be read
+	 */
+	static Located load(DataBlock dataBlock) throws IOException {
+		ByteBuffer buffer = ByteBuffer.allocate(BUFFER_SIZE);
+		buffer.order(ByteOrder.LITTLE_ENDIAN);
+		long pos = locate(dataBlock, buffer);
+		return new Located(pos, new ZipEndOfCentralDirectoryRecord(buffer.getShort(), buffer.getShort(),
+				buffer.getShort(), buffer.getShort(), buffer.getInt(), buffer.getInt(), buffer.getShort()));
+	}
+
+	private static long locate(DataBlock dataBlock, ByteBuffer buffer) throws IOException {
+		long endPos = dataBlock.size();
+		debug.log("Finding EndOfCentralDirectoryRecord starting at end position %s", endPos);
+		while (endPos > 0) {
+			buffer.clear();
+			long totalRead = dataBlock.size() - endPos;
+			if (totalRead > MAXIMUM_SIZE) {
+				throw new IOException(
+						"Zip 'End Of Central Directory Record' not found after reading " + totalRead + " bytes");
+			}
+			long startPos = endPos - buffer.limit();
+			if (startPos < 0) {
+				buffer.limit((int) startPos + buffer.limit());
+				startPos = 0;
+			}
+			debug.log("Finding EndOfCentralDirectoryRecord from %s with limit %s", startPos, buffer.limit());
+			dataBlock.readFully(buffer, startPos);
+			int offset = findInBuffer(buffer);
+			if (offset >= 0) {
+				debug.log("Found EndOfCentralDirectoryRecord at %s + %s", startPos, offset);
+				return startPos + offset;
+			}
+			endPos = endPos - BUFFER_SIZE + MINIMUM_SIZE;
+		}
+		throw new IOException("Zip 'End Of Central Directory Record' not found after reading entire data block");
+	}
+
+	private static int findInBuffer(ByteBuffer buffer) {
+		for (int pos = buffer.limit() - 4; pos >= 0; pos--) {
+			buffer.position(pos);
+			if (buffer.getInt() == SIGNATURE) {
+				return pos;
+			}
+		}
+		return -1;
+	}
+
+	/**
+	 * A located {@link ZipEndOfCentralDirectoryRecord}.
+	 *
+	 * @param pos the position of the record
+	 * @param endOfCentralDirectoryRecord the located end of central directory record
+	 */
+	record Located(long pos, ZipEndOfCentralDirectoryRecord endOfCentralDirectoryRecord) {
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipLocalFileHeaderRecord.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipLocalFileHeaderRecord.java
new file mode 100644
index 000000000000..daed69afb9b7
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipLocalFileHeaderRecord.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+import org.springframework.boot.loader.log.DebugLogger;
+
+/**
+ * A ZIP File "Local file header record" (LFH).
+ *
+ * @param versionNeededToExtract the version needed to extract the zip
+ * @param generalPurposeBitFlag the general purpose bit flag
+ * @param compressionMethod the compression method used for this entry
+ * @param lastModFileTime the last modified file time
+ * @param lastModFileDate the last modified file date
+ * @param crc32 the CRC32 checksum
+ * @param compressedSize the size of the entry when compressed
+ * @param uncompressedSize the size of the entry when uncompressed
+ * @param fileNameLength the file name length
+ * @param extraFieldLength the extra field length
+ * @author Phillip Webb
+ * @see <a href="https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT">Chapter
+ * 4.3.7 of the Zip File Format Specification</a>
+ */
+record ZipLocalFileHeaderRecord(short versionNeededToExtract, short generalPurposeBitFlag, short compressionMethod,
+		short lastModFileTime, short lastModFileDate, int crc32, int compressedSize, int uncompressedSize,
+		short fileNameLength, short extraFieldLength) {
+
+	private static final DebugLogger debug = DebugLogger.get(ZipLocalFileHeaderRecord.class);
+
+	private static final int SIGNATURE = 0x04034b50;
+
+	private static final int MINIMUM_SIZE = 30;
+
+	/**
+	 * Return the size of this record.
+	 * @return the record size
+	 */
+	long size() {
+		return MINIMUM_SIZE + fileNameLength() + extraFieldLength();
+	}
+
+	/**
+	 * Return a new {@link ZipLocalFileHeaderRecord} with a new
+	 * {@link #extraFieldLength()}.
+	 * @param extraFieldLength the new extra field length
+	 * @return a new {@link ZipLocalFileHeaderRecord} instance
+	 */
+	ZipLocalFileHeaderRecord withExtraFieldLength(short extraFieldLength) {
+		return new ZipLocalFileHeaderRecord(this.versionNeededToExtract, this.generalPurposeBitFlag,
+				this.compressionMethod, this.lastModFileTime, this.lastModFileDate, this.crc32, this.compressedSize,
+				this.uncompressedSize, this.fileNameLength, extraFieldLength);
+	}
+
+	/**
+	 * Return a new {@link ZipLocalFileHeaderRecord} with a new {@link #fileNameLength()}.
+	 * @param fileNameLength the new file name length
+	 * @return a new {@link ZipLocalFileHeaderRecord} instance
+	 */
+	ZipLocalFileHeaderRecord withFileNameLength(short fileNameLength) {
+		return new ZipLocalFileHeaderRecord(this.versionNeededToExtract, this.generalPurposeBitFlag,
+				this.compressionMethod, this.lastModFileTime, this.lastModFileDate, this.crc32, this.compressedSize,
+				this.uncompressedSize, fileNameLength, this.extraFieldLength);
+	}
+
+	/**
+	 * Return the contents of this record as a byte array suitable for writing to a zip.
+	 * @return the record as a byte array
+	 */
+	byte[] asByteArray() {
+		ByteBuffer buffer = ByteBuffer.allocate(MINIMUM_SIZE);
+		buffer.order(ByteOrder.LITTLE_ENDIAN);
+		buffer.putInt(SIGNATURE);
+		buffer.putShort(this.versionNeededToExtract);
+		buffer.putShort(this.generalPurposeBitFlag);
+		buffer.putShort(this.compressionMethod);
+		buffer.putShort(this.lastModFileTime);
+		buffer.putShort(this.lastModFileDate);
+		buffer.putInt(this.crc32);
+		buffer.putInt(this.compressedSize);
+		buffer.putInt(this.uncompressedSize);
+		buffer.putShort(this.fileNameLength);
+		buffer.putShort(this.extraFieldLength);
+		return buffer.array();
+	}
+
+	/**
+	 * Load the {@link ZipLocalFileHeaderRecord} from the given data block.
+	 * @param dataBlock the source data block
+	 * @param pos the position of the record
+	 * @return a new {@link ZipLocalFileHeaderRecord} instance
+	 * @throws IOException on I/O error
+	 */
+	static ZipLocalFileHeaderRecord load(DataBlock dataBlock, long pos) throws IOException {
+		debug.log("Loading LocalFileHeaderRecord from position %s", pos);
+		ByteBuffer buffer = ByteBuffer.allocate(MINIMUM_SIZE);
+		buffer.order(ByteOrder.LITTLE_ENDIAN);
+		dataBlock.readFully(buffer, pos);
+		buffer.rewind();
+		if (buffer.getInt() != SIGNATURE) {
+			throw new IOException("Zip 'Local File Header Record' not found at position " + pos);
+		}
+		return new ZipLocalFileHeaderRecord(buffer.getShort(), buffer.getShort(), buffer.getShort(), buffer.getShort(),
+				buffer.getShort(), buffer.getInt(), buffer.getInt(), buffer.getInt(), buffer.getShort(),
+				buffer.getShort());
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipString.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipString.java
new file mode 100644
index 000000000000..bf246e0c7d60
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipString.java
@@ -0,0 +1,320 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+
+import org.springframework.boot.loader.log.DebugLogger;
+
+/**
+ * Internal utility class for working with the string content of zip records. Provides
+ * methods that work with raw bytes to save creating temporary strings.
+ *
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ */
+final class ZipString {
+
+	private static final DebugLogger debug = DebugLogger.get(ZipString.class);
+
+	static final int BUFFER_SIZE = 256;
+
+	private static final int[] INITIAL_BYTE_BITMASK = { 0x7F, 0x1F, 0x0F, 0x07 };
+
+	private static final int SUBSEQUENT_BYTE_BITMASK = 0x3F;
+
+	private static final int EMPTY_HASH = "".hashCode();
+
+	private static final int EMPTY_SLASH_HASH = "/".hashCode();
+
+	private ZipString() {
+	}
+
+	/**
+	 * Return a hash for a char sequence, optionally appending '/'.
+	 * @param charSequence the source char sequence
+	 * @param addEndSlash if slash should be added to the string if it's not already
+	 * present
+	 * @return the hash
+	 */
+	static int hash(CharSequence charSequence, boolean addEndSlash) {
+		return hash(0, charSequence, addEndSlash);
+	}
+
+	/**
+	 * Return a hash for a char sequence, optionally appending '/'.
+	 * @param initialHash the initial hash value
+	 * @param charSequence the source char sequence
+	 * @param addEndSlash if slash should be added to the string if it's not already
+	 * present
+	 * @return the hash
+	 */
+	static int hash(int initialHash, CharSequence charSequence, boolean addEndSlash) {
+		if (charSequence == null || charSequence.isEmpty()) {
+			return (!addEndSlash) ? EMPTY_HASH : EMPTY_SLASH_HASH;
+		}
+		boolean endsWithSlash = charSequence.charAt(charSequence.length() - 1) == '/';
+		int hash = initialHash;
+		if (charSequence instanceof String && initialHash == 0) {
+			// We're compatible with String.hashCode and it might be already calculated
+			hash = charSequence.hashCode();
+		}
+		else {
+			for (int i = 0; i < charSequence.length(); i++) {
+				char ch = charSequence.charAt(i);
+				hash = 31 * hash + ch;
+			}
+		}
+		hash = (addEndSlash && !endsWithSlash) ? 31 * hash + '/' : hash;
+		debug.log("%s calculated for charsequence '%s' (addEndSlash=%s)", hash, charSequence, endsWithSlash);
+		return hash;
+	}
+
+	/**
+	 * Return a hash for bytes read from a {@link DataBlock}, optionally appending '/'.
+	 * @param buffer the buffer to use or {@code null}
+	 * @param dataBlock the source data block
+	 * @param pos the position in the data block where the string starts
+	 * @param len the number of bytes to read from the block
+	 * @param addEndSlash if slash should be added to the string if it's not already
+	 * present
+	 * @return the hash
+	 * @throws IOException on I/O error
+	 */
+	static int hash(ByteBuffer buffer, DataBlock dataBlock, long pos, int len, boolean addEndSlash) throws IOException {
+		if (len == 0) {
+			return (!addEndSlash) ? EMPTY_HASH : EMPTY_SLASH_HASH;
+		}
+		buffer = (buffer != null) ? buffer : ByteBuffer.allocate(BUFFER_SIZE);
+		byte[] bytes = buffer.array();
+		int hash = 0;
+		char lastChar = 0;
+		while (len > 0) {
+			int count = readInBuffer(dataBlock, pos, buffer, len);
+			len -= count;
+			pos += count;
+			for (int byteIndex = 0; byteIndex < count;) {
+				int codePointSize = getCodePointSize(bytes, byteIndex);
+				if (!hasEnoughBytes(byteIndex, codePointSize, count)) {
+					pos--;
+					len++;
+					break;
+				}
+				int codePoint = getCodePoint(bytes, byteIndex, codePointSize);
+				byteIndex += codePointSize;
+				if (codePoint <= 0xFFFF) {
+					lastChar = (char) (codePoint & 0xFFFF);
+					hash = 31 * hash + lastChar;
+				}
+				else {
+					lastChar = 0;
+					hash = 31 * hash + Character.highSurrogate(codePoint);
+					hash = 31 * hash + Character.lowSurrogate(codePoint);
+				}
+			}
+		}
+		hash = (addEndSlash && lastChar != '/') ? 31 * hash + '/' : hash;
+		debug.log("%08X calculated for datablock position %s size %s (addEndSlash=%s)", hash, pos, len, addEndSlash);
+		return hash;
+	}
+
+	/**
+	 * Return if the bytes read from a {@link DataBlock} matches the give
+	 * {@link CharSequence}.
+	 * @param buffer the buffer to use or {@code null}
+	 * @param dataBlock the source data block
+	 * @param pos the position in the data block where the string starts
+	 * @param len the number of bytes to read from the block
+	 * @param charSequence the char sequence with which to compare
+	 * @param addSlash also accept {@code charSequence + '/'} when it doesn't already end
+	 * with one
+	 * @return true if the contents are considered equal
+	 */
+	static boolean matches(ByteBuffer buffer, DataBlock dataBlock, long pos, int len, CharSequence charSequence,
+			boolean addSlash) {
+		if (charSequence.isEmpty()) {
+			return true;
+		}
+		buffer = (buffer != null) ? buffer : ByteBuffer.allocate(BUFFER_SIZE);
+		try {
+			return compare(buffer, dataBlock, pos, len, charSequence,
+					(!addSlash) ? CompareType.MATCHES : CompareType.MATCHES_ADDING_SLASH) != -1;
+		}
+		catch (IOException ex) {
+			throw new UncheckedIOException(ex);
+		}
+	}
+
+	/**
+	 * Returns if the bytes read from a {@link DataBlock} starts with the given
+	 * {@link CharSequence}.
+	 * @param buffer the buffer to use or {@code null}
+	 * @param dataBlock the source data block
+	 * @param pos the position in the data block where the string starts
+	 * @param len the number of bytes to read from the block
+	 * @param charSequence the required starting chars
+	 * @return {@code -1} if the data block does not start with the char sequence, or a
+	 * positive number indicating the number of bytes that contain the starting chars
+	 */
+	static int startsWith(ByteBuffer buffer, DataBlock dataBlock, long pos, int len, CharSequence charSequence) {
+		if (charSequence.isEmpty()) {
+			return 0;
+		}
+		buffer = (buffer != null) ? buffer : ByteBuffer.allocate(BUFFER_SIZE);
+		try {
+			return compare(buffer, dataBlock, pos, len, charSequence, CompareType.STARTS_WITH);
+		}
+		catch (IOException ex) {
+			throw new UncheckedIOException(ex);
+		}
+	}
+
+	private static int compare(ByteBuffer buffer, DataBlock dataBlock, long pos, int len, CharSequence charSequence,
+			CompareType compareType) throws IOException {
+		if (charSequence.isEmpty()) {
+			return 0;
+		}
+		boolean addSlash = compareType == CompareType.MATCHES_ADDING_SLASH && !endsWith(charSequence, '/');
+		int charSequenceIndex = 0;
+		int maxCharSequenceLength = (!addSlash) ? charSequence.length() : charSequence.length() + 1;
+		int result = 0;
+		byte[] bytes = buffer.array();
+		while (len > 0) {
+			int count = readInBuffer(dataBlock, pos, buffer, len);
+			len -= count;
+			pos += count;
+			for (int byteIndex = 0; byteIndex < count;) {
+				int codePointSize = getCodePointSize(bytes, byteIndex);
+				if (!hasEnoughBytes(byteIndex, codePointSize, count)) {
+					pos--;
+					len++;
+					break;
+				}
+				int codePoint = getCodePoint(bytes, byteIndex, codePointSize);
+				result += codePointSize;
+				if (codePoint <= 0xFFFF) {
+					char ch = (char) (codePoint & 0xFFFF);
+					if (charSequenceIndex >= maxCharSequenceLength
+							|| getChar(charSequence, charSequenceIndex++) != ch) {
+						return -1;
+					}
+				}
+				else {
+					char ch = Character.highSurrogate(codePoint);
+					if (charSequenceIndex >= maxCharSequenceLength
+							|| getChar(charSequence, charSequenceIndex++) != ch) {
+						return -1;
+					}
+					ch = Character.lowSurrogate(codePoint);
+					if (charSequenceIndex >= charSequence.length()
+							|| getChar(charSequence, charSequenceIndex++) != ch) {
+						return -1;
+					}
+				}
+				if (compareType == CompareType.STARTS_WITH && charSequenceIndex >= charSequence.length()) {
+					return result;
+				}
+				byteIndex += codePointSize;
+			}
+		}
+		return (charSequenceIndex >= charSequence.length()) ? result : -1;
+	}
+
+	private static boolean hasEnoughBytes(int byteIndex, int codePointSize, int count) {
+		return (byteIndex + codePointSize - 1) < count;
+	}
+
+	private static boolean endsWith(CharSequence charSequence, char ch) {
+		return !charSequence.isEmpty() && charSequence.charAt(charSequence.length() - 1) == ch;
+	}
+
+	private static char getChar(CharSequence charSequence, int index) {
+		return (index != charSequence.length()) ? charSequence.charAt(index) : '/';
+	}
+
+	/**
+	 * Read a string value from the given data block.
+	 * @param data the source data
+	 * @param pos the position to read from
+	 * @param len the number of bytes to read
+	 * @return the contents as a string
+	 */
+	static String readString(DataBlock data, long pos, long len) {
+		try {
+			if (len > Integer.MAX_VALUE) {
+				throw new IllegalStateException("String is too long to read");
+			}
+			ByteBuffer buffer = ByteBuffer.allocate((int) len);
+			buffer.order(ByteOrder.LITTLE_ENDIAN);
+			data.readFully(buffer, pos);
+			return new String(buffer.array(), StandardCharsets.UTF_8);
+		}
+		catch (IOException ex) {
+			throw new UncheckedIOException(ex);
+		}
+	}
+
+	private static int readInBuffer(DataBlock dataBlock, long pos, ByteBuffer buffer, int maxLen) throws IOException {
+		buffer.clear();
+		if (buffer.remaining() > maxLen) {
+			buffer.limit(maxLen);
+		}
+		int count = dataBlock.read(buffer, pos);
+		if (count <= 0) {
+			throw new EOFException();
+		}
+		return count;
+	}
+
+	private static int getCodePointSize(byte[] bytes, int i) {
+		int b = bytes[i] & 0xFF;
+		if ((b & 0b1_0000000) == 0b0_0000000) {
+			return 1;
+		}
+		if ((b & 0b111_00000) == 0b110_00000) {
+			return 2;
+		}
+		if ((b & 0b1111_0000) == 0b1110_0000) {
+			return 3;
+		}
+		return 4;
+	}
+
+	private static int getCodePoint(byte[] bytes, int i, int codePointSize) {
+		int codePoint = bytes[i] & 0xFF;
+		codePoint &= INITIAL_BYTE_BITMASK[codePointSize - 1];
+		for (int j = 1; j < codePointSize; j++) {
+			codePoint = (codePoint << 6) + (bytes[i + j] & SUBSEQUENT_BYTE_BITMASK);
+		}
+		return codePoint;
+	}
+
+	/**
+	 * Supported compare types.
+	 */
+	private enum CompareType {
+
+		MATCHES, MATCHES_ADDING_SLASH, STARTS_WITH
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/package-info.java
new file mode 100644
index 000000000000..38bd93390b2e
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/package-info.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Provides low-level support for handling zip content, including support for nested and
+ * virtual zip files.
+ */
+package org.springframework.boot.loader.zip;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/resources/META-INF/services/java.nio.file.spi.FileSystemProvider b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/resources/META-INF/services/java.nio.file.spi.FileSystemProvider
new file mode 100644
index 000000000000..425737d36fdd
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/resources/META-INF/services/java.nio.file.spi.FileSystemProvider
@@ -0,0 +1 @@
+org.springframework.boot.loader.nio.file.NestedFileSystemProvider
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/AbstractExecutableArchiveLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/AbstractExecutableArchiveLauncherTests.java
deleted file mode 100644
index 48d7340ee384..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/AbstractExecutableArchiveLauncherTests.java
+++ /dev/null
@@ -1,149 +0,0 @@
-/*
- * Copyright 2012-2021 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.loader;
-
-import java.io.ByteArrayOutputStream;
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.OutputStreamWriter;
-import java.io.Writer;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.nio.charset.StandardCharsets;
-import java.util.Collections;
-import java.util.Enumeration;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Set;
-import java.util.jar.JarEntry;
-import java.util.jar.JarFile;
-import java.util.jar.JarOutputStream;
-import java.util.jar.Manifest;
-import java.util.zip.CRC32;
-import java.util.zip.ZipEntry;
-
-import org.junit.jupiter.api.io.TempDir;
-
-import org.springframework.boot.loader.archive.Archive;
-import org.springframework.util.FileCopyUtils;
-
-/**
- * Base class for testing {@link ExecutableArchiveLauncher} implementations.
- *
- * @author Andy Wilkinson
- * @author Madhura Bhave
- * @author Scott Frederick
- */
-public abstract class AbstractExecutableArchiveLauncherTests {
-
-	@TempDir
-	File tempDir;
-
-	protected File createJarArchive(String name, String entryPrefix) throws IOException {
-		return createJarArchive(name, entryPrefix, false, Collections.emptyList());
-	}
-
-	@SuppressWarnings("resource")
-	protected File createJarArchive(String name, String entryPrefix, boolean indexed, List<String> extraLibs)
-			throws IOException {
-		return createJarArchive(name, null, entryPrefix, indexed, extraLibs);
-	}
-
-	@SuppressWarnings("resource")
-	protected File createJarArchive(String name, Manifest manifest, String entryPrefix, boolean indexed,
-			List<String> extraLibs) throws IOException {
-		File archive = new File(this.tempDir, name);
-		JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(archive));
-		if (manifest != null) {
-			jarOutputStream.putNextEntry(new JarEntry("META-INF/"));
-			jarOutputStream.putNextEntry(new JarEntry("META-INF/MANIFEST.MF"));
-			manifest.write(jarOutputStream);
-			jarOutputStream.closeEntry();
-		}
-		jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/"));
-		jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/classes/"));
-		jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/lib/"));
-		if (indexed) {
-			jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/classpath.idx"));
-			Writer writer = new OutputStreamWriter(jarOutputStream, StandardCharsets.UTF_8);
-			writer.write("- \"" + entryPrefix + "/lib/foo.jar\"\n");
-			writer.write("- \"" + entryPrefix + "/lib/bar.jar\"\n");
-			writer.write("- \"" + entryPrefix + "/lib/baz.jar\"\n");
-			writer.flush();
-			jarOutputStream.closeEntry();
-		}
-		addNestedJars(entryPrefix, "/lib/foo.jar", jarOutputStream);
-		addNestedJars(entryPrefix, "/lib/bar.jar", jarOutputStream);
-		addNestedJars(entryPrefix, "/lib/baz.jar", jarOutputStream);
-		for (String lib : extraLibs) {
-			addNestedJars(entryPrefix, "/lib/" + lib, jarOutputStream);
-		}
-		jarOutputStream.close();
-		return archive;
-	}
-
-	private void addNestedJars(String entryPrefix, String lib, JarOutputStream jarOutputStream) throws IOException {
-		JarEntry libFoo = new JarEntry(entryPrefix + lib);
-		libFoo.setMethod(ZipEntry.STORED);
-		ByteArrayOutputStream fooJarStream = new ByteArrayOutputStream();
-		new JarOutputStream(fooJarStream).close();
-		libFoo.setSize(fooJarStream.size());
-		CRC32 crc32 = new CRC32();
-		crc32.update(fooJarStream.toByteArray());
-		libFoo.setCrc(crc32.getValue());
-		jarOutputStream.putNextEntry(libFoo);
-		jarOutputStream.write(fooJarStream.toByteArray());
-	}
-
-	protected File explode(File archive) throws IOException {
-		File exploded = new File(this.tempDir, "exploded");
-		exploded.mkdirs();
-		JarFile jarFile = new JarFile(archive);
-		Enumeration<JarEntry> entries = jarFile.entries();
-		while (entries.hasMoreElements()) {
-			JarEntry entry = entries.nextElement();
-			File entryFile = new File(exploded, entry.getName());
-			if (entry.isDirectory()) {
-				entryFile.mkdirs();
-			}
-			else {
-				FileCopyUtils.copy(jarFile.getInputStream(entry), new FileOutputStream(entryFile));
-			}
-		}
-		jarFile.close();
-		return exploded;
-	}
-
-	protected Set<URL> getUrls(List<Archive> archives) throws MalformedURLException {
-		Set<URL> urls = new LinkedHashSet<>(archives.size());
-		for (Archive archive : archives) {
-			urls.add(archive.getUrl());
-		}
-		return urls;
-	}
-
-	protected final URL toUrl(File file) {
-		try {
-			return file.toURI().toURL();
-		}
-		catch (MalformedURLException ex) {
-			throw new IllegalStateException(ex);
-		}
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/JarLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/JarLauncherTests.java
deleted file mode 100644
index fa713034304a..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/JarLauncherTests.java
+++ /dev/null
@@ -1,154 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.loader;
-
-import java.io.File;
-import java.io.FileOutputStream;
-import java.net.URL;
-import java.net.URLClassLoader;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
-import java.util.jar.Attributes;
-import java.util.jar.Attributes.Name;
-import java.util.jar.Manifest;
-
-import org.junit.jupiter.api.Test;
-
-import org.springframework.boot.loader.archive.Archive;
-import org.springframework.boot.loader.archive.ExplodedArchive;
-import org.springframework.boot.loader.archive.JarFileArchive;
-import org.springframework.core.io.ClassPathResource;
-import org.springframework.core.test.tools.SourceFile;
-import org.springframework.core.test.tools.TestCompiler;
-import org.springframework.util.FileCopyUtils;
-import org.springframework.util.function.ThrowingConsumer;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Tests for {@link JarLauncher}.
- *
- * @author Andy Wilkinson
- * @author Madhura Bhave
- */
-class JarLauncherTests extends AbstractExecutableArchiveLauncherTests {
-
-	@Test
-	void explodedJarHasOnlyBootInfClassesAndContentsOfBootInfLibOnClasspath() throws Exception {
-		File explodedRoot = explode(createJarArchive("archive.jar", "BOOT-INF"));
-		JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot, true));
-		List<Archive> archives = new ArrayList<>();
-		launcher.getClassPathArchivesIterator().forEachRemaining(archives::add);
-		assertThat(getUrls(archives)).containsExactlyInAnyOrder(getExpectedFileUrls(explodedRoot));
-		for (Archive archive : archives) {
-			archive.close();
-		}
-	}
-
-	@Test
-	void archivedJarHasOnlyBootInfClassesAndContentsOfBootInfLibOnClasspath() throws Exception {
-		File jarRoot = createJarArchive("archive.jar", "BOOT-INF");
-		try (JarFileArchive archive = new JarFileArchive(jarRoot)) {
-			JarLauncher launcher = new JarLauncher(archive);
-			List<Archive> classPathArchives = new ArrayList<>();
-			launcher.getClassPathArchivesIterator().forEachRemaining(classPathArchives::add);
-			assertThat(classPathArchives).hasSize(4);
-			assertThat(getUrls(classPathArchives)).containsOnly(
-					new URL("jar:" + jarRoot.toURI().toURL() + "!/BOOT-INF/classes!/"),
-					new URL("jar:" + jarRoot.toURI().toURL() + "!/BOOT-INF/lib/foo.jar!/"),
-					new URL("jar:" + jarRoot.toURI().toURL() + "!/BOOT-INF/lib/bar.jar!/"),
-					new URL("jar:" + jarRoot.toURI().toURL() + "!/BOOT-INF/lib/baz.jar!/"));
-			for (Archive classPathArchive : classPathArchives) {
-				classPathArchive.close();
-			}
-		}
-	}
-
-	@Test
-	void explodedJarShouldPreserveClasspathOrderWhenIndexPresent() throws Exception {
-		File explodedRoot = explode(createJarArchive("archive.jar", "BOOT-INF", true, Collections.emptyList()));
-		JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot, true));
-		Iterator<Archive> archives = launcher.getClassPathArchivesIterator();
-		URLClassLoader classLoader = (URLClassLoader) launcher.createClassLoader(archives);
-		URL[] urls = classLoader.getURLs();
-		assertThat(urls).containsExactly(getExpectedFileUrls(explodedRoot));
-	}
-
-	@Test
-	void jarFilesPresentInBootInfLibsAndNotInClasspathIndexShouldBeAddedAfterBootInfClasses() throws Exception {
-		ArrayList<String> extraLibs = new ArrayList<>(Arrays.asList("extra-1.jar", "extra-2.jar"));
-		File explodedRoot = explode(createJarArchive("archive.jar", "BOOT-INF", true, extraLibs));
-		JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot, true));
-		Iterator<Archive> archives = launcher.getClassPathArchivesIterator();
-		URLClassLoader classLoader = (URLClassLoader) launcher.createClassLoader(archives);
-		URL[] urls = classLoader.getURLs();
-		List<File> expectedFiles = getExpectedFilesWithExtraLibs(explodedRoot);
-		URL[] expectedFileUrls = expectedFiles.stream().map(this::toUrl).toArray(URL[]::new);
-		assertThat(urls).containsExactly(expectedFileUrls);
-	}
-
-	@Test
-	void explodedJarDefinedPackagesIncludeManifestAttributes() {
-		Manifest manifest = new Manifest();
-		Attributes attributes = manifest.getMainAttributes();
-		attributes.put(Name.MANIFEST_VERSION, "1.0");
-		attributes.put(Name.IMPLEMENTATION_TITLE, "test");
-		SourceFile sourceFile = SourceFile.of("explodedsample/ExampleClass.java",
-				new ClassPathResource("explodedsample/ExampleClass.txt"));
-		TestCompiler.forSystem().compile(sourceFile, ThrowingConsumer.of((compiled) -> {
-			File explodedRoot = explode(
-					createJarArchive("archive.jar", manifest, "BOOT-INF", true, Collections.emptyList()));
-			File target = new File(explodedRoot, "BOOT-INF/classes/explodedsample/ExampleClass.class");
-			target.getParentFile().mkdirs();
-			FileCopyUtils.copy(compiled.getClassLoader().getResourceAsStream("explodedsample/ExampleClass.class"),
-					new FileOutputStream(target));
-			JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot, true));
-			Iterator<Archive> archives = launcher.getClassPathArchivesIterator();
-			URLClassLoader classLoader = (URLClassLoader) launcher.createClassLoader(archives);
-			Class<?> loaded = classLoader.loadClass("explodedsample.ExampleClass");
-			assertThat(loaded.getPackage().getImplementationTitle()).isEqualTo("test");
-		}));
-	}
-
-	protected final URL[] getExpectedFileUrls(File explodedRoot) {
-		return getExpectedFiles(explodedRoot).stream().map(this::toUrl).toArray(URL[]::new);
-	}
-
-	protected final List<File> getExpectedFiles(File parent) {
-		List<File> expected = new ArrayList<>();
-		expected.add(new File(parent, "BOOT-INF/classes"));
-		expected.add(new File(parent, "BOOT-INF/lib/foo.jar"));
-		expected.add(new File(parent, "BOOT-INF/lib/bar.jar"));
-		expected.add(new File(parent, "BOOT-INF/lib/baz.jar"));
-		return expected;
-	}
-
-	protected final List<File> getExpectedFilesWithExtraLibs(File parent) {
-		List<File> expected = new ArrayList<>();
-		expected.add(new File(parent, "BOOT-INF/classes"));
-		expected.add(new File(parent, "BOOT-INF/lib/extra-1.jar"));
-		expected.add(new File(parent, "BOOT-INF/lib/extra-2.jar"));
-		expected.add(new File(parent, "BOOT-INF/lib/foo.jar"));
-		expected.add(new File(parent, "BOOT-INF/lib/bar.jar"));
-		expected.add(new File(parent, "BOOT-INF/lib/baz.jar"));
-		return expected;
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/WarLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/WarLauncherTests.java
deleted file mode 100644
index aef78cfa53d9..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/WarLauncherTests.java
+++ /dev/null
@@ -1,121 +0,0 @@
-/*
- * Copyright 2012-2021 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.loader;
-
-import java.io.File;
-import java.net.URL;
-import java.net.URLClassLoader;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
-
-import org.junit.jupiter.api.Test;
-
-import org.springframework.boot.loader.archive.Archive;
-import org.springframework.boot.loader.archive.ExplodedArchive;
-import org.springframework.boot.loader.archive.JarFileArchive;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Tests for {@link WarLauncher}.
- *
- * @author Andy Wilkinson
- * @author Scott Frederick
- */
-class WarLauncherTests extends AbstractExecutableArchiveLauncherTests {
-
-	@Test
-	void explodedWarHasOnlyWebInfClassesAndContentsOfWebInfLibOnClasspath() throws Exception {
-		File explodedRoot = explode(createJarArchive("archive.war", "WEB-INF"));
-		WarLauncher launcher = new WarLauncher(new ExplodedArchive(explodedRoot, true));
-		List<Archive> archives = new ArrayList<>();
-		launcher.getClassPathArchivesIterator().forEachRemaining(archives::add);
-		assertThat(getUrls(archives)).containsExactlyInAnyOrder(getExpectedFileUrls(explodedRoot));
-		for (Archive archive : archives) {
-			archive.close();
-		}
-	}
-
-	@Test
-	void archivedWarHasOnlyWebInfClassesAndContentsOfWebInfLibOnClasspath() throws Exception {
-		File jarRoot = createJarArchive("archive.war", "WEB-INF");
-		try (JarFileArchive archive = new JarFileArchive(jarRoot)) {
-			WarLauncher launcher = new WarLauncher(archive);
-			List<Archive> classPathArchives = new ArrayList<>();
-			launcher.getClassPathArchivesIterator().forEachRemaining(classPathArchives::add);
-			assertThat(getUrls(classPathArchives)).containsOnly(
-					new URL("jar:" + jarRoot.toURI().toURL() + "!/WEB-INF/classes!/"),
-					new URL("jar:" + jarRoot.toURI().toURL() + "!/WEB-INF/lib/foo.jar!/"),
-					new URL("jar:" + jarRoot.toURI().toURL() + "!/WEB-INF/lib/bar.jar!/"),
-					new URL("jar:" + jarRoot.toURI().toURL() + "!/WEB-INF/lib/baz.jar!/"));
-			for (Archive classPathArchive : classPathArchives) {
-				classPathArchive.close();
-			}
-		}
-	}
-
-	@Test
-	void explodedWarShouldPreserveClasspathOrderWhenIndexPresent() throws Exception {
-		File explodedRoot = explode(createJarArchive("archive.war", "WEB-INF", true, Collections.emptyList()));
-		WarLauncher launcher = new WarLauncher(new ExplodedArchive(explodedRoot, true));
-		Iterator<Archive> archives = launcher.getClassPathArchivesIterator();
-		URLClassLoader classLoader = (URLClassLoader) launcher.createClassLoader(archives);
-		URL[] urls = classLoader.getURLs();
-		assertThat(urls).containsExactly(getExpectedFileUrls(explodedRoot));
-	}
-
-	@Test
-	void warFilesPresentInWebInfLibsAndNotInClasspathIndexShouldBeAddedAfterWebInfClasses() throws Exception {
-		ArrayList<String> extraLibs = new ArrayList<>(Arrays.asList("extra-1.jar", "extra-2.jar"));
-		File explodedRoot = explode(createJarArchive("archive.war", "WEB-INF", true, extraLibs));
-		WarLauncher launcher = new WarLauncher(new ExplodedArchive(explodedRoot, true));
-		Iterator<Archive> archives = launcher.getClassPathArchivesIterator();
-		URLClassLoader classLoader = (URLClassLoader) launcher.createClassLoader(archives);
-		URL[] urls = classLoader.getURLs();
-		List<File> expectedFiles = getExpectedFilesWithExtraLibs(explodedRoot);
-		URL[] expectedFileUrls = expectedFiles.stream().map(this::toUrl).toArray(URL[]::new);
-		assertThat(urls).containsExactly(expectedFileUrls);
-	}
-
-	protected final URL[] getExpectedFileUrls(File explodedRoot) {
-		return getExpectedFiles(explodedRoot).stream().map(this::toUrl).toArray(URL[]::new);
-	}
-
-	protected final List<File> getExpectedFiles(File parent) {
-		List<File> expected = new ArrayList<>();
-		expected.add(new File(parent, "WEB-INF/classes"));
-		expected.add(new File(parent, "WEB-INF/lib/foo.jar"));
-		expected.add(new File(parent, "WEB-INF/lib/bar.jar"));
-		expected.add(new File(parent, "WEB-INF/lib/baz.jar"));
-		return expected;
-	}
-
-	protected final List<File> getExpectedFilesWithExtraLibs(File parent) {
-		List<File> expected = new ArrayList<>();
-		expected.add(new File(parent, "WEB-INF/classes"));
-		expected.add(new File(parent, "WEB-INF/lib/extra-1.jar"));
-		expected.add(new File(parent, "WEB-INF/lib/extra-2.jar"));
-		expected.add(new File(parent, "WEB-INF/lib/foo.jar"));
-		expected.add(new File(parent, "WEB-INF/lib/bar.jar"));
-		expected.add(new File(parent, "WEB-INF/lib/baz.jar"));
-		return expected;
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/ManifestInfoTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/ManifestInfoTests.java
new file mode 100644
index 000000000000..619080b175a7
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/ManifestInfoTests.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.jar;
+
+import java.util.jar.Attributes.Name;
+import java.util.jar.Manifest;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ManifestInfo}.
+ *
+ * @author Phillip Webb
+ */
+class ManifestInfoTests {
+
+	@Test
+	void noneReturnsNoDetails() {
+		assertThat(ManifestInfo.NONE.getManifest()).isNull();
+		assertThat(ManifestInfo.NONE.isMultiRelease()).isFalse();
+	}
+
+	@Test
+	void getManifestReturnsManifest() {
+		Manifest manifest = new Manifest();
+		ManifestInfo info = new ManifestInfo(manifest);
+		assertThat(info.getManifest()).isSameAs(manifest);
+	}
+
+	@Test
+	void isMultiReleaseWhenHasMultiReleaseAttributeReturnsTrue() {
+		Manifest manifest = new Manifest();
+		manifest.getMainAttributes().put(new Name("Multi-Release"), "true");
+		ManifestInfo info = new ManifestInfo(manifest);
+		assertThat(info.isMultiRelease()).isTrue();
+	}
+
+	@Test
+	void isMultiReleaseWhenHasNoMultiReleaseAttributeReturnsFalse() {
+		Manifest manifest = new Manifest();
+		manifest.getMainAttributes().put(new Name("Random-Release"), "true");
+		ManifestInfo info = new ManifestInfo(manifest);
+		assertThat(info.isMultiRelease()).isFalse();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/MetaInfVersionsInfoTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/MetaInfVersionsInfoTests.java
new file mode 100644
index 000000000000..d556c9cbea57
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/MetaInfVersionsInfoTests.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.jar;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.loader.zip.ZipContent;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link MetaInfVersionsInfo}.
+ *
+ * @author Phillip Webb
+ */
+class MetaInfVersionsInfoTests {
+
+	@Test
+	void getParsesVersionsAndEntries() {
+		List<ZipContent.Entry> entries = new ArrayList<>();
+		entries.add(mockEntry("META-INF/"));
+		entries.add(mockEntry("META-INF/MANIFEST.MF"));
+		entries.add(mockEntry("META-INF/versions/"));
+		entries.add(mockEntry("META-INF/versions/9/"));
+		entries.add(mockEntry("META-INF/versions/9/Foo.class"));
+		entries.add(mockEntry("META-INF/versions/11/"));
+		entries.add(mockEntry("META-INF/versions/11/Foo.class"));
+		entries.add(mockEntry("META-INF/versions/10/"));
+		entries.add(mockEntry("META-INF/versions/10/Foo.class"));
+		MetaInfVersionsInfo info = MetaInfVersionsInfo.get(entries.size(), entries::get);
+		assertThat(info.versions()).containsExactly(9, 10, 11);
+		assertThat(info.directories()).containsExactly("META-INF/versions/9/", "META-INF/versions/10/",
+				"META-INF/versions/11/");
+	}
+
+	@Test
+	void getWhenHasBadEntryParsesGoodVersionsAndEntries() {
+		List<ZipContent.Entry> entries = new ArrayList<>();
+		entries.add(mockEntry("META-INF/versions/9/Foo.class"));
+		entries.add(mockEntry("META-INF/versions/0x11/Foo.class"));
+		MetaInfVersionsInfo info = MetaInfVersionsInfo.get(entries.size(), entries::get);
+		assertThat(info.versions()).containsExactly(9);
+		assertThat(info.directories()).containsExactly("META-INF/versions/9/");
+	}
+
+	@Test
+	void getWhenHasNoEntriesReturnsNone() {
+		List<ZipContent.Entry> entries = new ArrayList<>();
+		MetaInfVersionsInfo info = MetaInfVersionsInfo.get(entries.size(), entries::get);
+		assertThat(info.versions()).isEmpty();
+		assertThat(info.directories()).isEmpty();
+		assertThat(info).isSameAs(MetaInfVersionsInfo.NONE);
+	}
+
+	private ZipContent.Entry mockEntry(String name) {
+		ZipContent.Entry entry = mock(ZipContent.Entry.class);
+		given(entry.getName()).willReturn(name);
+		given(entry.hasNameStartingWith(any()))
+			.willAnswer((invocation) -> name.startsWith(invocation.getArgument(0, CharSequence.class).toString()));
+		given(entry.isDirectory()).willAnswer((invocation) -> name.endsWith("/"));
+		return entry;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/NestedJarFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/NestedJarFileTests.java
new file mode 100644
index 000000000000..8050866078df
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/NestedJarFileTests.java
@@ -0,0 +1,370 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.jar;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.ref.Cleaner.Cleanable;
+import java.nio.charset.Charset;
+import java.util.Enumeration;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.jar.Manifest;
+import java.util.zip.ZipFile;
+
+import org.assertj.core.extractor.Extractors;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.boot.loader.ref.Cleaner;
+import org.springframework.boot.loader.testsupport.TestJar;
+import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed;
+import org.springframework.boot.loader.zip.ZipContent;
+import org.springframework.util.FileCopyUtils;
+import org.springframework.util.StopWatch;
+import org.springframework.util.StreamUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.atMostOnce;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link NestedJarFile}.
+ *
+ * @author Phillip Webb
+ * @author Martin Lau
+ * @author Andy Wilkinson
+ * @author Madhura Bhave
+ */
+@AssertFileChannelDataBlocksClosed
+class NestedJarFileTests {
+
+	@TempDir
+	File tempDir;
+
+	private File file;
+
+	@BeforeEach
+	void setup() throws Exception {
+		this.file = new File(this.tempDir, "test.jar");
+		TestJar.create(this.file);
+	}
+
+	@Test
+	void createOpensJar() throws IOException {
+		try (NestedJarFile jar = new NestedJarFile(this.file)) {
+			try (JarFile jdkJar = new JarFile(this.file)) {
+				assertThat(jar.size()).isEqualTo(jdkJar.size());
+				assertThat(jar.getComment()).isEqualTo(jdkJar.getComment());
+				Enumeration<JarEntry> entries = jar.entries();
+				Enumeration<JarEntry> jdkEntries = jdkJar.entries();
+				while (entries.hasMoreElements()) {
+					assertThat(entries.nextElement().getName()).isEqualTo(jdkEntries.nextElement().getName());
+				}
+				assertThat(jdkEntries.hasMoreElements()).isFalse();
+				try (InputStream in = jar.getInputStream(jar.getEntry("1.dat"))) {
+					assertThat(in.readAllBytes()).containsExactly(new byte[] { 1 });
+				}
+			}
+		}
+	}
+
+	@Test
+	void createWhenNestedJarFileOpensJar() throws IOException {
+		try (NestedJarFile jar = new NestedJarFile(this.file, "nested.jar")) {
+			assertThat(jar.size()).isEqualTo(5);
+			assertThat(jar.stream().map(JarEntry::getName)).containsExactly("META-INF/", "META-INF/MANIFEST.MF",
+					"3.dat", "4.dat", "\u00E4.dat");
+		}
+	}
+
+	@Test
+	void createWhenNestedJarDirectoryOpensJar() throws IOException {
+		try (NestedJarFile jar = new NestedJarFile(this.file, "d/")) {
+			assertThat(jar.getName()).isEqualTo(this.file.getAbsolutePath() + "!/d/");
+			assertThat(jar.size()).isEqualTo(3);
+			assertThat(jar.stream().map(JarEntry::getName)).containsExactly("META-INF/", "META-INF/MANIFEST.MF",
+					"9.dat");
+		}
+	}
+
+	@Test
+	void createWhenJarHasFrontMatterOpensJar() throws IOException {
+		File file = new File(this.tempDir, "frontmatter.jar");
+		InputStream sourceJarContent = new FileInputStream(this.file);
+		FileOutputStream outputStream = new FileOutputStream(file);
+		StreamUtils.copy("#/bin/bash", Charset.defaultCharset(), outputStream);
+		FileCopyUtils.copy(sourceJarContent, outputStream);
+		try (NestedJarFile jar = new NestedJarFile(file)) {
+			assertThat(jar.size()).isEqualTo(12);
+		}
+		try (NestedJarFile jar = new NestedJarFile(this.file, "nested.jar")) {
+			assertThat(jar.size()).isEqualTo(5);
+		}
+	}
+
+	@Test
+	void getEntryReturnsEntry() throws IOException {
+		try (NestedJarFile jar = new NestedJarFile(this.file)) {
+			JarEntry entry = jar.getEntry("1.dat");
+			assertEntryOne(entry);
+		}
+	}
+
+	@Test
+	void getEntryWhenClosedThrowsException() throws IOException {
+		try (NestedJarFile jar = new NestedJarFile(this.file)) {
+			jar.close();
+			assertThatIllegalStateException().isThrownBy(() -> jar.getEntry("1.dat")).withMessage("Zip file closed");
+		}
+	}
+
+	@Test
+	void getJarEntryReturnsEntry() throws IOException {
+		try (NestedJarFile jar = new NestedJarFile(this.file)) {
+			JarEntry entry = jar.getJarEntry("1.dat");
+			assertEntryOne(entry);
+		}
+	}
+
+	@Test
+	void getJarEntryWhenClosedThrowsException() throws IOException {
+		try (NestedJarFile jar = new NestedJarFile(this.file)) {
+			jar.close();
+			assertThatIllegalStateException().isThrownBy(() -> jar.getJarEntry("1.dat")).withMessage("Zip file closed");
+		}
+	}
+
+	private void assertEntryOne(JarEntry entry) {
+		assertThat(entry.getName()).isEqualTo("1.dat");
+		assertThat(entry.getRealName()).isEqualTo("1.dat");
+		assertThat(entry.getSize()).isEqualTo(1);
+		assertThat(entry.getCompressedSize()).isEqualTo(3);
+		assertThat(entry.getCrc()).isEqualTo(2768625435L);
+		assertThat(entry.getMethod()).isEqualTo(8);
+	}
+
+	@Test
+	void getEntryWhenMultiReleaseEntryReturnsEntry() throws IOException {
+		File multiReleaseFile = new File(this.tempDir, "mutli.zip");
+		try (ZipContent zip = ZipContent.open(this.file.toPath(), "multi-release.jar")) {
+			try (InputStream in = zip.openRawZipData().asInputStream()) {
+				try (FileOutputStream out = new FileOutputStream(multiReleaseFile)) {
+					in.transferTo(out);
+				}
+			}
+		}
+		try (NestedJarFile jar = new NestedJarFile(this.file, "multi-release.jar", JarFile.runtimeVersion())) {
+			try (JarFile jdkJar = new JarFile(multiReleaseFile, true, ZipFile.OPEN_READ, JarFile.runtimeVersion())) {
+				JarEntry entry = jar.getJarEntry("multi-release.dat");
+				JarEntry jdkEntry = jdkJar.getJarEntry("multi-release.dat");
+				assertThat(entry.getName()).isEqualTo(jdkEntry.getName());
+				assertThat(entry.getRealName()).isEqualTo(jdkEntry.getRealName());
+				try (InputStream inputStream = jdkJar.getInputStream(entry)) {
+					assertThat(inputStream.available()).isOne();
+					assertThat(inputStream.read()).isEqualTo(Runtime.version().feature());
+				}
+				try (InputStream inputStream = jar.getInputStream(entry)) {
+					assertThat(inputStream.available()).isOne();
+					assertThat(inputStream.read()).isEqualTo(Runtime.version().feature());
+				}
+			}
+		}
+	}
+
+	@Test
+	void getManifestReturnsManifest() throws IOException {
+		try (NestedJarFile jar = new NestedJarFile(this.file)) {
+			Manifest manifest = jar.getManifest();
+			assertThat(manifest).isNotNull();
+			assertThat(manifest.getEntries()).isEmpty();
+			assertThat(manifest.getMainAttributes().getValue("Manifest-Version")).isEqualTo("1.0");
+		}
+	}
+
+	@Test
+	void getCommentReturnsComment() throws IOException {
+		try (NestedJarFile jar = new NestedJarFile(this.file)) {
+			assertThat(jar.getComment()).isEqualTo("outer");
+		}
+	}
+
+	@Test
+	void getCommentWhenClosedThrowsException() throws IOException {
+		try (NestedJarFile jar = new NestedJarFile(this.file)) {
+			jar.close();
+			assertThatIllegalStateException().isThrownBy(() -> jar.getComment()).withMessage("Zip file closed");
+		}
+	}
+
+	@Test
+	void getNameReturnsName() throws IOException {
+		try (NestedJarFile jar = new NestedJarFile(this.file)) {
+			assertThat(jar.getName()).isEqualTo(this.file.getAbsolutePath());
+		}
+	}
+
+	@Test
+	void getNameWhenNestedReturnsName() throws IOException {
+		try (NestedJarFile jar = new NestedJarFile(this.file, "nested.jar")) {
+			assertThat(jar.getName()).isEqualTo(this.file.getAbsolutePath() + "!/nested.jar");
+		}
+	}
+
+	@Test
+	void sizeReturnsSize() throws IOException {
+		try (NestedJarFile jar = new NestedJarFile(this.file)) {
+			assertThat(jar.size()).isEqualByComparingTo(12);
+		}
+	}
+
+	@Test
+	void sizeWhenClosedThrowsException() throws Exception {
+		try (NestedJarFile jar = new NestedJarFile(this.file)) {
+			jar.close();
+			assertThatIllegalStateException().isThrownBy(() -> jar.size()).withMessage("Zip file closed");
+		}
+	}
+
+	@Test
+	void getEntryTime() throws IOException {
+		try (NestedJarFile jar = new NestedJarFile(this.file)) {
+			try (JarFile jdkJar = new JarFile(this.file)) {
+				assertThat(jar.getEntry("META-INF/MANIFEST.MF").getTime())
+					.isEqualTo(jar.getEntry("META-INF/MANIFEST.MF").getTime());
+			}
+		}
+	}
+
+	@Test
+	void closeTriggersCleanupOnlyOnce() throws IOException {
+		Cleaner cleaner = mock(Cleaner.class);
+		ArgumentCaptor<Runnable> action = ArgumentCaptor.forClass(Runnable.class);
+		Cleanable cleanable = mock(Cleanable.class);
+		given(cleaner.register(any(), action.capture())).willReturn(cleanable);
+		NestedJarFile jar = new NestedJarFile(this.file, null, null, false, cleaner);
+		jar.close();
+		jar.close();
+		then(cleanable).should(atMostOnce()).clean();
+		action.getValue().run();
+	}
+
+	@Test
+	void cleanupFromReleasesResources() throws IOException {
+		Cleaner cleaner = mock(Cleaner.class);
+		ArgumentCaptor<Runnable> action = ArgumentCaptor.forClass(Runnable.class);
+		Cleanable cleanable = mock(Cleanable.class);
+		given(cleaner.register(any(), action.capture())).willReturn(cleanable);
+		try (NestedJarFile jar = new NestedJarFile(this.file, null, null, false, cleaner)) {
+			Object channel = Extractors.byName("resources.zipContent.data.channel").apply(jar);
+			assertThat(channel).extracting("referenceCount").isEqualTo(1);
+			action.getValue().run();
+			assertThat(channel).extracting("referenceCount").isEqualTo(0);
+		}
+	}
+
+	@Test
+	void getInputStreamReturnsInputStream() throws IOException {
+		try (NestedJarFile jarFile = new NestedJarFile(this.file)) {
+			JarEntry entry = jarFile.getJarEntry("2.dat");
+			try (InputStream in = jarFile.getInputStream(entry)) {
+				assertThat(in).hasBinaryContent(new byte[] { 0x02 });
+			}
+		}
+	}
+
+	@Test
+	void getInputStreamWhenIsDirectory() throws IOException {
+		try (NestedJarFile jar = new NestedJarFile(this.file)) {
+			try (InputStream inputStream = jar.getInputStream(jar.getEntry("d/"))) {
+				assertThat(inputStream).isNotNull();
+				assertThat(inputStream.read()).isEqualTo(-1);
+			}
+		}
+	}
+
+	@Test
+	void getInputStreamWhenNameWithoutSlashAndIsDirectory() throws IOException {
+		try (NestedJarFile jar = new NestedJarFile(this.file)) {
+			try (InputStream inputStream = jar.getInputStream(jar.getEntry("d"))) {
+				assertThat(inputStream).isNotNull();
+				assertThat(inputStream.read()).isEqualTo(-1);
+			}
+		}
+	}
+
+	@Test
+	void verifySignedJar() throws Exception {
+		File signedJarFile = TestJar.getSigned();
+		assertThat(signedJarFile).exists();
+		try (JarFile expected = new JarFile(signedJarFile)) {
+			try (NestedJarFile actual = new NestedJarFile(signedJarFile)) {
+				StopWatch stopWatch = new StopWatch();
+				Enumeration<JarEntry> actualEntries = actual.entries();
+				while (actualEntries.hasMoreElements()) {
+					JarEntry actualEntry = actualEntries.nextElement();
+					JarEntry expectedEntry = expected.getJarEntry(actualEntry.getName());
+					StreamUtils.drain(expected.getInputStream(expectedEntry));
+					if (!actualEntry.getName().equals("META-INF/MANIFEST.MF")) {
+						assertThat(actualEntry.getCertificates()).as(actualEntry.getName())
+							.isEqualTo(expectedEntry.getCertificates());
+						assertThat(actualEntry.getCodeSigners()).as(actualEntry.getName())
+							.isEqualTo(expectedEntry.getCodeSigners());
+					}
+				}
+				assertThat(stopWatch.getTotalTimeSeconds()).isLessThan(3.0);
+			}
+		}
+	}
+
+	@Test
+	void closeAllowsFileToBeDeleted() throws Exception {
+		new NestedJarFile(this.file).close();
+		assertThat(this.file.delete()).isTrue();
+	}
+
+	@Test
+	void streamStreamsEntries() throws IOException {
+		try (NestedJarFile jar = new NestedJarFile(this.file, "multi-release.jar")) {
+			assertThat(jar.stream().map((entry) -> entry.getName() + ":" + entry.getRealName())).containsExactly(
+					"META-INF/:META-INF/", "META-INF/MANIFEST.MF:META-INF/MANIFEST.MF",
+					"multi-release.dat:multi-release.dat",
+					"META-INF/versions/%1$d/multi-release.dat:META-INF/versions/%1$d/multi-release.dat"
+						.formatted(TestJar.MULTI_JAR_VERSION));
+		}
+	}
+
+	@Test
+	void versionedStreamStreamsEntries() throws IOException {
+		try (NestedJarFile jar = new NestedJarFile(this.file, "multi-release.jar", Runtime.version())) {
+			assertThat(jar.versionedStream().map((entry) -> entry.getName() + ":" + entry.getRealName()))
+				.containsExactly("META-INF/:META-INF/", "META-INF/MANIFEST.MF:META-INF/MANIFEST.MF",
+						"multi-release.dat:META-INF/versions/%1$d/multi-release.dat"
+							.formatted(TestJar.MULTI_JAR_VERSION));
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/SecurityInfoTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/SecurityInfoTests.java
new file mode 100644
index 000000000000..21fb5f6ae0ae
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/SecurityInfoTests.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.jar;
+
+import java.io.File;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.springframework.boot.loader.testsupport.TestJar;
+import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed;
+import org.springframework.boot.loader.zip.ZipContent;
+import org.springframework.boot.loader.zip.ZipContent.Entry;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link SecurityInfo}.
+ *
+ * @author Phillip Webb
+ */
+@AssertFileChannelDataBlocksClosed
+class SecurityInfoTests {
+
+	@TempDir
+	File temp;
+
+	@Test
+	void getWhenNoSignatureFileReturnsNone() throws Exception {
+		File file = new File(this.temp, "test.jar");
+		TestJar.create(file);
+		try (ZipContent content = ZipContent.open(file.toPath())) {
+			SecurityInfo info = SecurityInfo.get(content);
+			assertThat(info).isSameAs(SecurityInfo.NONE);
+			for (int i = 0; i < content.size(); i++) {
+				Entry entry = content.getEntry(i);
+				assertThat(info.getCertificates(entry)).isNull();
+				assertThat(info.getCodeSigners(entry)).isNull();
+			}
+		}
+	}
+
+	@Test
+	void getWhenHasSignatureFileButNoSecurityMaterialReturnsNone() throws Exception {
+		File file = new File(this.temp, "test.jar");
+		TestJar.create(file, false, true);
+		try (ZipContent content = ZipContent.open(file.toPath())) {
+			assertThat(content.hasJarSignatureFile()).isTrue();
+			SecurityInfo info = SecurityInfo.get(content);
+			assertThat(info).isSameAs(SecurityInfo.NONE);
+		}
+	}
+
+	@Test
+	void getWhenJarIsSigned() throws Exception {
+		File file = TestJar.getSigned();
+		try (ZipContent content = ZipContent.open(file.toPath())) {
+			assertThat(content.hasJarSignatureFile()).isTrue();
+			SecurityInfo info = SecurityInfo.get(content);
+			for (int i = 0; i < content.size(); i++) {
+				Entry entry = content.getEntry(i);
+				if (entry.getName().endsWith(".class")) {
+					assertThat(info.getCertificates(entry)).isNotNull();
+					assertThat(info.getCodeSigners(entry)).isNotNull();
+				}
+			}
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jarmode/TestJarMode.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jarmode/TestJarMode.java
new file mode 100644
index 000000000000..2e17175690a5
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jarmode/TestJarMode.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.jarmode;
+
+import java.util.Arrays;
+
+/**
+ * {@link JarMode} for testing.
+ *
+ * @author Phillip Webb
+ */
+class TestJarMode implements JarMode {
+
+	@Override
+	public boolean accepts(String mode) {
+		return "test".equals(mode);
+	}
+
+	@Override
+	public void run(String mode, String[] args) {
+		System.out.println("running in " + mode + " jar mode " + Arrays.asList(args));
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/AbstractExecutableArchiveLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/AbstractExecutableArchiveLauncherTests.java
new file mode 100644
index 000000000000..efdc7012d2ea
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/AbstractExecutableArchiveLauncherTests.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.jar.JarOutputStream;
+import java.util.jar.Manifest;
+import java.util.zip.CRC32;
+import java.util.zip.ZipEntry;
+
+import org.junit.jupiter.api.io.TempDir;
+
+import org.springframework.util.FileCopyUtils;
+
+/**
+ * Base class for testing {@link ExecutableArchiveLauncher} implementations.
+ *
+ * @author Andy Wilkinson
+ * @author Madhura Bhave
+ * @author Scott Frederick
+ */
+abstract class AbstractExecutableArchiveLauncherTests {
+
+	@TempDir
+	File tempDir;
+
+	protected File createJarArchive(String name, String entryPrefix) throws IOException {
+		return createJarArchive(name, entryPrefix, false, Collections.emptyList());
+	}
+
+	protected File createJarArchive(String name, String entryPrefix, boolean indexed, List<String> extraLibs)
+			throws IOException {
+		return createJarArchive(name, null, entryPrefix, indexed, extraLibs);
+	}
+
+	protected File createJarArchive(String name, Manifest manifest, String entryPrefix, boolean indexed,
+			List<String> extraLibs) throws IOException {
+		File archive = new File(this.tempDir, name);
+		JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(archive));
+		if (manifest != null) {
+			jarOutputStream.putNextEntry(new JarEntry("META-INF/"));
+			jarOutputStream.putNextEntry(new JarEntry("META-INF/MANIFEST.MF"));
+			manifest.write(jarOutputStream);
+			jarOutputStream.closeEntry();
+		}
+		jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/"));
+		jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/classes/"));
+		jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/lib/"));
+		if (indexed) {
+			jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/classpath.idx"));
+			Writer writer = new OutputStreamWriter(jarOutputStream, StandardCharsets.UTF_8);
+			writer.write("- \"" + entryPrefix + "/lib/foo.jar\"\n");
+			writer.write("- \"" + entryPrefix + "/lib/bar.jar\"\n");
+			writer.write("- \"" + entryPrefix + "/lib/baz.jar\"\n");
+			writer.flush();
+			jarOutputStream.closeEntry();
+		}
+		addNestedJars(entryPrefix, "/lib/foo.jar", jarOutputStream);
+		addNestedJars(entryPrefix, "/lib/bar.jar", jarOutputStream);
+		addNestedJars(entryPrefix, "/lib/baz.jar", jarOutputStream);
+		for (String lib : extraLibs) {
+			addNestedJars(entryPrefix, "/lib/" + lib, jarOutputStream);
+		}
+		jarOutputStream.close();
+		return archive;
+	}
+
+	private void addNestedJars(String entryPrefix, String lib, JarOutputStream jarOutputStream) throws IOException {
+		JarEntry libFoo = new JarEntry(entryPrefix + lib);
+		libFoo.setMethod(ZipEntry.STORED);
+		ByteArrayOutputStream fooJarStream = new ByteArrayOutputStream();
+		new JarOutputStream(fooJarStream).close();
+		libFoo.setSize(fooJarStream.size());
+		CRC32 crc32 = new CRC32();
+		crc32.update(fooJarStream.toByteArray());
+		libFoo.setCrc(crc32.getValue());
+		jarOutputStream.putNextEntry(libFoo);
+		jarOutputStream.write(fooJarStream.toByteArray());
+	}
+
+	protected File explode(File archive) throws IOException {
+		File exploded = new File(this.tempDir, "exploded");
+		exploded.mkdirs();
+		JarFile jarFile = new JarFile(archive);
+		Enumeration<JarEntry> entries = jarFile.entries();
+		while (entries.hasMoreElements()) {
+			JarEntry entry = entries.nextElement();
+			File entryFile = new File(exploded, entry.getName());
+			if (entry.isDirectory()) {
+				entryFile.mkdirs();
+			}
+			else {
+				FileCopyUtils.copy(jarFile.getInputStream(entry), new FileOutputStream(entryFile));
+			}
+		}
+		jarFile.close();
+		return exploded;
+	}
+
+	protected final URL toUrl(File file) {
+		try {
+			return file.toURI().toURL();
+		}
+		catch (MalformedURLException ex) {
+			throw new IllegalStateException(ex);
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ArchiveTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ArchiveTests.java
new file mode 100644
index 000000000000..77371024b4b2
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ArchiveTests.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+import java.io.File;
+import java.security.CodeSource;
+import java.security.ProtectionDomain;
+import java.util.function.Predicate;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.springframework.boot.loader.launch.Archive.Entry;
+import org.springframework.boot.loader.testsupport.TestJar;
+import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.withSettings;
+
+/**
+ * Tests for {@link Archive}.
+ *
+ * @author Phillip Webb
+ */
+@AssertFileChannelDataBlocksClosed
+class ArchiveTests {
+
+	@TempDir
+	File temp;
+
+	@Test
+	void getClassPathUrlsWithOnlyIncludeFilterSearchesAllDirectories() throws Exception {
+		Archive archive = mock(Archive.class, withSettings().defaultAnswer(CALLS_REAL_METHODS));
+		Predicate<Entry> includeFilter = (entry) -> false;
+		archive.getClassPathUrls(includeFilter);
+		then(archive).should().getClassPathUrls(includeFilter, Archive.ALL_ENTRIES);
+	}
+
+	@Test
+	void isExplodedWhenHasRootDirectoryReturnsTrue() {
+		Archive archive = mock(Archive.class, withSettings().defaultAnswer(CALLS_REAL_METHODS));
+		given(archive.getRootDirectory()).willReturn(this.temp);
+		assertThat(archive.isExploded()).isTrue();
+	}
+
+	@Test
+	void isExplodedWhenHasNoRootDirectoryReturnsFalse() {
+		Archive archive = mock(Archive.class, withSettings().defaultAnswer(CALLS_REAL_METHODS));
+		given(archive.getRootDirectory()).willReturn(null);
+		assertThat(archive.isExploded()).isFalse();
+	}
+
+	@Test
+	void createFromProtectionDomainCreatesJarArchive() throws Exception {
+		File jarFile = new File(this.temp, "test.jar");
+		TestJar.create(jarFile);
+		ProtectionDomain protectionDomain = mock(ProtectionDomain.class);
+		CodeSource codeSource = mock(CodeSource.class);
+		given(protectionDomain.getCodeSource()).willReturn(codeSource);
+		given(codeSource.getLocation()).willReturn(jarFile.toURI().toURL());
+		try (Archive archive = Archive.create(protectionDomain)) {
+			assertThat(archive).isInstanceOf(JarFileArchive.class);
+		}
+	}
+
+	@Test
+	void createFromProtectionDomainWhenNoLocationThrowsException() throws Exception {
+		File jarFile = new File(this.temp, "test.jar");
+		TestJar.create(jarFile);
+		ProtectionDomain protectionDomain = mock(ProtectionDomain.class);
+		assertThatIllegalStateException().isThrownBy(() -> Archive.create(protectionDomain))
+			.withMessage("Unable to determine code source archive");
+	}
+
+	@Test
+	void createFromFileWhenFileDoesNotExistThrowsException() {
+		File target = new File(this.temp, "missing");
+		assertThatIllegalStateException().isThrownBy(() -> Archive.create(target))
+			.withMessageContaining("Unable to determine code source archive");
+	}
+
+	@Test
+	void createFromFileWhenJarFileReturnsJarFileArchive() throws Exception {
+		File target = new File(this.temp, "missing");
+		TestJar.create(target);
+		try (Archive archive = Archive.create(target)) {
+			assertThat(archive).isInstanceOf(JarFileArchive.class);
+		}
+	}
+
+	@Test
+	void createFromFileWhenDirectoryReturnsExplodedFileArchive() throws Exception {
+		File target = this.temp;
+		try (Archive archive = Archive.create(target)) {
+			assertThat(archive).isInstanceOf(ExplodedArchive.class);
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ClassPathIndexFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ClassPathIndexFileTests.java
new file mode 100644
index 000000000000..4f175b2832b2
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ClassPathIndexFileTests.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ClassPathIndexFile}.
+ *
+ * @author Madhura Bhave
+ * @author Phillip Webb
+ */
+class ClassPathIndexFileTests {
+
+	@TempDir
+	File temp;
+
+	@Test
+	void loadIfPossibleWhenRootDoesNotExistReturnsNull() throws Exception {
+		File root = new File(this.temp, "missing");
+		assertThat(ClassPathIndexFile.loadIfPossible(root, "test.idx")).isNull();
+	}
+
+	@Test
+	void loadIfPossibleWhenRootIsDirectoryThrowsException() throws Exception {
+		File root = new File(this.temp, "directory");
+		root.mkdirs();
+		assertThat(ClassPathIndexFile.loadIfPossible(root, "test.idx")).isNull();
+	}
+
+	@Test
+	void loadIfPossibleReturnsInstance() throws Exception {
+		ClassPathIndexFile indexFile = copyAndLoadTestIndexFile();
+		assertThat(indexFile).isNotNull();
+	}
+
+	@Test
+	void sizeReturnsNumberOfLines() throws Exception {
+		ClassPathIndexFile indexFile = copyAndLoadTestIndexFile();
+		assertThat(indexFile.size()).isEqualTo(5);
+	}
+
+	@Test
+	void getUrlsReturnsUrls() throws Exception {
+		ClassPathIndexFile indexFile = copyAndLoadTestIndexFile();
+		List<URL> urls = indexFile.getUrls();
+		List<File> expected = new ArrayList<>();
+		expected.add(new File(this.temp, "BOOT-INF/layers/one/lib/a.jar"));
+		expected.add(new File(this.temp, "BOOT-INF/layers/one/lib/b.jar"));
+		expected.add(new File(this.temp, "BOOT-INF/layers/one/lib/c.jar"));
+		expected.add(new File(this.temp, "BOOT-INF/layers/two/lib/d.jar"));
+		expected.add(new File(this.temp, "BOOT-INF/layers/two/lib/e.jar"));
+		assertThat(urls).containsExactly(expected.stream().map(this::toUrl).toArray(URL[]::new));
+	}
+
+	private URL toUrl(File file) {
+		try {
+			return file.toURI().toURL();
+		}
+		catch (MalformedURLException ex) {
+			throw new IllegalStateException(ex);
+		}
+	}
+
+	private ClassPathIndexFile copyAndLoadTestIndexFile() throws IOException {
+		copyTestIndexFile();
+		ClassPathIndexFile indexFile = ClassPathIndexFile.loadIfPossible(this.temp, "test.idx");
+		return indexFile;
+	}
+
+	private void copyTestIndexFile() throws IOException {
+		Files.copy(getClass().getResourceAsStream("classpath-index-file.idx"),
+				new File(this.temp, "test.idx").toPath());
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ExplodedArchiveTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ExplodedArchiveTests.java
new file mode 100755
index 000000000000..b18164f7513b
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ExplodedArchiveTests.java
@@ -0,0 +1,159 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.Enumeration;
+import java.util.Set;
+import java.util.UUID;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.springframework.boot.loader.launch.Archive.Entry;
+import org.springframework.boot.loader.testsupport.TestJar;
+import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed;
+import org.springframework.util.StringUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ExplodedArchive}.
+ *
+ * @author Phillip Webb
+ * @author Dave Syer
+ * @author Andy Wilkinson
+ */
+@AssertFileChannelDataBlocksClosed
+class ExplodedArchiveTests {
+
+	@TempDir
+	File tempDir;
+
+	private File rootDirectory;
+
+	private ExplodedArchive archive;
+
+	@BeforeEach
+	void setup() throws Exception {
+		createArchive();
+	}
+
+	@AfterEach
+	void tearDown() throws Exception {
+		if (this.archive != null) {
+			this.archive.close();
+		}
+	}
+
+	@Test
+	void isExplodedReturnsTrue() {
+		assertThat(this.archive.isExploded()).isTrue();
+	}
+
+	@Test
+	void getRootDirectoryReturnsRootDirectory() {
+		assertThat(this.archive.getRootDirectory()).isEqualTo(this.rootDirectory);
+	}
+
+	@Test
+	void getManifestReturnsManifest() throws Exception {
+		assertThat(this.archive.getManifest().getMainAttributes().getValue("Built-By")).isEqualTo("j1");
+	}
+
+	@Test
+	void getClassPathUrlsWhenNoPredicatesReturnsUrls() throws Exception {
+		Set<URL> urls = this.archive.getClassPathUrls(Archive.ALL_ENTRIES);
+		URL[] expectedUrls = TestJar.expectedEntries().stream().map(this::toUrl).toArray(URL[]::new);
+		assertThat(urls).containsExactlyInAnyOrder(expectedUrls);
+	}
+
+	@Test
+	void getClassPathUrlsWhenHasIncludeFilterReturnsUrls() throws Exception {
+		Set<URL> urls = this.archive.getClassPathUrls(this::entryNameIsNestedJar);
+		assertThat(urls).containsOnly(toUrl("nested.jar"));
+	}
+
+	@Test
+	void getClassPathUrlsWhenHasIncludeFilterAndSpaceInRootNameReturnsUrls() throws Exception {
+		createArchive("spaces in the name");
+		Set<URL> urls = this.archive.getClassPathUrls(this::entryNameIsNestedJar);
+		assertThat(urls).containsOnly(toUrl("nested.jar"));
+	}
+
+	@Test
+	void getClassPathUrlsWhenHasSearchFilterReturnsUrls() throws Exception {
+		Set<URL> urls = this.archive.getClassPathUrls(Archive.ALL_ENTRIES, (entry) -> !entry.name().equals("d/"));
+		assertThat(urls).contains(toUrl("nested.jar")).doesNotContain(toUrl("d/9.dat"));
+	}
+
+	private void createArchive() throws Exception {
+		createArchive(null);
+	}
+
+	private void createArchive(String directoryName) throws Exception {
+		File file = new File(this.tempDir, "test.jar");
+		TestJar.create(file);
+		this.rootDirectory = (StringUtils.hasText(directoryName) ? new File(this.tempDir, directoryName)
+				: new File(this.tempDir, UUID.randomUUID().toString()));
+		try (JarFile jarFile = new JarFile(file)) {
+			Enumeration<JarEntry> entries = jarFile.entries();
+			while (entries.hasMoreElements()) {
+				JarEntry entry = entries.nextElement();
+				File destination = new File(this.rootDirectory, entry.getName());
+				destination.getParentFile().mkdirs();
+				if (entry.isDirectory()) {
+					destination.mkdir();
+				}
+				else {
+					try (InputStream in = jarFile.getInputStream(entry);
+							OutputStream out = new FileOutputStream(destination)) {
+						in.transferTo(out);
+					}
+				}
+			}
+			this.archive = new ExplodedArchive(this.rootDirectory);
+		}
+	}
+
+	private URL toUrl(String name) {
+		return toUrl(new File(this.rootDirectory, name));
+	}
+
+	private URL toUrl(File file) {
+		try {
+			return file.toURI().toURL();
+		}
+		catch (MalformedURLException ex) {
+			throw new IllegalStateException(ex);
+		}
+	}
+
+	private boolean entryNameIsNestedJar(Entry entry) {
+		return entry.name().equals("nested.jar");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/JarFileArchiveTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/JarFileArchiveTests.java
new file mode 100755
index 000000000000..ac4a521388a7
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/JarFileArchiveTests.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.util.Set;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.springframework.boot.loader.launch.Archive.Entry;
+import org.springframework.boot.loader.net.protocol.jar.JarUrl;
+import org.springframework.boot.loader.testsupport.TestJar;
+import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed;
+import org.springframework.util.FileCopyUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link JarFileArchive}.
+ *
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ * @author Camille Vienot
+ */
+@AssertFileChannelDataBlocksClosed
+class JarFileArchiveTests {
+
+	@TempDir
+	File tempDir;
+
+	private File file;
+
+	private JarFileArchive archive;
+
+	@BeforeEach
+	void setup() throws Exception {
+		createTestJarArchive(false);
+	}
+
+	@AfterEach
+	void tearDown() throws Exception {
+		this.archive.close();
+	}
+
+	@Test
+	void isExplodedReturnsFalse() {
+		assertThat(this.archive.isExploded()).isFalse();
+	}
+
+	@Test
+	void getRootDirectoryReturnsNull() {
+		assertThat(this.archive.getRootDirectory()).isNull();
+	}
+
+	@Test
+	void getManifestReturnsManifest() throws Exception {
+		assertThat(this.archive.getManifest().getMainAttributes().getValue("Built-By")).isEqualTo("j1");
+	}
+
+	@Test
+	void getClassPathUrlsWhenNoPredicatesReturnsUrls() throws Exception {
+		Set<URL> urls = this.archive.getClassPathUrls(Archive.ALL_ENTRIES);
+		URL[] expected = TestJar.expectedEntries()
+			.stream()
+			.map((name) -> JarUrl.create(this.file, name))
+			.toArray(URL[]::new);
+		assertThat(urls).containsExactly(expected);
+	}
+
+	@Test
+	void getClassPathUrlsWhenHasIncludeFilterReturnsUrls() throws Exception {
+		Set<URL> urls = this.archive.getClassPathUrls(this::entryNameIsNestedJar);
+		assertThat(urls).containsOnly(JarUrl.create(this.file, "nested.jar"));
+	}
+
+	@Test
+	void getClassPathUrlsWhenHasSearchFilterAllUrlsSinceSearchFilterIsNotUsed() throws Exception {
+		Set<URL> urls = this.archive.getClassPathUrls(Archive.ALL_ENTRIES, (entry) -> false);
+		URL[] expected = TestJar.expectedEntries()
+			.stream()
+			.map((name) -> JarUrl.create(this.file, name))
+			.toArray(URL[]::new);
+		assertThat(urls).containsExactly(expected);
+	}
+
+	@Test
+	void getClassPathUrlsWhenHasUnpackCommentUnpacksAndReturnsUrls() throws Exception {
+		createTestJarArchive(true);
+		Set<URL> urls = this.archive.getClassPathUrls(this::entryNameIsNestedJar);
+		assertThat(urls).hasSize(1);
+		URL url = urls.iterator().next();
+		assertThat(url).isNotEqualTo(JarUrl.create(this.file, "nested.jar"));
+		assertThat(url.toString()).startsWith("jar:file:").endsWith("/nested.jar!/");
+	}
+
+	@Test
+	void getClassPathUrlsWhenHasUnpackCommentUnpacksToUniqueLocationsPerArchive() throws Exception {
+		createTestJarArchive(true);
+		URL firstNestedUrl = this.archive.getClassPathUrls(this::entryNameIsNestedJar).iterator().next();
+		createTestJarArchive(true);
+		URL secondNestedUrl = this.archive.getClassPathUrls(this::entryNameIsNestedJar).iterator().next();
+		assertThat(secondNestedUrl).isNotEqualTo(firstNestedUrl);
+	}
+
+	@Test
+	void getClassPathUrlsWhenHasUnpackCommentUnpacksAndShareSameParent() throws Exception {
+		createTestJarArchive(true);
+		URL nestedUrl = this.archive.getClassPathUrls(this::entryNameIsNestedJar).iterator().next();
+		URL anotherNestedUrl = this.archive.getClassPathUrls((entry) -> entry.name().equals("another-nested.jar"))
+			.iterator()
+			.next();
+		assertThat(nestedUrl.toString())
+			.isEqualTo(anotherNestedUrl.toString().replace("another-nested.jar", "nested.jar"));
+	}
+
+	@Test
+	void getClassPathUrlsWhenZip64ListsAllEntries() throws Exception {
+		File file = new File(this.tempDir, "test.jar");
+		FileCopyUtils.copy(writeZip64Jar(), file);
+		try (Archive jarArchive = new JarFileArchive(file)) {
+			Set<URL> urls = jarArchive.getClassPathUrls(Archive.ALL_ENTRIES);
+			assertThat(urls).hasSize(65537);
+		}
+	}
+
+	private byte[] writeZip64Jar() throws IOException {
+		ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+		try (JarOutputStream jarOutput = new JarOutputStream(bytes)) {
+			for (int i = 0; i < 65537; i++) {
+				jarOutput.putNextEntry(new JarEntry(i + ".dat"));
+				jarOutput.closeEntry();
+			}
+		}
+		return bytes.toByteArray();
+	}
+
+	private void createTestJarArchive(boolean unpackNested) throws Exception {
+		if (this.archive != null) {
+			this.archive.close();
+		}
+		this.file = new File(this.tempDir, "root.jar");
+		TestJar.create(this.file, unpackNested);
+		this.archive = new JarFileArchive(this.file);
+	}
+
+	private boolean entryNameIsNestedJar(Entry entry) {
+		return entry.name().equals("nested.jar");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/JarLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/JarLauncherTests.java
new file mode 100644
index 000000000000..7e231949bea4
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/JarLauncherTests.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.jar.Attributes;
+import java.util.jar.Attributes.Name;
+import java.util.jar.Manifest;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.loader.net.protocol.jar.JarUrl;
+import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.core.test.tools.SourceFile;
+import org.springframework.core.test.tools.TestCompiler;
+import org.springframework.util.FileCopyUtils;
+import org.springframework.util.function.ThrowingConsumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link JarLauncher}.
+ *
+ * @author Andy Wilkinson
+ * @author Madhura Bhave
+ * @author Phillip Webb
+ */
+@AssertFileChannelDataBlocksClosed
+class JarLauncherTests extends AbstractExecutableArchiveLauncherTests {
+
+	@Test
+	void explodedJarHasOnlyBootInfClassesAndContentsOfBootInfLibOnClasspath() throws Exception {
+		File explodedRoot = explode(createJarArchive("archive.jar", "BOOT-INF"));
+		JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot));
+		Set<URL> urls = launcher.getClassPathUrls();
+		assertThat(urls).containsExactlyInAnyOrder(getExpectedFileUrls(explodedRoot));
+	}
+
+	@Test
+	void archivedJarHasOnlyBootInfClassesAndContentsOfBootInfLibOnClasspath() throws Exception {
+		File jarRoot = createJarArchive("archive.jar", "BOOT-INF");
+		try (JarFileArchive archive = new JarFileArchive(jarRoot)) {
+			JarLauncher launcher = new JarLauncher(archive);
+			Set<URL> urls = launcher.getClassPathUrls();
+			List<URL> expectedUrls = new ArrayList<>();
+			expectedUrls.add(JarUrl.create(jarRoot, "BOOT-INF/classes/"));
+			expectedUrls.add(JarUrl.create(jarRoot, "BOOT-INF/lib/foo.jar"));
+			expectedUrls.add(JarUrl.create(jarRoot, "BOOT-INF/lib/bar.jar"));
+			expectedUrls.add(JarUrl.create(jarRoot, "BOOT-INF/lib/baz.jar"));
+			assertThat(urls).containsOnlyOnceElementsOf(expectedUrls);
+		}
+	}
+
+	@Test
+	void explodedJarShouldPreserveClasspathOrderWhenIndexPresent() throws Exception {
+		File explodedRoot = explode(createJarArchive("archive.jar", "BOOT-INF", true, Collections.emptyList()));
+		JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot));
+		URLClassLoader classLoader = createClassLoader(launcher);
+		assertThat(classLoader.getURLs()).containsExactly(getExpectedFileUrls(explodedRoot));
+	}
+
+	@Test
+	void jarFilesPresentInBootInfLibsAndNotInClasspathIndexShouldBeAddedAfterBootInfClasses() throws Exception {
+		ArrayList<String> extraLibs = new ArrayList<>(Arrays.asList("extra-1.jar", "extra-2.jar"));
+		File explodedRoot = explode(createJarArchive("archive.jar", "BOOT-INF", true, extraLibs));
+		JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot));
+		URLClassLoader classLoader = createClassLoader(launcher);
+		List<File> expectedFiles = getExpectedFilesWithExtraLibs(explodedRoot);
+		URL[] expectedFileUrls = expectedFiles.stream().map(this::toUrl).toArray(URL[]::new);
+		assertThat(classLoader.getURLs()).containsExactly(expectedFileUrls);
+	}
+
+	@Test
+	void explodedJarDefinedPackagesIncludeManifestAttributes() {
+		Manifest manifest = new Manifest();
+		Attributes attributes = manifest.getMainAttributes();
+		attributes.put(Name.MANIFEST_VERSION, "1.0");
+		attributes.put(Name.IMPLEMENTATION_TITLE, "test");
+		SourceFile sourceFile = SourceFile.of("explodedsample/ExampleClass.java",
+				new ClassPathResource("explodedsample/ExampleClass.txt"));
+		TestCompiler.forSystem().compile(sourceFile, ThrowingConsumer.of((compiled) -> {
+			File explodedRoot = explode(
+					createJarArchive("archive.jar", manifest, "BOOT-INF", true, Collections.emptyList()));
+			File target = new File(explodedRoot, "BOOT-INF/classes/explodedsample/ExampleClass.class");
+			target.getParentFile().mkdirs();
+			FileCopyUtils.copy(compiled.getClassLoader().getResourceAsStream("explodedsample/ExampleClass.class"),
+					new FileOutputStream(target));
+			JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot));
+			URLClassLoader classLoader = createClassLoader(launcher);
+			Class<?> loaded = classLoader.loadClass("explodedsample.ExampleClass");
+			assertThat(loaded.getPackage().getImplementationTitle()).isEqualTo("test");
+		}));
+	}
+
+	private URLClassLoader createClassLoader(JarLauncher launcher) throws Exception {
+		return (URLClassLoader) launcher.createClassLoader(launcher.getClassPathUrls());
+	}
+
+	private URL[] getExpectedFileUrls(File explodedRoot) {
+		return getExpectedFiles(explodedRoot).stream().map(this::toUrl).toArray(URL[]::new);
+	}
+
+	private List<File> getExpectedFiles(File parent) {
+		List<File> expected = new ArrayList<>();
+		expected.add(new File(parent, "BOOT-INF/classes"));
+		expected.add(new File(parent, "BOOT-INF/lib/foo.jar"));
+		expected.add(new File(parent, "BOOT-INF/lib/bar.jar"));
+		expected.add(new File(parent, "BOOT-INF/lib/baz.jar"));
+		return expected;
+	}
+
+	private List<File> getExpectedFilesWithExtraLibs(File parent) {
+		List<File> expected = new ArrayList<>();
+		expected.add(new File(parent, "BOOT-INF/classes"));
+		expected.add(new File(parent, "BOOT-INF/lib/extra-1.jar"));
+		expected.add(new File(parent, "BOOT-INF/lib/extra-2.jar"));
+		expected.add(new File(parent, "BOOT-INF/lib/foo.jar"));
+		expected.add(new File(parent, "BOOT-INF/lib/bar.jar"));
+		expected.add(new File(parent, "BOOT-INF/lib/baz.jar"));
+		return expected;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/LaunchedClassLoaderTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/LaunchedClassLoaderTests.java
new file mode 100644
index 000000000000..d3a92f50c689
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/LaunchedClassLoaderTests.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+import java.net.URL;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.loader.jarmode.JarMode;
+import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link LaunchedClassLoader}.
+ *
+ * @author Dave Syer
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ */
+@AssertFileChannelDataBlocksClosed
+class LaunchedClassLoaderTests {
+
+	@Test
+	void loadClassWhenJarModeClassLoadsInLaunchedClassLoader() throws Exception {
+		try (LaunchedClassLoader classLoader = new LaunchedClassLoader(false, new URL[] {},
+				getClass().getClassLoader())) {
+			Class<?> jarModeClass = classLoader.loadClass(JarMode.class.getName());
+			Class<?> jarModeRunnerClass = classLoader.loadClass(JarModeRunner.class.getName());
+			assertThat(jarModeClass.getClassLoader()).isSameAs(classLoader);
+			assertThat(jarModeRunnerClass.getClassLoader()).isSameAs(classLoader);
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/LauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/LauncherTests.java
new file mode 100644
index 000000000000..f300970c6d64
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/LauncherTests.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+import java.net.URL;
+import java.util.Collections;
+import java.util.Set;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed;
+import org.springframework.boot.testsupport.system.CapturedOutput;
+import org.springframework.boot.testsupport.system.OutputCaptureExtension;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link Launcher}.
+ *
+ * @author Phillip Webb
+ */
+@ExtendWith(OutputCaptureExtension.class)
+@AssertFileChannelDataBlocksClosed
+class LauncherTests {
+
+	/**
+	 * Jar Mode tests.
+	 */
+	@Nested
+	class JarMode {
+
+		@BeforeEach
+		void setup() {
+			System.setProperty(JarModeRunner.DISABLE_SYSTEM_EXIT, "true");
+		}
+
+		@AfterEach
+		void cleanup() {
+			System.clearProperty("jarmode");
+			System.clearProperty(JarModeRunner.DISABLE_SYSTEM_EXIT);
+		}
+
+		@Test
+		void launchWhenJarModePropertyIsSetLaunchesJarMode(CapturedOutput out) throws Exception {
+			System.setProperty("jarmode", "test");
+			new TestLauncher().launch(new String[] { "boot" });
+			assertThat(out).contains("running in test jar mode [boot]");
+		}
+
+		@Test
+		void launchWhenJarModePropertyIsNotAcceptedThrowsException(CapturedOutput out) throws Exception {
+			System.setProperty("jarmode", "idontexist");
+			new TestLauncher().launch(new String[] { "boot" });
+			assertThat(out).contains("Unsupported jarmode 'idontexist'");
+		}
+
+	}
+
+	private static class TestLauncher extends Launcher {
+
+		@Override
+		protected String getMainClass() throws Exception {
+			throw new IllegalStateException("Should not be called");
+		}
+
+		@Override
+		protected Archive getArchive() {
+			return null;
+		}
+
+		@Override
+		protected Set<URL> getClassPathUrls() throws Exception {
+			return Collections.emptySet();
+		}
+
+		@Override
+		protected void launch(String[] args) throws Exception {
+			super.launch(args);
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/PropertiesLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/PropertiesLauncherTests.java
new file mode 100644
index 000000000000..9bac00e9e74c
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/PropertiesLauncherTests.java
@@ -0,0 +1,422 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.jar.Attributes;
+import java.util.jar.JarFile;
+import java.util.jar.JarOutputStream;
+import java.util.jar.Manifest;
+import java.util.zip.ZipEntry;
+
+import org.assertj.core.api.Condition;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.springframework.boot.loader.net.protocol.jar.JarUrl;
+import org.springframework.boot.loader.testsupport.TestJar;
+import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed;
+import org.springframework.boot.testsupport.system.CapturedOutput;
+import org.springframework.boot.testsupport.system.OutputCaptureExtension;
+import org.springframework.core.io.FileSystemResource;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.util.FileCopyUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.hamcrest.Matchers.containsString;
+
+/**
+ * Tests for {@link PropertiesLauncher}.
+ *
+ * @author Dave Syer
+ * @author Andy Wilkinson
+ */
+@ExtendWith(OutputCaptureExtension.class)
+@AssertFileChannelDataBlocksClosed
+class PropertiesLauncherTests {
+
+	@TempDir
+	File tempDir;
+
+	private PropertiesLauncher launcher;
+
+	private ClassLoader contextClassLoader;
+
+	private CapturedOutput output;
+
+	@BeforeEach
+	void setup(CapturedOutput capturedOutput) {
+		this.contextClassLoader = Thread.currentThread().getContextClassLoader();
+		System.setProperty("loader.home", new File("src/test/resources").getAbsolutePath());
+		this.output = capturedOutput;
+	}
+
+	@AfterEach
+	void close() throws Exception {
+		Thread.currentThread().setContextClassLoader(this.contextClassLoader);
+		System.clearProperty("loader.home");
+		System.clearProperty("loader.path");
+		System.clearProperty("loader.main");
+		System.clearProperty("loader.config.name");
+		System.clearProperty("loader.config.location");
+		System.clearProperty("loader.system");
+		System.clearProperty("loader.classLoader");
+		if (this.launcher != null) {
+			this.launcher.close();
+		}
+	}
+
+	@Test
+	void testDefaultHome() throws Exception {
+		System.clearProperty("loader.home");
+		this.launcher = new PropertiesLauncher();
+		assertThat(this.launcher.getHomeDirectory()).isEqualTo(new File(System.getProperty("user.dir")));
+	}
+
+	@Test
+	void testAlternateHome() throws Exception {
+		System.setProperty("loader.home", "src/test/resources/home");
+		this.launcher = new PropertiesLauncher();
+		assertThat(this.launcher.getHomeDirectory()).isEqualTo(new File(System.getProperty("loader.home")));
+		assertThat(this.launcher.getMainClass()).isEqualTo("demo.HomeApplication");
+	}
+
+	@Test
+	void testNonExistentHome() {
+		System.setProperty("loader.home", "src/test/resources/nonexistent");
+		assertThatIllegalArgumentException().isThrownBy(PropertiesLauncher::new)
+			.withMessageContaining("Invalid source directory");
+	}
+
+	@Test
+	void testUserSpecifiedMain() throws Exception {
+		this.launcher = new PropertiesLauncher();
+		assertThat(this.launcher.getMainClass()).isEqualTo("demo.Application");
+		assertThat(System.getProperty("loader.main")).isNull();
+	}
+
+	@Test
+	void testUserSpecifiedConfigName() throws Exception {
+		System.setProperty("loader.config.name", "foo");
+		this.launcher = new PropertiesLauncher();
+		assertThat(this.launcher.getMainClass()).isEqualTo("my.Application");
+		assertThat(ReflectionTestUtils.getField(this.launcher, "paths")).hasToString("[etc/]");
+	}
+
+	@Test
+	void testRootOfClasspathFirst() throws Exception {
+		System.setProperty("loader.config.name", "bar");
+		this.launcher = new PropertiesLauncher();
+		assertThat(this.launcher.getMainClass()).isEqualTo("my.BarApplication");
+	}
+
+	@Test
+	void testUserSpecifiedDotPath() throws Exception {
+		System.setProperty("loader.path", ".");
+		this.launcher = new PropertiesLauncher();
+		assertThat(ReflectionTestUtils.getField(this.launcher, "paths")).hasToString("[.]");
+	}
+
+	@Test
+	void testUserSpecifiedSlashPath() throws Exception {
+		System.setProperty("loader.path", "jars/");
+		this.launcher = new PropertiesLauncher();
+		assertThat(ReflectionTestUtils.getField(this.launcher, "paths")).hasToString("[jars/]");
+		Set<URL> urls = this.launcher.getClassPathUrls();
+		assertThat(urls).areExactly(1, endingWith("app.jar"));
+	}
+
+	@Test
+	void testUserSpecifiedWildcardPath() throws Exception {
+		System.setProperty("loader.path", "jars/*");
+		System.setProperty("loader.main", "demo.Application");
+		this.launcher = new PropertiesLauncher();
+		assertThat(ReflectionTestUtils.getField(this.launcher, "paths")).hasToString("[jars/]");
+		this.launcher.launch(new String[0]);
+		waitFor("Hello World");
+	}
+
+	@Test
+	void testUserSpecifiedJarPath() throws Exception {
+		System.setProperty("loader.path", "jars/app.jar");
+		System.setProperty("loader.main", "demo.Application");
+		this.launcher = new PropertiesLauncher();
+		assertThat(ReflectionTestUtils.getField(this.launcher, "paths")).hasToString("[jars/app.jar]");
+		this.launcher.launch(new String[0]);
+		waitFor("Hello World");
+	}
+
+	@Test
+	void testUserSpecifiedRootOfJarPath() throws Exception {
+		System.setProperty("loader.path", "jar:file:./src/test/resources/nested-jars/app.jar!/");
+		this.launcher = new PropertiesLauncher();
+		assertThat(ReflectionTestUtils.getField(this.launcher, "paths"))
+			.hasToString("[jar:file:./src/test/resources/nested-jars/app.jar!/]");
+		Set<URL> urls = this.launcher.getClassPathUrls();
+		assertThat(urls).areExactly(1, endingWith("foo.jar!/"));
+		assertThat(urls).areExactly(1, endingWith("app.jar!/"));
+	}
+
+	@Test
+	void testUserSpecifiedRootOfJarPathWithDot() throws Exception {
+		System.setProperty("loader.path", "nested-jars/app.jar!/./");
+		this.launcher = new PropertiesLauncher();
+		Set<URL> urls = this.launcher.getClassPathUrls();
+		assertThat(urls).areExactly(1, endingWith("foo.jar!/"));
+		assertThat(urls).areExactly(1, endingWith("app.jar!/"));
+	}
+
+	@Test
+	void testUserSpecifiedRootOfJarPathWithDotAndJarPrefix() throws Exception {
+		System.setProperty("loader.path", "jar:file:./src/test/resources/nested-jars/app.jar!/./");
+		this.launcher = new PropertiesLauncher();
+		Set<URL> urls = this.launcher.getClassPathUrls();
+		assertThat(urls).areExactly(1, endingWith("foo.jar!/"));
+	}
+
+	@Test
+	void testUserSpecifiedJarFileWithNestedArchives() throws Exception {
+		System.setProperty("loader.path", "nested-jars/app.jar");
+		System.setProperty("loader.main", "demo.Application");
+		this.launcher = new PropertiesLauncher();
+		Set<URL> urls = this.launcher.getClassPathUrls();
+		assertThat(urls).areExactly(1, endingWith("foo.jar!/"));
+		assertThat(urls).areExactly(1, endingWith("app.jar"));
+	}
+
+	@Test
+	void testUserSpecifiedNestedJarPath() throws Exception {
+		System.setProperty("loader.path", "nested-jars/nested-jar-app.jar!/BOOT-INF/classes/");
+		System.setProperty("loader.main", "demo.Application");
+		this.launcher = new PropertiesLauncher();
+		assertThat(ReflectionTestUtils.getField(this.launcher, "paths"))
+			.hasToString("[nested-jars/nested-jar-app.jar!/BOOT-INF/classes/]");
+		this.launcher.launch(new String[0]);
+		waitFor("Hello World");
+	}
+
+	@Test
+	void testUserSpecifiedDirectoryContainingJarFileWithNestedArchives() throws Exception {
+		System.setProperty("loader.path", "nested-jars");
+		System.setProperty("loader.main", "demo.Application");
+		this.launcher = new PropertiesLauncher();
+		this.launcher.launch(new String[0]);
+		waitFor("Hello World");
+	}
+
+	@Test
+	void testUserSpecifiedJarPathWithDot() throws Exception {
+		System.setProperty("loader.path", "./jars/app.jar");
+		System.setProperty("loader.main", "demo.Application");
+		this.launcher = new PropertiesLauncher();
+		assertThat(ReflectionTestUtils.getField(this.launcher, "paths")).hasToString("[jars/app.jar]");
+		this.launcher.launch(new String[0]);
+		waitFor("Hello World");
+	}
+
+	@Test
+	void testUserSpecifiedClassLoader() throws Exception {
+		System.setProperty("loader.path", "jars/app.jar");
+		System.setProperty("loader.classLoader", URLClassLoader.class.getName());
+		this.launcher = new PropertiesLauncher();
+		assertThat(ReflectionTestUtils.getField(this.launcher, "paths")).hasToString("[jars/app.jar]");
+		this.launcher.launch(new String[0]);
+		waitFor("Hello World");
+	}
+
+	@Test
+	void testUserSpecifiedClassPathOrder() throws Exception {
+		System.setProperty("loader.path", "more-jars/app.jar,jars/app.jar");
+		System.setProperty("loader.classLoader", URLClassLoader.class.getName());
+		this.launcher = new PropertiesLauncher();
+		assertThat(ReflectionTestUtils.getField(this.launcher, "paths"))
+			.hasToString("[more-jars/app.jar, jars/app.jar]");
+		this.launcher.launch(new String[0]);
+		waitFor("Hello Other World");
+	}
+
+	@Test
+	void testCustomClassLoaderCreation() throws Exception {
+		System.setProperty("loader.classLoader", TestLoader.class.getName());
+		this.launcher = new PropertiesLauncher();
+		ClassLoader loader = this.launcher.createClassLoader(classPathUrls());
+		assertThat(loader).isNotNull();
+		assertThat(loader.getClass().getName()).isEqualTo(TestLoader.class.getName());
+	}
+
+	private Set<URL> classPathUrls() throws Exception {
+		Set<URL> urls = new LinkedHashSet<>();
+		String classPath = System.getProperty("java.class.path");
+		for (String path : classPath.split(File.pathSeparator)) {
+			File file = new FileSystemResource(path).getFile();
+			if (file.exists()) {
+				urls.add(file.toURI().toURL());
+			}
+		}
+		return urls;
+	}
+
+	@Test
+	void testUserSpecifiedConfigPathWins() throws Exception {
+		System.setProperty("loader.config.name", "foo");
+		System.setProperty("loader.config.location", "classpath:bar.properties");
+		this.launcher = new PropertiesLauncher();
+		assertThat(this.launcher.getMainClass()).isEqualTo("my.BarApplication");
+	}
+
+	@Test
+	void testSystemPropertySpecifiedMain() throws Exception {
+		System.setProperty("loader.main", "foo.Bar");
+		this.launcher = new PropertiesLauncher();
+		assertThat(this.launcher.getMainClass()).isEqualTo("foo.Bar");
+	}
+
+	@Test
+	void testSystemPropertiesSet() throws Exception {
+		System.setProperty("loader.system", "true");
+		new PropertiesLauncher();
+		assertThat(System.getProperty("loader.main")).isEqualTo("demo.Application");
+	}
+
+	@Test
+	void testArgsEnhanced() throws Exception {
+		System.setProperty("loader.args", "foo");
+		this.launcher = new PropertiesLauncher();
+		assertThat(Arrays.asList(this.launcher.getArgs("bar"))).hasToString("[foo, bar]");
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	void testLoadPathCustomizedUsingManifest() throws Exception {
+		System.setProperty("loader.home", this.tempDir.getAbsolutePath());
+		Manifest manifest = new Manifest();
+		manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0");
+		manifest.getMainAttributes().putValue("Loader-Path", "/foo.jar, /bar");
+		File manifestFile = new File(this.tempDir, "META-INF/MANIFEST.MF");
+		manifestFile.getParentFile().mkdirs();
+		try (FileOutputStream manifestStream = new FileOutputStream(manifestFile)) {
+			manifest.write(manifestStream);
+		}
+		this.launcher = new PropertiesLauncher();
+		assertThat((List<String>) ReflectionTestUtils.getField(this.launcher, "paths")).containsExactly("/foo.jar",
+				"/bar/");
+	}
+
+	@Test
+	void testManifestWithPlaceholders() throws Exception {
+		System.setProperty("loader.home", "src/test/resources/placeholders");
+		this.launcher = new PropertiesLauncher();
+		assertThat(this.launcher.getMainClass()).isEqualTo("demo.FooApplication");
+	}
+
+	@Test
+	void encodedFileUrlLoaderPathIsHandledCorrectly() throws Exception {
+		File loaderPath = new File(this.tempDir, "loader path");
+		loaderPath.mkdir();
+		System.setProperty("loader.path", loaderPath.toURI().toURL().toString());
+		this.launcher = new PropertiesLauncher();
+		Set<URL> urls = this.launcher.getClassPathUrls();
+		assertThat(urls).hasSize(1);
+		assertThat(urls.iterator().next()).isEqualTo(loaderPath.toURI().toURL());
+	}
+
+	@Test // gh-21575
+	void loadResourceFromJarFile() throws Exception {
+		File file = new File(this.tempDir, "app.jar");
+		TestJar.create(file);
+		System.setProperty("loader.home", this.tempDir.getAbsolutePath());
+		System.setProperty("loader.path", "app.jar");
+		this.launcher = new PropertiesLauncher();
+		try {
+			this.launcher.launch(new String[0]);
+		}
+		catch (Exception ex) {
+			// Expected ClassNotFoundException
+			LaunchedClassLoader classLoader = (LaunchedClassLoader) Thread.currentThread().getContextClassLoader();
+			classLoader.close();
+		}
+		URL resource = JarUrl.create(file, "nested.jar", "3.dat");
+		byte[] bytes = FileCopyUtils.copyToByteArray(resource.openStream());
+		assertThat(bytes).isNotEmpty();
+	}
+
+	@Test // gh-37992
+	void classPathWithoutLoaderPathDefaultsToJarLauncherIncludes() throws Exception {
+		File file = new File(this.tempDir, "test.jar");
+		try (JarOutputStream out = new JarOutputStream(new FileOutputStream(file))) {
+			try (JarFile in = new JarFile(new File("src/test/resources/jars/app.jar"))) {
+				out.putNextEntry(new ZipEntry("BOOT-INF/"));
+				out.putNextEntry(new ZipEntry("BOOT-INF/classes/"));
+				out.putNextEntry(new ZipEntry("BOOT-INF/classes/demo/"));
+				out.putNextEntry(new ZipEntry("BOOT-INF/classes/demo/Application.class"));
+				try (InputStream classIn = in.getInputStream(in.getEntry("demo/Application.class"))) {
+					classIn.transferTo(out);
+				}
+				out.closeEntry();
+			}
+		}
+		Archive archive = new JarFileArchive(file);
+		System.setProperty("loader.main", "demo.Application");
+		this.launcher = new PropertiesLauncher(archive);
+		this.launcher.launch(new String[0]);
+		waitFor("Hello World");
+
+	}
+
+	private void waitFor(String value) {
+		Awaitility.waitAtMost(Duration.ofSeconds(5)).until(this.output::toString, containsString(value));
+	}
+
+	private Condition<URL> endingWith(String value) {
+		return new Condition<>() {
+
+			@Override
+			public boolean matches(URL archive) {
+				return archive.toString().endsWith(value);
+			}
+
+		};
+	}
+
+	static class TestLoader extends URLClassLoader {
+
+		TestLoader(ClassLoader parent) {
+			super(new URL[0], parent);
+		}
+
+		@Override
+		protected Class<?> findClass(String name) throws ClassNotFoundException {
+			return super.findClass(name);
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/WarLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/WarLauncherTests.java
new file mode 100644
index 000000000000..cea89eabe7c7
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/WarLauncherTests.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+import java.io.File;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.loader.net.protocol.jar.JarUrl;
+import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link WarLauncher}.
+ *
+ * @author Andy Wilkinson
+ * @author Scott Frederick
+ * @author Phillip Webb
+ */
+@AssertFileChannelDataBlocksClosed
+class WarLauncherTests extends AbstractExecutableArchiveLauncherTests {
+
+	@Test
+	void explodedWarHasOnlyWebInfClassesAndContentsOfWebInfLibOnClasspath() throws Exception {
+		File explodedRoot = explode(createJarArchive("archive.war", "WEB-INF"));
+		WarLauncher launcher = new WarLauncher(new ExplodedArchive(explodedRoot));
+		Set<URL> urls = launcher.getClassPathUrls();
+		assertThat(urls).containsExactlyInAnyOrder(getExpectedFileUrls(explodedRoot));
+	}
+
+	@Test
+	void archivedWarHasOnlyWebInfClassesAndContentsOfWebInfLibOnClasspath() throws Exception {
+		File file = createJarArchive("archive.war", "WEB-INF");
+		try (JarFileArchive archive = new JarFileArchive(file)) {
+			WarLauncher launcher = new WarLauncher(archive);
+			Set<URL> urls = launcher.getClassPathUrls();
+			List<URL> expected = new ArrayList<>();
+			expected.add(JarUrl.create(file, "WEB-INF/classes/"));
+			expected.add(JarUrl.create(file, "WEB-INF/lib/foo.jar"));
+			expected.add(JarUrl.create(file, "WEB-INF/lib/bar.jar"));
+			expected.add(JarUrl.create(file, "WEB-INF/lib/baz.jar"));
+			assertThat(urls).containsOnly(expected.toArray(URL[]::new));
+		}
+	}
+
+	@Test
+	void explodedWarShouldPreserveClasspathOrderWhenIndexPresent() throws Exception {
+		File explodedRoot = explode(createJarArchive("archive.war", "WEB-INF", true, Collections.emptyList()));
+		WarLauncher launcher = new WarLauncher(new ExplodedArchive(explodedRoot));
+		URLClassLoader classLoader = createClassLoader(launcher);
+		URL[] urls = classLoader.getURLs();
+		assertThat(urls).containsExactly(getExpectedFileUrls(explodedRoot));
+	}
+
+	@Test
+	void warFilesPresentInWebInfLibsAndNotInClasspathIndexShouldBeAddedAfterWebInfClasses() throws Exception {
+		ArrayList<String> extraLibs = new ArrayList<>(Arrays.asList("extra-1.jar", "extra-2.jar"));
+		File explodedRoot = explode(createJarArchive("archive.war", "WEB-INF", true, extraLibs));
+		WarLauncher launcher = new WarLauncher(new ExplodedArchive(explodedRoot));
+		URLClassLoader classLoader = createClassLoader(launcher);
+		URL[] urls = classLoader.getURLs();
+		List<File> expectedFiles = getExpectedFilesWithExtraLibs(explodedRoot);
+		URL[] expectedFileUrls = expectedFiles.stream().map(this::toUrl).toArray(URL[]::new);
+		assertThat(urls).containsExactly(expectedFileUrls);
+	}
+
+	private URLClassLoader createClassLoader(Launcher launcher) throws Exception {
+		return (URLClassLoader) launcher.createClassLoader(launcher.getClassPathUrls());
+	}
+
+	private URL[] getExpectedFileUrls(File explodedRoot) {
+		return getExpectedFiles(explodedRoot).stream().map(this::toUrl).toArray(URL[]::new);
+	}
+
+	private List<File> getExpectedFiles(File parent) {
+		List<File> expected = new ArrayList<>();
+		expected.add(new File(parent, "WEB-INF/classes"));
+		expected.add(new File(parent, "WEB-INF/lib/foo.jar"));
+		expected.add(new File(parent, "WEB-INF/lib/bar.jar"));
+		expected.add(new File(parent, "WEB-INF/lib/baz.jar"));
+		return expected;
+	}
+
+	private List<File> getExpectedFilesWithExtraLibs(File parent) {
+		List<File> expected = new ArrayList<>();
+		expected.add(new File(parent, "WEB-INF/classes"));
+		expected.add(new File(parent, "WEB-INF/lib/extra-1.jar"));
+		expected.add(new File(parent, "WEB-INF/lib/extra-2.jar"));
+		expected.add(new File(parent, "WEB-INF/lib/foo.jar"));
+		expected.add(new File(parent, "WEB-INF/lib/bar.jar"));
+		expected.add(new File(parent, "WEB-INF/lib/baz.jar"));
+		return expected;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/CanonicalizerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/CanonicalizerTests.java
new file mode 100644
index 000000000000..7f585e37d430
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/CanonicalizerTests.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link Canonicalizer}.
+ *
+ * @author Phillip Webb
+ */
+class CanonicalizerTests {
+
+	@Test
+	void canonicalizeAfterOnlyChangesAfterPos() {
+		String prefix = "/foo/.././bar/.!/foo/.././bar/.";
+		String canonicalized = Canonicalizer.canonicalizeAfter(prefix, prefix.indexOf("!/"));
+		assertThat(canonicalized).isEqualTo("/foo/.././bar/.!/bar/");
+	}
+
+	@Test
+	void canonicalizeWhenHasEmbeddedSlashDotDotSlash() {
+		assertThat(Canonicalizer.canonicalize("/foo/../bar/bif/bam/../../baz")).isEqualTo("/bar/baz");
+	}
+
+	@Test
+	void canonicalizeWhenHasEmbeddedSlashDotSlash() {
+		assertThat(Canonicalizer.canonicalize("/foo/./bar/bif/bam/././baz")).isEqualTo("/foo/bar/bif/bam/baz");
+	}
+
+	@Test
+	void canonicalizeWhenHasTrailingSlashDotDot() {
+		assertThat(Canonicalizer.canonicalize("/foo/bar/baz/../..")).isEqualTo("/foo/");
+	}
+
+	@Test
+	void canonicalizeWhenHasTrailingSlashDot() {
+		assertThat(Canonicalizer.canonicalize("/foo/bar/baz/./.")).isEqualTo("/foo/bar/baz/");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/HandlerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/HandlerTests.java
new file mode 100644
index 000000000000..8d695721158b
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/HandlerTests.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+
+/**
+ * Tests for {@link Handler}.
+ *
+ * @author Andy Wilkinson
+ * @author Phillip Webb
+ */
+@AssertFileChannelDataBlocksClosed
+class HandlerTests {
+
+	private final Handler handler = new Handler();
+
+	@Test
+	void indexOfSeparator() {
+		String spec = "jar:nested:foo!bar!/some/entry#foo";
+		assertThat(Handler.indexOfSeparator(spec, 0, spec.indexOf('#'))).isEqualTo(spec.lastIndexOf("!/"));
+	}
+
+	@Test
+	void indexOfSeparatorWhenHasStartAndLimit() {
+		String spec = "a!/jar:nested:foo!bar!/some/entry#foo!/b";
+		int beginIndex = 3;
+		int endIndex = spec.length() - 4;
+		String substring = spec.substring(beginIndex, endIndex);
+		assertThat(Handler.indexOfSeparator(spec, 0, spec.indexOf('#')))
+			.isEqualTo(substring.lastIndexOf("!/") + beginIndex);
+	}
+
+	@Test
+	void parseUrlWhenAbsoluteParses() throws MalformedURLException {
+		URL url = createJarUrl("");
+		String spec = "jar:file:example.jar!/entry.txt";
+		this.handler.parseURL(url, spec, 4, spec.length());
+		assertThat(url.toExternalForm()).isEqualTo(spec);
+	}
+
+	@Test
+	void parseUrlWhenAbsoluteWithAnchorParses() throws MalformedURLException {
+		URL url = createJarUrl("");
+		String spec = "jar:file:example.jar!/entry.txt";
+		this.handler.parseURL(url, spec + "#foo", 4, spec.length());
+		assertThat(url.toExternalForm()).isEqualTo(spec + "#foo");
+	}
+
+	@Test
+	void parseUrlWhenAbsoluteWithNoSeparatorThrowsException() throws MalformedURLException {
+		URL url = createJarUrl("");
+		String spec = "jar:file:example.jar!\\entry.txt";
+		assertThatIllegalStateException().isThrownBy(() -> this.handler.parseURL(url, spec, 4, spec.length()))
+			.withMessage("no !/ in spec");
+	}
+
+	@Test
+	void parseUrlWhenAbsoluteWithMalformedInnerUrlThrowsException() throws MalformedURLException {
+		URL url = createJarUrl("");
+		String spec = "jar:example.jar!/entry.txt";
+		assertThatIllegalStateException().isThrownBy(() -> this.handler.parseURL(url, spec, 4, spec.length()))
+			.withMessage(
+					"invalid url: jar:example.jar!/entry.txt (java.net.MalformedURLException: no protocol: example.jar)");
+	}
+
+	@Test
+	void parseUrlWhenRelativeWithLeadingSlashParses() throws MalformedURLException {
+		URL url = createJarUrl("file:example.jar!/entry.txt");
+		String spec = "/other.txt";
+		this.handler.parseURL(url, spec, 0, spec.length());
+		assertThat(url.toExternalForm()).isEqualTo("jar:file:example.jar!/other.txt");
+	}
+
+	@Test
+	void parseUrlWhenRelativeWithLeadingSlashAndAnchorParses() throws MalformedURLException {
+		URL url = createJarUrl("file:example.jar!/entry.txt");
+		String spec = "/other.txt";
+		this.handler.parseURL(url, spec + "#relative", 0, spec.length());
+		assertThat(url.toExternalForm()).isEqualTo("jar:file:example.jar!/other.txt#relative");
+	}
+
+	@Test
+	void parseUrlWhenRelativeWithLeadingSlashAndNoSeparator() throws MalformedURLException {
+		URL url = createJarUrl("file:example.jar/entry.txt");
+		String spec = "/other.txt";
+		assertThatIllegalStateException().isThrownBy(() -> this.handler.parseURL(url, spec, 0, spec.length()))
+			.withMessage("malformed context url:jar:file:example.jar/entry.txt: no !/");
+	}
+
+	@Test
+	void parseUrlWhenRelativeWithoutLeadingSlashParses() throws MalformedURLException {
+		URL url = createJarUrl("file:example.jar!/foo/");
+		String spec = "bar.txt";
+		this.handler.parseURL(url, spec, 0, spec.length());
+		assertThat(url.toExternalForm()).isEqualTo("jar:file:example.jar!/foo/bar.txt");
+	}
+
+	@Test
+	void parseUrlWhenRelativeWithoutLeadingSlashAndWithoutTrailingSlashParses() throws MalformedURLException {
+		URL url = createJarUrl("file:example.jar!/foo/baz");
+		String spec = "bar.txt";
+		this.handler.parseURL(url, spec, 0, spec.length());
+		assertThat(url.toExternalForm()).isEqualTo("jar:file:example.jar!/foo/bar.txt");
+	}
+
+	@Test
+	void parseUrlWhenRelativeWithoutLeadingSlashAndWithoutContextSlashThrowsException() throws MalformedURLException {
+		URL url = createJarUrl("file:example.jar");
+		String spec = "bar.txt";
+		assertThatIllegalStateException().isThrownBy(() -> this.handler.parseURL(url, spec, 0, spec.length()))
+			.withMessage("malformed context url:jar:file:example.jar");
+	}
+
+	@Test
+	void parseUrlWhenAnchorOnly() throws MalformedURLException {
+		URL url = createJarUrl("file:example.jar!/entry.txt");
+		String spec = "#runtime";
+		this.handler.parseURL(url, spec, 0, 0);
+		assertThat(url.toExternalForm()).isEqualTo("jar:file:example.jar!/entry.txt#runtime");
+	}
+
+	@Test
+	void hashCodeGeneratesHashCode() throws MalformedURLException {
+		URL url = createJarUrl("file:example.jar!/entry.txt");
+		assertThat(this.handler.hashCode(url)).isEqualTo(1873709601);
+	}
+
+	@Test
+	void hashCodeWhenMalformedInnerUrlGeneratesHashCode() throws MalformedURLException {
+		URL url = createJarUrl("example.jar!/entry.txt");
+		assertThat(this.handler.hashCode(url)).isEqualTo(1870566566);
+	}
+
+	@Test
+	void sameFileWhenSameReturnsTrue() throws MalformedURLException {
+		URL url1 = createJarUrl("file:example.jar!/entry.txt");
+		URL url2 = createJarUrl("file:example.jar!/entry.txt");
+		assertThat(this.handler.sameFile(url1, url2)).isTrue();
+	}
+
+	@Test
+	void sameFileWhenMissingSeparatorReturnsFalse() throws MalformedURLException {
+		URL url1 = createJarUrl("file:example.jar!/entry.txt");
+		URL url2 = createJarUrl("file:example.jar/entry.txt");
+		assertThat(this.handler.sameFile(url1, url2)).isFalse();
+	}
+
+	@Test
+	void sameFileWhenDifferentEntryReturnsFalse() throws MalformedURLException {
+		URL url1 = createJarUrl("file:example.jar!/entry1.txt");
+		URL url2 = createJarUrl("file:example.jar!/entry2.txt");
+		assertThat(this.handler.sameFile(url1, url2)).isFalse();
+	}
+
+	@Test
+	void sameFileWhenDifferentInnerUrlReturnsFalse() throws MalformedURLException {
+		URL url1 = createJarUrl("file:example1.jar!/entry.txt");
+		URL url2 = createJarUrl("file:example2.jar!/entry.txt");
+		assertThat(this.handler.sameFile(url1, url2)).isFalse();
+	}
+
+	@Test
+	void sameFileWhenSameMalformedInnerUrlReturnsTrue() throws MalformedURLException {
+		URL url1 = createJarUrl("example.jar!/entry.txt");
+		URL url2 = createJarUrl("example.jar!/entry.txt");
+		assertThat(this.handler.sameFile(url1, url2)).isTrue();
+	}
+
+	@Test
+	void sameFileWhenDifferentMalformedInnerUrlReturnsFalse() throws MalformedURLException {
+		URL url1 = createJarUrl("example1.jar!/entry.txt");
+		URL url2 = createJarUrl("example2.jar!/entry.txt");
+		assertThat(this.handler.sameFile(url1, url2)).isFalse();
+	}
+
+	private URL createJarUrl(String file) throws MalformedURLException {
+		return new URL("jar", null, -1, file, this.handler);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarFileUrlKeyTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarFileUrlKeyTests.java
new file mode 100644
index 000000000000..b4131123d53e
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarFileUrlKeyTests.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.net.URL;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.loader.net.protocol.Handlers;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link JarFileUrlKey}.
+ *
+ * @author Phillip Webb
+ */
+class JarFileUrlKeyTests {
+
+	@BeforeAll
+	static void setup() {
+		Handlers.register();
+	}
+
+	@Test
+	void getCreatesKey() throws Exception {
+		URL url = new URL("jar:nested:/my.jar/!mynested.jar!/my/path");
+		assertThat(JarFileUrlKey.get(url)).isEqualTo("jar:nested:/my.jar/!mynested.jar!/my/path");
+	}
+
+	@Test
+	void getWhenUppercaseProtocolCreatesKey() throws Exception {
+		URL url = new URL("JAR:nested:/my.jar/!mynested.jar!/my/path");
+		assertThat(JarFileUrlKey.get(url)).isEqualTo("jar:nested:/my.jar/!mynested.jar!/my/path");
+	}
+
+	@Test
+	void getWhenHasHostAndPortCreatesKey() throws Exception {
+		URL url = new URL("https://example.com:1234/test");
+		assertThat(JarFileUrlKey.get(url)).isEqualTo("https:example.com:1234/test");
+	}
+
+	@Test
+	void getWhenHasUppercaseHostCreatesKey() throws Exception {
+		URL url = new URL("https://EXAMPLE.com:1234/test");
+		assertThat(JarFileUrlKey.get(url)).isEqualTo("https:example.com:1234/test");
+	}
+
+	@Test
+	void getWhenHasNoPortCreatesKeyWithDefaultPort() throws Exception {
+		URL url = new URL("https://EXAMPLE.com/test");
+		assertThat(JarFileUrlKey.get(url)).isEqualTo("https:example.com:443/test");
+	}
+
+	@Test
+	void getWhenHasNoFileCreatesKey() throws Exception {
+		URL url = new URL("https://EXAMPLE.com");
+		assertThat(JarFileUrlKey.get(url)).isEqualTo("https:example.com:443");
+	}
+
+	@Test
+	void getWhenHasRuntimeRefCreatesKey() throws Exception {
+		URL url = new URL("jar:nested:/my.jar/!mynested.jar!/my/path#runtime");
+		assertThat(JarFileUrlKey.get(url)).isEqualTo("jar:nested:/my.jar/!mynested.jar!/my/path#runtime");
+	}
+
+	@Test
+	void getWhenHasOtherRefCreatesKeyWithoutRef() throws Exception {
+		URL url = new URL("jar:nested:/my.jar/!mynested.jar!/my/path#example");
+		assertThat(JarFileUrlKey.get(url)).isEqualTo("jar:nested:/my.jar/!mynested.jar!/my/path");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlClassLoaderTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlClassLoaderTests.java
new file mode 100644
index 000000000000..d4eeed8c29b0
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlClassLoaderTests.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.file.Files;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+import java.util.zip.CRC32;
+import java.util.zip.ZipEntry;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.springframework.boot.loader.net.protocol.Handlers;
+import org.springframework.boot.loader.testsupport.TestJar;
+import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link JarUrlClassLoader}.
+ *
+ * @author Phillip Webb
+ */
+@AssertFileChannelDataBlocksClosed
+class JarUrlClassLoaderTests {
+
+	private static final URL APP_JAR;
+	static {
+		try {
+			APP_JAR = new URL("jar:file:src/test/resources/jars/app.jar!/");
+		}
+		catch (MalformedURLException ex) {
+			throw new IllegalStateException(ex);
+		}
+	}
+
+	@TempDir
+	File tempDir;
+
+	@BeforeAll
+	static void setup() {
+		Handlers.register();
+	}
+
+	@Test
+	void resolveResourceFromArchive() throws Exception {
+		try (JarUrlClassLoader loader = new TestJarUrlClassLoader(APP_JAR)) {
+			assertThat(loader.getResource("demo/Application.java")).isNotNull();
+		}
+	}
+
+	@Test
+	void resolveResourcesFromArchive() throws Exception {
+		try (JarUrlClassLoader loader = new TestJarUrlClassLoader(APP_JAR)) {
+			assertThat(loader.getResources("demo/Application.java").hasMoreElements()).isTrue();
+		}
+	}
+
+	@Test
+	void resolveRootPathFromArchive() throws Exception {
+		try (JarUrlClassLoader loader = new TestJarUrlClassLoader(APP_JAR)) {
+			assertThat(loader.getResource("")).isNotNull();
+		}
+	}
+
+	@Test
+	void resolveRootResourcesFromArchive() throws Exception {
+		try (JarUrlClassLoader loader = new TestJarUrlClassLoader(APP_JAR)) {
+			assertThat(loader.getResources("").hasMoreElements()).isTrue();
+		}
+	}
+
+	@Test
+	void resolveFromNested() throws Exception {
+		File jarFile = new File(this.tempDir, "test.jar");
+		TestJar.create(jarFile);
+		URL url = JarUrl.create(jarFile, "nested.jar");
+		try (JarUrlClassLoader loader = new TestJarUrlClassLoader(url)) {
+			URL resource = loader.getResource("3.dat");
+			assertThat(resource).hasToString(url + "3.dat");
+			try (InputStream input = resource.openConnection().getInputStream()) {
+				assertThat(input.read()).isEqualTo(3);
+			}
+		}
+	}
+
+	@Test
+	void loadClass() throws Exception {
+		try (JarUrlClassLoader loader = new TestJarUrlClassLoader(APP_JAR)) {
+			assertThat(loader.loadClass("demo.Application")).isNotNull().hasToString("class demo.Application");
+		}
+	}
+
+	@Test
+	void loadClassFromNested() throws Exception {
+		File appJar = new File("src/test/resources/jars/app.jar");
+		File jarFile = new File(this.tempDir, "test.jar");
+		FileOutputStream fileOutputStream = new FileOutputStream(jarFile);
+		try (JarOutputStream jarOutputStream = new JarOutputStream(fileOutputStream)) {
+			JarEntry nestedEntry = new JarEntry("app.jar");
+			byte[] nestedJarData = Files.readAllBytes(appJar.toPath());
+			nestedEntry.setSize(nestedJarData.length);
+			nestedEntry.setCompressedSize(nestedJarData.length);
+			CRC32 crc32 = new CRC32();
+			crc32.update(nestedJarData);
+			nestedEntry.setCrc(crc32.getValue());
+			nestedEntry.setMethod(ZipEntry.STORED);
+			jarOutputStream.putNextEntry(nestedEntry);
+			jarOutputStream.write(nestedJarData);
+			jarOutputStream.closeEntry();
+		}
+		URL url = JarUrl.create(jarFile, "app.jar");
+		try (JarUrlClassLoader loader = new TestJarUrlClassLoader(url)) {
+			assertThat(loader.loadClass("demo.Application")).isNotNull().hasToString("class demo.Application");
+		}
+	}
+
+	static class TestJarUrlClassLoader extends JarUrlClassLoader {
+
+		TestJarUrlClassLoader(URL... urls) {
+			super(urls, JarUrlClassLoaderTests.class.getClassLoader());
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnectionTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnectionTests.java
new file mode 100644
index 000000000000..c9553c731379
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnectionTests.java
@@ -0,0 +1,519 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URL;
+import java.net.URLConnection;
+import java.nio.charset.StandardCharsets;
+import java.security.Permission;
+import java.time.Instant;
+import java.time.temporal.ChronoField;
+import java.util.List;
+import java.util.Map;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.jar.JarOutputStream;
+import java.util.zip.ZipEntry;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.springframework.boot.loader.net.protocol.Handlers;
+import org.springframework.boot.loader.testsupport.TestJar;
+import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed;
+import org.springframework.boot.loader.zip.ZipContent;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIOException;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.BDDMockito.willThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+
+/**
+ * Tests for {@link JarUrlConnection}.
+ *
+ * @author Phillip Webb
+ */
+@AssertFileChannelDataBlocksClosed
+class JarUrlConnectionTests {
+
+	@TempDir
+	File temp;
+
+	private File file;
+
+	private URL url;
+
+	@BeforeAll
+	static void registerHandlers() {
+		Handlers.register();
+	}
+
+	@BeforeEach
+	@AfterEach
+	void reset() {
+		JarUrlConnection.clearCache();
+		Optimizations.disable();
+	}
+
+	@BeforeEach
+	void setup() throws Exception {
+		this.file = new File(this.temp, "test.jar");
+		TestJar.create(this.file);
+		this.url = JarUrl.create(this.file, "nested.jar");
+	}
+
+	@Test
+	void getJarFileReturnsJarFile() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		JarFile jarFile = connection.getJarFile();
+		assertThat(jarFile).isNotNull();
+		assertThat(jarFile.getEntry("3.dat")).isNotNull();
+	}
+
+	@Test
+	void getJarEntryReturnsJarEntry() throws Exception {
+		URL url = JarUrl.create(this.file, "nested.jar", "3.dat");
+		JarUrlConnection connection = JarUrlConnection.open(url);
+		JarEntry entry = connection.getJarEntry();
+		assertThat(entry).isNotNull();
+		assertThat(entry.getName()).isEqualTo("3.dat");
+	}
+
+	@Test
+	void getJarEntryWhenHasNoEntryNameReturnsNull() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		JarEntry entry = connection.getJarEntry();
+		assertThat(entry).isNull();
+	}
+
+	@Test
+	void getContentLengthReturnsContentLength() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		try (ZipContent content = ZipContent.open(this.file.toPath())) {
+			int expected = content.getEntry("nested.jar").getUncompressedSize();
+			assertThat(connection.getContentLength()).isEqualTo(expected);
+		}
+	}
+
+	@Test
+	void getContentLengthWhenLengthIsLargerThanMaxIntReturnsMinusOne() {
+		JarUrlConnection connection = mock(JarUrlConnection.class);
+		given(connection.getContentLength()).willCallRealMethod();
+		given(connection.getContentLengthLong()).willReturn((long) Integer.MAX_VALUE + 1);
+		assertThat(connection.getContentLength()).isEqualTo(-1);
+	}
+
+	@Test
+	void getContentLengthLongWhenHasNoEntryReturnsSizeOfJar() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		try (ZipContent content = ZipContent.open(this.file.toPath())) {
+			int expected = content.getEntry("nested.jar").getUncompressedSize();
+			assertThat(connection.getContentLengthLong()).isEqualTo(expected);
+		}
+	}
+
+	@Test
+	void getContentLengthLongWhenHasEntryReturnsEntrySize() throws Exception {
+		URL url = JarUrl.create(this.file, "nested.jar", "3.dat");
+		JarUrlConnection connection = JarUrlConnection.open(url);
+		assertThat(connection.getContentLengthLong()).isEqualTo(1);
+	}
+
+	@Test
+	void getContentLengthLongWhenCannotConnectReturnsMinusOne() throws IOException {
+		JarUrlConnection connection = mock(JarUrlConnection.class);
+		willThrow(IOException.class).given(connection).connect();
+		given(connection.getContentLengthLong()).willCallRealMethod();
+		assertThat(connection.getContentLengthLong()).isEqualTo(-1);
+	}
+
+	@Test
+	void getContentTypeWhenHasNoEntryReturnsJavaJar() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		assertThat(connection.getContentType()).isEqualTo("x-java/jar");
+	}
+
+	@Test
+	void getContentTypeWhenHasKnownStreamReturnsDeducedType() throws Exception {
+		String content = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><ok></ok>";
+		try (JarOutputStream out = new JarOutputStream(new FileOutputStream(this.file))) {
+			out.putNextEntry(new ZipEntry("test.dat"));
+			out.write(content.getBytes(StandardCharsets.UTF_8));
+			out.closeEntry();
+		}
+		JarUrlConnection connection = JarUrlConnection
+			.open(new URL("jar:file:" + this.file.getAbsolutePath() + "!/test.dat"));
+		assertThat(connection.getContentType()).isEqualTo("application/xml");
+	}
+
+	@Test
+	void getContentTypeWhenNotKnownInStreamButKnownNameReturnsDeducedType() throws Exception {
+		String content = "nothinguseful";
+		try (JarOutputStream out = new JarOutputStream(new FileOutputStream(this.file))) {
+			out.putNextEntry(new ZipEntry("test.xml"));
+			out.write(content.getBytes(StandardCharsets.UTF_8));
+			out.closeEntry();
+		}
+		JarUrlConnection connection = JarUrlConnection
+			.open(new URL("jar:file:" + this.file.getAbsolutePath() + "!/test.xml"));
+		assertThat(connection.getContentType()).isEqualTo("application/xml");
+	}
+
+	@Test
+	void getContentTypeWhenCannotBeDeducedReturnsContentUnknown() throws Exception {
+		String content = "nothinguseful";
+		try (JarOutputStream out = new JarOutputStream(new FileOutputStream(this.file))) {
+			out.putNextEntry(new ZipEntry("test.dat"));
+			out.write(content.getBytes(StandardCharsets.UTF_8));
+			out.closeEntry();
+		}
+		JarUrlConnection connection = JarUrlConnection
+			.open(new URL("jar:file:" + this.file.getAbsolutePath() + "!/test.dat"));
+		assertThat(connection.getContentType()).isEqualTo("content/unknown");
+	}
+
+	@Test
+	void getHeaderFieldDelegatesToJarFileConnection() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		URLConnection jarFileConnection = mock(URLConnection.class);
+		given(jarFileConnection.getHeaderField("test")).willReturn("test");
+		ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection);
+		assertThat(connection.getHeaderField("test")).isEqualTo("test");
+	}
+
+	@Test
+	void getContentWhenHasEntryReturnsContentFromEntry() throws Exception {
+		String content = "hello";
+		try (JarOutputStream out = new JarOutputStream(new FileOutputStream(this.file))) {
+			out.putNextEntry(new ZipEntry("test.txt"));
+			out.write(content.getBytes(StandardCharsets.UTF_8));
+			out.closeEntry();
+		}
+		JarUrlConnection connection = JarUrlConnection
+			.open(new URL("jar:file:" + this.file.getAbsolutePath() + "!/test.txt"));
+		assertThat(connection.getContent()).isInstanceOf(FilterInputStream.class);
+	}
+
+	@Test
+	void getContentWhenHasNoEntryReturnsJarFile() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		assertThat(connection.getContent()).isInstanceOf(JarFile.class);
+	}
+
+	@Test
+	void getPermissionReturnJarConnectionPermission() throws IOException {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		URLConnection jarFileConnection = mock(URLConnection.class);
+		Permission permission = mock(Permission.class);
+		given(jarFileConnection.getPermission()).willReturn(permission);
+		ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection);
+		assertThat(connection.getPermission()).isSameAs(permission);
+	}
+
+	@Test
+	void getInputStreamWhenNotNestedAndHasNoEntryThrowsException() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file));
+		assertThatIOException().isThrownBy(() -> connection.getInputStream()).withMessage("no entry name specified");
+	}
+
+	@Test
+	void getInputStreamWhenOptimizedWithoutReadAndHasCachedJarWithEntryReturnsEmptyInputStream() throws Exception {
+		JarUrlConnection setupConnection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar"));
+		setupConnection.connect();
+		assertThat(JarUrlConnection.jarFiles.getCached(setupConnection.getJarFileURL())).isNotNull();
+		JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "3.dat"));
+		connection.setUseCaches(false);
+		Optimizations.enable(false);
+		assertThat(connection.getInputStream()).isSameAs(JarUrlConnection.emptyInputStream);
+	}
+
+	@Test
+	void getInputStreamWhenNoEntryAndOptimizedThrowsException() throws Exception {
+		JarUrlConnection setupConnection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar"));
+		setupConnection.connect();
+		assertThat(JarUrlConnection.jarFiles.getCached(setupConnection.getJarFileURL())).isNotNull();
+		JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "missing.dat"));
+		Optimizations.enable(false);
+		assertThatExceptionOfType(FileNotFoundException.class).isThrownBy(connection::getInputStream)
+			.isSameAs(JarUrlConnection.FILE_NOT_FOUND_EXCEPTION);
+	}
+
+	@Test
+	void getInputStreamWhenNoEntryAndNotOptimizedThrowsException() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "missing.dat"));
+		assertThatExceptionOfType(FileNotFoundException.class).isThrownBy(connection::getInputStream)
+			.withMessageContaining("JAR entry missing.dat not found in");
+	}
+
+	@Test // gh-38047
+	void getInputStreamWhenNoEntryAndNestedReturnsFullJarInputStream() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar"));
+		File outFile = new File(this.temp, "out.zip");
+		try (OutputStream out = new FileOutputStream(outFile)) {
+			connection.getInputStream().transferTo(out);
+		}
+		try (JarFile outJar = new JarFile(outFile)) {
+			assertThat(outJar.getEntry("3.dat")).isNotNull();
+		}
+	}
+
+	@Test
+	void getInputStreamReturnsInputStream() throws IOException {
+		JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "3.dat"));
+		try (InputStream in = connection.getInputStream()) {
+			assertThat(in).hasBinaryContent(new byte[] { 3 });
+		}
+	}
+
+	@Test
+	void getInputStreamWhenNoCachedClosesJarFileOnClose() throws IOException {
+		JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "3.dat"));
+		connection.setUseCaches(false);
+		InputStream in = connection.getInputStream();
+		JarFile jarFile = (JarFile) ReflectionTestUtils.getField(connection, "jarFile");
+		jarFile = spy(jarFile);
+		ReflectionTestUtils.setField(connection, "jarFile", jarFile);
+		in.close();
+		then(jarFile).should().close();
+	}
+
+	@Test
+	void getAllowUserInteractionDelegatesToJarFileConnection() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		URLConnection jarFileConnection = mock(URLConnection.class);
+		ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection);
+		given(jarFileConnection.getAllowUserInteraction()).willReturn(true);
+		assertThat(connection.getAllowUserInteraction()).isTrue();
+		then(jarFileConnection).should().getAllowUserInteraction();
+	}
+
+	@Test
+	void setAllowUserInteractionDelegatesToJarFileConnection() throws IOException {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		URLConnection jarFileConnection = mock(URLConnection.class);
+		ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection);
+		connection.setAllowUserInteraction(true);
+		then(jarFileConnection).should().setAllowUserInteraction(true);
+	}
+
+	@Test
+	void getUseCachesDelegatesToJarFileConnection() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		URLConnection jarFileConnection = mock(URLConnection.class);
+		ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection);
+		given(jarFileConnection.getUseCaches()).willReturn(true);
+		assertThat(connection.getUseCaches()).isTrue();
+		then(jarFileConnection).should().getUseCaches();
+	}
+
+	@Test
+	void setUseCachesDelegatesToJarFileConnection() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		URLConnection jarFileConnection = mock(URLConnection.class);
+		ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection);
+		connection.setUseCaches(true);
+		then(jarFileConnection).should().setUseCaches(true);
+	}
+
+	@Test
+	void getDefaultUseCachesDelegatesToJarFileConnection() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		URLConnection jarFileConnection = mock(URLConnection.class);
+		ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection);
+		given(jarFileConnection.getDefaultUseCaches()).willReturn(true);
+		assertThat(connection.getDefaultUseCaches()).isTrue();
+		then(jarFileConnection).should().getDefaultUseCaches();
+	}
+
+	@Test
+	void setDefaultUseCachesDelegatesToJarFileConnection() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		URLConnection jarFileConnection = mock(URLConnection.class);
+		ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection);
+		connection.setDefaultUseCaches(true);
+		then(jarFileConnection).should().setDefaultUseCaches(true);
+	}
+
+	@Test
+	void setIfModifiedSinceDelegatesToJarFileConnection() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		URLConnection jarFileConnection = mock(URLConnection.class);
+		ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection);
+		connection.setIfModifiedSince(123L);
+		then(jarFileConnection).should().setIfModifiedSince(123L);
+	}
+
+	@Test
+	void getRequestPropertyDelegatesToJarFileConnection() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		URLConnection jarFileConnection = mock(URLConnection.class);
+		ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection);
+		given(jarFileConnection.getRequestProperty("test")).willReturn("test");
+		assertThat(connection.getRequestProperty("test")).isEqualTo("test");
+		then(jarFileConnection).should().getRequestProperty("test");
+	}
+
+	@Test
+	void setRequestPropertyDelegatesToJarFileConnection() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		URLConnection jarFileConnection = mock(URLConnection.class);
+		ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection);
+		connection.setRequestProperty("test", "testvalue");
+		then(jarFileConnection).should().setRequestProperty("test", "testvalue");
+	}
+
+	@Test
+	void addRequestPropertyDelegatesToJarFileConnection() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		URLConnection jarFileConnection = mock(URLConnection.class);
+		ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection);
+		connection.addRequestProperty("test", "testvalue");
+		then(jarFileConnection).should().addRequestProperty("test", "testvalue");
+	}
+
+	@Test
+	void getRequestPropertiesDelegatesToJarFileConnection() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		URLConnection jarFileConnection = mock(URLConnection.class);
+		ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection);
+		Map<String, List<String>> properties = Map.of("test", List.of("testvalue"));
+		given(jarFileConnection.getRequestProperties()).willReturn(properties);
+		assertThat(connection.getRequestProperties()).isEqualTo(properties);
+		then(jarFileConnection).should().getRequestProperties();
+	}
+
+	@Test
+	void connectWhenConnectedDoesNotReconnect() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		connection.connect();
+		ReflectionTestUtils.setField(connection, "jarFile", null);
+		connection.connect();
+		assertThat(ReflectionTestUtils.getField(connection, "jarFile")).isNull();
+	}
+
+	@Test
+	void connectWhenHasNotFoundSupplierThrowsException() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "missing.dat"));
+		assertThat(connection).extracting("notFound").isNotNull();
+		assertThatExceptionOfType(FileNotFoundException.class).isThrownBy(connection::connect)
+			.withMessageContaining("JAR entry missing.dat not found in");
+	}
+
+	@Test
+	void connectWhenOptimizationsEnabledAndHasCachedJarWithoutEntryThrowsException() throws Exception {
+		JarUrlConnection setupConnection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar"));
+		setupConnection.connect();
+		assertThat(JarUrlConnection.jarFiles.getCached(setupConnection.getJarFileURL())).isNotNull();
+		JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "missing.dat"));
+		Optimizations.enable(true);
+		assertThatExceptionOfType(FileNotFoundException.class).isThrownBy(connection::connect)
+			.isSameAs(JarUrlConnection.FILE_NOT_FOUND_EXCEPTION);
+	}
+
+	@Test
+	void connectWhenHasNoEntryConnects() throws Exception {
+		JarUrlConnection setupConnection = JarUrlConnection.open(this.url);
+		setupConnection.connect();
+		assertThat(setupConnection.getJarFile()).isNotNull();
+	}
+
+	@Test
+	void connectWhenEntryDoesNotExistAndOptimizationsEnabledThrowsException() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "missing.dat"));
+		Optimizations.enable(true);
+		assertThatExceptionOfType(FileNotFoundException.class).isThrownBy(connection::connect)
+			.isSameAs(JarUrlConnection.FILE_NOT_FOUND_EXCEPTION);
+	}
+
+	@Test
+	void connectWhenEntryDoesNotExistAndNoOptimizationsEnabledThrowsException() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "missing.dat"));
+		assertThatExceptionOfType(FileNotFoundException.class).isThrownBy(connection::connect)
+			.withMessageContaining("JAR entry missing.dat not found in");
+	}
+
+	@Test
+	void connectWhenEntryExists() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "3.dat"));
+		connection.connect();
+		assertThat(connection.getJarEntry()).isNotNull();
+	}
+
+	@Test
+	void connectWhenAddedToCacheReconnects() throws IOException {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		Object originalConnection = ReflectionTestUtils.getField(connection, "jarFileConnection");
+		connection.connect();
+		assertThat(connection).extracting("jarFileConnection").isNotSameAs(originalConnection);
+	}
+
+	@Test
+	void openWhenNestedAndInCachedWithoutEntryAndOptimizationsEnabledReturnsNoFoundConnection() throws Exception {
+		JarUrlConnection setupConnection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar"));
+		setupConnection.connect();
+		assertThat(JarUrlConnection.jarFiles.getCached(setupConnection.getJarFileURL())).isNotNull();
+		Optimizations.enable(true);
+		JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "missing.dat"));
+		assertThat(connection).isSameAs(JarUrlConnection.NOT_FOUND_CONNECTION);
+	}
+
+	@Test
+	void openReturnsConnection() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		assertThat(connection).isNotNull();
+	}
+
+	@Test // gh-38204
+	void getLastModifiedReturnsFileModifiedTime() throws Exception {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		assertThat(connection.getLastModified()).isEqualTo(this.file.lastModified());
+	}
+
+	@Test // gh-38204
+	void getLastModifiedHeaderReturnsFileModifiedTime() throws IOException {
+		JarUrlConnection connection = JarUrlConnection.open(this.url);
+		URLConnection fileConnection = this.file.toURI().toURL().openConnection();
+		try {
+			assertThat(connection.getHeaderFieldDate("last-modified", 0))
+				.isEqualTo(withoutNanos(this.file.lastModified()))
+				.isEqualTo(fileConnection.getHeaderFieldDate("last-modified", 0));
+		}
+		finally {
+			fileConnection.getInputStream().close();
+		}
+	}
+
+	private long withoutNanos(long epochMilli) {
+		return Instant.ofEpochMilli(epochMilli).with(ChronoField.NANO_OF_SECOND, 0).toEpochMilli();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlTests.java
new file mode 100644
index 000000000000..082550058e60
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlTests.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.io.File;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.jar.JarEntry;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link JarUrl}.
+ *
+ * @author Phillip Webb
+ */
+class JarUrlTests {
+
+	@TempDir
+	File temp;
+
+	File jarFile;
+
+	String jarFileUrlPath;
+
+	@BeforeEach
+	void setup() throws MalformedURLException {
+		this.jarFile = new File(this.temp, "my.jar");
+		this.jarFileUrlPath = this.temp.toURI().toURL().toString().substring("file:".length());
+	}
+
+	@Test
+	void createWithFileReturnsUrl() {
+		URL url = JarUrl.create(this.temp);
+		assertThat(url).hasToString("jar:file:%s!/".formatted(this.jarFileUrlPath));
+	}
+
+	@Test
+	void createWithFileAndEntryReturnsUrl() {
+		JarEntry entry = new JarEntry("lib.jar");
+		URL url = JarUrl.create(this.temp, entry);
+		assertThat(url).hasToString("jar:nested:%s/!lib.jar!/".formatted(this.jarFileUrlPath));
+	}
+
+	@Test
+	void createWithFileAndNullEntryReturnsUrl() {
+		URL url = JarUrl.create(this.temp, (JarEntry) null);
+		assertThat(url).hasToString("jar:file:%s!/".formatted(this.jarFileUrlPath));
+	}
+
+	@Test
+	void createWithFileAndNameReturnsUrl() {
+		URL url = JarUrl.create(this.temp, "lib.jar");
+		assertThat(url).hasToString("jar:nested:%s/!lib.jar!/".formatted(this.jarFileUrlPath));
+	}
+
+	@Test
+	void createWithFileAndNullNameReturnsUrl() {
+		URL url = JarUrl.create(this.temp, (String) null);
+		assertThat(url).hasToString("jar:file:%s!/".formatted(this.jarFileUrlPath));
+	}
+
+	@Test
+	void createWithFileNameAndPathReturnsUrl() {
+		URL url = JarUrl.create(this.temp, "lib.jar", "com/example/My.class");
+		assertThat(url).hasToString("jar:nested:%s/!lib.jar!/com/example/My.class".formatted(this.jarFileUrlPath));
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/LazyDelegatingInputStreamTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/LazyDelegatingInputStreamTests.java
new file mode 100644
index 000000000000..f272a6d8aa21
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/LazyDelegatingInputStreamTests.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link LazyDelegatingInputStream}.
+ *
+ * @author Phillip Webb
+ */
+class LazyDelegatingInputStreamTests {
+
+	private InputStream delegate = mock(InputStream.class);
+
+	private TestLazyDelegatingInputStream inputStream = new TestLazyDelegatingInputStream();
+
+	@Test
+	void noOperationsDoesNotGetDelegateInputStream() {
+		then(this.delegate).shouldHaveNoInteractions();
+	}
+
+	@Test
+	void readDelegatesToInputStream() throws Exception {
+		this.inputStream.read();
+		then(this.delegate).should().read();
+	}
+
+	@Test
+	void readWithByteArrayDelegatesToInputStream() throws Exception {
+		byte[] bytes = new byte[1];
+		this.inputStream.read(bytes);
+		then(this.delegate).should().read(bytes);
+	}
+
+	@Test
+	void readWithByteArrayAndOffsetAndLenDelegatesToInputStream() throws Exception {
+		byte[] bytes = new byte[1];
+		this.inputStream.read(bytes, 0, 1);
+		then(this.delegate).should().read(bytes, 0, 1);
+	}
+
+	@Test
+	void skipDelegatesToInputStream() throws Exception {
+		this.inputStream.skip(10);
+		then(this.delegate).should().skip(10);
+	}
+
+	@Test
+	void availableDelegatesToInputStream() throws Exception {
+		this.inputStream.available();
+		then(this.delegate).should().available();
+	}
+
+	@Test
+	void markSupportedDelegatesToInputStream() {
+		this.inputStream.markSupported();
+		then(this.delegate).should().markSupported();
+	}
+
+	@Test
+	void markDelegatesToInputStream() {
+		this.inputStream.mark(10);
+		then(this.delegate).should().mark(10);
+	}
+
+	@Test
+	void resetDelegatesToInputStream() throws Exception {
+		this.inputStream.reset();
+		then(this.delegate).should().reset();
+	}
+
+	@Test
+	void closeWhenDelegateNotCreatedDoesNothing() throws Exception {
+		this.inputStream.close();
+		then(this.delegate).shouldHaveNoInteractions();
+	}
+
+	@Test
+	void closeDelegatesToInputStream() throws Exception {
+		this.inputStream.available();
+		this.inputStream.close();
+		then(this.delegate).should().close();
+	}
+
+	@Test
+	void getDelegateInputStreamIsOnlyCalledOnce() throws Exception {
+		this.inputStream.available();
+		this.inputStream.mark(10);
+		this.inputStream.read();
+		assertThat(this.inputStream.count).isOne();
+	}
+
+	private class TestLazyDelegatingInputStream extends LazyDelegatingInputStream {
+
+		private int count;
+
+		@Override
+		protected InputStream getDelegateInputStream() throws IOException {
+			this.count++;
+			return LazyDelegatingInputStreamTests.this.delegate;
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/OptimizationsTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/OptimizationsTests.java
new file mode 100644
index 000000000000..40afdb813abe
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/OptimizationsTests.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link Optimizations}.
+ *
+ * @author Phillip Webb
+ */
+class OptimizationsTests {
+
+	@AfterEach
+	void reset() {
+		Optimizations.disable();
+	}
+
+	@Test
+	void defaultIsNotEnabled() {
+		assertThat(Optimizations.isEnabled()).isFalse();
+		assertThat(Optimizations.isEnabled(true)).isFalse();
+		assertThat(Optimizations.isEnabled(false)).isFalse();
+	}
+
+	@Test
+	void enableWithReadContentsEnables() {
+		Optimizations.enable(true);
+		assertThat(Optimizations.isEnabled()).isTrue();
+		assertThat(Optimizations.isEnabled(true)).isTrue();
+		assertThat(Optimizations.isEnabled(false)).isFalse();
+	}
+
+	@Test
+	void enableWithoutReadContentsEnables() {
+		Optimizations.enable(false);
+		assertThat(Optimizations.isEnabled()).isTrue();
+		assertThat(Optimizations.isEnabled(true)).isFalse();
+		assertThat(Optimizations.isEnabled(false)).isTrue();
+	}
+
+	@Test
+	void enableIsByThread() throws InterruptedException {
+		Optimizations.enable(true);
+		boolean[] enabled = new boolean[1];
+		Thread thread = new Thread(() -> enabled[0] = Optimizations.isEnabled());
+		thread.start();
+		thread.join();
+		assertThat(enabled[0]).isFalse();
+	}
+
+	@Test
+	void disableDisables() {
+		Optimizations.enable(true);
+		Optimizations.disable();
+		assertThat(Optimizations.isEnabled()).isFalse();
+		assertThat(Optimizations.isEnabled(true)).isFalse();
+		assertThat(Optimizations.isEnabled(false)).isFalse();
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarEntryTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarEntryTests.java
new file mode 100644
index 000000000000..44d71008f3fa
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarEntryTests.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.util.jar.Attributes;
+import java.util.jar.JarEntry;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link UrlJarEntry}.
+ *
+ * @author Phillip Webb
+ */
+class UrlJarEntryTests {
+
+	@Test
+	void ofWhenEntryIsNullReturnsNull() {
+		assertThat(UrlJarEntry.of(null, null)).isNull();
+	}
+
+	@Test
+	void ofReturnsUrlJarEntry() {
+		JarEntry entry = new JarEntry("test");
+		assertThat(UrlJarEntry.of(entry, null)).isNotNull();
+
+	}
+
+	@Test
+	void getAttributesDelegatesToUrlJarManifest() throws Exception {
+		JarEntry entry = new JarEntry("test");
+		UrlJarManifest manifest = mock(UrlJarManifest.class);
+		Attributes attributes = mock(Attributes.class);
+		given(manifest.getEntryAttributes(any())).willReturn(attributes);
+		assertThat(UrlJarEntry.of(entry, manifest).getAttributes()).isSameAs(attributes);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactoryTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactoryTests.java
new file mode 100644
index 000000000000..69ace5f6d752
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactoryTests.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.net.InetSocketAddress;
+import java.net.URL;
+import java.util.function.Consumer;
+import java.util.jar.JarFile;
+
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import org.springframework.boot.loader.net.protocol.Handlers;
+import org.springframework.boot.loader.testsupport.TestJar;
+import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link UrlJarFileFactory}.
+ *
+ * @author Phillip Webb
+ */
+@AssertFileChannelDataBlocksClosed
+class UrlJarFileFactoryTests {
+
+	@TempDir
+	File temp;
+
+	private final UrlJarFileFactory factory = new UrlJarFileFactory();
+
+	@Mock
+	private Consumer<JarFile> closeAction;
+
+	@BeforeAll
+	static void registerHandlers() {
+		Handlers.register();
+	}
+
+	@BeforeEach
+	void setup() {
+		MockitoAnnotations.openMocks(this);
+	}
+
+	@Test
+	void createJarFileWhenLocalFile() throws Throwable {
+		File file = new File(this.temp, "test.jar");
+		TestJar.create(file);
+		URL url = file.toURI().toURL();
+		JarFile jarFile = this.factory.createJarFile(url, this.closeAction);
+		assertThat(jarFile).isInstanceOf(UrlJarFile.class);
+		assertThat(jarFile).hasFieldOrPropertyWithValue("closeAction", this.closeAction);
+	}
+
+	@Test
+	void createJarFileWhenNested() throws Throwable {
+		File file = new File(this.temp, "test.jar");
+		TestJar.create(file);
+		URL url = new URL("nested:" + file.getPath() + "/!nested.jar");
+		JarFile jarFile = this.factory.createJarFile(url, this.closeAction);
+		assertThat(jarFile).isInstanceOf(UrlNestedJarFile.class);
+		assertThat(jarFile).hasFieldOrPropertyWithValue("closeAction", this.closeAction);
+	}
+
+	@Test
+	void createJarFileWhenStream() throws Exception {
+		File file = new File(this.temp, "test.jar");
+		TestJar.create(file);
+		HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+		server.createContext("/test", (exchange) -> {
+			exchange.sendResponseHeaders(200, file.length());
+			try (InputStream in = new FileInputStream(file)) {
+				in.transferTo(exchange.getResponseBody());
+			}
+			exchange.close();
+		});
+		server.start();
+		try {
+			URL url = new URL("http://localhost:" + server.getAddress().getPort() + "/test");
+			JarFile jarFile = this.factory.createJarFile(url, this.closeAction);
+			assertThat(jarFile).isInstanceOf(UrlJarFile.class);
+			assertThat(jarFile).hasFieldOrPropertyWithValue("closeAction", this.closeAction);
+		}
+		finally {
+			server.stop(0);
+		}
+	}
+
+	@Test
+	void createWhenHasRuntimeRef() {
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileTests.java
new file mode 100644
index 000000000000..0640483c8c0c
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileTests.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.io.File;
+import java.util.function.Consumer;
+import java.util.jar.JarFile;
+import java.util.jar.Manifest;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import org.springframework.boot.loader.testsupport.TestJar;
+import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.then;
+
+/**
+ * Tests for {@link UrlJarFile}.
+ *
+ * @author Phillip Webb
+ */
+@AssertFileChannelDataBlocksClosed
+class UrlJarFileTests {
+
+	@TempDir
+	File temp;
+
+	private UrlJarFile jarFile;
+
+	@Mock
+	private Consumer<JarFile> closeAction;
+
+	@BeforeEach
+	void setup() throws Exception {
+		MockitoAnnotations.openMocks(this);
+		File file = new File(this.temp, "test.jar");
+		TestJar.create(file);
+		this.jarFile = new UrlJarFile(file, Runtime.version(), this.closeAction);
+	}
+
+	@AfterEach
+	void cleanup() throws Exception {
+		this.jarFile.close();
+	}
+
+	@Test
+	void getEntryWhenNotfoundReturnsNull() {
+		assertThat(this.jarFile.getEntry("missing")).isNull();
+	}
+
+	@Test
+	void getEntryWhenFoundReturnsUrlJarEntry() {
+		assertThat(this.jarFile.getEntry("1.dat")).isInstanceOf(UrlJarEntry.class);
+	}
+
+	@Test
+	void getManifestReturnsNewCopy() throws Exception {
+		Manifest manifest1 = this.jarFile.getManifest();
+		Manifest manifest2 = this.jarFile.getManifest();
+		assertThat(manifest1).isNotSameAs(manifest2);
+	}
+
+	@Test
+	void closeCallsCloseAction() throws Exception {
+		this.jarFile.close();
+		then(this.closeAction).should().accept(this.jarFile);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFilesTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFilesTests.java
new file mode 100644
index 000000000000..f7a6ed089fc5
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFilesTests.java
@@ -0,0 +1,164 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.io.File;
+import java.net.URL;
+import java.net.URLConnection;
+import java.util.jar.JarFile;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.springframework.boot.loader.net.protocol.Handlers;
+import org.springframework.boot.loader.testsupport.TestJar;
+import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+
+/**
+ * Tests for {@link UrlJarFiles}.
+ *
+ * @author Phillip Webb
+ */
+@AssertFileChannelDataBlocksClosed
+class UrlJarFilesTests {
+
+	@TempDir
+	File temp;
+
+	private UrlJarFileFactory factory = mock(UrlJarFileFactory.class);
+
+	private final UrlJarFiles jarFiles = new UrlJarFiles(this.factory);
+
+	private File file;
+
+	private URL url;
+
+	@BeforeAll
+	static void registerHandlers() {
+		Handlers.register();
+	}
+
+	@BeforeEach
+	void setup() throws Exception {
+		this.file = new File(this.temp, "test.jar");
+		this.url = new URL("nested:" + this.file.getAbsolutePath() + "/!nested.jar");
+		TestJar.create(this.file);
+	}
+
+	@Test
+	void getOrCreateWhenNotUsingCachesAlwaysCreatesNewJarFile() throws Exception {
+		given(this.factory.createJarFile(any(), any())).willCallRealMethod();
+		JarFile jarFile1 = this.jarFiles.getOrCreate(false, this.url);
+		JarFile jarFile2 = this.jarFiles.getOrCreate(false, this.url);
+		JarFile jarFile3 = this.jarFiles.getOrCreate(false, this.url);
+		assertThat(jarFile1).isNotSameAs(jarFile2).isNotSameAs(jarFile3);
+	}
+
+	@Test
+	void getOrCreateWhenUsingCachingReturnsCachedWhenAvailable() throws Exception {
+		given(this.factory.createJarFile(any(), any())).willCallRealMethod();
+		JarFile jarFile1 = this.jarFiles.getOrCreate(true, this.url);
+		this.jarFiles.cacheIfAbsent(true, this.url, jarFile1);
+		JarFile jarFile2 = this.jarFiles.getOrCreate(true, this.url);
+		JarFile jarFile3 = this.jarFiles.getOrCreate(true, this.url);
+		assertThat(jarFile1).isSameAs(jarFile2).isSameAs(jarFile3);
+	}
+
+	@Test
+	void getCachedWhenNotCachedReturnsNull() {
+		assertThat(this.jarFiles.getCached(this.url)).isNull();
+	}
+
+	@Test
+	void getCachedWhenCachedReturnsCachedJar() throws Exception {
+		given(this.factory.createJarFile(any(), any())).willCallRealMethod();
+		JarFile jarFile = this.factory.createJarFile(this.url, null);
+		this.jarFiles.cacheIfAbsent(true, this.url, jarFile);
+		assertThat(this.jarFiles.getCached(this.url)).isSameAs(jarFile);
+	}
+
+	@Test
+	void cacheIfAbsentWhenNotUsingCachesDoesNotCacheAndReturnsFalse() throws Exception {
+		given(this.factory.createJarFile(any(), any())).willCallRealMethod();
+		JarFile jarFile = this.factory.createJarFile(this.url, null);
+		this.jarFiles.cacheIfAbsent(false, this.url, jarFile);
+		assertThat(this.jarFiles.getCached(this.url)).isNull();
+	}
+
+	@Test
+	void cacheIfAbsentWhenUsingCachingAndNotAlreadyCachedCachesAndReturnsTrue() throws Exception {
+		given(this.factory.createJarFile(any(), any())).willCallRealMethod();
+		JarFile jarFile = this.factory.createJarFile(this.url, null);
+		assertThat(this.jarFiles.cacheIfAbsent(true, this.url, jarFile)).isTrue();
+		assertThat(this.jarFiles.getCached(this.url)).isSameAs(jarFile);
+	}
+
+	@Test
+	void cacheIfAbsentWhenUsingCachingAndAlreadyCachedLeavesCacheAndReturnsFalse() throws Exception {
+		given(this.factory.createJarFile(any(), any())).willCallRealMethod();
+		JarFile jarFile1 = this.factory.createJarFile(this.url, null);
+		JarFile jarFile2 = this.factory.createJarFile(this.url, null);
+		assertThat(this.jarFiles.cacheIfAbsent(true, this.url, jarFile1)).isTrue();
+		assertThat(this.jarFiles.cacheIfAbsent(true, this.url, jarFile2)).isFalse();
+		assertThat(this.jarFiles.getCached(this.url)).isSameAs(jarFile1);
+	}
+
+	@Test
+	void closeIfNotCachedWhenNotCachedClosesJarFile() throws Exception {
+		JarFile jarFile = mock(JarFile.class);
+		this.jarFiles.closeIfNotCached(this.url, jarFile);
+		then(jarFile).should().close();
+	}
+
+	@Test
+	void closeIfNotCachedWhenCachedDoesNotCloseJarFile() throws Exception {
+		JarFile jarFile = mock(JarFile.class);
+		this.jarFiles.cacheIfAbsent(true, this.url, jarFile);
+		this.jarFiles.closeIfNotCached(this.url, jarFile);
+		then(jarFile).should(never()).close();
+	}
+
+	@Test
+	void reconnectReconnectsAndAppliesUseCaches() throws Exception {
+		JarFile jarFile = mock(JarFile.class);
+		this.jarFiles.cacheIfAbsent(true, this.url, jarFile);
+		URLConnection existingConnection = mock(URLConnection.class);
+		given(existingConnection.getUseCaches()).willReturn(true);
+		URLConnection connection = this.jarFiles.reconnect(jarFile, existingConnection);
+		assertThat(connection).isNotSameAs(existingConnection);
+		assertThat(connection.getUseCaches()).isTrue();
+	}
+
+	@Test
+	void reconnectWhenExistingConnectionIsNullReconnects() throws Exception {
+		JarFile jarFile = mock(JarFile.class);
+		this.jarFiles.cacheIfAbsent(true, this.url, jarFile);
+		URLConnection connection = this.jarFiles.reconnect(jarFile, null);
+		assertThat(connection).isNotNull();
+		assertThat(connection.getUseCaches()).isTrue();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarManifestTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarManifestTests.java
new file mode 100644
index 000000000000..0bc83a023f1a
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarManifestTests.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.io.IOException;
+import java.util.jar.Attributes;
+import java.util.jar.JarEntry;
+import java.util.jar.Manifest;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.loader.net.protocol.jar.UrlJarManifest.ManifestSupplier;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+
+/**
+ * Tests for {@link UrlJarManifest}.
+ *
+ * @author Phillip Webb
+ */
+class UrlJarManifestTests {
+
+	@Test
+	void getWhenSuppliedManifestIsNullReturnsNull() throws Exception {
+		UrlJarManifest urlJarManifest = new UrlJarManifest(() -> null);
+		assertThat(urlJarManifest.get()).isNull();
+	}
+
+	@Test
+	void getAlwaysReturnsDeepCopy() throws Exception {
+		Manifest manifest = new Manifest();
+		UrlJarManifest urlJarManifest = new UrlJarManifest(() -> manifest);
+		manifest.getMainAttributes().putValue("test", "one");
+		manifest.getEntries().put("spring", new Attributes());
+		Manifest copy = urlJarManifest.get();
+		assertThat(copy).isNotSameAs(manifest);
+		manifest.getMainAttributes().clear();
+		manifest.getEntries().clear();
+		assertThat(copy.getMainAttributes()).isNotEmpty();
+		assertThat(copy.getAttributes("spring")).isNotNull();
+	}
+
+	@Test
+	void getEntryAttributesWhenSuppliedManifestIsNullReturnsNull() throws Exception {
+		UrlJarManifest urlJarManifest = new UrlJarManifest(() -> null);
+		assertThat(urlJarManifest.getEntryAttributes(new JarEntry("test"))).isNull();
+	}
+
+	@Test
+	void getEntryAttributesReturnsDeepCopy() throws Exception {
+		Manifest manifest = new Manifest();
+		UrlJarManifest urlJarManifest = new UrlJarManifest(() -> manifest);
+		Attributes attributes = new Attributes();
+		attributes.putValue("test", "test");
+		manifest.getEntries().put("spring", attributes);
+		Attributes copy = urlJarManifest.getEntryAttributes(new JarEntry("spring"));
+		assertThat(copy).isNotSameAs(attributes);
+		attributes.clear();
+		assertThat(copy.getValue("test")).isNotNull();
+
+	}
+
+	@Test
+	void supplierIsOnlyCalledOnce() throws IOException {
+		ManifestSupplier supplier = mock(ManifestSupplier.class);
+		UrlJarManifest urlJarManifest = new UrlJarManifest(supplier);
+		urlJarManifest.get();
+		urlJarManifest.get();
+		then(supplier).should(times(1)).getManifest();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlNestedJarFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlNestedJarFileTests.java
new file mode 100644
index 000000000000..137caca278ee
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlNestedJarFileTests.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.jar;
+
+import java.io.File;
+import java.util.function.Consumer;
+import java.util.jar.JarFile;
+import java.util.jar.Manifest;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import org.springframework.boot.loader.testsupport.TestJar;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.then;
+
+/**
+ * Tests for {@link UrlNestedJarFile}.
+ *
+ * @author Phillip Webb
+ */
+class UrlNestedJarFileTests {
+
+	@TempDir
+	File temp;
+
+	private UrlNestedJarFile jarFile;
+
+	@Mock
+	private Consumer<JarFile> closeAction;
+
+	@BeforeEach
+	void setup() throws Exception {
+		MockitoAnnotations.openMocks(this);
+		File file = new File(this.temp, "test.jar");
+		TestJar.create(file);
+		this.jarFile = new UrlNestedJarFile(file, "multi-release.jar", Runtime.version(), this.closeAction);
+	}
+
+	@AfterEach
+	void cleanup() throws Exception {
+		this.jarFile.close();
+	}
+
+	@Test
+	void getEntryWhenNotfoundReturnsNull() {
+		assertThat(this.jarFile.getEntry("missing")).isNull();
+	}
+
+	@Test
+	void getEntryWhenFoundReturnsUrlJarEntry() {
+		assertThat(this.jarFile.getEntry("multi-release.dat")).isInstanceOf(UrlJarEntry.class);
+	}
+
+	@Test
+	void getManifestReturnsNewCopy() throws Exception {
+		Manifest manifest1 = this.jarFile.getManifest();
+		Manifest manifest2 = this.jarFile.getManifest();
+		assertThat(manifest1).isNotSameAs(manifest2);
+	}
+
+	@Test
+	void closeCallsCloseAction() throws Exception {
+		this.jarFile.close();
+		then(this.closeAction).should().accept(this.jarFile);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/HandlerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/HandlerTests.java
new file mode 100644
index 000000000000..b6d73394473b
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/HandlerTests.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.nested;
+
+import java.io.File;
+import java.net.URL;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.springframework.boot.loader.net.protocol.Handlers;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.assertThatNoException;
+
+/**
+ * Tests for {@link Handler}.
+ *
+ * @author Phillip Webb
+ */
+class HandlerTests {
+
+	@TempDir
+	File temp;
+
+	@BeforeAll
+	static void registerHandlers() {
+		Handlers.register();
+	}
+
+	@Test
+	void openConnectionReturnsNestedUrlConnection() throws Exception {
+		URL url = new URL("nested:" + this.temp.getAbsolutePath() + "/!nested.jar");
+		assertThat(url.openConnection()).isInstanceOf(NestedUrlConnection.class);
+	}
+
+	@Test
+	void assertUrlIsNotMalformedWhenUrlIsNullThrowsException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> Handler.assertUrlIsNotMalformed(null))
+			.withMessageContaining("'url' must not be null");
+	}
+
+	@Test
+	void assertUrlIsNotMalformedWhenUrlIsNotNestedThrowsException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> Handler.assertUrlIsNotMalformed("file:"))
+			.withMessageContaining("must use 'nested'");
+	}
+
+	@Test
+	void assertUrlIsNotMalformedWhenUrlIsMalformedThrowsException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> Handler.assertUrlIsNotMalformed("nested:bad"))
+			.withMessageContaining("'path' must contain '/!'");
+	}
+
+	@Test
+	void assertUrlIsNotMalformedWhenUrlIsValidDoesNotThrowException() {
+		String url = "nested:" + this.temp.getAbsolutePath() + "/!nested.jar";
+		assertThatNoException().isThrownBy(() -> Handler.assertUrlIsNotMalformed(url));
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/NestedLocationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/NestedLocationTests.java
new file mode 100644
index 000000000000..55970dfb245a
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/NestedLocationTests.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.nested;
+
+import java.io.File;
+import java.net.URI;
+import java.net.URL;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.springframework.boot.loader.net.protocol.Handlers;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link NestedLocation}.
+ *
+ * @author Phillip Webb
+ */
+class NestedLocationTests {
+
+	@TempDir
+	File temp;
+
+	@BeforeAll
+	static void registerHandlers() {
+		Handlers.register();
+	}
+
+	@Test
+	void createWhenPathIsNullThrowsException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> new NestedLocation(null, "nested.jar"))
+			.withMessageContaining("'path' must not be null");
+	}
+
+	@Test
+	void createWhenNestedEntryNameIsNullThrowsException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> new NestedLocation(Path.of("test.jar"), null))
+			.withMessageContaining("'nestedEntryName' must not be empty");
+	}
+
+	@Test
+	void createWhenNestedEntryNameIsEmptyThrowsException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> new NestedLocation(Path.of("test.jar"), null))
+			.withMessageContaining("'nestedEntryName' must not be empty");
+	}
+
+	@Test
+	void fromUrlWhenUrlIsNullThrowsException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> NestedLocation.fromUrl(null))
+			.withMessageContaining("'url' must not be null");
+	}
+
+	@Test
+	void fromUrlWhenNotNestedProtocolThrowsException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> NestedLocation.fromUrl(new URL("file://test.jar")))
+			.withMessageContaining("must use 'nested' protocol");
+	}
+
+	@Test
+	void fromUrlWhenNoPathThrowsException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> NestedLocation.fromUrl(new URL("nested:")))
+			.withMessageContaining("'path' must not be empty");
+	}
+
+	@Test
+	void fromUrlWhenNoSeparatorThrowsException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> NestedLocation.fromUrl(new URL("nested:test.jar!nested.jar")))
+			.withMessageContaining("'path' must contain '/!'");
+	}
+
+	@Test
+	void fromUrlReturnsNestedLocation() throws Exception {
+		File file = new File(this.temp, "test.jar");
+		NestedLocation location = NestedLocation
+			.fromUrl(new URL("nested:" + file.getAbsolutePath() + "/!lib/nested.jar"));
+		assertThat(location.path()).isEqualTo(file.toPath());
+		assertThat(location.nestedEntryName()).isEqualTo("lib/nested.jar");
+	}
+
+	@Test
+	void fromUriWhenUrlIsNullThrowsException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> NestedLocation.fromUri(null))
+			.withMessageContaining("'uri' must not be null");
+	}
+
+	@Test
+	void fromUriWhenNotNestedProtocolThrowsException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> NestedLocation.fromUri(new URI("file://test.jar")))
+			.withMessageContaining("must use 'nested' scheme");
+	}
+
+	@Test
+	void fromUriWhenNoSeparatorThrowsException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> NestedLocation.fromUri(new URI("nested:test.jar!nested.jar")))
+			.withMessageContaining("'path' must contain '/!'");
+	}
+
+	@Test
+	void fromUriReturnsNestedLocation() throws Exception {
+		File file = new File(this.temp, "test.jar");
+		NestedLocation location = NestedLocation
+			.fromUri(new URI("nested:" + file.getAbsoluteFile().toURI().getPath() + "/!lib/nested.jar"));
+		assertThat(location.path()).isEqualTo(file.toPath());
+		assertThat(location.nestedEntryName()).isEqualTo("lib/nested.jar");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnectionTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnectionTests.java
new file mode 100644
index 000000000000..c3a8d8aa566a
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnectionTests.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.protocol.nested;
+
+import java.io.File;
+import java.io.FilePermission;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.ref.Cleaner.Cleanable;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLConnection;
+import java.security.Permission;
+import java.time.Instant;
+import java.time.temporal.ChronoField;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.boot.loader.net.protocol.Handlers;
+import org.springframework.boot.loader.ref.Cleaner;
+import org.springframework.boot.loader.testsupport.TestJar;
+import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed;
+import org.springframework.boot.loader.zip.ZipContent;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link NestedUrlConnection}.
+ *
+ * @author Phillip Webb
+ */
+@AssertFileChannelDataBlocksClosed
+class NestedUrlConnectionTests {
+
+	@TempDir
+	File temp;
+
+	private File jarFile;
+
+	private URL url;
+
+	@BeforeAll
+	static void registerHandlers() {
+		Handlers.register();
+	}
+
+	@BeforeEach
+	void setup() throws Exception {
+		this.jarFile = new File(this.temp, "test.jar");
+		TestJar.create(this.jarFile);
+		this.url = new URL("nested:" + this.jarFile.getAbsolutePath() + "/!nested.jar");
+	}
+
+	@Test
+	void createWhenMalformedUrlThrowsException() throws Exception {
+		URL url = new URL("nested:bad.jar");
+		assertThatExceptionOfType(MalformedURLException.class).isThrownBy(() -> new NestedUrlConnection(url))
+			.withMessage("'path' must contain '/!'");
+	}
+
+	@Test
+	void getContentLengthWhenContentLengthMoreThanMaxIntReturnsMinusOne() {
+		NestedUrlConnection connection = mock(NestedUrlConnection.class);
+		given(connection.getContentLength()).willCallRealMethod();
+		given(connection.getContentLengthLong()).willReturn((long) Integer.MAX_VALUE + 1);
+		assertThat(connection.getContentLength()).isEqualTo(-1);
+	}
+
+	@Test
+	void getContentLengthGetsContentLength() throws Exception {
+		NestedUrlConnection connection = new NestedUrlConnection(this.url);
+		try (ZipContent zipContent = ZipContent.open(this.jarFile.toPath())) {
+			int expectedSize = zipContent.getEntry("nested.jar").getUncompressedSize();
+			assertThat(connection.getContentLength()).isEqualTo(expectedSize);
+		}
+	}
+
+	@Test
+	void getContentLengthLongReturnsContentLength() throws Exception {
+		NestedUrlConnection connection = new NestedUrlConnection(this.url);
+		try (ZipContent zipContent = ZipContent.open(this.jarFile.toPath())) {
+			int expectedSize = zipContent.getEntry("nested.jar").getUncompressedSize();
+			assertThat(connection.getContentLengthLong()).isEqualTo(expectedSize);
+		}
+	}
+
+	@Test
+	void getContentTypeReturnsJavaJar() throws Exception {
+		NestedUrlConnection connection = new NestedUrlConnection(this.url);
+		assertThat(connection.getContentType()).isEqualTo("x-java/jar");
+	}
+
+	@Test
+	void getLastModifiedReturnsFileLastModified() throws Exception {
+		NestedUrlConnection connection = new NestedUrlConnection(this.url);
+		assertThat(connection.getLastModified()).isEqualTo(this.jarFile.lastModified());
+	}
+
+	@Test
+	void getPermissionReturnsFilePermission() throws Exception {
+		NestedUrlConnection connection = new NestedUrlConnection(this.url);
+		Permission permission = connection.getPermission();
+		assertThat(permission).isInstanceOf(FilePermission.class);
+		assertThat(permission.getName()).isEqualTo(this.jarFile.getCanonicalPath());
+	}
+
+	@Test
+	void getInputStreamReturnsContentOfNestedJar() throws Exception {
+		NestedUrlConnection connection = new NestedUrlConnection(this.url);
+		try (InputStream actual = connection.getInputStream()) {
+			try (ZipContent zipContent = ZipContent.open(this.jarFile.toPath())) {
+				try (InputStream expected = zipContent.getEntry("nested.jar").openContent().asInputStream()) {
+					assertThat(actual).hasSameContentAs(expected);
+				}
+			}
+		}
+	}
+
+	@Test
+	void inputStreamCloseCleansResource() throws Exception {
+		Cleaner cleaner = mock(Cleaner.class);
+		Cleanable cleanable = mock(Cleanable.class);
+		given(cleaner.register(any(), any())).willReturn(cleanable);
+		NestedUrlConnection connection = new NestedUrlConnection(this.url, cleaner);
+		connection.getInputStream().close();
+		then(cleanable).should().clean();
+		ArgumentCaptor<Runnable> actionCaptor = ArgumentCaptor.forClass(Runnable.class);
+		then(cleaner).should().register(any(), actionCaptor.capture());
+		actionCaptor.getValue().run();
+	}
+
+	@Test // gh-38204
+	void getLastModifiedReturnsFileModifiedTime() throws Exception {
+		NestedUrlConnection connection = new NestedUrlConnection(this.url);
+		assertThat(connection.getLastModified()).isEqualTo(this.jarFile.lastModified());
+	}
+
+	@Test // gh-38204
+	void getLastModifiedHeaderReturnsFileModifiedTime() throws IOException {
+		NestedUrlConnection connection = new NestedUrlConnection(this.url);
+		URLConnection fileConnection = this.jarFile.toURI().toURL().openConnection();
+		try {
+			assertThat(connection.getHeaderFieldDate("last-modified", 0))
+				.isEqualTo(withoutNanos(this.jarFile.lastModified()))
+				.isEqualTo(fileConnection.getHeaderFieldDate("last-modified", 0));
+		}
+		finally {
+			fileConnection.getInputStream().close();
+		}
+	}
+
+	private long withoutNanos(long epochMilli) {
+		return Instant.ofEpochMilli(epochMilli).with(ChronoField.NANO_OF_SECOND, 0).toEpochMilli();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/util/UrlDecoderTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/util/UrlDecoderTests.java
new file mode 100644
index 000000000000..84708a0ba507
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/util/UrlDecoderTests.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.net.util;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link UrlDecoder}.
+ *
+ * @author Phillip Webb
+ */
+class UrlDecoderTests {
+
+	@Test
+	void decodeWhenBasicString() {
+		assertThat(UrlDecoder.decode("a/b/C.class")).isEqualTo("a/b/C.class");
+	}
+
+	@Test
+	void decodeWhenHasSingleByteEncodedCharacters() {
+		assertThat(UrlDecoder.decode("%61/%62/%43.class")).isEqualTo("a/b/C.class");
+	}
+
+	@Test
+	void decodeWhenHasDoubleByteEncodedCharacters() {
+		assertThat(UrlDecoder.decode("%c3%a1/b/C.class")).isEqualTo("\u00e1/b/C.class");
+	}
+
+	@Test
+	void decodeWhenHasMixtureOfEncodedAndUnencodedDoubleByteCharacters() {
+		assertThat(UrlDecoder.decode("%c3%a1/b/\u00c7.class")).isEqualTo("\u00e1/b/\u00c7.class");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedByteChannelTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedByteChannelTests.java
new file mode 100644
index 000000000000..d9ff2e83a1ef
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedByteChannelTests.java
@@ -0,0 +1,178 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.nio.file;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.ref.Cleaner.Cleanable;
+import java.nio.ByteBuffer;
+import java.nio.channels.ClosedChannelException;
+import java.nio.channels.NonWritableChannelException;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.boot.loader.ref.Cleaner;
+import org.springframework.boot.loader.testsupport.TestJar;
+import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed;
+import org.springframework.boot.loader.zip.ZipContent;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link NestedByteChannel}.
+ *
+ * @author Phillip Webb
+ */
+@AssertFileChannelDataBlocksClosed
+class NestedByteChannelTests {
+
+	@TempDir
+	File temp;
+
+	private File file;
+
+	private NestedByteChannel channel;
+
+	@BeforeEach
+	void setup() throws Exception {
+		this.file = new File(this.temp, "test.jar");
+		TestJar.create(this.file);
+		this.channel = new NestedByteChannel(this.file.toPath(), "nested.jar");
+	}
+
+	@AfterEach
+	void cleanup() throws Exception {
+		this.channel.close();
+	}
+
+	@Test
+	void isOpenWhenOpenReturnsTrue() {
+		assertThat(this.channel.isOpen()).isTrue();
+	}
+
+	@Test
+	void isOpenWhenClosedReturnsFalse() throws Exception {
+		this.channel.close();
+		assertThat(this.channel.isOpen()).isFalse();
+	}
+
+	@Test
+	void closeCleansResources() throws Exception {
+		Cleaner cleaner = mock(Cleaner.class);
+		Cleanable cleanable = mock(Cleanable.class);
+		given(cleaner.register(any(), any())).willReturn(cleanable);
+		NestedByteChannel channel = new NestedByteChannel(this.file.toPath(), "nested.jar", cleaner);
+		channel.close();
+		then(cleanable).should().clean();
+		ArgumentCaptor<Runnable> actionCaptor = ArgumentCaptor.forClass(Runnable.class);
+		then(cleaner).should().register(any(), actionCaptor.capture());
+		actionCaptor.getValue().run();
+	}
+
+	@Test
+	void closeWhenAlreadyClosedDoesNothing() throws IOException {
+		Cleaner cleaner = mock(Cleaner.class);
+		Cleanable cleanable = mock(Cleanable.class);
+		given(cleaner.register(any(), any())).willReturn(cleanable);
+		NestedByteChannel channel = new NestedByteChannel(this.file.toPath(), "nested.jar", cleaner);
+		channel.close();
+		then(cleanable).should().clean();
+		ArgumentCaptor<Runnable> actionCaptor = ArgumentCaptor.forClass(Runnable.class);
+		then(cleaner).should().register(any(), actionCaptor.capture());
+		actionCaptor.getValue().run();
+		channel.close();
+		then(cleaner).shouldHaveNoMoreInteractions();
+	}
+
+	@Test
+	void readReadsBytesAndIncrementsPosition() throws IOException {
+		ByteBuffer dst = ByteBuffer.allocate(10);
+		assertThat(this.channel.position()).isZero();
+		this.channel.read(dst);
+		assertThat(this.channel.position()).isEqualTo(10L);
+		assertThat(dst.array()).isNotEqualTo(ByteBuffer.allocate(10).array());
+	}
+
+	@Test
+	void writeThrowsException() {
+		assertThatExceptionOfType(NonWritableChannelException.class)
+			.isThrownBy(() -> this.channel.write(ByteBuffer.allocate(10)));
+	}
+
+	@Test
+	void positionWhenClosedThrowsException() throws Exception {
+		this.channel.close();
+		assertThatExceptionOfType(ClosedChannelException.class).isThrownBy(() -> this.channel.position());
+	}
+
+	@Test
+	void positionWhenOpenReturnsPosition() throws Exception {
+		assertThat(this.channel.position()).isEqualTo(0L);
+	}
+
+	@Test
+	void positionWithLongWhenClosedThrowsException() throws Exception {
+		this.channel.close();
+		assertThatExceptionOfType(ClosedChannelException.class).isThrownBy(() -> this.channel.position(0L));
+	}
+
+	@Test
+	void positionWithLongWhenLessThanZeroThrowsException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.channel.position(-1));
+	}
+
+	@Test
+	void positionWithLongWhenEqualToSizeThrowsException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.channel.position(this.channel.size()));
+	}
+
+	@Test
+	void positionWithLongWhenOpenUpdatesPosition() throws Exception {
+		ByteBuffer dst1 = ByteBuffer.allocate(10);
+		ByteBuffer dst2 = ByteBuffer.allocate(10);
+		dst2.position(1);
+		this.channel.read(dst1);
+		this.channel.position(1);
+		this.channel.read(dst2);
+		dst2.array()[0] = dst1.array()[0];
+		assertThat(dst1.array()).isEqualTo(dst2.array());
+	}
+
+	@Test
+	void sizeWhenClosedThrowsException() throws Exception {
+		this.channel.close();
+		assertThatExceptionOfType(ClosedChannelException.class).isThrownBy(() -> this.channel.size());
+	}
+
+	@Test
+	void sizeWhenOpenReturnsSize() throws IOException {
+		try (ZipContent content = ZipContent.open(this.file.toPath())) {
+			assertThat(this.channel.size()).isEqualTo(content.getEntry("nested.jar").getUncompressedSize());
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileStoreTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileStoreTests.java
new file mode 100644
index 000000000000..9baf2b4f9b10
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileStoreTests.java
@@ -0,0 +1,150 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.nio.file;
+
+import java.io.File;
+import java.nio.file.FileStore;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributeView;
+import java.nio.file.attribute.FileStoreAttributeView;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link NestedFileStore}.
+ *
+ * @author Phillip Webb
+ */
+class NestedFileStoreTests {
+
+	@TempDir
+	File temp;
+
+	private NestedFileSystemProvider provider;
+
+	private Path jarPath;
+
+	private NestedFileSystem fileSystem;
+
+	private TestNestedFileStore fileStore;
+
+	@BeforeEach
+	void setup() {
+		this.provider = new NestedFileSystemProvider();
+		this.jarPath = new File(this.temp, "test.jar").toPath();
+		this.fileSystem = new NestedFileSystem(this.provider, this.jarPath);
+		this.fileStore = new TestNestedFileStore(this.fileSystem);
+	}
+
+	@Test
+	void nameReturnsName() {
+		assertThat(this.fileStore.name()).isEqualTo(this.jarPath.toAbsolutePath().toString());
+	}
+
+	@Test
+	void typeReturnsNestedFs() {
+		assertThat(this.fileStore.type()).isEqualTo("nestedfs");
+	}
+
+	@Test
+	void isReadOnlyReturnsTrue() {
+		assertThat(this.fileStore.isReadOnly()).isTrue();
+	}
+
+	@Test
+	void getTotalSpaceReturnsZero() throws Exception {
+		assertThat(this.fileStore.getTotalSpace()).isZero();
+	}
+
+	@Test
+	void getUsableSpaceReturnsZero() throws Exception {
+		assertThat(this.fileStore.getUsableSpace()).isZero();
+	}
+
+	@Test
+	void getUnallocatedSpaceReturnsZero() throws Exception {
+		assertThat(this.fileStore.getUnallocatedSpace()).isZero();
+	}
+
+	@Test
+	void supportsFileAttributeViewWithClassDelegatesToJarPathFileStore() {
+		FileStore jarFileStore = mock(FileStore.class);
+		given(jarFileStore.supportsFileAttributeView(BasicFileAttributeView.class)).willReturn(true);
+		this.fileStore.setJarPathFileStore(jarFileStore);
+		assertThat(this.fileStore.supportsFileAttributeView(BasicFileAttributeView.class)).isTrue();
+		then(jarFileStore).should().supportsFileAttributeView(BasicFileAttributeView.class);
+	}
+
+	@Test
+	void supportsFileAttributeViewWithStringDelegatesToJarPathFileStore() {
+		FileStore jarFileStore = mock(FileStore.class);
+		given(jarFileStore.supportsFileAttributeView("basic")).willReturn(true);
+		this.fileStore.setJarPathFileStore(jarFileStore);
+		assertThat(this.fileStore.supportsFileAttributeView("basic")).isTrue();
+		then(jarFileStore).should().supportsFileAttributeView("basic");
+	}
+
+	@Test
+	void getFileStoreAttributeViewDelegatesToJarPathFileStore() {
+		FileStore jarFileStore = mock(FileStore.class);
+		TestFileStoreAttributeView attributeView = mock(TestFileStoreAttributeView.class);
+		given(jarFileStore.getFileStoreAttributeView(TestFileStoreAttributeView.class)).willReturn(attributeView);
+		this.fileStore.setJarPathFileStore(jarFileStore);
+		assertThat(this.fileStore.getFileStoreAttributeView(TestFileStoreAttributeView.class)).isEqualTo(attributeView);
+		then(jarFileStore).should().getFileStoreAttributeView(TestFileStoreAttributeView.class);
+	}
+
+	@Test
+	void getAttributeDelegatesToJarPathFileStore() throws Exception {
+		FileStore jarFileStore = mock(FileStore.class);
+		given(jarFileStore.getAttribute("test")).willReturn("spring");
+		this.fileStore.setJarPathFileStore(jarFileStore);
+		assertThat(this.fileStore.getAttribute("test")).isEqualTo("spring");
+		then(jarFileStore).should().getAttribute("test");
+	}
+
+	static class TestNestedFileStore extends NestedFileStore {
+
+		TestNestedFileStore(NestedFileSystem fileSystem) {
+			super(fileSystem);
+		}
+
+		private FileStore jarPathFileStore;
+
+		void setJarPathFileStore(FileStore jarPathFileStore) {
+			this.jarPathFileStore = jarPathFileStore;
+		}
+
+		@Override
+		protected FileStore getJarPathFileStore() {
+			return (this.jarPathFileStore != null) ? this.jarPathFileStore : super.getJarPathFileStore();
+		}
+
+	}
+
+	abstract static class TestFileStoreAttributeView implements FileStoreAttributeView {
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileSystemProviderTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileSystemProviderTests.java
new file mode 100644
index 000000000000..1204705a2098
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileSystemProviderTests.java
@@ -0,0 +1,276 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.nio.file;
+
+import java.io.File;
+import java.net.URI;
+import java.nio.channels.SeekableByteChannel;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystemAlreadyExistsException;
+import java.nio.file.FileSystemNotFoundException;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.NotDirectoryException;
+import java.nio.file.Path;
+import java.nio.file.ReadOnlyFileSystemException;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.BasicFileAttributeView;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.spi.FileSystemProvider;
+import java.util.Collections;
+import java.util.Set;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.springframework.boot.loader.testsupport.TestJar;
+import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link NestedFileSystemProvider}.
+ *
+ * @author Phillip Webb
+ */
+@AssertFileChannelDataBlocksClosed
+class NestedFileSystemProviderTests {
+
+	@TempDir
+	File temp;
+
+	private File file;
+
+	private TestNestedFileSystemProvider provider = new TestNestedFileSystemProvider();
+
+	private String uriPrefix;
+
+	@BeforeEach
+	void setup() throws Exception {
+		this.file = new File(this.temp, "test.jar");
+		TestJar.create(this.file);
+		this.uriPrefix = "nested:" + this.file.toURI().getPath() + "/!";
+	}
+
+	@Test
+	void getSchemeReturnsScheme() {
+		assertThat(this.provider.getScheme()).isEqualTo("nested");
+	}
+
+	@Test
+	void newFilesSystemWhenBadUrlThrowsException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.provider.newFileSystem(new URI("bad:notreal"), Collections.emptyMap()))
+			.withMessageContaining("must use 'nested' scheme");
+	}
+
+	@Test
+	void newFileSystemWhenAlreadyExistsThrowsException() throws Exception {
+		this.provider.newFileSystem(new URI(this.uriPrefix + "nested.jar"), null);
+		assertThatExceptionOfType(FileSystemAlreadyExistsException.class)
+			.isThrownBy(() -> this.provider.newFileSystem(new URI(this.uriPrefix + "other.jar"), null));
+	}
+
+	@Test
+	void newFileSystemReturnsFileSystem() throws Exception {
+		FileSystem fileSystem = this.provider.newFileSystem(new URI(this.uriPrefix + "nested.jar"), null);
+		assertThat(fileSystem).isInstanceOf(NestedFileSystem.class);
+	}
+
+	@Test
+	void getFileSystemWhenBadUrlThrowsException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.provider.getFileSystem(new URI("bad:notreal")))
+			.withMessageContaining("must use 'nested' scheme");
+	}
+
+	@Test
+	void getFileSystemWhenNotCreatedThrowsException() {
+		assertThatExceptionOfType(FileSystemNotFoundException.class)
+			.isThrownBy(() -> this.provider.getFileSystem(new URI(this.uriPrefix + "nested.jar")));
+	}
+
+	@Test
+	void getFileSystemReturnsFileSystem() throws Exception {
+		FileSystem expected = this.provider.newFileSystem(new URI(this.uriPrefix + "nested.jar"), null);
+		assertThat(this.provider.getFileSystem(new URI(this.uriPrefix + "nested.jar"))).isSameAs(expected);
+	}
+
+	@Test
+	void getPathWhenFileSystemExistsReturnsPath() throws Exception {
+		URI uri = new URI(this.uriPrefix + "nested.jar");
+		this.provider.newFileSystem(uri, null);
+		assertThat(this.provider.getPath(uri)).isInstanceOf(NestedPath.class);
+	}
+
+	@Test
+	void getPathWhenFileSystemDoesNtExistReturnsPath() throws Exception {
+		URI uri = new URI(this.uriPrefix + "nested.jar");
+		assertThat(this.provider.getPath(uri)).isInstanceOf(NestedPath.class);
+	}
+
+	@Test
+	void newByteChannelReturnsByteChannel() throws Exception {
+		URI uri = new URI(this.uriPrefix + "nested.jar");
+		Path path = this.provider.getPath(uri);
+		try (SeekableByteChannel byteChannel = this.provider.newByteChannel(path, Set.of(StandardOpenOption.READ))) {
+			assertThat(byteChannel).isInstanceOf(NestedByteChannel.class);
+		}
+	}
+
+	@Test
+	void newDirectoryStreamThrowsException() throws Exception {
+		URI uri = new URI(this.uriPrefix + "nested.jar");
+		Path path = this.provider.getPath(uri);
+		assertThatExceptionOfType(NotDirectoryException.class)
+			.isThrownBy(() -> this.provider.newDirectoryStream(path, null));
+	}
+
+	@Test
+	void createDirectoryThrowsException() throws Exception {
+		URI uri = new URI(this.uriPrefix + "nested.jar");
+		Path path = this.provider.getPath(uri);
+		assertThatExceptionOfType(ReadOnlyFileSystemException.class)
+			.isThrownBy(() -> this.provider.createDirectory(path));
+	}
+
+	@Test
+	void deleteThrowsException() throws Exception {
+		URI uri = new URI(this.uriPrefix + "nested.jar");
+		Path path = this.provider.getPath(uri);
+		assertThatExceptionOfType(ReadOnlyFileSystemException.class).isThrownBy(() -> this.provider.delete(path));
+	}
+
+	@Test
+	void copyThrowsException() throws Exception {
+		URI uri = new URI(this.uriPrefix + "nested.jar");
+		Path path = this.provider.getPath(uri);
+		assertThatExceptionOfType(ReadOnlyFileSystemException.class).isThrownBy(() -> this.provider.copy(path, path));
+	}
+
+	@Test
+	void moveThrowsException() throws Exception {
+		URI uri = new URI(this.uriPrefix + "nested.jar");
+		Path path = this.provider.getPath(uri);
+		assertThatExceptionOfType(ReadOnlyFileSystemException.class).isThrownBy(() -> this.provider.move(path, path));
+	}
+
+	@Test
+	void isSameFileWhenSameReturnsTrue() throws Exception {
+		Path p1 = this.provider.getPath(new URI(this.uriPrefix + "nested.jar"));
+		Path p2 = this.provider.getPath(new URI(this.uriPrefix + "nested.jar"));
+		assertThat(this.provider.isSameFile(p1, p1)).isTrue();
+		assertThat(this.provider.isSameFile(p1, p2)).isTrue();
+	}
+
+	@Test
+	void isSameFileWhenDifferentReturnsFalse() throws Exception {
+		Path p1 = this.provider.getPath(new URI(this.uriPrefix + "nested.jar"));
+		Path p2 = this.provider.getPath(new URI(this.uriPrefix + "other.jar"));
+		assertThat(this.provider.isSameFile(p1, p2)).isFalse();
+	}
+
+	@Test
+	void isHiddenReturnsFalse() throws Exception {
+		Path path = this.provider.getPath(new URI(this.uriPrefix + "nested.jar"));
+		assertThat(this.provider.isHidden(path)).isFalse();
+	}
+
+	@Test
+	void getFileStoreWhenFileDoesNotExistThrowsException() throws Exception {
+		Path path = this.provider.getPath(new URI(this.uriPrefix + "missing.jar"));
+		assertThatExceptionOfType(NoSuchFileException.class).isThrownBy(() -> this.provider.getFileStore(path));
+	}
+
+	@Test
+	void getFileStoreReturnsFileStore() throws Exception {
+		Path path = this.provider.getPath(new URI(this.uriPrefix + "nested.jar"));
+		assertThat(this.provider.getFileStore(path)).isInstanceOf(NestedFileStore.class);
+	}
+
+	@Test
+	void checkAccessDelegatesToJarPath() throws Exception {
+		Path path = this.provider.getPath(new URI(this.uriPrefix + "nested.jar"));
+		Path jarPath = mockJarPath();
+		this.provider.setMockJarPath(jarPath);
+		this.provider.checkAccess(path);
+		then(jarPath.getFileSystem().provider()).should().checkAccess(jarPath);
+	}
+
+	@Test
+	void getFileAttributeViewDelegatesToJarPath() throws Exception {
+		Path path = this.provider.getPath(new URI(this.uriPrefix + "nested.jar"));
+		Path jarPath = mockJarPath();
+		this.provider.setMockJarPath(jarPath);
+		this.provider.getFileAttributeView(path, BasicFileAttributeView.class);
+		then(jarPath.getFileSystem().provider()).should().getFileAttributeView(jarPath, BasicFileAttributeView.class);
+	}
+
+	@Test
+	void readAttributesWithTypeDelegatesToJarPath() throws Exception {
+		Path path = this.provider.getPath(new URI(this.uriPrefix + "nested.jar"));
+		Path jarPath = mockJarPath();
+		this.provider.setMockJarPath(jarPath);
+		this.provider.readAttributes(path, BasicFileAttributes.class);
+		then(jarPath.getFileSystem().provider()).should().readAttributes(jarPath, BasicFileAttributes.class);
+	}
+
+	@Test
+	void readAttributesWithNameDelegatesToJarPath() throws Exception {
+		Path path = this.provider.getPath(new URI(this.uriPrefix + "nested.jar"));
+		Path jarPath = mockJarPath();
+		this.provider.setMockJarPath(jarPath);
+		this.provider.readAttributes(path, "basic");
+		then(jarPath.getFileSystem().provider()).should().readAttributes(jarPath, "basic");
+	}
+
+	@Test
+	void setAttributeThrowsException() throws Exception {
+		Path path = this.provider.getPath(new URI(this.uriPrefix + "nested.jar"));
+		assertThatExceptionOfType(ReadOnlyFileSystemException.class)
+			.isThrownBy(() -> this.provider.setAttribute(path, "test", "test"));
+	}
+
+	private Path mockJarPath() {
+		Path path = mock(Path.class);
+		FileSystem fileSystem = mock(FileSystem.class);
+		given(path.getFileSystem()).willReturn(fileSystem);
+		FileSystemProvider provider = mock(FileSystemProvider.class);
+		given(fileSystem.provider()).willReturn(provider);
+		return path;
+	}
+
+	static class TestNestedFileSystemProvider extends NestedFileSystemProvider {
+
+		private Path mockJarPath;
+
+		@Override
+		protected Path getJarPath(Path path) {
+			return (this.mockJarPath != null) ? this.mockJarPath : super.getJarPath(path);
+		}
+
+		void setMockJarPath(Path mockJarPath) {
+			this.mockJarPath = mockJarPath;
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileSystemTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileSystemTests.java
new file mode 100644
index 000000000000..d8b8825dc8b4
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileSystemTests.java
@@ -0,0 +1,191 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.nio.file;
+
+import java.io.File;
+import java.nio.file.ClosedFileSystemException;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link NestedFileSystem}.
+ *
+ * @author Phillip Webb
+ */
+class NestedFileSystemTests {
+
+	@TempDir
+	File temp;
+
+	private NestedFileSystemProvider provider;
+
+	private Path jarPath;
+
+	private NestedFileSystem fileSystem;
+
+	@BeforeEach
+	void setup() {
+		this.provider = new NestedFileSystemProvider();
+		this.jarPath = new File(this.temp, "test.jar").toPath();
+		this.fileSystem = new NestedFileSystem(this.provider, this.jarPath);
+	}
+
+	@Test
+	void providerReturnsProvider() {
+		assertThat(this.fileSystem.provider()).isSameAs(this.provider);
+	}
+
+	@Test
+	void getJarPathReturnsJarPath() {
+		assertThat(this.fileSystem.getJarPath()).isSameAs(this.jarPath);
+	}
+
+	@Test
+	void closeClosesFileSystem() throws Exception {
+		this.fileSystem.close();
+		assertThat(this.fileSystem.isOpen()).isFalse();
+	}
+
+	@Test
+	void closeWhenAlreadyClosedDoesNothing() throws Exception {
+		this.fileSystem.close();
+		this.fileSystem.close();
+		assertThat(this.fileSystem.isOpen()).isFalse();
+	}
+
+	@Test
+	void isOpenWhenOpenReturnsTrue() {
+		assertThat(this.fileSystem.isOpen()).isTrue();
+	}
+
+	@Test
+	void isOpenWhenClosedReturnsFalse() throws Exception {
+		this.fileSystem.close();
+		assertThat(this.fileSystem.isOpen()).isFalse();
+	}
+
+	@Test
+	void isReadOnlyReturnsTrue() {
+		assertThat(this.fileSystem.isReadOnly()).isTrue();
+	}
+
+	@Test
+	void getSeparatorReturnsSeparator() {
+		assertThat(this.fileSystem.getSeparator()).isEqualTo("/!");
+	}
+
+	@Test
+	void getRootDirectoryWhenOpenReturnsEmptyIterable() {
+		assertThat(this.fileSystem.getRootDirectories()).isEmpty();
+	}
+
+	@Test
+	void getRootDirectoryWhenClosedThrowsException() throws Exception {
+		this.fileSystem.close();
+		assertThatExceptionOfType(ClosedFileSystemException.class)
+			.isThrownBy(() -> this.fileSystem.getRootDirectories());
+	}
+
+	@Test
+	void supportedFileAttributeViewsWhenOpenReturnsBasic() {
+		assertThat(this.fileSystem.supportedFileAttributeViews()).containsExactly("basic");
+	}
+
+	@Test
+	void supportedFileAttributeViewsWhenClosedThrowsException() throws Exception {
+		this.fileSystem.close();
+		assertThatExceptionOfType(ClosedFileSystemException.class)
+			.isThrownBy(() -> this.fileSystem.supportedFileAttributeViews());
+	}
+
+	@Test
+	void getPathWhenClosedThrowsException() throws Exception {
+		this.fileSystem.close();
+		assertThatExceptionOfType(ClosedFileSystemException.class)
+			.isThrownBy(() -> this.fileSystem.getPath("nested.jar"));
+	}
+
+	@Test
+	void getPathWhenFirstIsNullThrowsException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.fileSystem.getPath(null))
+			.withMessage("Nested paths must contain a single element");
+	}
+
+	@Test
+	void getPathWhenFirstIsBlankThrowsException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.fileSystem.getPath(""))
+			.withMessage("Nested paths must contain a single element");
+	}
+
+	@Test
+	void getPathWhenMoreIsNotEmptyThrowsException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.fileSystem.getPath("nested.jar", "another.jar"))
+			.withMessage("Nested paths must contain a single element");
+	}
+
+	@Test
+	void getPathReturnsPath() {
+		assertThat(this.fileSystem.getPath("nested.jar")).isInstanceOf(NestedPath.class);
+	}
+
+	@Test
+	void getPathMatchThrowsException() {
+		assertThatExceptionOfType(UnsupportedOperationException.class)
+			.isThrownBy(() -> this.fileSystem.getPathMatcher("*"))
+			.withMessage("Nested paths do not support path matchers");
+	}
+
+	@Test
+	void getUserPrincipalLookupServiceThrowsException() {
+		assertThatExceptionOfType(UnsupportedOperationException.class)
+			.isThrownBy(() -> this.fileSystem.getUserPrincipalLookupService())
+			.withMessage("Nested paths do not have a user principal lookup service");
+	}
+
+	@Test
+	void newWatchServiceThrowsException() {
+		assertThatExceptionOfType(UnsupportedOperationException.class)
+			.isThrownBy(() -> this.fileSystem.newWatchService())
+			.withMessage("Nested paths do not support the WacherService");
+	}
+
+	@Test
+	void toStringReturnsString() {
+		assertThat(this.fileSystem).hasToString(this.jarPath.toAbsolutePath().toString());
+	}
+
+	@Test
+	void equalsAndHashCode() {
+		Path jp1 = new File(this.temp, "test1.jar").toPath();
+		Path jp2 = new File(this.temp, "test1.jar").toPath();
+		Path jp3 = new File(this.temp, "test2.jar").toPath();
+		NestedFileSystem f1 = new NestedFileSystem(this.provider, jp1);
+		NestedFileSystem f2 = new NestedFileSystem(this.provider, jp1);
+		NestedFileSystem f3 = new NestedFileSystem(this.provider, jp2);
+		NestedFileSystem f4 = new NestedFileSystem(this.provider, jp3);
+		assertThat(f1.hashCode()).isEqualTo(f2.hashCode());
+		assertThat(f1).isEqualTo(f1).isEqualTo(f2).isEqualTo(f3).isNotEqualTo(f4);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileSystemZipFileSystemIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileSystemZipFileSystemIntegrationTests.java
new file mode 100644
index 000000000000..28e26b7f8d48
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileSystemZipFileSystemIntegrationTests.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.nio.file;
+
+import java.io.File;
+import java.net.URI;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.springframework.boot.loader.net.protocol.jar.JarUrl;
+import org.springframework.boot.loader.testsupport.TestJar;
+import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests for {@link NestedFileSystem} in combination with
+ * {@code ZipFileSystem}.
+ *
+ * @author Phillip Webb
+ */
+@AssertFileChannelDataBlocksClosed
+class NestedFileSystemZipFileSystemIntegrationTests {
+
+	@TempDir
+	File temp;
+
+	@Test
+	void zip() throws Exception {
+		File file = new File(this.temp, "test.jar");
+		TestJar.create(file);
+		URI uri = JarUrl.create(file).toURI();
+		try (FileSystem fs = FileSystems.newFileSystem(uri, Collections.emptyMap())) {
+			assertThat(Files.readAllBytes(fs.getPath("1.dat"))).containsExactly(0x1);
+		}
+	}
+
+	@Test
+	void nestedZip() throws Exception {
+		File file = new File(this.temp, "test.jar");
+		TestJar.create(file);
+		URI uri = JarUrl.create(file, "nested.jar").toURI();
+		try (FileSystem fs = FileSystems.newFileSystem(uri, Collections.emptyMap())) {
+			assertThat(Files.readAllBytes(fs.getPath("3.dat"))).containsExactly(0x3);
+		}
+	}
+
+	@Test
+	void nestedZipWithoutNewFileSystem() throws Exception {
+		File file = new File(this.temp, "test.jar");
+		TestJar.create(file);
+		URI uri = JarUrl.create(file, "nested.jar", "3.dat").toURI();
+		Path path = Path.of(uri);
+		assertThat(Files.readAllBytes(path)).containsExactly(0x3);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedPathTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedPathTests.java
new file mode 100644
index 000000000000..df75d9d930f3
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedPathTests.java
@@ -0,0 +1,235 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.nio.file;
+
+import java.io.File;
+import java.net.URI;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.ProviderMismatchException;
+import java.nio.file.WatchService;
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.springframework.boot.loader.testsupport.TestJar;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link NestedPath}.
+ *
+ * @author Phillip Webb
+ */
+class NestedPathTests {
+
+	@TempDir
+	File temp;
+
+	private NestedFileSystem fileSystem;
+
+	private NestedFileSystemProvider provider;
+
+	private Path jarPath;
+
+	private NestedPath path;
+
+	@BeforeEach
+	void setup() {
+		this.jarPath = new File(this.temp, "test.jar").toPath();
+		this.provider = new NestedFileSystemProvider();
+		this.fileSystem = new NestedFileSystem(this.provider, this.jarPath);
+		this.path = new NestedPath(this.fileSystem, "nested.jar");
+	}
+
+	@Test
+	void getJarPathReturnsJarPath() {
+		assertThat(this.path.getJarPath()).isEqualTo(this.jarPath);
+	}
+
+	@Test
+	void getNestedEntryNameReturnsNestedEntryName() {
+		assertThat(this.path.getNestedEntryName()).isEqualTo("nested.jar");
+	}
+
+	@Test
+	void getFileSystemReturnsFileSystem() {
+		assertThat(this.path.getFileSystem()).isSameAs(this.fileSystem);
+	}
+
+	@Test
+	void isAbsoluteReturnsTrue() {
+		assertThat(this.path.isAbsolute()).isTrue();
+	}
+
+	@Test
+	void getRootReturnsNull() {
+		assertThat(this.path.getRoot()).isNull();
+	}
+
+	@Test
+	void getFileNameReturnsPath() {
+		assertThat(this.path.getFileName()).isSameAs(this.path);
+	}
+
+	@Test
+	void getParentReturnsNull() {
+		assertThat(this.path.getParent()).isNull();
+	}
+
+	@Test
+	void getNameCountReturnsOne() {
+		assertThat(this.path.getNameCount()).isEqualTo(1);
+	}
+
+	@Test
+	void subPathWhenBeginZeroEndOneReturnsPath() {
+		assertThat(this.path.subpath(0, 1)).isSameAs(this.path);
+	}
+
+	@Test
+	void subPathWhenBeginIndexNotZeroThrowsException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.path.subpath(1, 1))
+			.withMessage("Nested paths only have a single element");
+	}
+
+	@Test
+	void subPathThenEndIndexNotOneThrowsException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.path.subpath(0, 2))
+			.withMessage("Nested paths only have a single element");
+	}
+
+	@Test
+	void startsWithWhenStartsWithReturnsTrue() {
+		NestedPath otherPath = new NestedPath(this.fileSystem, "nested.jar");
+		assertThat(this.path.startsWith(otherPath)).isTrue();
+	}
+
+	@Test
+	void startsWithWhenNotStartsWithReturnsFalse() {
+		NestedPath otherPath = new NestedPath(this.fileSystem, "other.jar");
+		assertThat(this.path.startsWith(otherPath)).isFalse();
+	}
+
+	@Test
+	void endsWithWhenEndsWithReturnsTrue() {
+		NestedPath otherPath = new NestedPath(this.fileSystem, "nested.jar");
+		assertThat(this.path.endsWith(otherPath)).isTrue();
+	}
+
+	@Test
+	void endsWithWhenNotEndsWithReturnsFalse() {
+		NestedPath otherPath = new NestedPath(this.fileSystem, "other.jar");
+		assertThat(this.path.endsWith(otherPath)).isFalse();
+	}
+
+	@Test
+	void normalizeReturnsPath() {
+		assertThat(this.path.normalize()).isSameAs(this.path);
+	}
+
+	@Test
+	void resolveThrowsException() {
+		assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> this.path.resolve(this.path))
+			.withMessage("Unable to resolve nested path");
+	}
+
+	@Test
+	void relativizeThrowsException() {
+		assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> this.path.relativize(this.path))
+			.withMessage("Unable to relativize nested path");
+	}
+
+	@Test
+	void toUriReturnsUri() throws Exception {
+		assertThat(this.path.toUri()).isEqualTo(new URI("nested:" + this.jarPath.toUri().getPath() + "/!nested.jar"));
+	}
+
+	@Test
+	void toAbsolutePathReturnsPath() {
+		assertThat(this.path.toAbsolutePath()).isSameAs(this.path);
+	}
+
+	@Test
+	void toRealPathReturnsPath() throws Exception {
+		assertThat(this.path.toRealPath()).isSameAs(this.path);
+	}
+
+	@Test
+	void registerThrowsException() {
+		WatchService watcher = mock(WatchService.class);
+		assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> this.path.register(watcher))
+			.withMessage("Nested paths cannot be watched");
+	}
+
+	@Test
+	void compareToComparesOnNestedEntryName() {
+		NestedPath a = new NestedPath(this.fileSystem, "a.jar");
+		NestedPath b = new NestedPath(this.fileSystem, "b.jar");
+		NestedPath c = new NestedPath(this.fileSystem, "c.jar");
+		assertThat(new TreeSet<>(Set.of(c, a, b))).containsExactly(a, b, c);
+	}
+
+	@Test
+	void hashCodeAndEquals() {
+		NestedFileSystem fs2 = new NestedFileSystem(this.provider, new File(this.temp, "test2.jar").toPath());
+		NestedPath p1 = new NestedPath(this.fileSystem, "a.jar");
+		NestedPath p2 = new NestedPath(this.fileSystem, "a.jar");
+		NestedPath p3 = new NestedPath(this.fileSystem, "c.jar");
+		NestedPath p4 = new NestedPath(fs2, "c.jar");
+		assertThat(p1.hashCode()).isEqualTo(p2.hashCode());
+		assertThat(p1).isEqualTo(p1).isEqualTo(p2).isNotEqualTo(p3).isNotEqualTo(p4);
+	}
+
+	@Test
+	void toStringReturnsString() {
+		assertThat(this.path).hasToString(this.jarPath.toString() + "/!nested.jar");
+	}
+
+	@Test
+	void assertExistsWhenExists() throws Exception {
+		TestJar.create(this.jarPath.toFile());
+		this.path.assertExists();
+	}
+
+	@Test
+	void assertExistsWhenDoesNotExistThrowsException() {
+		assertThatExceptionOfType(NoSuchFileException.class).isThrownBy(this.path::assertExists);
+	}
+
+	@Test
+	void castWhenNestedPathReturnsNestedPath() {
+		assertThat(NestedPath.cast(this.path)).isSameAs(this.path);
+	}
+
+	@Test
+	void castWhenNullThrowsException() {
+		assertThatExceptionOfType(ProviderMismatchException.class).isThrownBy(() -> NestedPath.cast(null));
+	}
+
+	@Test
+	void castWhenNotNestedPathThrowsException() {
+		assertThatExceptionOfType(ProviderMismatchException.class).isThrownBy(() -> NestedPath.cast(this.jarPath));
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/ref/DefaultCleanerTracking.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/ref/DefaultCleanerTracking.java
new file mode 100644
index 000000000000..c2342c7a5425
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/ref/DefaultCleanerTracking.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.ref;
+
+import java.lang.ref.Cleaner.Cleanable;
+import java.util.function.BiConsumer;
+
+/**
+ * Utility that allows tests to set a tracker on {@link DefaultCleaner}.
+ *
+ * @author Phillip Webb
+ */
+public final class DefaultCleanerTracking {
+
+	private DefaultCleanerTracking() {
+	}
+
+	public static void set(BiConsumer<Object, Cleanable> tracker) {
+		DefaultCleaner.tracker = tracker;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/testsupport/TestJar.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/testsupport/TestJar.java
new file mode 100644
index 000000000000..137f684c0796
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/testsupport/TestJar.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.testsupport;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.List;
+import java.util.jar.Attributes;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+import java.util.jar.Manifest;
+import java.util.zip.CRC32;
+import java.util.zip.ZipEntry;
+
+/**
+ * Support class to create or get test jars.
+ *
+ * @author Phillip Webb
+ */
+public abstract class TestJar {
+
+	public static final int MULTI_JAR_VERSION = Runtime.version().feature();
+
+	private static final int BASE_VERSION = 8;
+
+	public static void create(File file) throws Exception {
+		create(file, false);
+	}
+
+	public static void create(File file, boolean unpackNested) throws Exception {
+		create(file, unpackNested, false);
+	}
+
+	public static void create(File file, boolean unpackNested, boolean addSignatureFile) throws Exception {
+		FileOutputStream fileOutputStream = new FileOutputStream(file);
+		try (JarOutputStream jarOutputStream = new JarOutputStream(fileOutputStream)) {
+			jarOutputStream.setComment("outer");
+			writeManifest(jarOutputStream, "j1");
+			if (addSignatureFile) {
+				writeEntry(jarOutputStream, "META-INF/some.DSA", 0);
+			}
+			writeEntry(jarOutputStream, "1.dat", 1);
+			writeEntry(jarOutputStream, "2.dat", 2);
+			writeDirEntry(jarOutputStream, "d/");
+			writeEntry(jarOutputStream, "d/9.dat", 9);
+			writeDirEntry(jarOutputStream, "special/");
+			writeEntry(jarOutputStream, "special/\u00EB.dat", '\u00EB');
+			writeNestedEntry("nested.jar", unpackNested, jarOutputStream);
+			writeNestedEntry("another-nested.jar", unpackNested, jarOutputStream);
+			writeNestedEntry("space nested.jar", unpackNested, jarOutputStream);
+			writeNestedMultiReleaseEntry("multi-release.jar", unpackNested, jarOutputStream);
+		}
+	}
+
+	public static List<String> expectedEntries() {
+		return List.of("META-INF/", "META-INF/MANIFEST.MF", "1.dat", "2.dat", "d/", "d/9.dat", "special/",
+				"special/\u00EB.dat", "nested.jar", "another-nested.jar", "space nested.jar", "multi-release.jar");
+	}
+
+	private static void writeNestedEntry(String name, boolean unpackNested, JarOutputStream jarOutputStream)
+			throws Exception {
+		writeNestedEntry(name, unpackNested, jarOutputStream, false);
+	}
+
+	private static void writeNestedMultiReleaseEntry(String name, boolean unpackNested, JarOutputStream jarOutputStream)
+			throws Exception {
+		writeNestedEntry(name, unpackNested, jarOutputStream, true);
+	}
+
+	private static void writeNestedEntry(String name, boolean unpackNested, JarOutputStream jarOutputStream,
+			boolean multiRelease) throws Exception {
+		JarEntry nestedEntry = new JarEntry(name);
+		byte[] nestedJarData = getNestedJarData(multiRelease);
+		nestedEntry.setSize(nestedJarData.length);
+		nestedEntry.setCompressedSize(nestedJarData.length);
+		if (unpackNested) {
+			nestedEntry.setComment("UNPACK:0000000000000000000000000000000000000000");
+		}
+		CRC32 crc32 = new CRC32();
+		crc32.update(nestedJarData);
+		nestedEntry.setCrc(crc32.getValue());
+		nestedEntry.setMethod(ZipEntry.STORED);
+		jarOutputStream.putNextEntry(nestedEntry);
+		jarOutputStream.write(nestedJarData);
+		jarOutputStream.closeEntry();
+	}
+
+	private static byte[] getNestedJarData(boolean multiRelease) throws Exception {
+		ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
+		JarOutputStream jarOutputStream = new JarOutputStream(byteArrayOutputStream);
+		jarOutputStream.setComment("nested");
+		writeManifest(jarOutputStream, "j2", multiRelease);
+		if (multiRelease) {
+			writeEntry(jarOutputStream, "multi-release.dat", BASE_VERSION);
+			writeEntry(jarOutputStream, String.format("META-INF/versions/%d/multi-release.dat", MULTI_JAR_VERSION),
+					MULTI_JAR_VERSION);
+		}
+		else {
+			writeEntry(jarOutputStream, "3.dat", 3);
+			writeEntry(jarOutputStream, "4.dat", 4);
+			writeEntry(jarOutputStream, "\u00E4.dat", '\u00E4');
+		}
+		jarOutputStream.close();
+		return byteArrayOutputStream.toByteArray();
+	}
+
+	private static void writeManifest(JarOutputStream jarOutputStream, String name) throws Exception {
+		writeManifest(jarOutputStream, name, false);
+	}
+
+	private static void writeManifest(JarOutputStream jarOutputStream, String name, boolean multiRelease)
+			throws Exception {
+		writeDirEntry(jarOutputStream, "META-INF/");
+		Manifest manifest = new Manifest();
+		manifest.getMainAttributes().putValue("Built-By", name);
+		manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0");
+		if (multiRelease) {
+			manifest.getMainAttributes().putValue("Multi-Release", Boolean.toString(true));
+		}
+		jarOutputStream.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF"));
+		manifest.write(jarOutputStream);
+		jarOutputStream.closeEntry();
+	}
+
+	private static void writeDirEntry(JarOutputStream jarOutputStream, String name) throws IOException {
+		jarOutputStream.putNextEntry(new JarEntry(name));
+		jarOutputStream.closeEntry();
+	}
+
+	private static void writeEntry(JarOutputStream jarOutputStream, String name, int data) throws IOException {
+		jarOutputStream.putNextEntry(new JarEntry(name));
+		jarOutputStream.write(new byte[] { (byte) data });
+		jarOutputStream.closeEntry();
+	}
+
+	public static File getSigned() {
+		String[] entries = System.getProperty("java.class.path").split(System.getProperty("path.separator"));
+		for (String entry : entries) {
+			if (entry.contains("bcprov")) {
+				return new File(entry);
+			}
+		}
+		return null;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/AssertFileChannelDataBlocksClosed.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/AssertFileChannelDataBlocksClosed.java
new file mode 100644
index 000000000000..75c208e5853f
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/AssertFileChannelDataBlocksClosed.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.junit.jupiter.api.extension.ExtendWith;
+
+/**
+ * Annotation that can be added to tests to assert that {@link FileChannelDataBlock} files
+ * are not left open.
+ *
+ * @author Phillip Webb
+ */
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@ExtendWith(AssertFileChannelDataBlocksClosedExtension.class)
+public @interface AssertFileChannelDataBlocksClosed {
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/AssertFileChannelDataBlocksClosedExtension.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/AssertFileChannelDataBlocksClosedExtension.java
new file mode 100644
index 000000000000..997e0e02c50c
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/AssertFileChannelDataBlocksClosedExtension.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.lang.ref.Cleaner.Cleanable;
+import java.nio.channels.FileChannel;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.junit.jupiter.api.extension.AfterEachCallback;
+import org.junit.jupiter.api.extension.BeforeEachCallback;
+import org.junit.jupiter.api.extension.ExtensionContext;
+
+import org.springframework.boot.loader.ref.DefaultCleanerTracking;
+import org.springframework.boot.loader.zip.FileChannelDataBlock.Tracker;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Extension for {@link AssertFileChannelDataBlocksClosed @TrackFileChannelDataBlock}.
+ */
+class AssertFileChannelDataBlocksClosedExtension implements BeforeEachCallback, AfterEachCallback {
+
+	private static OpenFilesTracker tracker = new OpenFilesTracker();
+
+	@Override
+	public void beforeEach(ExtensionContext context) throws Exception {
+		tracker.clear();
+		FileChannelDataBlock.tracker = tracker;
+		DefaultCleanerTracking.set(tracker::addedCleanable);
+	}
+
+	@Override
+	public void afterEach(ExtensionContext context) throws Exception {
+		tracker.assertAllClosed();
+		FileChannelDataBlock.tracker = null;
+	}
+
+	private static class OpenFilesTracker implements Tracker {
+
+		private final Set<Path> paths = new LinkedHashSet<>();
+
+		private final List<Cleanable> clean = new ArrayList<>();
+
+		private final List<Closeable> close = new ArrayList<>();
+
+		@Override
+		public void openedFileChannel(Path path, FileChannel fileChannel) {
+			this.paths.add(path);
+		}
+
+		@Override
+		public void closedFileChannel(Path path, FileChannel fileChannel) {
+			this.paths.remove(path);
+		}
+
+		void clear() {
+			this.paths.clear();
+			this.clean.clear();
+		}
+
+		void assertAllClosed() throws IOException {
+			for (Closeable closeable : this.close) {
+				closeable.close();
+			}
+			this.clean.forEach(Cleanable::clean);
+			assertThat(this.paths).as("open paths").isEmpty();
+		}
+
+		private void addedCleanable(Object obj, Cleanable cleanable) {
+			if (cleanable != null) {
+				this.clean.add(cleanable);
+			}
+			if (obj instanceof Closeable closeable) {
+				this.close.add(closeable);
+			}
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ByteArrayDataBlockTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ByteArrayDataBlockTests.java
new file mode 100644
index 000000000000..7c78ec4276fb
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ByteArrayDataBlockTests.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.nio.ByteBuffer;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ByteArrayDataBlock}.
+ *
+ * @author Phillip Webb
+ */
+class ByteArrayDataBlockTests {
+
+	private final byte[] BYTES = { 0, 1, 2, 3, 4, 5, 6, 7 };
+
+	@Test
+	void sizeReturnsByteArrayLength() throws Exception {
+		ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(this.BYTES);
+		assertThat(dataBlock.size()).isEqualTo(this.BYTES.length);
+	}
+
+	@Test
+	void readPutsBytes() throws Exception {
+		ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(this.BYTES);
+		ByteBuffer dst = ByteBuffer.allocate(8);
+		int result = dataBlock.read(dst, 0);
+		assertThat(result).isEqualTo(8);
+		assertThat(dst.array()).containsExactly(this.BYTES);
+	}
+
+	@Test
+	void readWhenLessBytesThanRemainingInBufferPutsBytes() throws Exception {
+		ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(this.BYTES);
+		ByteBuffer dst = ByteBuffer.allocate(9);
+		int result = dataBlock.read(dst, 0);
+		assertThat(result).isEqualTo(8);
+		assertThat(dst.array()).containsExactly(0, 1, 2, 3, 4, 5, 6, 7, 0);
+	}
+
+	@Test
+	void readWhenLessRemainingInBufferThanLengthPutsBytes() throws Exception {
+		ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(this.BYTES);
+		ByteBuffer dst = ByteBuffer.allocate(7);
+		int result = dataBlock.read(dst, 0);
+		assertThat(result).isEqualTo(7);
+		assertThat(dst.array()).containsExactly(0, 1, 2, 3, 4, 5, 6);
+	}
+
+	@Test
+	void readWhenHasPosOffsetReadsBytes() throws Exception {
+		ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(this.BYTES);
+		ByteBuffer dst = ByteBuffer.allocate(3);
+		int result = dataBlock.read(dst, 4);
+		assertThat(result).isEqualTo(3);
+		assertThat(dst.array()).containsExactly(4, 5, 6);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/DataBlockInputStreamTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/DataBlockInputStreamTests.java
new file mode 100644
index 000000000000..d5330ba9bed3
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/DataBlockInputStreamTests.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIOException;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+
+/**
+ * Tests for {@link DataBlockInputStream}.
+ *
+ * @author Phillip Webb
+ */
+class DataBlockInputStreamTests {
+
+	private ByteArrayDataBlock dataBlock;
+
+	private InputStream inputStream;
+
+	@BeforeEach
+	void setup() throws Exception {
+		this.dataBlock = new ByteArrayDataBlock(new byte[] { 0, 1, 2 });
+		this.inputStream = this.dataBlock.asInputStream();
+	}
+
+	@Test
+	void readSingleByteReadsByte() throws Exception {
+		assertThat(this.inputStream.read()).isEqualTo(0);
+		assertThat(this.inputStream.read()).isEqualTo(1);
+		assertThat(this.inputStream.read()).isEqualTo(2);
+		assertThat(this.inputStream.read()).isEqualTo(-1);
+	}
+
+	@Test
+	void readByteArrayWhenNotOpenThrowsException() throws Exception {
+		byte[] bytes = new byte[10];
+		this.inputStream.close();
+		assertThatIOException().isThrownBy(() -> this.inputStream.read(bytes)).withMessage("InputStream closed");
+	}
+
+	@Test
+	void readByteArrayWhenReadingMultipleTimesReadsBytes() throws Exception {
+		byte[] bytes = new byte[3];
+		assertThat(this.inputStream.read(bytes, 0, 2)).isEqualTo(2);
+		assertThat(this.inputStream.read(bytes, 2, 1)).isEqualTo(1);
+		assertThat(bytes).containsExactly(0, 1, 2);
+	}
+
+	@Test
+	void readByteArrayWhenReadingMoreThanAvailableReadsRemainingBytes() throws Exception {
+		byte[] bytes = new byte[5];
+		assertThat(this.inputStream.read(bytes, 0, 5)).isEqualTo(3);
+		assertThat(bytes).containsExactly(0, 1, 2, 0, 0);
+	}
+
+	@Test
+	void skipSkipsBytes() throws Exception {
+		assertThat(this.inputStream.skip(2)).isEqualTo(2);
+		assertThat(this.inputStream.read()).isEqualTo(2);
+		assertThat(this.inputStream.read()).isEqualTo(-1);
+	}
+
+	@Test
+	void skipWhenSkippingMoreThanRemainingSkipsBytes() throws Exception {
+		assertThat(this.inputStream.skip(100)).isEqualTo(3);
+		assertThat(this.inputStream.read()).isEqualTo(-1);
+	}
+
+	@Test
+	void skipBackwardsSkipsBytes() throws IOException {
+		assertThat(this.inputStream.skip(2)).isEqualTo(2);
+		assertThat(this.inputStream.skip(-1)).isEqualTo(-1);
+		assertThat(this.inputStream.read()).isEqualTo(1);
+	}
+
+	@Test
+	void skipBackwardsPastBeginningSkipsBytes() throws Exception {
+		assertThat(this.inputStream.skip(1)).isEqualTo(1);
+		assertThat(this.inputStream.skip(-100)).isEqualTo(-1);
+		assertThat(this.inputStream.read()).isEqualTo(0);
+	}
+
+	@Test
+	void availableReturnsRemainingBytes() throws IOException {
+		assertThat(this.inputStream.available()).isEqualTo(3);
+		this.inputStream.read();
+		assertThat(this.inputStream.available()).isEqualTo(2);
+		this.inputStream.skip(1);
+		assertThat(this.inputStream.available()).isEqualTo(1);
+	}
+
+	@Test
+	void availableWhenClosedReturnsZero() throws IOException {
+		this.inputStream.close();
+		assertThat(this.inputStream.available()).isZero();
+	}
+
+	@Test
+	void closeClosesDataBlock() throws Exception {
+		this.dataBlock = spy(new ByteArrayDataBlock(new byte[] { 0, 1, 2 }));
+		this.inputStream = this.dataBlock.asInputStream();
+		this.inputStream.close();
+		then(this.dataBlock).should().close();
+	}
+
+	@Test
+	void closeMultipleTimesClosesDataBlockOnce() throws Exception {
+		this.dataBlock = spy(new ByteArrayDataBlock(new byte[] { 0, 1, 2 }));
+		this.inputStream = this.dataBlock.asInputStream();
+		this.inputStream.close();
+		this.inputStream.close();
+		then(this.dataBlock).should(times(1)).close();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/DataBlockTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/DataBlockTests.java
new file mode 100644
index 000000000000..eed800f981b3
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/DataBlockTests.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.stubbing.Answer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.withSettings;
+
+/**
+ * Tests for {@link DataBlock}.
+ *
+ * @author Phillip Webb
+ */
+class DataBlockTests {
+
+	@Test
+	void readFullyReadsAllBytesByCallingReadMultipleTimes() throws IOException {
+		DataBlock dataBlock = mock(DataBlock.class, withSettings().defaultAnswer(CALLS_REAL_METHODS));
+		given(dataBlock.read(any(), anyLong()))
+			.will(putBytes(new byte[] { 0, 1 }, new byte[] { 2 }, new byte[] { 3, 4, 5 }));
+		ByteBuffer dst = ByteBuffer.allocate(6);
+		dataBlock.readFully(dst, 0);
+		assertThat(dst.array()).containsExactly(0, 1, 2, 3, 4, 5);
+	}
+
+	private Answer<?> putBytes(byte[]... bytes) {
+		AtomicInteger count = new AtomicInteger();
+		return (invocation) -> {
+			int index = count.getAndIncrement();
+			invocation.getArgument(0, ByteBuffer.class).put(bytes[index]);
+			return bytes.length;
+		};
+	}
+
+	@Test
+	void readFullyWhenReadReturnsNegativeResultThrowsException() throws Exception {
+		DataBlock dataBlock = mock(DataBlock.class, withSettings().defaultAnswer(CALLS_REAL_METHODS));
+		given(dataBlock.read(any(), anyLong())).willReturn(-1);
+		ByteBuffer dst = ByteBuffer.allocate(8);
+		assertThatExceptionOfType(EOFException.class).isThrownBy(() -> dataBlock.readFully(dst, 0));
+	}
+
+	@Test
+	void asInputStreamReturnsDataBlockInputStream() throws Exception {
+		DataBlock dataBlock = mock(DataBlock.class, withSettings().defaultAnswer(CALLS_REAL_METHODS));
+		assertThat(dataBlock.asInputStream()).isInstanceOf(DataBlockInputStream.class);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/FileChannelDataBlockTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/FileChannelDataBlockTests.java
new file mode 100644
index 000000000000..df015ff68d55
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/FileChannelDataBlockTests.java
@@ -0,0 +1,262 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.ClosedByInterruptException;
+import java.nio.channels.FileChannel;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.springframework.boot.loader.zip.FileChannelDataBlock.Tracker;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link FileChannelDataBlock}.
+ *
+ * @author Phillip Webb
+ */
+class FileChannelDataBlockTests {
+
+	private static final byte[] CONTENT = new byte[] { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05 };
+
+	@TempDir
+	File tempDir;
+
+	File tempFile;
+
+	@BeforeEach
+	void writeTempFile() throws IOException {
+		this.tempFile = new File(this.tempDir, "content");
+		Files.write(this.tempFile.toPath(), CONTENT);
+	}
+
+	@AfterEach
+	void resetTracker() {
+		FileChannelDataBlock.tracker = null;
+	}
+
+	@Test
+	void sizeReturnsFileSize() throws IOException {
+		try (FileChannelDataBlock block = createAndOpenBlock()) {
+			assertThat(block.size()).isEqualTo(CONTENT.length);
+		}
+	}
+
+	@Test
+	void readReadsFile() throws IOException {
+		try (FileChannelDataBlock block = createAndOpenBlock()) {
+			ByteBuffer buffer = ByteBuffer.allocate(CONTENT.length);
+			assertThat(block.read(buffer, 0)).isEqualTo(6);
+			assertThat(buffer.array()).containsExactly(CONTENT);
+		}
+	}
+
+	@Test
+	void readReadsFileWhenAnotherThreadHasBeenInterrupted() throws IOException, InterruptedException {
+		try (FileChannelDataBlock block = createAndOpenBlock()) {
+			ByteBuffer buffer = ByteBuffer.allocate(CONTENT.length);
+			AtomicReference<IOException> failure = new AtomicReference<>();
+			Thread thread = new Thread(() -> {
+				Thread.currentThread().interrupt();
+				try {
+					block.read(ByteBuffer.allocate(CONTENT.length), 0);
+				}
+				catch (IOException ex) {
+					failure.set(ex);
+				}
+			});
+			thread.start();
+			thread.join();
+			assertThat(failure.get()).isInstanceOf(ClosedByInterruptException.class);
+			assertThat(block.read(buffer, 0)).isEqualTo(6);
+			assertThat(buffer.array()).containsExactly(CONTENT);
+		}
+	}
+
+	@Test
+	void readDoesNotReadPastEndOfFile() throws IOException {
+		try (FileChannelDataBlock block = createAndOpenBlock()) {
+			ByteBuffer buffer = ByteBuffer.allocate(CONTENT.length);
+			assertThat(block.read(buffer, 2)).isEqualTo(4);
+			assertThat(buffer.array()).containsExactly(0x02, 0x03, 0x04, 0x05, 0x0, 0x0);
+		}
+	}
+
+	@Test
+	void readWhenPosAtSizeReturnsMinusOne() throws IOException {
+		try (FileChannelDataBlock block = createAndOpenBlock()) {
+			ByteBuffer buffer = ByteBuffer.allocate(CONTENT.length);
+			assertThat(block.read(buffer, 6)).isEqualTo(-1);
+		}
+	}
+
+	@Test
+	void readWhenPosOverSizeReturnsMinusOne() throws IOException {
+		try (FileChannelDataBlock block = createAndOpenBlock()) {
+			ByteBuffer buffer = ByteBuffer.allocate(CONTENT.length);
+			assertThat(block.read(buffer, 7)).isEqualTo(-1);
+		}
+	}
+
+	@Test
+	void readWhenPosIsNegativeThrowsException() throws IOException {
+		try (FileChannelDataBlock block = createAndOpenBlock()) {
+			ByteBuffer buffer = ByteBuffer.allocate(CONTENT.length);
+			assertThatIllegalArgumentException().isThrownBy(() -> block.read(buffer, -1));
+		}
+	}
+
+	@Test
+	void sliceWhenOffsetIsNegativeThrowsException() throws IOException {
+		try (FileChannelDataBlock block = createAndOpenBlock()) {
+			assertThatIllegalArgumentException().isThrownBy(() -> block.slice(-1, 0))
+				.withMessage("Offset must not be negative");
+		}
+	}
+
+	@Test
+	void sliceWhenSizeIsNegativeThrowsException() throws IOException {
+		try (FileChannelDataBlock block = createAndOpenBlock()) {
+			assertThatIllegalArgumentException().isThrownBy(() -> block.slice(0, -1))
+				.withMessage("Size must not be negative and must be within bounds");
+		}
+	}
+
+	@Test
+	void sliceWhenSizeIsOutOfBoundsThrowsException() throws IOException {
+		try (FileChannelDataBlock block = createAndOpenBlock()) {
+			assertThatIllegalArgumentException().isThrownBy(() -> block.slice(2, 5))
+				.withMessage("Size must not be negative and must be within bounds");
+		}
+	}
+
+	@Test
+	void sliceReturnsSlice() throws IOException {
+		try (FileChannelDataBlock slice = createAndOpenBlock().slice(1, 4)) {
+			assertThat(slice.size()).isEqualTo(4);
+			ByteBuffer buffer = ByteBuffer.allocate(4);
+			assertThat(slice.read(buffer, 0)).isEqualTo(4);
+			assertThat(buffer.array()).containsExactly(0x01, 0x02, 0x03, 0x04);
+		}
+	}
+
+	@Test
+	void openAndCloseHandleReferenceCounting() throws IOException {
+		TestTracker tracker = new TestTracker();
+		FileChannelDataBlock.tracker = tracker;
+		FileChannelDataBlock block = createBlock();
+		assertThat(block).extracting("channel.referenceCount").isEqualTo(0);
+		tracker.assertOpenCloseCounts(0, 0);
+		block.open();
+		assertThat(block).extracting("channel.referenceCount").isEqualTo(1);
+		tracker.assertOpenCloseCounts(1, 0);
+		block.open();
+		assertThat(block).extracting("channel.referenceCount").isEqualTo(2);
+		tracker.assertOpenCloseCounts(1, 0);
+		block.close();
+		assertThat(block).extracting("channel.referenceCount").isEqualTo(1);
+		tracker.assertOpenCloseCounts(1, 0);
+		block.close();
+		assertThat(block).extracting("channel.referenceCount").isEqualTo(0);
+		tracker.assertOpenCloseCounts(1, 1);
+		block.open();
+		assertThat(block).extracting("channel.referenceCount").isEqualTo(1);
+		tracker.assertOpenCloseCounts(2, 1);
+		block.close();
+		assertThat(block).extracting("channel.referenceCount").isEqualTo(0);
+		tracker.assertOpenCloseCounts(2, 2);
+	}
+
+	@Test
+	void openAndCloseSliceHandleReferenceCounting() throws IOException {
+		TestTracker tracker = new TestTracker();
+		FileChannelDataBlock.tracker = tracker;
+		FileChannelDataBlock block = createBlock();
+		FileChannelDataBlock slice = block.slice(1, 4);
+		assertThat(block).extracting("channel.referenceCount").isEqualTo(0);
+		tracker.assertOpenCloseCounts(0, 0);
+		block.open();
+		assertThat(block).extracting("channel.referenceCount").isEqualTo(1);
+		tracker.assertOpenCloseCounts(1, 0);
+		slice.open();
+		assertThat(slice).extracting("channel.referenceCount").isEqualTo(2);
+		tracker.assertOpenCloseCounts(1, 0);
+		slice.open();
+		assertThat(slice).extracting("channel.referenceCount").isEqualTo(3);
+		tracker.assertOpenCloseCounts(1, 0);
+		slice.close();
+		assertThat(slice).extracting("channel.referenceCount").isEqualTo(2);
+		tracker.assertOpenCloseCounts(1, 0);
+		slice.close();
+		assertThat(slice).extracting("channel.referenceCount").isEqualTo(1);
+		tracker.assertOpenCloseCounts(1, 0);
+		block.close();
+		assertThat(block).extracting("channel.referenceCount").isEqualTo(0);
+		tracker.assertOpenCloseCounts(1, 1);
+		slice.open();
+		assertThat(slice).extracting("channel.referenceCount").isEqualTo(1);
+		tracker.assertOpenCloseCounts(2, 1);
+		slice.close();
+		assertThat(slice).extracting("channel.referenceCount").isEqualTo(0);
+		tracker.assertOpenCloseCounts(2, 2);
+	}
+
+	private FileChannelDataBlock createAndOpenBlock() throws IOException {
+		FileChannelDataBlock block = createBlock();
+		block.open();
+		return block;
+	}
+
+	private FileChannelDataBlock createBlock() throws IOException {
+		return new FileChannelDataBlock(this.tempFile.toPath());
+	}
+
+	static class TestTracker implements Tracker {
+
+		private int openCount;
+
+		private int closeCount;
+
+		@Override
+		public void openedFileChannel(Path path, FileChannel fileChannel) {
+			this.openCount++;
+		}
+
+		@Override
+		public void closedFileChannel(Path path, FileChannel fileChannel) {
+			this.closeCount++;
+		}
+
+		void assertOpenCloseCounts(int expectedOpenCount, int expectedCloseCount) {
+			assertThat(this.openCount).as("openCount").isEqualTo(expectedOpenCount);
+			assertThat(this.closeCount).as("closeCount").isEqualTo(expectedCloseCount);
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/VirtualDataBlockTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/VirtualDataBlockTests.java
new file mode 100644
index 000000000000..c2b8c8338392
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/VirtualDataBlockTests.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link VirtualDataBlock}.
+ *
+ * @author Phillip Webb
+ */
+class VirtualDataBlockTests {
+
+	private VirtualDataBlock virtualDataBlock;
+
+	@BeforeEach
+	void setup() throws IOException {
+		List<DataBlock> subsections = new ArrayList<>();
+		subsections.add(new ByteArrayDataBlock("abc".getBytes(StandardCharsets.UTF_8)));
+		subsections.add(new ByteArrayDataBlock("defg".getBytes(StandardCharsets.UTF_8)));
+		subsections.add(new ByteArrayDataBlock("h".getBytes(StandardCharsets.UTF_8)));
+		this.virtualDataBlock = new VirtualDataBlock(subsections);
+	}
+
+	@Test
+	void sizeReturnsSize() throws IOException {
+		assertThat(this.virtualDataBlock.size()).isEqualTo(8);
+	}
+
+	@Test
+	void readFullyReadsAllBlocks() throws IOException {
+		ByteBuffer dst = ByteBuffer.allocate((int) this.virtualDataBlock.size());
+		this.virtualDataBlock.readFully(dst, 0);
+		assertThat(dst.array()).containsExactly("abcdefgh".getBytes(StandardCharsets.UTF_8));
+	}
+
+	@Test
+	void readWithShortBlock() throws IOException {
+		ByteBuffer dst = ByteBuffer.allocate(2);
+		assertThat(this.virtualDataBlock.read(dst, 1)).isEqualTo(2);
+		assertThat(dst.array()).containsExactly("bc".getBytes(StandardCharsets.UTF_8));
+	}
+
+	@Test
+	void readWithShortBlockAcrossSubsections() throws IOException {
+		ByteBuffer dst = ByteBuffer.allocate(3);
+		assertThat(this.virtualDataBlock.read(dst, 2)).isEqualTo(3);
+		assertThat(dst.array()).containsExactly("cde".getBytes(StandardCharsets.UTF_8));
+	}
+
+	@Test
+	void readWithBigBlock() throws IOException {
+		ByteBuffer dst = ByteBuffer.allocate(16);
+		assertThat(this.virtualDataBlock.read(dst, 1)).isEqualTo(7);
+		assertThat(dst.array()).startsWith("bcdefgh".getBytes(StandardCharsets.UTF_8));
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/VirtualZipDataBlockTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/VirtualZipDataBlockTests.java
new file mode 100644
index 000000000000..33f93bfb0f02
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/VirtualZipDataBlockTests.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.springframework.boot.loader.testsupport.TestJar;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+/**
+ * Tests for {@link VirtualZipDataBlock}.
+ *
+ * @author Phillip Webb
+ */
+@AssertFileChannelDataBlocksClosed
+class VirtualZipDataBlockTests {
+
+	@TempDir
+	File tempDir;
+
+	private File file;
+
+	@BeforeEach
+	void setup() throws Exception {
+		this.file = new File(this.tempDir, "test.jar");
+		TestJar.create(this.file);
+	}
+
+	@Test
+	void createContainsValidZipContent() throws IOException {
+		FileChannelDataBlock data = new FileChannelDataBlock(this.file.toPath());
+		data.open();
+		List<ZipCentralDirectoryFileHeaderRecord> centralRecords = new ArrayList<>();
+		List<Long> centralRecordPositions = new ArrayList<>();
+		ZipEndOfCentralDirectoryRecord eocd = ZipEndOfCentralDirectoryRecord.load(data).endOfCentralDirectoryRecord();
+		long pos = eocd.offsetToStartOfCentralDirectory();
+		for (int i = 0; i < eocd.totalNumberOfCentralDirectoryEntries(); i++) {
+			ZipCentralDirectoryFileHeaderRecord centralRecord = ZipCentralDirectoryFileHeaderRecord.load(data, pos);
+			String name = ZipString.readString(data, pos + ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET,
+					centralRecord.fileNameLength());
+			if (name.endsWith(".jar")) {
+				centralRecords.add(centralRecord);
+				centralRecordPositions.add(pos);
+			}
+			pos += centralRecord.size();
+		}
+		NameOffsetLookups nameOffsetLookups = new NameOffsetLookups(2, centralRecords.size());
+		for (int i = 0; i < centralRecords.size(); i++) {
+			nameOffsetLookups.enable(i, true);
+		}
+		nameOffsetLookups.enable(0, true);
+		File outputFile = new File(this.tempDir, "out.jar");
+		try (VirtualZipDataBlock block = new VirtualZipDataBlock(data, nameOffsetLookups,
+				centralRecords.toArray(ZipCentralDirectoryFileHeaderRecord[]::new),
+				centralRecordPositions.stream().mapToLong(Long::longValue).toArray())) {
+			try (FileOutputStream out = new FileOutputStream(outputFile)) {
+				block.asInputStream().transferTo(out);
+			}
+		}
+		try (FileSystem fileSystem = FileSystems.newFileSystem(outputFile.toPath())) {
+			assertThatExceptionOfType(NoSuchFileException.class)
+				.isThrownBy(() -> Files.size(fileSystem.getPath("nessted.jar")));
+			assertThat(Files.size(fileSystem.getPath("sted.jar"))).isGreaterThan(0);
+			assertThat(Files.size(fileSystem.getPath("other-nested.jar"))).isGreaterThan(0);
+			assertThat(Files.size(fileSystem.getPath("ace nested.jar"))).isGreaterThan(0);
+			assertThat(Files.size(fileSystem.getPath("lti-release.jar"))).isGreaterThan(0);
+		}
+	}
+
+	@Test // gh-38063
+	void createWithDescriptorRecordContainsValidZipContent() throws Exception {
+		try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(this.file))) {
+			ZipEntry entry = new ZipEntry("META-INF/");
+			entry.setMethod(ZipEntry.DEFLATED);
+			zip.putNextEntry(entry);
+			zip.write(new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8 });
+			zip.closeEntry();
+		}
+		byte[] bytes = Files.readAllBytes(this.file.toPath());
+		CloseableDataBlock data = new ByteArrayDataBlock(bytes);
+		List<ZipCentralDirectoryFileHeaderRecord> centralRecords = new ArrayList<>();
+		List<Long> centralRecordPositions = new ArrayList<>();
+		ZipEndOfCentralDirectoryRecord eocd = ZipEndOfCentralDirectoryRecord.load(data).endOfCentralDirectoryRecord();
+		long pos = eocd.offsetToStartOfCentralDirectory();
+		for (int i = 0; i < eocd.totalNumberOfCentralDirectoryEntries(); i++) {
+			ZipCentralDirectoryFileHeaderRecord centralRecord = ZipCentralDirectoryFileHeaderRecord.load(data, pos);
+			centralRecords.add(centralRecord);
+			centralRecordPositions.add(pos);
+			pos += centralRecord.size();
+		}
+		NameOffsetLookups nameOffsetLookups = new NameOffsetLookups(0, centralRecords.size());
+		for (int i = 0; i < centralRecords.size(); i++) {
+			nameOffsetLookups.enable(i, true);
+		}
+		nameOffsetLookups.enable(0, true);
+		File outputFile = new File(this.tempDir, "out.jar");
+		try (VirtualZipDataBlock block = new VirtualZipDataBlock(data, nameOffsetLookups,
+				centralRecords.toArray(ZipCentralDirectoryFileHeaderRecord[]::new),
+				centralRecordPositions.stream().mapToLong(Long::longValue).toArray())) {
+			try (FileOutputStream out = new FileOutputStream(outputFile)) {
+				block.asInputStream().transferTo(out);
+			}
+		}
+		byte[] virtualBytes = Files.readAllBytes(outputFile.toPath());
+		assertThat(bytes).isEqualTo(virtualBytes);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryLocatorTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryLocatorTests.java
new file mode 100644
index 000000000000..78b5a00498ce
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryLocatorTests.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link Zip64EndOfCentralDirectoryLocator}.
+ *
+ * @author Phillip Webb
+ */
+class Zip64EndOfCentralDirectoryLocatorTests {
+
+	@Test
+	void findReturnsRecord() throws Exception {
+		DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { //
+				0x50, 0x4b, 0x06, 0x07, //
+				0x01, 0x00, 0x00, 0x00, //
+				0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+				0x03, 0x00, 0x00, 0x00 }); //
+		Zip64EndOfCentralDirectoryLocator eocd = Zip64EndOfCentralDirectoryLocator.find(dataBlock, 20);
+		assertThat(eocd.pos()).isEqualTo(0);
+		assertThat(eocd.numberOfThisDisk()).isEqualTo(1);
+		assertThat(eocd.offsetToZip64EndOfCentralDirectoryRecord()).isEqualTo(2);
+		assertThat(eocd.totalNumberOfDisks()).isEqualTo(3);
+	}
+
+	@Test
+	void findWhenSignatureDoesNotMatchReturnsNull() throws IOException {
+		DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { //
+				0x51, 0x4b, 0x06, 0x07, //
+				0x01, 0x00, 0x00, 0x00, //
+				0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+				0x03, 0x00, 0x00, 0x00 }); //
+		Zip64EndOfCentralDirectoryLocator eocd = Zip64EndOfCentralDirectoryLocator.find(dataBlock, 20);
+		assertThat(eocd).isNull();
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryRecordTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryRecordTests.java
new file mode 100644
index 000000000000..486d34970ddd
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryRecordTests.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIOException;
+
+/**
+ * Tests for {@link Zip64EndOfCentralDirectoryRecord}.
+ *
+ * @author Phillip Webb
+ */
+class Zip64EndOfCentralDirectoryRecordTests {
+
+	@Test
+	void loadLoadsData() throws Exception {
+		DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { //
+				0x50, 0x4b, 0x06, 0x06, //
+				0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+				0x02, 0x00, //
+				0x03, 0x00, //
+				0x04, 0x00, 0x00, 0x00, //
+				0x05, 0x00, 0x00, 0x00, //
+				0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+				0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+				0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+				0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }); //
+		Zip64EndOfCentralDirectoryLocator locator = new Zip64EndOfCentralDirectoryLocator(56, 0, 0, 0);
+		Zip64EndOfCentralDirectoryRecord eocd = Zip64EndOfCentralDirectoryRecord.load(dataBlock, locator);
+		assertThat(eocd.size()).isEqualTo(56);
+		assertThat(eocd.sizeOfZip64EndOfCentralDirectoryRecord()).isEqualTo(1);
+		assertThat(eocd.versionMadeBy()).isEqualTo((short) 2);
+		assertThat(eocd.versionNeededToExtract()).isEqualTo((short) 3);
+		assertThat(eocd.numberOfThisDisk()).isEqualTo(4);
+		assertThat(eocd.diskWhereCentralDirectoryStarts()).isEqualTo(5);
+		assertThat(eocd.numberOfCentralDirectoryEntriesOnThisDisk()).isEqualTo(6);
+		assertThat(eocd.totalNumberOfCentralDirectoryEntries()).isEqualTo(7);
+		assertThat(eocd.sizeOfCentralDirectory()).isEqualTo(8);
+		assertThat(eocd.offsetToStartOfCentralDirectory());
+	}
+
+	@Test
+	void loadWhenSignatureDoesNotMatchThrowsException() {
+		DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { //
+				0x51, 0x4b, 0x06, 0x06, //
+				0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+				0x02, 0x00, //
+				0x03, 0x00, //
+				0x04, 0x00, 0x00, 0x00, //
+				0x05, 0x00, 0x00, 0x00, //
+				0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+				0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+				0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+				0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }); //
+		Zip64EndOfCentralDirectoryLocator locator = new Zip64EndOfCentralDirectoryLocator(56, 0, 0, 0);
+		assertThatIOException().isThrownBy(() -> Zip64EndOfCentralDirectoryRecord.load(dataBlock, locator))
+			.withMessageContaining("Zip64 'End Of Central Directory Record' not found at position");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipCentralDirectoryFileHeaderRecordTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipCentralDirectoryFileHeaderRecordTests.java
new file mode 100644
index 000000000000..5fe7e9ee8897
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipCentralDirectoryFileHeaderRecordTests.java
@@ -0,0 +1,216 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.zip.ZipEntry;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIOException;
+
+/**
+ * Tests for {@link ZipCentralDirectoryFileHeaderRecord}.
+ *
+ * @author Phillip Webb
+ */
+class ZipCentralDirectoryFileHeaderRecordTests {
+
+	@Test
+	void loadLoadsData() throws Exception {
+		DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { //
+				0x50, 0x4b, 0x01, 0x02, //
+				0x01, 0x00, //
+				0x02, 0x00, //
+				0x03, 0x00, //
+				0x04, 0x00, //
+				0x05, 0x00, //
+				0x06, 0x00, //
+				0x07, 0x00, 0x00, 0x00, //
+				0x08, 0x00, 0x00, 0x00, //
+				0x09, 0x00, 0x00, 0x00, //
+				0x0A, 0x00, //
+				0x0B, 0x00, //
+				0x0C, 0x00, //
+				0x0D, 0x00, //
+				0x0E, 0x00, //
+				0x0F, 0x00, 0x00, 0x00, //
+				0x10, 0x00, 0x00, 0x00 }); //
+		ZipCentralDirectoryFileHeaderRecord record = ZipCentralDirectoryFileHeaderRecord.load(dataBlock, 0);
+		assertThat(record.versionMadeBy()).isEqualTo((short) 1);
+		assertThat(record.versionNeededToExtract()).isEqualTo((short) 2);
+		assertThat(record.generalPurposeBitFlag()).isEqualTo((short) 3);
+		assertThat(record.compressionMethod()).isEqualTo((short) 4);
+		assertThat(record.lastModFileTime()).isEqualTo((short) 5);
+		assertThat(record.lastModFileDate()).isEqualTo((short) 6);
+		assertThat(record.crc32()).isEqualTo(7);
+		assertThat(record.compressedSize()).isEqualTo(8);
+		assertThat(record.uncompressedSize()).isEqualTo(9);
+		assertThat(record.fileNameLength()).isEqualTo((short) 10);
+		assertThat(record.extraFieldLength()).isEqualTo((short) 11);
+		assertThat(record.fileCommentLength()).isEqualTo((short) 12);
+		assertThat(record.diskNumberStart()).isEqualTo((short) 13);
+		assertThat(record.internalFileAttributes()).isEqualTo((short) 14);
+		assertThat(record.externalFileAttributes()).isEqualTo(15);
+		assertThat(record.offsetToLocalHeader()).isEqualTo(16);
+	}
+
+	@Test
+	void loadWhenSignatureDoesNotMatchThrowsException() {
+		DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { //
+				0x51, 0x4b, 0x01, 0x02, //
+				0x01, 0x00, //
+				0x02, 0x00, //
+				0x03, 0x00, //
+				0x04, 0x00, //
+				0x05, 0x00, //
+				0x06, 0x00, //
+				0x07, 0x00, 0x00, 0x00, //
+				0x08, 0x00, 0x00, 0x00, //
+				0x09, 0x00, 0x00, 0x00, //
+				0x0A, 0x00, //
+				0x0B, 0x00, //
+				0x0C, 0x00, //
+				0x0D, 0x00, //
+				0x0E, 0x00, //
+				0x0F, 0x00, 0x00, 0x00, //
+				0x10, 0x00, 0x00, 0x00 }); //
+		assertThatIOException().isThrownBy(() -> ZipCentralDirectoryFileHeaderRecord.load(dataBlock, 0))
+			.withMessageContaining("'Central Directory File Header Record' not found");
+	}
+
+	@Test
+	void sizeReturnsSize() {
+		ZipCentralDirectoryFileHeaderRecord record = new ZipCentralDirectoryFileHeaderRecord((short) 1, (short) 2,
+				(short) 3, (short) 4, (short) 5, (short) 6, 7, 8, 9, (short) 10, (short) 11, (short) 12, (short) 13,
+				(short) 14, 15, 16);
+		assertThat(record.size()).isEqualTo(79L);
+	}
+
+	@Test
+	void copyToCopiesDataToZipEntry() throws Exception {
+		DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { //
+				0x50, 0x4b, 0x01, 0x02, //
+				0x00, 0x00, //
+				0x00, 0x00, //
+				0x00, 0x00, //
+				0x08, 0x00, //
+				0x23, 0x74, //
+				0x58, 0x36, //
+				(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, //
+				0x01, 0x00, 0x00, 0x00, //
+				0x02, 0x00, 0x00, 0x00, //
+				0x01, 0x00, //
+				0x01, 0x00, //
+				0x01, 0x00, //
+				0x00, 0x00, //
+				0x00, 0x00, //
+				0x00, 0x00, 0x00, 0x00, //
+				0x00, 0x00, 0x00, 0x00, //
+				0x61, //
+				0x62, //
+				0x63 }); //
+		ZipCentralDirectoryFileHeaderRecord record = ZipCentralDirectoryFileHeaderRecord.load(dataBlock, 0);
+		ZipEntry entry = new ZipEntry("");
+		record.copyTo(dataBlock, 0, entry);
+		assertThat(entry.getMethod()).isEqualTo(ZipEntry.DEFLATED);
+		assertThat(entry.getTimeLocal()).hasYear(2007);
+		ZonedDateTime expectedTime = ZonedDateTime.of(2007, 02, 24, 14, 33, 06, 0, ZoneId.systemDefault());
+		assertThat(entry.getTime()).isEqualTo(expectedTime.toEpochSecond() * 1000);
+		assertThat(entry.getCrc()).isEqualTo(0xFFFFFFFFL);
+		assertThat(entry.getCompressedSize()).isEqualTo(1);
+		assertThat(entry.getSize()).isEqualTo(2);
+		assertThat(entry.getExtra()).containsExactly(0x62);
+		assertThat(entry.getComment()).isEqualTo("c");
+	}
+
+	@Test
+	void withFileNameLengthReturnsUpdatedInstance() {
+		ZipCentralDirectoryFileHeaderRecord record = new ZipCentralDirectoryFileHeaderRecord((short) 1, (short) 2,
+				(short) 3, (short) 4, (short) 5, (short) 6, 7, 8, 9, (short) 10, (short) 11, (short) 12, (short) 13,
+				(short) 14, 15, 16)
+			.withFileNameLength((short) 100);
+		assertThat(record.versionMadeBy()).isEqualTo((short) 1);
+		assertThat(record.versionNeededToExtract()).isEqualTo((short) 2);
+		assertThat(record.generalPurposeBitFlag()).isEqualTo((short) 3);
+		assertThat(record.compressionMethod()).isEqualTo((short) 4);
+		assertThat(record.lastModFileTime()).isEqualTo((short) 5);
+		assertThat(record.lastModFileDate()).isEqualTo((short) 6);
+		assertThat(record.crc32()).isEqualTo(7);
+		assertThat(record.compressedSize()).isEqualTo(8);
+		assertThat(record.uncompressedSize()).isEqualTo(9);
+		assertThat(record.fileNameLength()).isEqualTo((short) 100);
+		assertThat(record.extraFieldLength()).isEqualTo((short) 11);
+		assertThat(record.fileCommentLength()).isEqualTo((short) 12);
+		assertThat(record.diskNumberStart()).isEqualTo((short) 13);
+		assertThat(record.internalFileAttributes()).isEqualTo((short) 14);
+		assertThat(record.externalFileAttributes()).isEqualTo(15);
+		assertThat(record.offsetToLocalHeader()).isEqualTo(16);
+	}
+
+	@Test
+	void withOffsetToLocalHeaderReturnsUpdatedInstance() {
+		ZipCentralDirectoryFileHeaderRecord record = new ZipCentralDirectoryFileHeaderRecord((short) 1, (short) 2,
+				(short) 3, (short) 4, (short) 5, (short) 6, 7, 8, 9, (short) 10, (short) 11, (short) 12, (short) 13,
+				(short) 14, 15, 16)
+			.withOffsetToLocalHeader(100);
+		assertThat(record.versionMadeBy()).isEqualTo((short) 1);
+		assertThat(record.versionNeededToExtract()).isEqualTo((short) 2);
+		assertThat(record.generalPurposeBitFlag()).isEqualTo((short) 3);
+		assertThat(record.compressionMethod()).isEqualTo((short) 4);
+		assertThat(record.lastModFileTime()).isEqualTo((short) 5);
+		assertThat(record.lastModFileDate()).isEqualTo((short) 6);
+		assertThat(record.crc32()).isEqualTo(7);
+		assertThat(record.compressedSize()).isEqualTo(8);
+		assertThat(record.uncompressedSize()).isEqualTo(9);
+		assertThat(record.fileNameLength()).isEqualTo((short) 10);
+		assertThat(record.extraFieldLength()).isEqualTo((short) 11);
+		assertThat(record.fileCommentLength()).isEqualTo((short) 12);
+		assertThat(record.diskNumberStart()).isEqualTo((short) 13);
+		assertThat(record.internalFileAttributes()).isEqualTo((short) 14);
+		assertThat(record.externalFileAttributes()).isEqualTo(15);
+		assertThat(record.offsetToLocalHeader()).isEqualTo(100);
+	}
+
+	@Test
+	void asByteArrayReturnsByteArray() throws Exception {
+		byte[] bytes = new byte[] { //
+				0x50, 0x4b, 0x01, 0x02, //
+				0x01, 0x00, //
+				0x02, 0x00, //
+				0x03, 0x00, //
+				0x04, 0x00, //
+				0x05, 0x00, //
+				0x06, 0x00, //
+				0x07, 0x00, 0x00, 0x00, //
+				0x08, 0x00, 0x00, 0x00, //
+				0x09, 0x00, 0x00, 0x00, //
+				0x0A, 0x00, //
+				0x0B, 0x00, //
+				0x0C, 0x00, //
+				0x0D, 0x00, //
+				0x0E, 0x00, //
+				0x0F, 0x00, 0x00, 0x00, //
+				0x10, 0x00, 0x00, 0x00 };
+		DataBlock dataBlock = new ByteArrayDataBlock(bytes);
+		ZipCentralDirectoryFileHeaderRecord record = ZipCentralDirectoryFileHeaderRecord.load(dataBlock, 0);
+		assertThat(record.asByteArray()).containsExactly(bytes);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipContentTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipContentTests.java
new file mode 100644
index 000000000000..fe0e8bd54263
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipContentTests.java
@@ -0,0 +1,437 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.attribute.FileTime;
+import java.time.Instant;
+import java.util.Iterator;
+import java.util.Random;
+import java.util.jar.Manifest;
+import java.util.zip.CRC32;
+import java.util.zip.Inflater;
+import java.util.zip.InflaterInputStream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+import java.util.zip.ZipOutputStream;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.springframework.boot.loader.testsupport.TestJar;
+import org.springframework.boot.loader.zip.ZipContent.Entry;
+import org.springframework.util.FileCopyUtils;
+import org.springframework.util.StreamUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIOException;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+
+/**
+ * Tests for {@link ZipContent}.
+ *
+ * @author Phillip Webb
+ * @author Martin Lau
+ * @author Andy Wilkinson
+ * @author Madhura Bhave
+ */
+class ZipContentTests {
+
+	@TempDir
+	File tempDir;
+
+	private File file;
+
+	private ZipContent zipContent;
+
+	@BeforeEach
+	void setup() throws Exception {
+		this.file = new File(this.tempDir, "test.jar");
+		TestJar.create(this.file);
+		this.zipContent = ZipContent.open(this.file.toPath());
+	}
+
+	@AfterEach
+	void tearDown() throws Exception {
+		if (this.zipContent != null) {
+			try {
+				this.zipContent.close();
+			}
+			catch (IllegalStateException ex) {
+			}
+		}
+	}
+
+	@Test
+	void getCommentReturnsComment() {
+		assertThat(this.zipContent.getComment()).isEqualTo("outer");
+	}
+
+	@Test
+	void getCommentWhenClosedThrowsException() throws IOException {
+		this.zipContent.close();
+		assertThatIllegalStateException().isThrownBy(() -> this.zipContent.getComment())
+			.withMessage("Zip content closed");
+	}
+
+	@Test
+	void getEntryWhenPresentReturnsEntry() {
+		Entry entry = this.zipContent.getEntry("1.dat");
+		assertThat(entry).isNotNull();
+		assertThat(entry.getName()).isEqualTo("1.dat");
+	}
+
+	@Test
+	void getEntryWhenMissingReturnsNull() {
+		assertThat(this.zipContent.getEntry("missing.dat")).isNull();
+	}
+
+	@Test
+	void getEntryWithPrefixWhenPresentReturnsEntry() {
+		Entry entry = this.zipContent.getEntry("1", ".dat");
+		assertThat(entry).isNotNull();
+		assertThat(entry.getName()).isEqualTo("1.dat");
+	}
+
+	@Test
+	void getEntryWithLongPrefixWhenNameIsShorterReturnsNull() {
+		Entry entry = this.zipContent.getEntry("iamaverylongprefixandiwontfindanything", "1.dat");
+		assertThat(entry).isNull();
+	}
+
+	@Test
+	void getEntryWithPrefixWhenMissingReturnsNull() {
+		assertThat(this.zipContent.getEntry("miss", "ing.dat")).isNull();
+	}
+
+	@Test
+	void getEntryWhenUsingSlashesIsCompatibleWithZipFile() throws IOException {
+		try (ZipFile zipFile = new ZipFile(this.file)) {
+			assertThat(zipFile.getEntry("META-INF").getName()).isEqualTo("META-INF/");
+			assertThat(this.zipContent.getEntry("META-INF").getName()).isEqualTo("META-INF/");
+			assertThat(zipFile.getEntry("META-INF/").getName()).isEqualTo("META-INF/");
+			assertThat(this.zipContent.getEntry("META-INF/").getName()).isEqualTo("META-INF/");
+			assertThat(zipFile.getEntry("d/9.dat").getName()).isEqualTo("d/9.dat");
+			assertThat(this.zipContent.getEntry("d/9.dat").getName()).isEqualTo("d/9.dat");
+			assertThat(zipFile.getEntry("d/9.dat/")).isNull();
+			assertThat(this.zipContent.getEntry("d/9.dat/")).isNull();
+		}
+	}
+
+	@Test
+	void getManifestEntry() throws Exception {
+		Entry entry = this.zipContent.getEntry("META-INF/MANIFEST.MF");
+		try (CloseableDataBlock dataBlock = entry.openContent()) {
+			Manifest manifest = new Manifest(asInflaterInputStream(dataBlock));
+			assertThat(manifest.getMainAttributes().getValue("Built-By")).isEqualTo("j1");
+		}
+	}
+
+	@Test
+	void getEntryAsCreatesCompatibleEntries() throws IOException {
+		try (ZipFile zipFile = new ZipFile(this.file)) {
+			Iterator<? extends ZipEntry> expected = zipFile.entries().asIterator();
+			int i = 0;
+			while (expected.hasNext()) {
+				Entry actual = this.zipContent.getEntry(i++);
+				assertThatFieldsAreEqual(actual.as(ZipEntry::new), expected.next());
+			}
+		}
+	}
+
+	private void assertThatFieldsAreEqual(ZipEntry actual, ZipEntry expected) {
+		assertThat(actual.getName()).isEqualTo(expected.getName());
+		assertThat(actual.getTime()).isEqualTo(expected.getTime());
+		assertThat(actual.getLastModifiedTime()).isEqualTo(expected.getLastModifiedTime());
+		assertThat(actual.getLastAccessTime()).isEqualTo(expected.getLastAccessTime());
+		assertThat(actual.getCreationTime()).isEqualTo(expected.getCreationTime());
+		assertThat(actual.getSize()).isEqualTo(expected.getSize());
+		assertThat(actual.getCompressedSize()).isEqualTo(expected.getCompressedSize());
+		assertThat(actual.getCrc()).isEqualTo(expected.getCrc());
+		assertThat(actual.getMethod()).isEqualTo(expected.getMethod());
+		assertThat(actual.getExtra()).isEqualTo(expected.getExtra());
+		assertThat(actual.getComment()).isEqualTo(expected.getComment());
+	}
+
+	@Test
+	void sizeReturnsNumberOfEntries() {
+		assertThat(this.zipContent.size()).isEqualTo(12);
+	}
+
+	@Test
+	void nestedJarFileReturnsNestedJar() throws IOException {
+		try (ZipContent nested = ZipContent.open(this.file.toPath(), "nested.jar")) {
+			assertThat(nested.size()).isEqualTo(5);
+			assertThat(nested.getComment()).isEqualTo("nested");
+			assertThat(nested.size()).isEqualTo(5);
+			assertThat(nested.getEntry(0).getName()).isEqualTo("META-INF/");
+			assertThat(nested.getEntry(1).getName()).isEqualTo("META-INF/MANIFEST.MF");
+			assertThat(nested.getEntry(2).getName()).isEqualTo("3.dat");
+			assertThat(nested.getEntry(3).getName()).isEqualTo("4.dat");
+			assertThat(nested.getEntry(4).getName()).isEqualTo("\u00E4.dat");
+		}
+	}
+
+	@Test
+	void nestedJarFileWhenNameEndsInSlashThrowsException() {
+		assertThatIOException().isThrownBy(() -> ZipContent.open(this.file.toPath(), "nested.jar/"))
+			.withMessageStartingWith("Nested entry 'nested.jar/' not found in container zip");
+	}
+
+	@Test
+	void nestedDirectoryReturnsNestedJar() throws IOException {
+		try (ZipContent nested = ZipContent.open(this.file.toPath(), "d/")) {
+			assertThat(nested.size()).isEqualTo(3);
+			assertThat(nested.getEntry("9.dat")).isNotNull();
+			assertThat(nested.getEntry(0).getName()).isEqualTo("META-INF/");
+			assertThat(nested.getEntry(1).getName()).isEqualTo("META-INF/MANIFEST.MF");
+			assertThat(nested.getEntry(2).getName()).isEqualTo("9.dat");
+		}
+	}
+
+	@Test
+	void nestedDirectoryWhenNotEndingInSlashThrowsException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> ZipContent.open(this.file.toPath(), "d"))
+			.withMessage("Nested entry name must end with '/'");
+	}
+
+	@Test
+	void getDataWhenNestedDirectoryReturnsVirtualZipDataBlock() throws IOException {
+		try (ZipContent nested = ZipContent.open(this.file.toPath(), "d/")) {
+			File file = new File(this.tempDir, "included.zip");
+			write(file, nested.openRawZipData());
+			try (ZipFile loadedZipFile = new ZipFile(file)) {
+				assertThat(loadedZipFile.size()).isEqualTo(3);
+				assertThat(loadedZipFile.stream().map(ZipEntry::getName)).containsExactly("META-INF/",
+						"META-INF/MANIFEST.MF", "9.dat");
+				assertThat(loadedZipFile.getEntry("9.dat")).isNotNull();
+				try (InputStream in = loadedZipFile.getInputStream(loadedZipFile.getEntry("9.dat"))) {
+					ByteArrayOutputStream out = new ByteArrayOutputStream();
+					in.transferTo(out);
+					assertThat(out.toByteArray()).containsExactly(0x09);
+				}
+			}
+		}
+	}
+
+	@Test
+	void loadWhenHasFrontMatterOpensZip() throws IOException {
+		File fileWithFrontMatter = new File(this.tempDir, "withfrontmatter.jar");
+		FileOutputStream outputStream = new FileOutputStream(fileWithFrontMatter);
+		StreamUtils.copy("#/bin/bash", Charset.defaultCharset(), outputStream);
+		FileCopyUtils.copy(new FileInputStream(this.file), outputStream);
+		try (ZipContent zip = ZipContent.open(fileWithFrontMatter.toPath())) {
+			assertThat(zip.size()).isEqualTo(12);
+			assertThat(zip.getEntry(0).getName()).isEqualTo("META-INF/");
+			assertThat(zip.getEntry(1).getName()).isEqualTo("META-INF/MANIFEST.MF");
+			assertThat(zip.getEntry(2).getName()).isEqualTo("1.dat");
+			assertThat(zip.getEntry(3).getName()).isEqualTo("2.dat");
+			assertThat(zip.getEntry(4).getName()).isEqualTo("d/");
+			assertThat(zip.getEntry(5).getName()).isEqualTo("d/9.dat");
+			assertThat(zip.getEntry(6).getName()).isEqualTo("special/");
+			assertThat(zip.getEntry(7).getName()).isEqualTo("special/\u00EB.dat");
+			assertThat(zip.getEntry(8).getName()).isEqualTo("nested.jar");
+			assertThat(zip.getEntry(9).getName()).isEqualTo("another-nested.jar");
+			assertThat(zip.getEntry(10).getName()).isEqualTo("space nested.jar");
+			assertThat(zip.getEntry(11).getName()).isEqualTo("multi-release.jar");
+		}
+	}
+
+	@Test
+	void openWhenZip64ThatExceedsZipEntryLimitOpensZip() throws Exception {
+		File zip64File = new File(this.tempDir, "zip64.zip");
+		FileCopyUtils.copy(zip64Bytes(), zip64File);
+		try (ZipContent zip64Content = ZipContent.open(zip64File.toPath())) {
+			assertThat(zip64Content.size()).isEqualTo(65537);
+			for (int i = 0; i < zip64Content.size(); i++) {
+				Entry entry = zip64Content.getEntry(i);
+				try (CloseableDataBlock dataBlock = entry.openContent()) {
+					assertThat(asInflaterInputStream(dataBlock)).hasContent("Entry " + (i + 1));
+				}
+			}
+		}
+	}
+
+	@Test
+	void openWhenZip64ThatExceedsZipSizeLimitOpensZip() throws Exception {
+		Assumptions.assumeTrue(this.tempDir.getFreeSpace() > 6 * 1024 * 1024 * 1024, "Insufficient disk space");
+		File zip64File = new File(this.tempDir, "zip64.zip");
+		File entryFile = new File(this.tempDir, "entry.dat");
+		CRC32 crc32 = new CRC32();
+		try (FileOutputStream entryOut = new FileOutputStream(entryFile)) {
+			byte[] data = new byte[1024 * 1024];
+			new Random().nextBytes(data);
+			for (int i = 0; i < 1024; i++) {
+				entryOut.write(data);
+				crc32.update(data);
+			}
+		}
+		try (ZipOutputStream zipOutput = new ZipOutputStream(new FileOutputStream(zip64File))) {
+			for (int i = 0; i < 6; i++) {
+				ZipEntry storedEntry = new ZipEntry("huge-" + i);
+				storedEntry.setSize(entryFile.length());
+				storedEntry.setCompressedSize(entryFile.length());
+				storedEntry.setCrc(crc32.getValue());
+				storedEntry.setMethod(ZipEntry.STORED);
+				zipOutput.putNextEntry(storedEntry);
+				try (FileInputStream entryIn = new FileInputStream(entryFile)) {
+					StreamUtils.copy(entryIn, zipOutput);
+				}
+				zipOutput.closeEntry();
+			}
+		}
+		try (ZipContent zip64Content = ZipContent.open(zip64File.toPath())) {
+			assertThat(zip64Content.size()).isEqualTo(6);
+		}
+	}
+
+	@Test
+	void nestedZip64CanBeRead() throws Exception {
+		File containerFile = new File(this.tempDir, "outer.zip");
+		try (ZipOutputStream jarOutput = new ZipOutputStream(new FileOutputStream(containerFile))) {
+			ZipEntry nestedEntry = new ZipEntry("nested-zip64.zip");
+			byte[] contents = zip64Bytes();
+			nestedEntry.setSize(contents.length);
+			nestedEntry.setCompressedSize(contents.length);
+			CRC32 crc32 = new CRC32();
+			crc32.update(contents);
+			nestedEntry.setCrc(crc32.getValue());
+			nestedEntry.setMethod(ZipEntry.STORED);
+			jarOutput.putNextEntry(nestedEntry);
+			jarOutput.write(contents);
+			jarOutput.closeEntry();
+		}
+		try (ZipContent nestedZip = ZipContent.open(containerFile.toPath(), "nested-zip64.zip")) {
+			assertThat(nestedZip.size()).isEqualTo(65537);
+			for (int i = 0; i < nestedZip.size(); i++) {
+				Entry entry = nestedZip.getEntry(i);
+				try (CloseableDataBlock content = entry.openContent()) {
+					assertThat(asInflaterInputStream(content)).hasContent("Entry " + (i + 1));
+				}
+			}
+		}
+	}
+
+	private byte[] zip64Bytes() throws IOException {
+		ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+		ZipOutputStream zipOutput = new ZipOutputStream(bytes);
+		for (int i = 0; i < 65537; i++) {
+			zipOutput.putNextEntry(new ZipEntry(i + ".dat"));
+			zipOutput.write(("Entry " + (i + 1)).getBytes(StandardCharsets.UTF_8));
+			zipOutput.closeEntry();
+		}
+		zipOutput.close();
+		return bytes.toByteArray();
+	}
+
+	@Test
+	void entryWithEpochTimeOfZeroShouldNotFail() throws Exception {
+		File file = createZipFileWithEpochTimeOfZero();
+		try (ZipContent zip = ZipContent.open(file.toPath())) {
+			ZipEntry entry = zip.getEntry(0).as(ZipEntry::new);
+			assertThat(entry.getLastModifiedTime().toInstant()).isEqualTo(Instant.EPOCH);
+			assertThat(entry.getName()).isEqualTo("1.dat");
+		}
+	}
+
+	private File createZipFileWithEpochTimeOfZero() throws Exception {
+		File file = new File(this.tempDir, "temp.zip");
+		String comment = "outer";
+		try (ZipOutputStream zipOutput = new ZipOutputStream(new FileOutputStream(file))) {
+			zipOutput.setComment(comment);
+			ZipEntry entry = new ZipEntry("1.dat");
+			entry.setLastModifiedTime(FileTime.from(Instant.EPOCH));
+			zipOutput.putNextEntry(entry);
+			zipOutput.write(new byte[] { (byte) 1 });
+			zipOutput.closeEntry();
+		}
+		ByteBuffer data = ByteBuffer.wrap(Files.readAllBytes(file.toPath()));
+		data.order(ByteOrder.LITTLE_ENDIAN);
+		int endOfCentralDirectoryRecordPos = data.remaining() - ZipFile.ENDHDR - comment.getBytes().length;
+		data.position(endOfCentralDirectoryRecordPos + ZipFile.ENDOFF);
+		int startOfCentralDirectoryOffset = data.getInt();
+		data.position(startOfCentralDirectoryOffset + ZipFile.CENOFF);
+		int localHeaderPosition = data.getInt();
+		writeTimeBlock(data.array(), startOfCentralDirectoryOffset + ZipFile.CENTIM, 0);
+		writeTimeBlock(data.array(), localHeaderPosition + ZipFile.LOCTIM, 0);
+		File zerotimedFile = new File(this.tempDir, "zerotimed.zip");
+		Files.write(zerotimedFile.toPath(), data.array());
+		return zerotimedFile;
+	}
+
+	@Test
+	void getInfoReturnsComputedInfo() {
+		ZipInfo info = this.zipContent.getInfo(ZipInfo.class, ZipInfo::get);
+		assertThat(info.size()).isEqualTo(12);
+	}
+
+	private static void writeTimeBlock(byte[] data, int pos, int value) {
+		data[pos] = (byte) (value & 0xff);
+		data[pos + 1] = (byte) ((value >> 8) & 0xff);
+		data[pos + 2] = (byte) ((value >> 16) & 0xff);
+		data[pos + 3] = (byte) ((value >> 24) & 0xff);
+	}
+
+	private InputStream asInflaterInputStream(DataBlock dataBlock) throws IOException {
+		ByteBuffer buffer = ByteBuffer.allocate((int) dataBlock.size() + 1);
+		buffer.limit(buffer.limit() - 1);
+		dataBlock.readFully(buffer, 0);
+		ByteArrayInputStream in = new ByteArrayInputStream(buffer.array());
+		return new InflaterInputStream(in, new Inflater(true));
+	}
+
+	private void write(File file, CloseableDataBlock dataBlock) throws IOException {
+		ByteBuffer buffer = ByteBuffer.allocate((int) dataBlock.size());
+		dataBlock.readFully(buffer, 0);
+		Files.write(file.toPath(), buffer.array());
+		dataBlock.close();
+	}
+
+	private static class ZipInfo {
+
+		private int size;
+
+		ZipInfo(int size) {
+			this.size = size;
+		}
+
+		int size() {
+			return this.size;
+		}
+
+		static ZipInfo get(ZipContent content) {
+			return new ZipInfo(content.size());
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipDataDescriptorRecordTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipDataDescriptorRecordTests.java
new file mode 100644
index 000000000000..2af772eaccfc
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipDataDescriptorRecordTests.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ZipDataDescriptorRecord}.
+ *
+ * @author Phillip Webb
+ */
+class ZipDataDescriptorRecordTests {
+
+	private static final short S0 = 0;
+
+	@Test
+	void loadWhenHasSignatureLoadsData() throws Exception {
+		DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { //
+				0x50, 0x4b, 0x07, 0x08, //
+				0x01, 0x00, 0x00, 0x00, //
+				0x02, 0x00, 0x00, 0x00, //
+				0x03, 0x00, 0x00, 0x00 }); //
+		ZipDataDescriptorRecord record = ZipDataDescriptorRecord.load(dataBlock, 0);
+		assertThat(record.includeSignature()).isTrue();
+		assertThat(record.crc32()).isEqualTo(1);
+		assertThat(record.compressedSize()).isEqualTo(2);
+		assertThat(record.uncompressedSize()).isEqualTo(3);
+	}
+
+	@Test
+	void loadWhenHasNoSignatureLoadsData() throws Exception {
+		DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { //
+				0x01, 0x00, 0x00, 0x00, //
+				0x02, 0x00, 0x00, 0x00, //
+				0x03, 0x00, 0x00, 0x00 }); //
+		ZipDataDescriptorRecord record = ZipDataDescriptorRecord.load(dataBlock, 0);
+		assertThat(record.includeSignature()).isFalse();
+		assertThat(record.crc32()).isEqualTo(1);
+		assertThat(record.compressedSize()).isEqualTo(2);
+		assertThat(record.uncompressedSize()).isEqualTo(3);
+	}
+
+	@Test
+	void sizeWhenIncludeSignatureReturnsSize() {
+		ZipDataDescriptorRecord record = new ZipDataDescriptorRecord(true, 0, 0, 0);
+		assertThat(record.size()).isEqualTo(16);
+	}
+
+	@Test
+	void sizeWhenNotIncludeSignatureReturnsSize() {
+		ZipDataDescriptorRecord record = new ZipDataDescriptorRecord(false, 0, 0, 0);
+		assertThat(record.size()).isEqualTo(12);
+	}
+
+	@Test
+	void asByteArrayWhenIncludeSignatureReturnsByteArray() throws Exception {
+		byte[] bytes = new byte[] { //
+				0x50, 0x4b, 0x07, 0x08, //
+				0x01, 0x00, 0x00, 0x00, //
+				0x02, 0x00, 0x00, 0x00, //
+				0x03, 0x00, 0x00, 0x00 }; //
+		ZipDataDescriptorRecord record = ZipDataDescriptorRecord.load(new ByteArrayDataBlock(bytes), 0);
+		assertThat(record.asByteArray()).isEqualTo(bytes);
+	}
+
+	@Test
+	void asByteArrayWhenNotIncludeSignatureReturnsByteArray() throws Exception {
+		byte[] bytes = new byte[] { //
+				0x01, 0x00, 0x00, 0x00, //
+				0x02, 0x00, 0x00, 0x00, //
+				0x03, 0x00, 0x00, 0x00 }; //
+		ZipDataDescriptorRecord record = ZipDataDescriptorRecord.load(new ByteArrayDataBlock(bytes), 0);
+		assertThat(record.asByteArray()).isEqualTo(bytes);
+	}
+
+	@Test
+	void isPresentBasedOnFlagWhenPresentReturnsTrue() {
+		testIsPresentBasedOnFlag((short) 0x8, true);
+	}
+
+	@Test
+	void isPresentBasedOnFlagWhenNotPresentReturnsFalse() {
+		testIsPresentBasedOnFlag((short) 0x0, false);
+	}
+
+	private void testIsPresentBasedOnFlag(short flag, boolean expected) {
+		ZipCentralDirectoryFileHeaderRecord centralRecord = new ZipCentralDirectoryFileHeaderRecord(S0, S0, flag, S0,
+				S0, S0, S0, S0, S0, S0, S0, S0, S0, S0, S0, S0);
+		ZipLocalFileHeaderRecord localRecord = new ZipLocalFileHeaderRecord(S0, flag, S0, S0, S0, S0, S0, S0, S0, S0);
+		assertThat(ZipDataDescriptorRecord.isPresentBasedOnFlag(flag)).isEqualTo(expected);
+		assertThat(ZipDataDescriptorRecord.isPresentBasedOnFlag(centralRecord)).isEqualTo(expected);
+		assertThat(ZipDataDescriptorRecord.isPresentBasedOnFlag(localRecord)).isEqualTo(expected);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipEndOfCentralDirectoryRecordTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipEndOfCentralDirectoryRecordTests.java
new file mode 100644
index 000000000000..4a52c0be9b5b
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipEndOfCentralDirectoryRecordTests.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIOException;
+
+/**
+ * Tests for {@link ZipEndOfCentralDirectoryRecord}.
+ *
+ * @author Phillip Webb
+ */
+class ZipEndOfCentralDirectoryRecordTests {
+
+	@Test
+	void loadLocatesAndLoadsData() throws Exception {
+		DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { //
+				0x50, 0x4b, 0x05, 0x06, //
+				0x01, 0x00, //
+				0x02, 0x00, //
+				0x03, 0x00, //
+				0x04, 0x00, //
+				0x05, 0x00, 0x00, 0x00, //
+				0x06, 0x00, 0x00, 0x00, //
+				0x07, 0x00 }); //
+		ZipEndOfCentralDirectoryRecord.Located located = ZipEndOfCentralDirectoryRecord.load(dataBlock);
+		assertThat(located.pos()).isEqualTo(0L);
+		ZipEndOfCentralDirectoryRecord record = located.endOfCentralDirectoryRecord();
+		assertThat(record.numberOfThisDisk()).isEqualTo((short) 1);
+		assertThat(record.diskWhereCentralDirectoryStarts()).isEqualTo((short) 2);
+		assertThat(record.numberOfCentralDirectoryEntriesOnThisDisk()).isEqualTo((short) 3);
+		assertThat(record.totalNumberOfCentralDirectoryEntries()).isEqualTo((short) 4);
+		assertThat(record.sizeOfCentralDirectory()).isEqualTo(5);
+		assertThat(record.offsetToStartOfCentralDirectory()).isEqualTo(6);
+		assertThat(record.commentLength()).isEqualTo((short) 7);
+	}
+
+	@Test
+	void loadWhenMultipleBuffersBackLoadsData() throws Exception {
+		byte[] bytes = new byte[ZipEndOfCentralDirectoryRecord.BUFFER_SIZE * 4];
+		byte[] data = new byte[] { //
+				0x50, 0x4b, 0x05, 0x06, //
+				0x01, 0x00, //
+				0x02, 0x00, //
+				0x03, 0x00, //
+				0x04, 0x00, //
+				0x05, 0x00, 0x00, 0x00, //
+				0x06, 0x00, 0x00, 0x00, //
+				0x07, 0x00 }; //
+		System.arraycopy(data, 0, bytes, 4, data.length);
+		ZipEndOfCentralDirectoryRecord.Located located = ZipEndOfCentralDirectoryRecord
+			.load(new ByteArrayDataBlock(bytes));
+		assertThat(located.pos()).isEqualTo(4L);
+	}
+
+	@Test
+	void loadWhenSignatureDoesNotMatchThrowsException() {
+		DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { //
+				0x51, 0x4b, 0x05, 0x06, //
+				0x01, 0x00, //
+				0x02, 0x00, //
+				0x03, 0x00, //
+				0x04, 0x00, //
+				0x05, 0x00, 0x00, 0x00, //
+				0x06, 0x00, 0x00, 0x00, //
+				0x07, 0x00 }); //
+		assertThatIOException().isThrownBy(() -> ZipEndOfCentralDirectoryRecord.load(dataBlock))
+			.withMessageContaining("'End Of Central Directory Record' not found");
+	}
+
+	@Test
+	void asByteArrayReturnsByteArray() throws Exception {
+		byte[] bytes = new byte[] { //
+				0x50, 0x4b, 0x05, 0x06, //
+				0x01, 0x00, //
+				0x02, 0x00, //
+				0x03, 0x00, //
+				0x04, 0x00, //
+				0x05, 0x00, 0x00, 0x00, //
+				0x06, 0x00, 0x00, 0x00, //
+				0x07, 0x00 }; //
+		ZipEndOfCentralDirectoryRecord.Located located = ZipEndOfCentralDirectoryRecord
+			.load(new ByteArrayDataBlock(bytes));
+		assertThat(located.endOfCentralDirectoryRecord().asByteArray()).isEqualTo(bytes);
+	}
+
+	@Test
+	void sizeReturnsSize() {
+		ZipEndOfCentralDirectoryRecord record = new ZipEndOfCentralDirectoryRecord((short) 1, (short) 2, (short) 3,
+				(short) 4, 5, 6, (short) 7);
+		assertThat(record.size()).isEqualTo(29L);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipLocalFileHeaderRecordTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipLocalFileHeaderRecordTests.java
new file mode 100644
index 000000000000..02cc96fca27b
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipLocalFileHeaderRecordTests.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIOException;
+
+/**
+ * Tests for {@link ZipLocalFileHeaderRecord}.
+ *
+ * @author Phillip Webb
+ */
+class ZipLocalFileHeaderRecordTests {
+
+	@Test
+	void loadLoadsData() throws Exception {
+		DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { //
+				0x50, 0x4b, 0x03, 0x04, //
+				0x01, 0x00, //
+				0x02, 0x00, //
+				0x03, 0x00, //
+				0x04, 0x00, //
+				0x05, 0x00, //
+				0x06, 0x00, 0x00, 0x00, //
+				0x07, 0x00, 0x00, 0x00, //
+				0x08, 0x00, 0x00, 0x00, //
+				0x09, 0x00, //
+				0x0A, 0x00 }); //
+		ZipLocalFileHeaderRecord record = ZipLocalFileHeaderRecord.load(dataBlock, 0);
+		assertThat(record.versionNeededToExtract()).isEqualTo((short) 1);
+		assertThat(record.generalPurposeBitFlag()).isEqualTo((short) 2);
+		assertThat(record.compressionMethod()).isEqualTo((short) 3);
+		assertThat(record.lastModFileTime()).isEqualTo((short) 4);
+		assertThat(record.lastModFileDate()).isEqualTo((short) 5);
+		assertThat(record.crc32()).isEqualTo(6);
+		assertThat(record.compressedSize()).isEqualTo(7);
+		assertThat(record.uncompressedSize()).isEqualTo(8);
+		assertThat(record.fileNameLength()).isEqualTo((short) 9);
+		assertThat(record.extraFieldLength()).isEqualTo((short) 10);
+	}
+
+	@Test
+	void loadWhenSignatureDoesNotMatchThrowsException() {
+		DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { //
+				0x51, 0x4b, 0x03, 0x04, //
+				0x01, 0x00, //
+				0x02, 0x00, //
+				0x03, 0x00, //
+				0x04, 0x00, //
+				0x05, 0x00, //
+				0x06, 0x00, 0x00, 0x00, //
+				0x07, 0x00, 0x00, 0x00, //
+				0x08, 0x00, 0x00, 0x00, //
+				0x09, 0x00, //
+				0x0A, 0x00 }); //
+		assertThatIOException().isThrownBy(() -> ZipLocalFileHeaderRecord.load(dataBlock, 0))
+			.withMessageContaining("'Local File Header Record' not found");
+	}
+
+	@Test
+	void sizeReturnsSize() {
+		ZipLocalFileHeaderRecord record = new ZipLocalFileHeaderRecord((short) 1, (short) 2, (short) 3, (short) 4,
+				(short) 5, 6, 7, 8, (short) 9, (short) 10);
+		assertThat(record.size()).isEqualTo(49L);
+	}
+
+	@Test
+	void withExtraFieldLengthReturnsUpdatedInstance() {
+		ZipLocalFileHeaderRecord record = new ZipLocalFileHeaderRecord((short) 1, (short) 2, (short) 3, (short) 4,
+				(short) 5, 6, 7, 8, (short) 9, (short) 10)
+			.withExtraFieldLength((short) 100);
+		assertThat(record.extraFieldLength()).isEqualTo((short) 100);
+	}
+
+	@Test
+	void withFileNameLengthReturnsUpdatedInstance() {
+		ZipLocalFileHeaderRecord record = new ZipLocalFileHeaderRecord((short) 1, (short) 2, (short) 3, (short) 4,
+				(short) 5, 6, 7, 8, (short) 9, (short) 10)
+			.withFileNameLength((short) 100);
+		assertThat(record.fileNameLength()).isEqualTo((short) 100);
+	}
+
+	@Test
+	void asByteArrayReturnsByteArray() throws Exception {
+		byte[] bytes = new byte[] { //
+				0x50, 0x4b, 0x03, 0x04, //
+				0x01, 0x00, //
+				0x02, 0x00, //
+				0x03, 0x00, //
+				0x04, 0x00, //
+				0x05, 0x00, //
+				0x06, 0x00, 0x00, 0x00, //
+				0x07, 0x00, 0x00, 0x00, //
+				0x08, 0x00, 0x00, 0x00, //
+				0x09, 0x00, //
+				0x0A, 0x00 }; //
+		ZipLocalFileHeaderRecord record = ZipLocalFileHeaderRecord.load(new ByteArrayDataBlock(bytes), 0);
+		assertThat(record.asByteArray()).isEqualTo(bytes);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipStringTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipStringTests.java
new file mode 100644
index 000000000000..d421c2514520
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipStringTests.java
@@ -0,0 +1,194 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.zip;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import org.assertj.core.api.AbstractBooleanAssert;
+import org.assertj.core.api.AbstractIntegerAssert;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ZipString}.
+ *
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ */
+class ZipStringTests {
+
+	@ParameterizedTest
+	@EnumSource
+	void hashGeneratesCorrectHashCode(HashSourceType sourceType) throws Exception {
+		testHash(sourceType, true, "abcABC123xyz!");
+		testHash(sourceType, false, "abcABC123xyz!");
+	}
+
+	@ParameterizedTest
+	@EnumSource
+	void hashWhenHasSpecialCharsGeneratesCorrectHashCode(HashSourceType sourceType) throws Exception {
+		testHash(sourceType, true, "special/\u00EB.dat");
+	}
+
+	@ParameterizedTest
+	@EnumSource
+	void hashWhenHasCyrillicCharsGeneratesCorrectHashCode(HashSourceType sourceType) throws Exception {
+		testHash(sourceType, true, "\u0432\u0435\u0441\u043D\u0430");
+	}
+
+	@ParameterizedTest
+	@EnumSource
+	void hashWhenHasEmojiGeneratesCorrectHashCode(HashSourceType sourceType) throws Exception {
+		testHash(sourceType, true, "\ud83d\udca9");
+	}
+
+	@ParameterizedTest
+	@EnumSource
+	void hashWhenOnlyDifferenceIsEndSlashGeneratesSameHashCode(HashSourceType sourceType) throws Exception {
+		testHash(sourceType, "", true, "/".hashCode());
+		testHash(sourceType, "/", true, "/".hashCode());
+		testHash(sourceType, "a/b", true, "a/b/".hashCode());
+		testHash(sourceType, "a/b/", true, "a/b/".hashCode());
+	}
+
+	void testHash(HashSourceType sourceType, boolean addSlash, String source) throws Exception {
+		String expected = (addSlash && !source.endsWith("/")) ? source + "/" : source;
+		testHash(sourceType, source, addSlash, expected.hashCode());
+	}
+
+	void testHash(HashSourceType sourceType, String source, boolean addEndSlash, int expected) throws Exception {
+		switch (sourceType) {
+			case STRING -> {
+				assertThat(ZipString.hash(source, addEndSlash)).isEqualTo(expected);
+			}
+			case CHAR_SEQUENCE -> {
+				CharSequence charSequence = new StringBuilder(source);
+				assertThat(ZipString.hash(charSequence, addEndSlash)).isEqualTo(expected);
+			}
+			case DATA_BLOCK -> {
+				ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(source.getBytes(StandardCharsets.UTF_8));
+				assertThat(ZipString.hash(null, dataBlock, 0, (int) dataBlock.size(), addEndSlash)).isEqualTo(expected);
+
+			}
+		}
+	}
+
+	@Test
+	void matchesWhenExactMatchReturnsTrue() throws Exception {
+		assertMatches("one/two/three", "one/two/three", false).isTrue();
+	}
+
+	@Test
+	void matchesWhenNotMatchWithSameLengthReturnsFalse() throws Exception {
+		assertMatches("one/two/three", "one/too/three", false).isFalse();
+	}
+
+	@Test
+	void matchesWhenExactMatchWithSpecialCharsReturnsTrue() throws Exception {
+		assertMatches("special/\u00EB.dat", "special/\u00EB.dat", false).isTrue();
+	}
+
+	@Test
+	void matchesWhenExactMatchWithCyrillicCharsReturnsTrue() throws Exception {
+		assertMatches("\u0432\u0435\u0441\u043D\u0430", "\u0432\u0435\u0441\u043D\u0430", false).isTrue();
+	}
+
+	@Test
+	void matchesWhenNoMatchWithCyrillicCharsReturnsFalse() throws Exception {
+		assertMatches("\u0432\u0435\u0441\u043D\u0430", "\u0432\u0435\u0441\u043D\u043D", false).isFalse();
+	}
+
+	@Test
+	void matchesWhenExactMatchWithEmojiCharsReturnsTrue() throws Exception {
+		assertMatches("\ud83d\udca9", "\ud83d\udca9", false).isTrue();
+	}
+
+	@Test
+	void matchesWithAddSlash() throws Exception {
+		assertMatches("META-INF/MANFIFEST.MF", "META-INF/MANFIFEST.MF", true).isTrue();
+		assertMatches("one/two/three/", "one/two/three", true).isTrue();
+		assertMatches("one/two/three", "one/two/three/", true).isFalse();
+		assertMatches("one/two/three/", "one/too/three", true).isFalse();
+		assertMatches("one/two/three", "one/too/three/", true).isFalse();
+		assertMatches("one/two/three//", "one/two/three", true).isFalse();
+		assertMatches("one/two/three", "one/two/three//", true).isFalse();
+	}
+
+	@Test
+	void matchesWhenDataBlockShorterThenCharSequenceReturnsFalse() throws Exception {
+		assertMatches("one/two/thre", "one/two/three", false).isFalse();
+	}
+
+	@Test
+	void matchesWhenCharSequenceShorterThanDataBlockReturnsFalse() throws Exception {
+		assertMatches("one/two/three", "one/two/thre", false).isFalse();
+	}
+
+	@Test
+	void startsWithWhenStartsWith() throws Exception {
+		assertStartsWith("one/two", "one/").isEqualTo(4);
+	}
+
+	@Test
+	void startsWithWhenExact() throws Exception {
+		assertStartsWith("one/", "one/").isEqualTo(4);
+	}
+
+	@Test
+	void startsWithWhenTooShort() throws Exception {
+		assertStartsWith("one/two", "one/two/three/").isEqualTo(-1);
+	}
+
+	@Test
+	void startsWithWhenDoesNotStartWith() throws Exception {
+		assertStartsWith("one/three/", "one/two/").isEqualTo(-1);
+	}
+
+	@Test
+	void zipStringWhenMultiCodePointAtBufferBoundary() throws Exception {
+		StringBuilder source = new StringBuilder();
+		for (int i = 0; i < ZipString.BUFFER_SIZE - 1; i++) {
+			source.append("A");
+		}
+		source.append("\u1EFF");
+		String charSequence = source.toString();
+		source.append("suffix");
+		assertStartsWith(source.toString(), charSequence);
+	}
+
+	private AbstractBooleanAssert<?> assertMatches(String source, CharSequence charSequence, boolean addSlash)
+			throws Exception {
+		ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(source.getBytes(StandardCharsets.UTF_8));
+		return assertThat(ZipString.matches(null, dataBlock, 0, (int) dataBlock.size(), charSequence, addSlash));
+	}
+
+	private AbstractIntegerAssert<?> assertStartsWith(String source, CharSequence charSequence) throws IOException {
+		ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(source.getBytes(StandardCharsets.UTF_8));
+		return assertThat(ZipString.startsWith(null, dataBlock, 0, (int) dataBlock.size(), charSequence));
+	}
+
+	enum HashSourceType {
+
+		STRING, CHAR_SEQUENCE, DATA_BLOCK
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/org/springframework/boot/loader/launch/classpath-index-file.idx b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/org/springframework/boot/loader/launch/classpath-index-file.idx
new file mode 100644
index 000000000000..b84b99a6b47e
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/org/springframework/boot/loader/launch/classpath-index-file.idx
@@ -0,0 +1,5 @@
+- "BOOT-INF/layers/one/lib/a.jar"
+- "BOOT-INF/layers/one/lib/b.jar"
+- "BOOT-INF/layers/one/lib/c.jar"
+- "BOOT-INF/layers/two/lib/d.jar"
+- "BOOT-INF/layers/two/lib/e.jar"
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/build.gradle
index fc4022c97070..61b25f2dc5a2 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/build.gradle
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/build.gradle
@@ -13,20 +13,28 @@ configurations {
 }
 
 dependencies {
+	asciidoctorExtensions("io.spring.asciidoctor:spring-asciidoctor-extensions-section-ids")
+
 	compileOnly("org.apache.maven.plugin-tools:maven-plugin-annotations")
 	compileOnly("org.sonatype.plexus:plexus-build-api")
-	compileOnly("org.apache.maven.shared:maven-common-artifact-filters") {
-		exclude(group: "javax.enterprise", module: "cdi-api")
+	compileOnly("org.apache.maven:maven-core") {
+		exclude(group: "javax.annotation", module: "javax.annotation-api")
 		exclude(group: "javax.inject", module: "javax.inject")
 	}
 	compileOnly("org.apache.maven:maven-plugin-api") {
+		exclude(group: "javax.annotation", module: "javax.annotation-api")
 		exclude(group: "javax.enterprise", module: "cdi-api")
 		exclude(group: "javax.inject", module: "javax.inject")
 	}
 
-	implementation("org.springframework:spring-context")
 	implementation(project(":spring-boot-project:spring-boot-tools:spring-boot-buildpack-platform"))
 	implementation(project(":spring-boot-project:spring-boot-tools:spring-boot-loader-tools"))
+	implementation("org.apache.maven.shared:maven-common-artifact-filters") {
+		exclude(group: "javax.annotation", module: "javax.annotation-api")
+		exclude(group: "javax.enterprise", module: "cdi-api")
+		exclude(group: "javax.inject", module: "javax.inject")
+	}
+	implementation("org.springframework:spring-context")
 
 	intTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-buildpack-platform"))
 	intTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-loader-tools"))
@@ -40,6 +48,7 @@ dependencies {
 	intTestImplementation("org.testcontainers:junit-jupiter")
 
 	mavenOptionalImplementation("org.apache.maven.plugins:maven-shade-plugin") {
+		exclude(group: "javax.annotation", module: "javax.annotation-api")
 		exclude(group: "javax.enterprise", module: "cdi-api")
 		exclude(group: "javax.inject", module: "javax.inject")
 	}
@@ -51,6 +60,15 @@ dependencies {
 
 	runtimeOnly("org.sonatype.plexus:plexus-build-api")
 
+	testImplementation("org.apache.maven:maven-core") {
+		exclude(group: "javax.annotation", module: "javax.annotation-api")
+		exclude(group: "javax.inject", module: "javax.inject")
+	}
+	testImplementation("org.apache.maven.shared:maven-common-artifact-filters") {
+		exclude(group: "javax.annotation", module: "javax.annotation-api")
+		exclude(group: "javax.enterprise", module: "cdi-api")
+		exclude(group: "javax.inject", module: "javax.inject")
+	}
 	testImplementation("org.assertj:assertj-core")
 	testImplementation("org.junit.jupiter:junit-jupiter")
 	testImplementation("org.mockito:mockito-core")
@@ -145,3 +163,19 @@ prepareMavenBinaries {
 artifacts {
 	"documentation" zip
 }
+
+tasks.named("documentPluginGoals") {
+	goalSections = [
+		"build-image": "build-image",
+		"build-image-no-fork": "build-image",
+		"build-info": "build-info",
+		"help": "help",
+		"process-aot": "aot",
+		"process-test-aot": "aot",
+		"repackage": "packaging",
+		"run": "run",
+		"start": "integration-tests",
+		"stop": "integration-tests",
+		"test-run": "run"
+	]
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/anchor-rewrite.properties b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/anchor-rewrite.properties
index 0cea3c652e57..90545b8a40c2 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/anchor-rewrite.properties
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/anchor-rewrite.properties
@@ -33,3 +33,200 @@ run-example-active-profiles=run.examples.specify-active-profiles
 using-parent-pom=using.parent-pom
 using-import=using.import
 using-overriding-command-line=using.overriding-command-line
+_processing_applications=aot.processing-applications
+_using_the_native_profile=aot.processing-applications.using-the-native-profile
+_processing_tests=aot.processing-tests
+
+# Consistent section IDs for goals
+goals-build-image=build-image.build-image-goal
+goals-build-image-no-fork=build-image.build-image-no-fork-goal
+goals-build-image-no-fork-parameters-details=build-image.build-image-no-fork-goal.parameter-details
+goals-build-image-no-fork-parameters-details-classifier=build-image.build-image-no-fork-goal.parameter-details.classifier
+goals-build-image-no-fork-parameters-details-docker=build-image.build-image-no-fork-goal.parameter-details.docker
+goals-build-image-no-fork-parameters-details-excludeDevtools=build-image.build-image-no-fork-goal.parameter-details.exclude-devtools
+goals-build-image-no-fork-parameters-details-excludeDockerCompose=build-image.build-image-no-fork-goal.parameter-details.exclude-docker-compose
+goals-build-image-no-fork-parameters-details-excludeGroupIds=build-image.build-image-no-fork-goal.parameter-details.exclude-group-ids
+goals-build-image-no-fork-parameters-details-excludes=build-image.build-image-no-fork-goal.parameter-details.excludes
+goals-build-image-no-fork-parameters-details-image=build-image.build-image-no-fork-goal.parameter-details.image
+goals-build-image-no-fork-parameters-details-includeSystemScope=build-image.build-image-no-fork-goal.parameter-details.include-system-scope
+goals-build-image-no-fork-parameters-details-includes=build-image.build-image-no-fork-goal.parameter-details.includes
+goals-build-image-no-fork-parameters-details-layers=build-image.build-image-no-fork-goal.parameter-details.layers
+goals-build-image-no-fork-parameters-details-layout=build-image.build-image-no-fork-goal.parameter-details.layout
+goals-build-image-no-fork-parameters-details-layoutFactory=build-image.build-image-no-fork-goal.parameter-details.layout-factory
+goals-build-image-no-fork-parameters-details-mainClass=build-image.build-image-no-fork-goal.parameter-details.main-class
+goals-build-image-no-fork-parameters-details-skip=build-image.build-image-no-fork-goal.parameter-details.skip
+goals-build-image-no-fork-parameters-details-sourceDirectory=build-image.build-image-no-fork-goal.parameter-details.source-directory
+goals-build-image-no-fork-parameters-optional=build-image.build-image-no-fork-goal.optional-parameters
+goals-build-image-no-fork-parameters-required=build-image.build-image-no-fork-goal.required-parameters
+goals-build-image-parameters-details=build-image.build-image-goal.parameter-details
+goals-build-image-parameters-details-classifier=build-image.build-image-goal.parameter-details.classifier
+goals-build-image-parameters-details-docker=build-image.build-image-goal.parameter-details.docker
+goals-build-image-parameters-details-excludeDevtools=build-image.build-image-goal.parameter-details.exclude-devtools
+goals-build-image-parameters-details-excludeDockerCompose=build-image.build-image-goal.parameter-details.exclude-docker-compose
+goals-build-image-parameters-details-excludeGroupIds=build-image.build-image-goal.parameter-details.exclude-group-ids
+goals-build-image-parameters-details-excludes=build-image.build-image-goal.parameter-details.excludes
+goals-build-image-parameters-details-image=build-image.build-image-goal.parameter-details.image
+goals-build-image-parameters-details-includeSystemScope=build-image.build-image-goal.parameter-details.include-system-scope
+goals-build-image-parameters-details-includes=build-image.build-image-goal.parameter-details.includes
+goals-build-image-parameters-details-layers=build-image.build-image-goal.parameter-details.layers
+goals-build-image-parameters-details-layout=build-image.build-image-goal.parameter-details.layout
+goals-build-image-parameters-details-layoutFactory=build-image.build-image-goal.parameter-details.layout-factory
+goals-build-image-parameters-details-mainClass=build-image.build-image-goal.parameter-details.main-class
+goals-build-image-parameters-details-skip=build-image.build-image-goal.parameter-details.skip
+goals-build-image-parameters-details-sourceDirectory=build-image.build-image-goal.parameter-details.source-directory
+goals-build-image-parameters-optional=build-image.build-image-goal.optional-parameters
+goals-build-image-parameters-required=build-image.build-image-goal.required-parameters
+goals-build-info=build-info.build-info-goal
+goals-build-info-parameters-details=build-info.build-info-goal.parameter-details
+goals-build-info-parameters-details-additionalProperties=build-info.build-info-goal.parameter-details.additional-properties
+goals-build-info-parameters-details-excludeInfoProperties=build-info.build-info-goal.parameter-details.exclude-info-properties
+goals-build-info-parameters-details-outputFile=build-info.build-info-goal.parameter-details.output-file
+goals-build-info-parameters-details-skip=build-info.build-info-goal.parameter-details.skip
+goals-build-info-parameters-details-time=build-info.build-info-goal.parameter-details.time
+goals-build-info-parameters-optional=build-info.build-info-goal.optional-parameters
+goals-help=help.help-goal
+goals-help-parameters-details=help.help-goal.parameter-details
+goals-help-parameters-details-detail=help.help-goal.parameter-details.detail
+goals-help-parameters-details-goal=help.help-goal.parameter-details.goal
+goals-help-parameters-details-indentSize=help.help-goal.parameter-details.indent-size
+goals-help-parameters-details-lineLength=help.help-goal.parameter-details.line-length
+goals-help-parameters-optional=help.help-goal.optional-parameters
+goals-process-aot=aot.process-aot-goal
+goals-process-aot-parameters-details=aot.process-aot-goal.parameter-details
+goals-process-aot-parameters-details-arguments=aot.process-aot-goal.parameter-details.arguments
+goals-process-aot-parameters-details-classesDirectory=aot.process-aot-goal.parameter-details.classes-directory
+goals-process-aot-parameters-details-compilerArguments=aot.process-aot-goal.parameter-details.compiler-arguments
+goals-process-aot-parameters-details-excludeGroupIds=aot.process-aot-goal.parameter-details.exclude-group-ids
+goals-process-aot-parameters-details-excludes=aot.process-aot-goal.parameter-details.excludes
+goals-process-aot-parameters-details-generatedClasses=aot.process-aot-goal.parameter-details.generated-classes
+goals-process-aot-parameters-details-generatedResources=aot.process-aot-goal.parameter-details.generated-resources
+goals-process-aot-parameters-details-generatedSources=aot.process-aot-goal.parameter-details.generated-sources
+goals-process-aot-parameters-details-includes=aot.process-aot-goal.parameter-details.includes
+goals-process-aot-parameters-details-jvmArguments=aot.process-aot-goal.parameter-details.jvm-arguments
+goals-process-aot-parameters-details-mainClass=aot.process-aot-goal.parameter-details.main-class
+goals-process-aot-parameters-details-profiles=aot.process-aot-goal.parameter-details.profiles
+goals-process-aot-parameters-details-skip=aot.process-aot-goal.parameter-details.skip
+goals-process-aot-parameters-details-systemPropertyVariables=aot.process-aot-goal.parameter-details.system-property-variables
+goals-process-aot-parameters-optional=aot.process-aot-goal.optional-parameters
+goals-process-aot-parameters-required=aot.process-aot-goal.required-parameters
+goals-process-test-aot=aot.process-test-aot-goal
+goals-process-test-aot-parameters-details=aot.process-test-aot-goal.parameter-details
+goals-process-test-aot-parameters-details-classesDirectory=aot.process-test-aot-goal.parameter-details.classes-directory
+goals-process-test-aot-parameters-details-compilerArguments=aot.process-test-aot-goal.parameter-details.compiler-arguments
+goals-process-test-aot-parameters-details-excludeGroupIds=aot.process-test-aot-goal.parameter-details.exclude-group-ids
+goals-process-test-aot-parameters-details-excludes=aot.process-test-aot-goal.parameter-details.excludes
+goals-process-test-aot-parameters-details-generatedClasses=aot.process-test-aot-goal.parameter-details.generated-classes
+goals-process-test-aot-parameters-details-generatedResources=aot.process-test-aot-goal.parameter-details.generated-resources
+goals-process-test-aot-parameters-details-generatedSources=aot.process-test-aot-goal.parameter-details.generated-sources
+goals-process-test-aot-parameters-details-generatedTestClasses=aot.process-test-aot-goal.parameter-details.generated-test-classes
+goals-process-test-aot-parameters-details-includes=aot.process-test-aot-goal.parameter-details.includes
+goals-process-test-aot-parameters-details-jvmArguments=aot.process-test-aot-goal.parameter-details.jvm-arguments
+goals-process-test-aot-parameters-details-skip=aot.process-test-aot-goal.parameter-details.skip
+goals-process-test-aot-parameters-details-systemPropertyVariables=aot.process-test-aot-goal.parameter-details.system-property-variables
+goals-process-test-aot-parameters-details-testClassesDirectory=aot.process-test-aot-goal.parameter-details.test-classes-directory
+goals-process-test-aot-parameters-optional=aot.process-test-aot-goal.optional-parameters
+goals-process-test-aot-parameters-required=aot.process-test-aot-goal.required-parameters
+goals-repackage=packaging.repackage-goal
+goals-repackage-parameters-details=packaging.repackage-goal.parameter-details
+goals-repackage-parameters-details-attach=packaging.repackage-goal.parameter-details.attach
+goals-repackage-parameters-details-classifier=packaging.repackage-goal.parameter-details.classifier
+goals-repackage-parameters-details-embeddedLaunchScript=packaging.repackage-goal.parameter-details.embedded-launch-script
+goals-repackage-parameters-details-embeddedLaunchScriptProperties=packaging.repackage-goal.parameter-details.embedded-launch-script-properties
+goals-repackage-parameters-details-excludeDevtools=packaging.repackage-goal.parameter-details.exclude-devtools
+goals-repackage-parameters-details-excludeDockerCompose=packaging.repackage-goal.parameter-details.exclude-docker-compose
+goals-repackage-parameters-details-excludeGroupIds=packaging.repackage-goal.parameter-details.exclude-group-ids
+goals-repackage-parameters-details-excludes=packaging.repackage-goal.parameter-details.excludes
+goals-repackage-parameters-details-executable=packaging.repackage-goal.parameter-details.executable
+goals-repackage-parameters-details-includeSystemScope=packaging.repackage-goal.parameter-details.include-system-scope
+goals-repackage-parameters-details-includes=packaging.repackage-goal.parameter-details.includes
+goals-repackage-parameters-details-layers=packaging.repackage-goal.parameter-details.layers
+goals-repackage-parameters-details-layout=packaging.repackage-goal.parameter-details.layout
+goals-repackage-parameters-details-layoutFactory=packaging.repackage-goal.parameter-details.layout-factory
+goals-repackage-parameters-details-mainClass=packaging.repackage-goal.parameter-details.main-class
+goals-repackage-parameters-details-outputDirectory=packaging.repackage-goal.parameter-details.output-directory
+goals-repackage-parameters-details-outputTimestamp=packaging.repackage-goal.parameter-details.output-timestamp
+goals-repackage-parameters-details-requiresUnpack=packaging.repackage-goal.parameter-details.requires-unpack
+goals-repackage-parameters-details-skip=packaging.repackage-goal.parameter-details.skip
+goals-repackage-parameters-optional=packaging.repackage-goal.optional-parameters
+goals-repackage-parameters-required=packaging.repackage-goal.required-parameters
+goals-run=run.run-goal
+goals-run-parameters-details=run.run-goal.parameter-details
+goals-run-parameters-details-addResources=run.run-goal.parameter-details.add-resources
+goals-run-parameters-details-additionalClasspathElements=run.run-goal.parameter-details.additional-classpath-elements
+goals-run-parameters-details-agents=run.run-goal.parameter-details.agents
+goals-run-parameters-details-arguments=run.run-goal.parameter-details.arguments
+goals-run-parameters-details-classesDirectory=run.run-goal.parameter-details.classes-directory
+goals-run-parameters-details-commandlineArguments=run.run-goal.parameter-details.commandline-arguments
+goals-run-parameters-details-directories=run.run-goal.parameter-details.directories
+goals-run-parameters-details-environmentVariables=run.run-goal.parameter-details.environment-variables
+goals-run-parameters-details-excludeGroupIds=run.run-goal.parameter-details.exclude-group-ids
+goals-run-parameters-details-excludes=run.run-goal.parameter-details.excludes
+goals-run-parameters-details-includes=run.run-goal.parameter-details.includes
+goals-run-parameters-details-jvmArguments=run.run-goal.parameter-details.jvm-arguments
+goals-run-parameters-details-mainClass=run.run-goal.parameter-details.main-class
+goals-run-parameters-details-noverify=run.run-goal.parameter-details.noverify
+goals-run-parameters-details-optimizedLaunch=run.run-goal.parameter-details.optimized-launch
+goals-run-parameters-details-profiles=run.run-goal.parameter-details.profiles
+goals-run-parameters-details-skip=run.run-goal.parameter-details.skip
+goals-run-parameters-details-systemPropertyVariables=run.run-goal.parameter-details.system-property-variables
+goals-run-parameters-details-useTestClasspath=run.run-goal.parameter-details.use-test-classpath
+goals-run-parameters-details-workingDirectory=run.run-goal.parameter-details.working-directory
+goals-run-parameters-optional=run.run-goal.optional-parameters
+goals-run-parameters-required=run.run-goal.required-parameters
+goals-start=integration-tests.start-goal
+goals-start-parameters-details=integration-tests.start-goal.parameter-details
+goals-start-parameters-details-addResources=integration-tests.start-goal.parameter-details.add-resources
+goals-start-parameters-details-additionalClasspathElements=integration-tests.start-goal.parameter-details.additional-classpath-elements
+goals-start-parameters-details-agents=integration-tests.start-goal.parameter-details.agents
+goals-start-parameters-details-arguments=integration-tests.start-goal.parameter-details.arguments
+goals-start-parameters-details-classesDirectory=integration-tests.start-goal.parameter-details.classes-directory
+goals-start-parameters-details-commandlineArguments=integration-tests.start-goal.parameter-details.commandline-arguments
+goals-start-parameters-details-directories=integration-tests.start-goal.parameter-details.directories
+goals-start-parameters-details-environmentVariables=integration-tests.start-goal.parameter-details.environment-variables
+goals-start-parameters-details-excludeGroupIds=integration-tests.start-goal.parameter-details.exclude-group-ids
+goals-start-parameters-details-excludes=integration-tests.start-goal.parameter-details.excludes
+goals-start-parameters-details-includes=integration-tests.start-goal.parameter-details.includes
+goals-start-parameters-details-jmxName=integration-tests.start-goal.parameter-details.jmx-name
+goals-start-parameters-details-jmxPort=integration-tests.start-goal.parameter-details.jmx-port
+goals-start-parameters-details-jvmArguments=integration-tests.start-goal.parameter-details.jvm-arguments
+goals-start-parameters-details-mainClass=integration-tests.start-goal.parameter-details.main-class
+goals-start-parameters-details-maxAttempts=integration-tests.start-goal.parameter-details.max-attempts
+goals-start-parameters-details-noverify=integration-tests.start-goal.parameter-details.noverify
+goals-start-parameters-details-profiles=integration-tests.start-goal.parameter-details.profiles
+goals-start-parameters-details-skip=integration-tests.start-goal.parameter-details.skip
+goals-start-parameters-details-systemPropertyVariables=integration-tests.start-goal.parameter-details.system-property-variables
+goals-start-parameters-details-useTestClasspath=integration-tests.start-goal.parameter-details.use-test-classpath
+goals-start-parameters-details-wait=integration-tests.start-goal.parameter-details.wait
+goals-start-parameters-details-workingDirectory=integration-tests.start-goal.parameter-details.working-directory
+goals-start-parameters-optional=integration-tests.start-goal.optional-parameters
+goals-start-parameters-required=integration-tests.start-goal.required-parameters
+goals-stop=integration-tests.stop-goal
+goals-stop-parameters-details=integration-tests.stop-goal.parameter-details
+goals-stop-parameters-details-jmxName=integration-tests.stop-goal.parameter-details.jmx-name
+goals-stop-parameters-details-jmxPort=integration-tests.stop-goal.parameter-details.jmx-port
+goals-stop-parameters-details-skip=integration-tests.stop-goal.parameter-details.skip
+goals-stop-parameters-optional=integration-tests.stop-goal.optional-parameters
+goals-test-run=run.test-run-goal
+goals-test-run-parameters-details=run.test-run-goal.parameter-details
+goals-test-run-parameters-details-addResources=run.test-run-goal.parameter-details.add-resources
+goals-test-run-parameters-details-additionalClasspathElements=run.test-run-goal.parameter-details.additional-classpath-elements
+goals-test-run-parameters-details-agents=run.test-run-goal.parameter-details.agents
+goals-test-run-parameters-details-arguments=run.test-run-goal.parameter-details.arguments
+goals-test-run-parameters-details-classesDirectory=run.test-run-goal.parameter-details.classes-directory
+goals-test-run-parameters-details-commandlineArguments=run.test-run-goal.parameter-details.commandline-arguments
+goals-test-run-parameters-details-directories=run.test-run-goal.parameter-details.directories
+goals-test-run-parameters-details-environmentVariables=run.test-run-goal.parameter-details.environment-variables
+goals-test-run-parameters-details-excludeGroupIds=run.test-run-goal.parameter-details.exclude-group-ids
+goals-test-run-parameters-details-excludes=run.test-run-goal.parameter-details.excludes
+goals-test-run-parameters-details-includes=run.test-run-goal.parameter-details.includes
+goals-test-run-parameters-details-jvmArguments=run.test-run-goal.parameter-details.jvm-arguments
+goals-test-run-parameters-details-mainClass=run.test-run-goal.parameter-details.main-class
+goals-test-run-parameters-details-noverify=run.test-run-goal.parameter-details.noverify
+goals-test-run-parameters-details-optimizedLaunch=run.test-run-goal.parameter-details.optimized-launch
+goals-test-run-parameters-details-profiles=run.test-run-goal.parameter-details.profiles
+goals-test-run-parameters-details-skip=run.test-run-goal.parameter-details.skip
+goals-test-run-parameters-details-systemPropertyVariables=run.test-run-goal.parameter-details.system-property-variables
+goals-test-run-parameters-details-testClassesDirectory=run.test-run-goal.parameter-details.test-classes-directory
+goals-test-run-parameters-details-workingDirectory=run.test-run-goal.parameter-details.working-directory
+goals-test-run-parameters-optional=run.test-run-goal.optional-parameters
+goals-test-run-parameters-required=run.test-run-goal.required-parameters
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/aot.adoc b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/aot.adoc
index feb59d421665..adae86df7c41 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/aot.adoc
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/aot.adoc
@@ -9,7 +9,7 @@ NOTE: For an overview of GraalVM Native Images support in Spring Boot, check the
 The Spring Boot Maven plugin offers goals that can be used to perform AOT processing on both application and test code.
 
 
-
+[[aot.processing-applications]]
 == Processing Applications
 To configure your application to use this feature, add an execution for the `process-aot` goal, as shown in the following example:
 
@@ -24,7 +24,7 @@ For instance, if you want to opt-in or opt-out for certain features, you need to
 The `process-aot` goal shares a number of properties with the <<run,run goal>> for that reason.
 
 
-
+[[aot.processing-applications.using-the-native-profile]]
 === Using the Native Profile
 If you use `spring-boot-starter-parent` as the `parent` of your project, a `native` profile can be used to streamline the steps required to build a native image.
 
@@ -77,7 +77,7 @@ Such module must define the Native Build Tools and Spring Boot plugins as descri
 include::goals/process-aot.adoc[leveloffset=+1]
 
 
-
+[[aot.processint-tests]]
 == Processing Tests
 The AOT engine can be applied to JUnit 5 tests that use Spring's Test Context Framework.
 Suitable tests are processed by the AOT engine in order to generate `ApplicationContextInitialzer` code.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/build-info.adoc b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/build-info.adoc
index 57eb95a810e2..b4ee3928ee9a 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/build-info.adoc
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/build-info.adoc
@@ -9,9 +9,9 @@ It also allows you to add an arbitrary number of additional properties, as shown
 include::../maven/build-info/pom.xml[tags=build-info]
 ----
 
-This configuration will generate a `build-info.properties` at the expected location with four additional keys.
+This configuration will generate a `build-info.properties` at the expected location with three additional keys.
 
-NOTE: `maven.compiler.source` and `maven.compiler.target` are expected to be regular properties available in the project.
-They will be interpolated as you would expect.
+NOTE: `java.version` is expected to be a regular property available in the project.
+It will be interpolated as you would expect.
 
 include::goals/build-info.adoc[leveloffset=+1]
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/index.adoc b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/index.adoc
index aa65458eaaad..4880c710d345 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/index.adoc
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/index.adoc
@@ -1,6 +1,6 @@
 [[spring-boot-maven-plugin-documentation]]
 = Spring Boot Maven Plugin Documentation
-Stephane Nicoll; Andy Wilkinson; Scott Frederick
+Stephane Nicoll; Andy Wilkinson; Scott Frederick; Moritz Halbritter
 v{gradle-project-version}
 :!version-label:
 :doctype: book
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/packaging-oci-image.adoc b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/packaging-oci-image.adoc
index f9b084ce7eea..adc1127821fc 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/packaging-oci-image.adoc
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/packaging-oci-image.adoc
@@ -28,7 +28,8 @@ The `spring-boot-devtools` and `spring-boot-docker-compose` modules are automati
 [[build-image.docker-daemon]]
 == Docker Daemon
 The `build-image` goal requires access to a Docker daemon.
-By default, it will communicate with a Docker daemon over a local connection.
+The goal will inspect local Docker CLI https://docs.docker.com/engine/reference/commandline/cli/#configuration-files[configuration files] to determine the current https://docs.docker.com/engine/context/working-with-contexts/[context] and use the context connection information to communicate with a Docker daemon.
+If the current context can not be determined or the context does not have connection information, then the goal will use a default local connection.
 This works with https://docs.docker.com/install/[Docker Engine] on all supported platforms without configuration.
 
 Environment variables can be set to configure the `build-image` goal to use an alternative local or remote connection.
@@ -37,6 +38,12 @@ The following table shows the environment variables and their values:
 |===
 | Environment variable | Description
 
+| DOCKER_CONFIG
+| Location of Docker CLI https://docs.docker.com/engine/reference/commandline/cli/#configuration-files[configuration files] used to determine the current context (defaults to `$HOME/.docker`)
+
+| DOCKER_CONTEXT
+| Name of a https://docs.docker.com/engine/context/working-with-contexts/[context] that should be used to retrieve host information from Docker CLI configuration files (overrides `DOCKER_HOST`)
+
 | DOCKER_HOST
 | URL containing the host and port for the Docker daemon - for example `tcp://192.168.99.100:2376`
 
@@ -53,6 +60,9 @@ The following table summarizes the available parameters:
 |===
 | Parameter | Description
 
+| `context`
+| Name of a https://docs.docker.com/engine/context/working-with-contexts/[context] that should be used to retrieve host information from Docker CLI https://docs.docker.com/engine/reference/commandline/cli/#configuration-files[configuration files]
+
 | `host`
 | URL containing the host and port for the Docker daemon - for example `tcp://192.168.99.100:2376`
 
@@ -121,7 +131,7 @@ The following table summarizes the available parameters and their default values
 | `builder` +
 (`spring-boot.build-image.builder`)
 | Name of the Builder image to use.
-| `paketobuildpacks/builder:base`
+| `paketobuildpacks/builder-jammy-base:latest`
 
 | `runImage` +
 (`spring-boot.build-image.runImage`)
@@ -189,19 +199,23 @@ The value supplied will be passed unvalidated to Docker when creating the builde
 
 | `tags`
 | One or more additional tags to apply to the generated image.
-The values provided to the `tags` option should be full image references in the form of `[image name]:[tag]` or `[repository]/[image name]:[tag]`.
+The values provided to the `tags` option should be *full* image references.
+See <<build-image.customization.tags, the tags section>> for more details.
 |
 
-| `caches`
-| Cache volume names that should be used by the builder instead of generating random names.
-|
+| `buildWorkspace`
+| A temporary workspace that will be used by the builder and buildpacks to store files during image building.
+The value can be a named volume or a bind mount location.
+| A named volume in the Docker daemon, with a name derived from the image name.
 
 | `buildCache`
 | A cache containing layers created by buildpacks and used by the image building process.
+The value can be a named volume or a bind mount location.
 | A named volume in the Docker daemon, with a name derived from the image name.
 
 | `launchCache`
 | A cache containing layers created by buildpacks and used by the image launching process.
+The value can be a named volume or a bind mount location.
 | A named volume in the Docker daemon, with a name derived from the image name.
 
 | `createdDate` +
@@ -217,6 +231,10 @@ The value must be a string in the ISO 8601 instant format, or `now` to use the c
 Application contents will also be in this location in the generated image.
 | `/workspace`
 
+| `securityOptions`
+| https://docs.docker.com/engine/reference/run/#security-configuration[Security options] that will be applied to the builder container, provided as an array of string values
+| `["label=disable"]` on Linux and macOS, `[]` on Windows
+
 |===
 
 NOTE: The plugin detects the target Java compatibility of the project using the compiler's plugin configuration or the `maven.compiler.target` property.
@@ -225,6 +243,24 @@ You can override this behaviour as shown in the <<build-image.examples.builder-c
 
 For more details, see also <<build-image.examples,examples>>.
 
+
+
+[[build-image.customization.tags]]
+=== Tags format
+
+The values provided to the `tags` option should be *full* image references.
+The accepted format is `[domainHost:port/][path/]name[:tag][@digest]`.
+
+If the domain is missing, it defaults to `docker.io`.
+If the path is missing, it defaults to `library`.
+If the tag is missing, it defaults to `latest`.
+
+Some examples:
+
+* `my-image` leads to the image reference `docker.io/library/my-image:latest`
+* `my-repository/my-image` leads to `docker.io/my-repository/my-image:latest`
+* `example.com/my-repository/my-image:1.0.0` will be used as is
+
 include::goals/build-image.adoc[leveloffset=+1]
 include::goals/build-image-no-fork.adoc[leveloffset=+1]
 
@@ -396,9 +432,10 @@ and reference the properties in the XML configuration:
 include::../maven/packaging-oci-image/docker-pom-authentication-command-line.xml[tags=docker]
 ----
 
-[[build-image.examples.caches]]
-=== Builder Cache Configuration
 
+
+[[build-image.examples.caches]]
+=== Builder Cache and Workspace Configuration
 The CNB builder caches layers that are used when building and launching an image.
 By default, these caches are stored as named volumes in the Docker daemon with names that are derived from the full name of the target image.
 If the image name changes frequently, for example when the project version is used as a tag in the image name, then the caches can be invalidated frequently.
@@ -410,12 +447,25 @@ The cache volumes can be configured to use alternative names to give more contro
 include::../maven/packaging-oci-image/caches-pom.xml[tags=caches]
 ----
 
+Builders and buildpacks need a location to store temporary files during image building.
+By default, this temporary build workspace is stored in a named volume.
+
+The caches and the build workspace can be configured to use bind mounts instead of named volumes, as shown in the following example:
+
+[source,xml,indent=0,subs="verbatim,attributes",tabsize=4]
+----
+include::../maven/packaging-oci-image/bind-caches-pom.xml[tags=caches]
+----
+
+
+
 [[build-image.examples.docker]]
 === Docker Configuration
 
+
+
 [[build-image.examples.docker.minikube]]
 ==== Docker Configuration for minikube
-
 The plugin can communicate with the https://minikube.sigs.k8s.io/docs/tasks/docker_daemon/[Docker daemon provided by minikube] instead of the default local connection.
 
 On Linux and macOS, environment variables can be set using the command `eval $(minikube docker-env)` after minikube has been started.
@@ -427,9 +477,10 @@ The plugin can also be configured to use the minikube daemon by providing connec
 include::../maven/packaging-oci-image/docker-minikube-pom.xml[tags=docker-minikube]
 ----
 
+
+
 [[build-image.examples.docker.podman]]
 ==== Docker Configuration for podman
-
 The plugin can communicate with a https://podman.io/[podman container engine].
 
 The plugin can be configured to use podman local connection by providing connection details similar to those shown in the following example:
@@ -439,11 +490,26 @@ The plugin can be configured to use podman local connection by providing connect
 include::../maven/packaging-oci-image/docker-podman-pom.xml[tags=docker-podman]
 ----
 
-TIP: With the `podman` CLI installed, the command `podman info --format='{{.Host.RemoteSocket.Path}}'` can be used to get the value for the `docker.host` configuration property shown in this example.
+TIP: With the `colima` CLI installed, the command `podman info --format='{{.Host.RemoteSocket.Path}}'` can be used to get the value for the `docker.host` configuration property shown in this example.
+
+
+
+[[build-image.examples.docker.colima]]
+==== Docker Configuration for Colima
+The plugin can communicate with the Docker daemon provided by https://github.com/abiosoft/colima[Colima].
+The `DOCKER_HOST` environment variable can be set by using the command `export DOCKER_HOST=$(docker context inspect colima -f '{{.Endpoints.docker.Host}}').`
+
+The plugin can also be configured to use Colima daemon by providing connection details similar to those shown in the following example:
+
+[source,xml,indent=0,subs="verbatim,attributes",tabsize=4]
+----
+include::../maven/packaging-oci-image/docker-colima-pom.xml[tags=docker-colima]
+----
+
+
 
 [[build-image.examples.docker.auth]]
 ==== Docker Configuration for Authentication
-
 If the builder or run image are stored in a private Docker registry that supports user authentication, authentication details can be provided using `docker.builderRegistry` parameters as shown in the following example:
 
 [source,xml,indent=0,subs="verbatim,attributes",tabsize=4]
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/packaging.adoc b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/packaging.adoc
index 08ec1a6c0c7e..b6e0e89969f3 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/packaging.adoc
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/packaging.adoc
@@ -210,7 +210,7 @@ include::../maven/packaging/custom-layout-pom.xml[tags=custom-layout]
 The layout factory is provided as an implementation of `LayoutFactory` (from `spring-boot-loader-tools`) explicitly specified in the pom.
 If there is only one custom `LayoutFactory` on the plugin classpath and it is listed in `META-INF/spring.factories` then it is unnecessary to explicitly set it in the plugin configuration.
 
-Layout factories are always ignored if an explicit <<goals-repackage-parameters-details-layoutFactory,layout>> is set.
+Layout factories are always ignored if an explicit <<packaging.repackage-goal.parameter-details.layout-factory,layout>> is set.
 
 
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/using.adoc b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/using.adoc
index 2b7847d1b273..5f1df1274881 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/using.adoc
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/using.adoc
@@ -8,7 +8,7 @@ The parent project provides the following features:
 * Compilation with `-parameters`.
 * A dependency management section, inherited from the `spring-boot-dependencies` POM, that manages the versions of common dependencies.
 This dependency management lets you omit `<version>` tags for those dependencies when used in your own POM.
-* An execution of the <<goals.adoc#goals-repackage, `repackage` goal>> with a `repackage` execution id.
+* An execution of the <<goals.adoc#packaging.repackage-goal, `repackage` goal>> with a `repackage` execution id.
 * A `native` profile that configures the build to be able to generate a Native image.
 * Sensible https://maven.apache.org/plugins/maven-resources-plugin/examples/filter.html[resource filtering].
 * Sensible plugin configuration (https://github.com/ktoso/maven-git-commit-id-plugin[Git commit ID], and https://maven.apache.org/plugins/maven-shade-plugin/[shade]).
@@ -17,7 +17,24 @@ This dependency management lets you omit `<version>` tags for those dependencies
 NOTE: Since the `application.properties` and `application.yml` files accept Spring style placeholders (`${...}`), the Maven filtering is changed to use `@..@` placeholders.
 (You can override that by setting a Maven property called `resource.delimiter`.)
 
+[NOTE]
+====
+The `spring-boot-starter-parent` sets the `maven.compiler.release` property, which restricts the `--add-exports`, `--add-reads`, and `--patch-module` options https://openjdk.org/jeps/247[if they modify system modules].
+In case you need to use those options, unset `maven.compiler.release`:
 
+[source,xml,indent=0,subs="verbatim,quotes,attributes"]
+----
+<maven.compiler.release></maven.compiler.release>
+----
+
+and then configure the source and the target options instead:
+
+[source,xml,indent=0,subs="verbatim,quotes,attributes"]
+----
+<maven.compiler.source>${java.version}</maven.compiler.source>
+<maven.compiler.target>${java.version}</maven.compiler.target>
+----
+====
 
 [[using.parent-pom]]
 == Inheriting the Starter Parent POM
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/maven/build-info/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/maven/build-info/pom.xml
index 976cd10e77c3..5191fc4ff886 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/maven/build-info/pom.xml
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/maven/build-info/pom.xml
@@ -17,8 +17,7 @@
 							<additionalProperties>
 								<encoding.source>UTF-8</encoding.source>
 								<encoding.reporting>UTF-8</encoding.reporting>
-								<java.source>${maven.compiler.source}</java.source>
-								<java.target>${maven.compiler.target}</java.target>
+								<java.version>${java.version}</java.version>
 							</additionalProperties>
 						</configuration>
 					</execution>
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/maven/packaging-oci-image/bind-caches-pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/maven/packaging-oci-image/bind-caches-pom.xml
new file mode 100644
index 000000000000..a67c45a0ed5d
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/maven/packaging-oci-image/bind-caches-pom.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- tag::caches[] -->
+<project>
+	<build>
+		<plugins>
+			<plugin>
+				<groupId>org.springframework.boot</groupId>
+				<artifactId>spring-boot-maven-plugin</artifactId>
+				<configuration>
+					<image>
+						<buildWorkspace>
+							<bind>
+								<source>/tmp/cache-${project.artifactId}.work</source>
+							</bind>
+						</buildWorkspace>
+						<buildCache>
+							<bind>
+								<source>/tmp/cache-${project.artifactId}.build</source>
+							</bind>
+						</buildCache>
+						<launchCache>
+							<bind>
+								<source>/tmp/cache-${project.artifactId}.launch</source>
+							</bind>
+						</launchCache>
+					</image>
+				</configuration>
+			</plugin>
+		</plugins>
+	</build>
+</project>
+<!-- end::caches[] -->
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/maven/packaging-oci-image/docker-colima-pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/maven/packaging-oci-image/docker-colima-pom.xml
new file mode 100644
index 000000000000..12a048f8ddd3
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/maven/packaging-oci-image/docker-colima-pom.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- tag::docker-colima[] -->
+<project>
+	<build>
+		<plugins>
+			<plugin>
+				<groupId>org.springframework.boot</groupId>
+				<artifactId>spring-boot-maven-plugin</artifactId>
+				<configuration>
+					<docker>
+						<host>unix:///${user.home}/.colima/docker.sock</host>
+					</docker>
+				</configuration>
+			</plugin>
+		</plugins>
+	</build>
+</project>
+<!-- end::docker-colima[] -->
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/BuildImageTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/BuildImageTests.java
index b80cdc0bc44e..b4d0fffeb5be 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/BuildImageTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/BuildImageTests.java
@@ -26,6 +26,7 @@
 import java.util.stream.IntStream;
 
 import org.junit.jupiter.api.TestTemplate;
+import org.junit.jupiter.api.condition.EnabledOnOs;
 import org.junit.jupiter.api.condition.OS;
 import org.junit.jupiter.api.extension.ExtendWith;
 
@@ -37,6 +38,7 @@
 import org.springframework.boot.buildpack.platform.docker.type.VolumeName;
 import org.springframework.boot.testsupport.junit.DisabledOnOs;
 import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable;
+import org.springframework.util.FileSystemUtils;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -305,7 +307,7 @@ void whenBuildImageIsInvokedWithZipPackaging(MavenBuild mavenBuild) {
 				assertThat(jar).isFile();
 				assertThat(buildLog(project)).contains("Building image")
 					.contains("docker.io/library/build-image-zip-packaging:0.0.1.BUILD-SNAPSHOT")
-					.contains("Main-Class: org.springframework.boot.loader.PropertiesLauncher")
+					.contains("Main-Class: org.springframework.boot.loader.launch.PropertiesLauncher")
 					.contains("Successfully built image");
 				removeImage("build-image-zip-packaging", "0.0.1.BUILD-SNAPSHOT");
 			});
@@ -385,19 +387,43 @@ void whenBuildImageIsInvokedWithTags(MavenBuild mavenBuild) {
 	@TestTemplate
 	void whenBuildImageIsInvokedWithVolumeCaches(MavenBuild mavenBuild) {
 		String testBuildId = randomString();
-		mavenBuild.project("build-image-caches")
+		mavenBuild.project("build-image-volume-caches")
 			.goals("package")
 			.systemProperty("spring-boot.build-image.pullPolicy", "IF_NOT_PRESENT")
 			.systemProperty("test-build-id", testBuildId)
 			.execute((project) -> {
 				assertThat(buildLog(project)).contains("Building image")
-					.contains("docker.io/library/build-image-caches:0.0.1.BUILD-SNAPSHOT")
+					.contains("docker.io/library/build-image-volume-caches:0.0.1.BUILD-SNAPSHOT")
 					.contains("Successfully built image");
-				removeImage("build-image-caches", "0.0.1.BUILD-SNAPSHOT");
+				removeImage("build-image-volume-caches", "0.0.1.BUILD-SNAPSHOT");
 				deleteVolumes("cache-" + testBuildId + ".build", "cache-" + testBuildId + ".launch");
 			});
 	}
 
+	@TestTemplate
+	@EnabledOnOs(value = OS.LINUX, disabledReason = "Works with Docker Engine on Linux but is not reliable with "
+			+ "Docker Desktop on other OSs")
+	void whenBuildImageIsInvokedWithBindCaches(MavenBuild mavenBuild) {
+		String testBuildId = randomString();
+		mavenBuild.project("build-image-bind-caches")
+			.goals("package")
+			.systemProperty("spring-boot.build-image.pullPolicy", "IF_NOT_PRESENT")
+			.systemProperty("test-build-id", testBuildId)
+			.execute((project) -> {
+				assertThat(buildLog(project)).contains("Building image")
+					.contains("docker.io/library/build-image-bind-caches:0.0.1.BUILD-SNAPSHOT")
+					.contains("Successfully built image");
+				removeImage("build-image-bind-caches", "0.0.1.BUILD-SNAPSHOT");
+				String tempDir = System.getProperty("java.io.tmpdir");
+				Path buildCachePath = Paths.get(tempDir, "junit-image-cache-" + testBuildId + "-build");
+				Path launchCachePath = Paths.get(tempDir, "junit-image-cache-" + testBuildId + "-launch");
+				assertThat(buildCachePath).exists().isDirectory();
+				assertThat(launchCachePath).exists().isDirectory();
+				FileSystemUtils.deleteRecursively(buildCachePath);
+				FileSystemUtils.deleteRecursively(launchCachePath);
+			});
+	}
+
 	@TestTemplate
 	void whenBuildImageIsInvokedWithCreatedDate(MavenBuild mavenBuild) {
 		String testBuildId = randomString();
@@ -454,6 +480,21 @@ void whenBuildImageIsInvokedWithApplicationDirectory(MavenBuild mavenBuild) {
 			});
 	}
 
+	@TestTemplate
+	void whenBuildImageIsInvokedWithEmptySecurityOptions(MavenBuild mavenBuild) {
+		String testBuildId = randomString();
+		mavenBuild.project("build-image-security-opts")
+			.goals("package")
+			.systemProperty("spring-boot.build-image.pullPolicy", "IF_NOT_PRESENT")
+			.systemProperty("test-build-id", testBuildId)
+			.execute((project) -> {
+				assertThat(buildLog(project)).contains("Building image")
+					.contains("docker.io/library/build-image-security-opts:0.0.1.BUILD-SNAPSHOT")
+					.contains("Successfully built image");
+				removeImage("build-image-security-opts", "0.0.1.BUILD-SNAPSHOT");
+			});
+	}
+
 	@TestTemplate
 	void failsWhenBuildImageIsInvokedOnMultiModuleProjectWithBuildImageGoal(MavenBuild mavenBuild) {
 		mavenBuild.project("build-image-multi-module")
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/JarIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/JarIntegrationTests.java
index 46ad4f7f3a83..b89459cdf340 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/JarIntegrationTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/JarIntegrationTests.java
@@ -57,7 +57,7 @@ void whenJarIsRepackagedInPlaceOnlyRepackagedJarIsInstalled(MavenBuild mavenBuil
 			File repackaged = new File(project, "target/jar-0.0.1.BUILD-SNAPSHOT.jar");
 			assertThat(launchScript(repackaged)).isEmpty();
 			assertThat(jar(repackaged)).manifest((manifest) -> {
-				manifest.hasMainClass("org.springframework.boot.loader.JarLauncher");
+				manifest.hasMainClass("org.springframework.boot.loader.launch.JarLauncher");
 				manifest.hasStartClass("some.random.Main");
 				manifest.hasAttribute("Not-Used", "Foo");
 			})
@@ -66,7 +66,27 @@ void whenJarIsRepackagedInPlaceOnlyRepackagedJarIsInstalled(MavenBuild mavenBuil
 				.hasEntryWithNameStartingWith("BOOT-INF/lib/spring-jcl")
 				.hasEntryWithNameStartingWith("BOOT-INF/lib/jakarta.servlet-api-6")
 				.hasEntryWithName("BOOT-INF/classes/org/test/SampleApplication.class")
-				.hasEntryWithName("org/springframework/boot/loader/JarLauncher.class");
+				.hasEntryWithName("org/springframework/boot/loader/launch/JarLauncher.class");
+			assertThat(buildLog(project))
+				.contains("Replacing main artifact " + repackaged + " with repackaged archive,")
+				.contains("The original artifact has been renamed to " + original)
+				.contains("Installing " + repackaged + " to")
+				.doesNotContain("Installing " + original + " to");
+		});
+	}
+
+	@TestTemplate
+	void whenJarWithClassicLoaderIsRepackagedInPlaceOnlyRepackagedJarIsInstalled(MavenBuild mavenBuild) {
+		mavenBuild.project("jar-with-classic-loader").goals("install").execute((project) -> {
+			File original = new File(project, "target/jar-with-classic-loader-0.0.1.BUILD-SNAPSHOT.jar.original");
+			assertThat(original).isFile();
+			File repackaged = new File(project, "target/jar-with-classic-loader-0.0.1.BUILD-SNAPSHOT.jar");
+			assertThat(launchScript(repackaged)).isEmpty();
+			assertThat(jar(repackaged)).manifest((manifest) -> {
+				manifest.hasMainClass("org.springframework.boot.loader.launch.JarLauncher");
+				manifest.hasStartClass("some.random.Main");
+				manifest.hasAttribute("Not-Used", "Foo");
+			}).hasEntryWithName("org/springframework/boot/loader/launch/JarLauncher.class");
 			assertThat(buildLog(project))
 				.contains("Replacing main artifact " + repackaged + " with repackaged archive,")
 				.contains("The original artifact has been renamed to " + original)
@@ -273,9 +293,9 @@ void whenAProjectIsBuiltWithALayoutPropertyTheSpecifiedLayoutIsUsed(MavenBuild m
 			.goals("package", "-Dspring-boot.repackage.layout=ZIP")
 			.execute((project) -> {
 				File main = new File(project, "target/jar-with-layout-property-0.0.1.BUILD-SNAPSHOT.jar");
-				assertThat(jar(main))
-					.manifest((manifest) -> manifest.hasMainClass("org.springframework.boot.loader.PropertiesLauncher")
-						.hasStartClass("org.test.SampleApplication"));
+				assertThat(jar(main)).manifest(
+						(manifest) -> manifest.hasMainClass("org.springframework.boot.loader.launch.PropertiesLauncher")
+							.hasStartClass("org.test.SampleApplication"));
 				assertThat(buildLog(project)).contains("Layout: ZIP");
 			});
 	}
@@ -284,9 +304,9 @@ void whenAProjectIsBuiltWithALayoutPropertyTheSpecifiedLayoutIsUsed(MavenBuild m
 	void whenALayoutIsConfiguredTheSpecifiedLayoutIsUsed(MavenBuild mavenBuild) {
 		mavenBuild.project("jar-with-zip-layout").execute((project) -> {
 			File main = new File(project, "target/jar-with-zip-layout-0.0.1.BUILD-SNAPSHOT.jar");
-			assertThat(jar(main))
-				.manifest((manifest) -> manifest.hasMainClass("org.springframework.boot.loader.PropertiesLauncher")
-					.hasStartClass("org.test.SampleApplication"));
+			assertThat(jar(main)).manifest(
+					(manifest) -> manifest.hasMainClass("org.springframework.boot.loader.launch.PropertiesLauncher")
+						.hasStartClass("org.test.SampleApplication"));
 			assertThat(buildLog(project)).contains("Layout: ZIP");
 		});
 	}
@@ -428,7 +448,8 @@ private String buildJarWithOutputTimestamp(MavenBuild mavenBuild) {
 	void whenJarIsRepackagedWithOutputTimestampConfiguredThenLibrariesAreSorted(MavenBuild mavenBuild) {
 		mavenBuild.project("jar-output-timestamp").execute((project) -> {
 			File repackaged = new File(project, "target/jar-output-timestamp-0.0.1.BUILD-SNAPSHOT.jar");
-			List<String> sortedLibs = Arrays.asList("BOOT-INF/lib/jakarta.servlet-api", "BOOT-INF/lib/spring-aop",
+			List<String> sortedLibs = Arrays.asList("BOOT-INF/lib/jakarta.servlet-api",
+					"BOOT-INF/lib/micrometer-commons", "BOOT-INF/lib/micrometer-observation", "BOOT-INF/lib/spring-aop",
 					"BOOT-INF/lib/spring-beans", "BOOT-INF/lib/spring-boot-jarmode-layertools",
 					"BOOT-INF/lib/spring-context", "BOOT-INF/lib/spring-core", "BOOT-INF/lib/spring-expression",
 					"BOOT-INF/lib/spring-jcl");
@@ -438,4 +459,12 @@ void whenJarIsRepackagedWithOutputTimestampConfiguredThenLibrariesAreSorted(Mave
 		});
 	}
 
+	@TestTemplate
+	void whenSigned(MavenBuild mavenBuild) {
+		mavenBuild.project("jar-signed").execute((project) -> {
+			File repackaged = new File(project, "target/jar-signed-0.0.1.BUILD-SNAPSHOT.jar");
+			assertThat(jar(repackaged)).hasEntryWithName("META-INF/BOOT.SF");
+		});
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/RunIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/RunIntegrationTests.java
index ec9f69901b2f..12382d58ef45 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/RunIntegrationTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/RunIntegrationTests.java
@@ -30,6 +30,7 @@
  * Integration tests for the Maven plugin's run goal.
  *
  * @author Andy Wilkinson
+ * @author Stephane Nicoll
  */
 @ExtendWith(MavenBuildExtension.class)
 class RunIntegrationTests {
@@ -107,6 +108,28 @@ void whenAWorkingDirectoryIsConfiguredTheApplicationIsRunFromThatDirectory(Maven
 			.execute((project) -> assertThat(buildLog(project)).containsPattern("I haz been run from.*src.main.java"));
 	}
 
+	@TestTemplate
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	void whenDirectoriesAreConfiguredTheyAreAvailableToTheApplication(MavenBuild mavenBuild) {
+		mavenBuild.project("run-directories")
+			.goals("spring-boot:run")
+			.execute((project) -> assertThat(buildLog(project)).contains("I haz been run"));
+	}
+
+	@TestTemplate
+	void whenAdditionalClasspathDirectoryIsConfiguredItsResourcesAreAvailableToTheApplication(MavenBuild mavenBuild) {
+		mavenBuild.project("run-additional-classpath-directory")
+			.goals("spring-boot:run")
+			.execute((project) -> assertThat(buildLog(project)).contains("I haz been run"));
+	}
+
+	@TestTemplate
+	void whenAdditionalClasspathFileIsConfiguredItsContentIsAvailableToTheApplication(MavenBuild mavenBuild) {
+		mavenBuild.project("run-additional-classpath-jar")
+			.goals("spring-boot:run")
+			.execute((project) -> assertThat(buildLog(project)).contains("I haz been run"));
+	}
+
 	@TestTemplate
 	@DisabledOnOs(OS.WINDOWS)
 	void whenAToolchainIsConfiguredItIsUsedToRunTheApplication(MavenBuild mavenBuild) {
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/WarIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/WarIntegrationTests.java
index 77fd8842ec71..e7cebf3c576b 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/WarIntegrationTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/WarIntegrationTests.java
@@ -57,10 +57,10 @@ void warRepackaging(MavenBuild mavenBuild) {
 				.hasEntryWithNameStartingWith("WEB-INF/lib/spring-core")
 				.hasEntryWithNameStartingWith("WEB-INF/lib/spring-jcl")
 				.hasEntryWithNameStartingWith("WEB-INF/lib-provided/jakarta.servlet-api-6")
-				.hasEntryWithName("org/springframework/boot/loader/WarLauncher.class")
+				.hasEntryWithName("org/springframework/boot/loader/launch/WarLauncher.class")
 				.hasEntryWithName("WEB-INF/classes/org/test/SampleApplication.class")
 				.hasEntryWithName("index.html")
-				.manifest((manifest) -> manifest.hasMainClass("org.springframework.boot.loader.WarLauncher")
+				.manifest((manifest) -> manifest.hasMainClass("org.springframework.boot.loader.launch.WarLauncher")
 					.hasStartClass("org.test.SampleApplication")
 					.hasAttribute("Not-Used", "Foo")));
 	}
@@ -122,8 +122,9 @@ void whenWarIsRepackagedWithOutputTimestampConfiguredThenLibrariesAreSorted(Mave
 			List<String> sortedLibs = Arrays.asList(
 					// these libraries are copied from the original war, sorted when
 					// packaged by Maven
-					"WEB-INF/lib/spring-aop", "WEB-INF/lib/spring-beans", "WEB-INF/lib/spring-context",
-					"WEB-INF/lib/spring-core", "WEB-INF/lib/spring-expression", "WEB-INF/lib/spring-jcl",
+					"WEB-INF/lib/micrometer-commons", "WEB-INF/lib/micrometer-observation", "WEB-INF/lib/spring-aop",
+					"WEB-INF/lib/spring-beans", "WEB-INF/lib/spring-context", "WEB-INF/lib/spring-core",
+					"WEB-INF/lib/spring-expression", "WEB-INF/lib/spring-jcl",
 					// these libraries are contributed by Spring Boot repackaging, and
 					// sorted separately
 					"WEB-INF/lib/spring-boot-jarmode-layertools");
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-bind-caches/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-bind-caches/pom.xml
new file mode 100644
index 000000000000..7f09ff829236
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-bind-caches/pom.xml
@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+		 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+	<modelVersion>4.0.0</modelVersion>
+	<groupId>org.springframework.boot.maven.it</groupId>
+	<artifactId>build-image-bind-caches</artifactId>
+	<version>0.0.1.BUILD-SNAPSHOT</version>
+	<properties>
+		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+		<maven.compiler.source>@java.version@</maven.compiler.source>
+		<maven.compiler.target>@java.version@</maven.compiler.target>
+	</properties>
+	<build>
+		<plugins>
+			<plugin>
+				<groupId>@project.groupId@</groupId>
+				<artifactId>@project.artifactId@</artifactId>
+				<version>@project.version@</version>
+				<executions>
+					<execution>
+						<goals>
+							<goal>build-image-no-fork</goal>
+						</goals>
+						<configuration>
+							<image>
+								<builder>projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2</builder>
+								<buildWorkspace>
+									<bind>
+										<source>${java.io.tmpdir}/junit-image-cache-${test-build-id}-work</source>
+									</bind>
+								</buildWorkspace>
+								<buildCache>
+									<bind>
+										<source>${java.io.tmpdir}/junit-image-cache-${test-build-id}-build</source>
+									</bind>
+								</buildCache>
+								<launchCache>
+									<bind>
+										<source>${java.io.tmpdir}/junit-image-cache-${test-build-id}-launch</source>
+									</bind>
+								</launchCache>
+							</image>
+						</configuration>
+					</execution>
+				</executions>
+			</plugin>
+		</plugins>
+	</build>
+</project>
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-bind-caches/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-bind-caches/src/main/java/org/test/SampleApplication.java
new file mode 100644
index 000000000000..03544b74e463
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-bind-caches/src/main/java/org/test/SampleApplication.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.test;
+
+public class SampleApplication {
+
+	public static void main(String[] args) throws Exception {
+		System.out.println("Launched");
+		synchronized(args) {
+			args.wait(); // Prevent exit
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches/pom.xml
deleted file mode 100644
index f95eb39f874e..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches/pom.xml
+++ /dev/null
@@ -1,44 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-		 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
-	<modelVersion>4.0.0</modelVersion>
-	<groupId>org.springframework.boot.maven.it</groupId>
-	<artifactId>build-image-caches</artifactId>
-	<version>0.0.1.BUILD-SNAPSHOT</version>
-	<properties>
-		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
-		<maven.compiler.source>@java.version@</maven.compiler.source>
-		<maven.compiler.target>@java.version@</maven.compiler.target>
-	</properties>
-	<build>
-		<plugins>
-			<plugin>
-				<groupId>@project.groupId@</groupId>
-				<artifactId>@project.artifactId@</artifactId>
-				<version>@project.version@</version>
-				<executions>
-					<execution>
-						<goals>
-							<goal>build-image-no-fork</goal>
-						</goals>
-						<configuration>
-							<image>
-								<builder>projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2</builder>
-								<buildCache>
-									<volume>
-										<name>cache-${test-build-id}.build</name>
-									</volume>
-								</buildCache>
-								<launchCache>
-									<volume>
-										<name>cache-${test-build-id}.launch</name>
-									</volume>
-								</launchCache>
-							</image>
-						</configuration>
-					</execution>
-				</executions>
-			</plugin>
-		</plugins>
-	</build>
-</project>
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches/src/main/java/org/test/SampleApplication.java
deleted file mode 100644
index e964724deacd..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches/src/main/java/org/test/SampleApplication.java
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * Copyright 2012-2021 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.test;
-
-public class SampleApplication {
-
-	public static void main(String[] args) throws Exception {
-		System.out.println("Launched");
-		synchronized(args) {
-			args.wait(); // Prevent exit
-		}
-	}
-
-}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-security-opts/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-security-opts/pom.xml
new file mode 100644
index 000000000000..5eee589a4660
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-security-opts/pom.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+	<modelVersion>4.0.0</modelVersion>
+	<groupId>org.springframework.boot.maven.it</groupId>
+	<artifactId>build-image-security-opts</artifactId>
+	<version>0.0.1.BUILD-SNAPSHOT</version>
+	<properties>
+		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+		<maven.compiler.source>@java.version@</maven.compiler.source>
+		<maven.compiler.target>@java.version@</maven.compiler.target>
+	</properties>
+	<build>
+		<plugins>
+			<plugin>
+				<groupId>@project.groupId@</groupId>
+				<artifactId>@project.artifactId@</artifactId>
+				<version>@project.version@</version>
+				<executions>
+					<execution>
+						<goals>
+							<goal>build-image-no-fork</goal>
+						</goals>
+						<configuration>
+							<image>
+								<builder>projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2</builder>
+								<security-options/>
+							</image>
+						</configuration>
+					</execution>
+				</executions>
+			</plugin>
+		</plugins>
+	</build>
+</project>
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-security-opts/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-security-opts/src/main/java/org/test/SampleApplication.java
new file mode 100644
index 000000000000..58ebebbbb234
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-security-opts/src/main/java/org/test/SampleApplication.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.test;
+
+public class SampleApplication {
+
+	public static void main(String[] args) throws Exception {
+		System.out.println("Launched");
+		synchronized(args) {
+			args.wait(); // Prevent exit"
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-volume-caches/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-volume-caches/pom.xml
new file mode 100644
index 000000000000..5a3d3ec76e86
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-volume-caches/pom.xml
@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+		 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+	<modelVersion>4.0.0</modelVersion>
+	<groupId>org.springframework.boot.maven.it</groupId>
+	<artifactId>build-image-volume-caches</artifactId>
+	<version>0.0.1.BUILD-SNAPSHOT</version>
+	<properties>
+		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+		<maven.compiler.source>@java.version@</maven.compiler.source>
+		<maven.compiler.target>@java.version@</maven.compiler.target>
+	</properties>
+	<build>
+		<plugins>
+			<plugin>
+				<groupId>@project.groupId@</groupId>
+				<artifactId>@project.artifactId@</artifactId>
+				<version>@project.version@</version>
+				<executions>
+					<execution>
+						<goals>
+							<goal>build-image-no-fork</goal>
+						</goals>
+						<configuration>
+							<image>
+								<builder>projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2</builder>
+								<buildWorkspace>
+									<volume>
+										<name>cache-${test-build-id}.work</name>
+									</volume>
+								</buildWorkspace>
+								<buildCache>
+									<volume>
+										<name>cache-${test-build-id}.build</name>
+									</volume>
+								</buildCache>
+								<launchCache>
+									<volume>
+										<name>cache-${test-build-id}.launch</name>
+									</volume>
+								</launchCache>
+							</image>
+						</configuration>
+					</execution>
+				</executions>
+			</plugin>
+		</plugins>
+	</build>
+</project>
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-volume-caches/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-volume-caches/src/main/java/org/test/SampleApplication.java
new file mode 100644
index 000000000000..03544b74e463
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-volume-caches/src/main/java/org/test/SampleApplication.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.test;
+
+public class SampleApplication {
+
+	public static void main(String[] args) throws Exception {
+		System.out.println("Launched");
+		synchronized(args) {
+			args.wait(); // Prevent exit
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-layered-custom/jar/src/layers.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-layered-custom/jar/src/layers.xml
index 418078fe423e..41e9157bb728 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-layered-custom/jar/src/layers.xml
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-layered-custom/jar/src/layers.xml
@@ -1,7 +1,7 @@
 <layers xmlns="http://www.springframework.org/schema/boot/layers"
 	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 	xsi:schemaLocation="http://www.springframework.org/schema/boot/layers
-					  https://www.springframework.org/schema/layers/layers-3.1.xsd">
+					  https://www.springframework.org/schema/layers/layers-3.2.xsd">
 	<application>
 		<into layer="configuration">
 			<include>**/application*.*</include>
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-signed/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-signed/pom.xml
new file mode 100644
index 000000000000..375d3c60b3dc
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-signed/pom.xml
@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+	<modelVersion>4.0.0</modelVersion>
+	<groupId>org.springframework.boot.maven.it</groupId>
+	<artifactId>jar-signed</artifactId>
+	<version>0.0.1.BUILD-SNAPSHOT</version>
+	<properties>
+		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+		<maven.compiler.source>@java.version@</maven.compiler.source>
+		<maven.compiler.target>@java.version@</maven.compiler.target>
+	</properties>
+	<build>
+		<plugins>
+			<plugin>
+				<groupId>@project.groupId@</groupId>
+				<artifactId>@project.artifactId@</artifactId>
+				<version>@project.version@</version>
+				<executions>
+					<execution>
+						<goals>
+							<goal>repackage</goal>
+						</goals>
+					</execution>
+				</executions>
+			</plugin>
+			<plugin>
+				<groupId>org.apache.maven.plugins</groupId>
+				<artifactId>maven-jar-plugin</artifactId>
+				<version>@maven-jar-plugin.version@</version>
+				<configuration>
+					<archive>
+						<manifest>
+							<mainClass>some.random.Main</mainClass>
+						</manifest>
+						<manifestEntries>
+							<Not-Used>Foo</Not-Used>
+						</manifestEntries>
+					</archive>
+				</configuration>
+			</plugin>
+		</plugins>
+	</build>
+	<dependencies>
+		<dependency>
+			<groupId>org.springframework</groupId>
+			<artifactId>spring-context</artifactId>
+			<version>@spring-framework.version@</version>
+		</dependency>
+		<dependency>
+			<groupId>jakarta.servlet</groupId>
+			<artifactId>jakarta.servlet-api</artifactId>
+			<version>@jakarta-servlet.version@</version>
+			<scope>provided</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.bouncycastle</groupId>
+			<artifactId>bcprov-jdk18on</artifactId>
+			<version>1.76</version>
+		</dependency>
+	</dependencies>
+</project>
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-signed/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-signed/src/main/java/org/test/SampleApplication.java
new file mode 100644
index 000000000000..5e51546d4e0d
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-signed/src/main/java/org/test/SampleApplication.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.test;
+
+public class SampleApplication {
+
+	public static void main(String[] args) {
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-with-classic-loader/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-with-classic-loader/pom.xml
new file mode 100644
index 000000000000..ce29e60f4029
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-with-classic-loader/pom.xml
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+	<modelVersion>4.0.0</modelVersion>
+	<groupId>org.springframework.boot.maven.it</groupId>
+	<artifactId>jar-with-classic-loader</artifactId>
+	<version>0.0.1.BUILD-SNAPSHOT</version>
+	<properties>
+		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+		<maven.compiler.source>@java.version@</maven.compiler.source>
+		<maven.compiler.target>@java.version@</maven.compiler.target>
+	</properties>
+	<build>
+		<plugins>
+			<plugin>
+				<groupId>@project.groupId@</groupId>
+				<artifactId>@project.artifactId@</artifactId>
+				<version>@project.version@</version>
+				<executions>
+					<execution>
+						<goals>
+							<goal>repackage</goal>
+						</goals>
+						<configuration>
+							<loaderImplementation>CLASSIC</loaderImplementation>
+						</configuration>
+					</execution>
+				</executions>
+			</plugin>
+			<plugin>
+				<groupId>org.apache.maven.plugins</groupId>
+				<artifactId>maven-jar-plugin</artifactId>
+				<version>@maven-jar-plugin.version@</version>
+				<configuration>
+					<archive>
+						<manifest>
+							<mainClass>some.random.Main</mainClass>
+						</manifest>
+						<manifestEntries>
+							<Not-Used>Foo</Not-Used>
+						</manifestEntries>
+					</archive>
+				</configuration>
+			</plugin>
+		</plugins>
+	</build>
+	<dependencies>
+		<dependency>
+			<groupId>org.springframework</groupId>
+			<artifactId>spring-context</artifactId>
+			<version>@spring-framework.version@</version>
+		</dependency>
+		<dependency>
+			<groupId>jakarta.servlet</groupId>
+			<artifactId>jakarta.servlet-api</artifactId>
+			<version>@jakarta-servlet.version@</version>
+			<scope>provided</scope>
+		</dependency>
+	</dependencies>
+</project>
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-with-classic-loader/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-with-classic-loader/src/main/java/org/test/SampleApplication.java
new file mode 100644
index 000000000000..5e51546d4e0d
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-with-classic-loader/src/main/java/org/test/SampleApplication.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.test;
+
+public class SampleApplication {
+
+	public static void main(String[] args) {
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/pom.xml
new file mode 100644
index 000000000000..a03170ba7d46
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/pom.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+	<modelVersion>4.0.0</modelVersion>
+	<groupId>org.springframework.boot.maven.it</groupId>
+	<artifactId>run-additional-classpath-directory</artifactId>
+	<version>0.0.1.BUILD-SNAPSHOT</version>
+	<properties>
+		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+		<maven.compiler.source>@java.version@</maven.compiler.source>
+		<maven.compiler.target>@java.version@</maven.compiler.target>
+	</properties>
+	<build>
+		<plugins>
+			<plugin>
+				<groupId>@project.groupId@</groupId>
+				<artifactId>@project.artifactId@</artifactId>
+				<version>@project.version@</version>
+				<configuration>
+					<additionalClasspathElements>
+						<additionalClasspathElement>src/main/additional-elements/</additionalClasspathElement>
+					</additionalClasspathElements>
+				</configuration>
+			</plugin>
+		</plugins>
+	</build>
+</project>
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/src/main/additional-elements/another/two.txt b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/src/main/additional-elements/another/two.txt
new file mode 100644
index 000000000000..d8263ee98605
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/src/main/additional-elements/another/two.txt
@@ -0,0 +1 @@
+2
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/src/main/additional-elements/one.txt b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/src/main/additional-elements/one.txt
new file mode 100644
index 000000000000..56a6051ca2b0
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/src/main/additional-elements/one.txt
@@ -0,0 +1 @@
+1
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/src/main/java/org/test/SampleApplication.java
new file mode 100644
index 000000000000..944441df246d
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/src/main/java/org/test/SampleApplication.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.test;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Scanner;
+
+public class SampleApplication {
+
+	public static void main(String[] args) {
+		if (!readContent("one.txt").contains("1")) {
+			throw new IllegalArgumentException("Invalid content for one.txt");
+		}
+		if (!readContent("another/two.txt").contains("2")) {
+			throw new IllegalArgumentException("Invalid content for another/two.txt");
+		}
+		System.out.println("I haz been run");
+	}
+
+	private static String readContent(String location) {
+		InputStream in = SampleApplication.class.getClassLoader().getResourceAsStream(location);
+		if (in == null) {
+			throw new IllegalArgumentException("Not found: '" + location + "'");
+		}
+		try (Scanner scanner = new Scanner(in, StandardCharsets.UTF_8)) {
+			return scanner.useDelimiter("\\A").next();
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-jar/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-jar/pom.xml
new file mode 100644
index 000000000000..7e1887b93fc4
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-jar/pom.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+	<modelVersion>4.0.0</modelVersion>
+	<groupId>org.springframework.boot.maven.it</groupId>
+	<artifactId>run-additional-classpath-directory</artifactId>
+	<version>0.0.1.BUILD-SNAPSHOT</version>
+	<properties>
+		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+		<maven.compiler.source>@java.version@</maven.compiler.source>
+		<maven.compiler.target>@java.version@</maven.compiler.target>
+	</properties>
+	<build>
+		<plugins>
+			<plugin>
+				<groupId>@project.groupId@</groupId>
+				<artifactId>@project.artifactId@</artifactId>
+				<version>@project.version@</version>
+				<configuration>
+					<additionalClasspathElements>
+						<additionalClasspathElement>src/main/additional-jar/resources-1.0.0.jar</additionalClasspathElement>
+					</additionalClasspathElements>
+				</configuration>
+			</plugin>
+		</plugins>
+	</build>
+</project>
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-jar/src/main/additional-jar/resources-1.0.0.jar b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-jar/src/main/additional-jar/resources-1.0.0.jar
new file mode 100644
index 000000000000..f6e05369c57d
Binary files /dev/null and b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-jar/src/main/additional-jar/resources-1.0.0.jar differ
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-jar/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-jar/src/main/java/org/test/SampleApplication.java
new file mode 100644
index 000000000000..944441df246d
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-jar/src/main/java/org/test/SampleApplication.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.test;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Scanner;
+
+public class SampleApplication {
+
+	public static void main(String[] args) {
+		if (!readContent("one.txt").contains("1")) {
+			throw new IllegalArgumentException("Invalid content for one.txt");
+		}
+		if (!readContent("another/two.txt").contains("2")) {
+			throw new IllegalArgumentException("Invalid content for another/two.txt");
+		}
+		System.out.println("I haz been run");
+	}
+
+	private static String readContent(String location) {
+		InputStream in = SampleApplication.class.getClassLoader().getResourceAsStream(location);
+		if (in == null) {
+			throw new IllegalArgumentException("Not found: '" + location + "'");
+		}
+		try (Scanner scanner = new Scanner(in, StandardCharsets.UTF_8)) {
+			return scanner.useDelimiter("\\A").next();
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/pom.xml
new file mode 100644
index 000000000000..4029ed38e431
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/pom.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+	<modelVersion>4.0.0</modelVersion>
+	<groupId>org.springframework.boot.maven.it</groupId>
+	<artifactId>run-directories</artifactId>
+	<version>0.0.1.BUILD-SNAPSHOT</version>
+	<properties>
+		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+		<maven.compiler.source>@java.version@</maven.compiler.source>
+		<maven.compiler.target>@java.version@</maven.compiler.target>
+	</properties>
+	<build>
+		<plugins>
+			<plugin>
+				<groupId>@project.groupId@</groupId>
+				<artifactId>@project.artifactId@</artifactId>
+				<version>@project.version@</version>
+				<configuration>
+					<directories>
+						<directory>src/main/additional-elements/</directory>
+					</directories>
+				</configuration>
+			</plugin>
+		</plugins>
+	</build>
+</project>
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/src/main/additional-elements/another/two.txt b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/src/main/additional-elements/another/two.txt
new file mode 100644
index 000000000000..d8263ee98605
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/src/main/additional-elements/another/two.txt
@@ -0,0 +1 @@
+2
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/src/main/additional-elements/one.txt b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/src/main/additional-elements/one.txt
new file mode 100644
index 000000000000..56a6051ca2b0
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/src/main/additional-elements/one.txt
@@ -0,0 +1 @@
+1
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/src/main/java/org/test/SampleApplication.java
new file mode 100644
index 000000000000..944441df246d
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/src/main/java/org/test/SampleApplication.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.test;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Scanner;
+
+public class SampleApplication {
+
+	public static void main(String[] args) {
+		if (!readContent("one.txt").contains("1")) {
+			throw new IllegalArgumentException("Invalid content for one.txt");
+		}
+		if (!readContent("another/two.txt").contains("2")) {
+			throw new IllegalArgumentException("Invalid content for another/two.txt");
+		}
+		System.out.println("I haz been run");
+	}
+
+	private static String readContent(String location) {
+		InputStream in = SampleApplication.class.getClassLoader().getResourceAsStream(location);
+		if (in == null) {
+			throw new IllegalArgumentException("Not found: '" + location + "'");
+		}
+		try (Scanner scanner = new Scanner(in, StandardCharsets.UTF_8)) {
+			return scanner.useDelimiter("\\A").next();
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/settings.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/settings.xml
index d63e2d6b8d06..8c1aed58a6cb 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/settings.xml
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/settings.xml
@@ -17,6 +17,7 @@
 					<snapshots>
 						<enabled>true</enabled>
 					</snapshots>
+					<checksumPolicy>ignore</checksumPolicy>
 				</repository>
 				<repository>
 					<id>spring-milestones</id>
@@ -42,6 +43,12 @@
 					<snapshots>
 						<enabled>true</enabled>
 					</snapshots>
+					<checksumPolicy>ignore</checksumPolicy>
+				</pluginRepository>
+				<pluginRepository>
+					<id>spring-milestones</id>
+					<name>Spring Milestones</name>
+					<url>https://repo.spring.io/milestone</url>
 				</pluginRepository>
 			</pluginRepositories>
 		</profile>
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/war-layered-custom/war/src/layers.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/war-layered-custom/war/src/layers.xml
index 418078fe423e..41e9157bb728 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/war-layered-custom/war/src/layers.xml
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/war-layered-custom/war/src/layers.xml
@@ -1,7 +1,7 @@
 <layers xmlns="http://www.springframework.org/schema/boot/layers"
 	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 	xsi:schemaLocation="http://www.springframework.org/schema/boot/layers
-					  https://www.springframework.org/schema/layers/layers-3.1.xsd">
+					  https://www.springframework.org/schema/layers/layers-3.2.xsd">
 	<application>
 		<into layer="configuration">
 			<include>**/application*.*</include>
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractAotMojo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractAotMojo.java
index 89287a2985ca..9b6707794d09 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractAotMojo.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractAotMojo.java
@@ -53,6 +53,7 @@
  *
  * @author Phillip Webb
  * @author Scott Frederick
+ * @author Omar YAYA
  * @since 3.0.0
  */
 public abstract class AbstractAotMojo extends AbstractDependencyFilterMojo {
@@ -95,6 +96,15 @@ public abstract class AbstractAotMojo extends AbstractDependencyFilterMojo {
 	@Parameter(property = "spring-boot.aot.compilerArguments")
 	private String compilerArguments;
 
+	/**
+	 * Return Maven execution session.
+	 * @return session
+	 * @since 3.0.10
+	 */
+	protected final MavenSession getSession() {
+		return this.session;
+	}
+
 	@Override
 	public void execute() throws MojoExecutionException, MojoFailureException {
 		if (this.skip) {
@@ -148,10 +158,16 @@ protected final void compileSourceFiles(URL[] classPath, File sourcesDirectory,
 				options.add(releaseVersion);
 			}
 			else {
-				options.add("--source");
-				options.add(compilerConfiguration.getSourceMajorVersion());
-				options.add("--target");
-				options.add(compilerConfiguration.getTargetMajorVersion());
+				String source = compilerConfiguration.getSourceMajorVersion();
+				if (source != null) {
+					options.add("--source");
+					options.add(source);
+				}
+				String target = compilerConfiguration.getTargetMajorVersion();
+				if (target != null) {
+					options.add("--target");
+					options.add(target);
+				}
 			}
 			options.addAll(new RunArguments(this.compilerArguments).getArgs());
 			Iterable<? extends JavaFileObject> compilationUnits = fileManager.getJavaFileObjectsFromPaths(sourceFiles);
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractPackagerMojo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractPackagerMojo.java
index b6dfdc04bb08..28d55d213a16 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractPackagerMojo.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractPackagerMojo.java
@@ -47,6 +47,7 @@
 import org.springframework.boot.loader.tools.Layouts.None;
 import org.springframework.boot.loader.tools.Layouts.War;
 import org.springframework.boot.loader.tools.Libraries;
+import org.springframework.boot.loader.tools.LoaderImplementation;
 import org.springframework.boot.loader.tools.Packager;
 import org.springframework.boot.loader.tools.layer.CustomLayers;
 
@@ -128,6 +129,15 @@ protected LayoutType getLayout() {
 		return null;
 	}
 
+	/**
+	 * Return the loader implementation that should be used.
+	 * @return the loader implementation or {@code null}
+	 * @since 3.2.0
+	 */
+	protected LoaderImplementation getLoaderImplementation() {
+		return null;
+	}
+
 	/**
 	 * Return the layout factory that will be used to determine the {@link LayoutType} if
 	 * no explicit layout is set.
@@ -145,6 +155,7 @@ protected LayoutFactory getLayoutFactory() {
 	 */
 	protected <P extends Packager> P getConfiguredPackager(Supplier<P> supplier) {
 		P packager = supplier.get();
+		packager.setLoaderImplementation(getLoaderImplementation());
 		packager.setLayoutFactory(getLayoutFactory());
 		packager.addMainClassTimeoutWarningListener(new LoggingMainClassTimeoutWarningListener(this::getLog));
 		packager.setMainClass(this.mainClass);
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractRunMojo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractRunMojo.java
index c2c6a9147722..18a4009620ad 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractRunMojo.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractRunMojo.java
@@ -39,6 +39,8 @@
 import org.apache.maven.toolchain.ToolchainManager;
 
 import org.springframework.boot.loader.tools.FileUtils;
+import org.springframework.util.Assert;
+import org.springframework.util.ObjectUtils;
 
 /**
  * Base class to run a Spring Boot application.
@@ -165,13 +167,24 @@ public abstract class AbstractRunMojo extends AbstractDependencyFilterMojo {
 	private String mainClass;
 
 	/**
-	 * Additional directories besides the classes directory that should be added to the
+	 * Additional directories containing classes or resources that should be added to the
 	 * classpath.
 	 * @since 1.0.0
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of
+	 * 'additionalClasspathElements'
 	 */
 	@Parameter(property = "spring-boot.run.directories")
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	private String[] directories;
 
+	/**
+	 * Additional classpath elements that should be added to the classpath. An element can
+	 * be a directory with classes and resources or a jar file.
+	 * @since 3.2.0
+	 */
+	@Parameter(property = "spring-boot.run.additional-classpath-elements")
+	private String[] additionalClasspathElements;
+
 	/**
 	 * Directory containing the classes and resource files that should be used to run the
 	 * application.
@@ -348,7 +361,7 @@ private void addClasspath(List<String> args) throws MojoExecutionException {
 	protected URL[] getClassPathUrls() throws MojoExecutionException {
 		try {
 			List<URL> urls = new ArrayList<>();
-			addUserDefinedDirectories(urls);
+			addAdditionalClasspathLocations(urls);
 			addResources(urls);
 			addProjectClasses(urls);
 			addDependencies(urls);
@@ -359,10 +372,15 @@ protected URL[] getClassPathUrls() throws MojoExecutionException {
 		}
 	}
 
-	private void addUserDefinedDirectories(List<URL> urls) throws MalformedURLException {
-		if (this.directories != null) {
-			for (String directory : this.directories) {
-				urls.add(new File(directory).toURI().toURL());
+	@SuppressWarnings("removal")
+	private void addAdditionalClasspathLocations(List<URL> urls) throws MalformedURLException {
+		Assert.state(ObjectUtils.isEmpty(this.directories) || ObjectUtils.isEmpty(this.additionalClasspathElements),
+				"Either additionalClasspathElements or directories (deprecated) should be set, not both");
+		String[] elements = !ObjectUtils.isEmpty(this.additionalClasspathElements) ? this.additionalClasspathElements
+				: this.directories;
+		if (elements != null) {
+			for (String element : elements) {
+				urls.add(new File(element).toURI().toURL());
 			}
 		}
 	}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ArtifactsLibraries.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ArtifactsLibraries.java
index 372f74a1b566..e6573112e4e9 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ArtifactsLibraries.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ArtifactsLibraries.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -85,7 +85,7 @@ public ArtifactsLibraries(Set<Artifact> artifacts, Collection<MavenProject> loca
 	/**
 	 * Creates a new {@code ArtifactsLibraries} from the given {@code artifacts}.
 	 * @param artifacts all artifacts that can be represented as libraries
-	 * @param includedArtifacts the actual artifacts to include in the fat jar
+	 * @param includedArtifacts the actual artifacts to include in the uber jar
 	 * @param localProjects projects for which {@link Library#isLocal() local} libraries
 	 * should be created
 	 * @param unpacks artifacts that should be unpacked on launch
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/BuildImageMojo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/BuildImageMojo.java
index 84589c01891f..79b62bf53030 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/BuildImageMojo.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/BuildImageMojo.java
@@ -48,6 +48,7 @@
 import org.springframework.boot.loader.tools.ImagePackager;
 import org.springframework.boot.loader.tools.LayoutFactory;
 import org.springframework.boot.loader.tools.Libraries;
+import org.springframework.boot.loader.tools.LoaderImplementation;
 import org.springframework.util.StringUtils;
 
 /**
@@ -187,6 +188,13 @@ public abstract class BuildImageMojo extends AbstractPackagerMojo {
 	@Parameter
 	private LayoutType layout;
 
+	/**
+	 * The loader implementation that should be used.
+	 * @since 3.2.0
+	 */
+	@Parameter
+	private LoaderImplementation loaderImplementation;
+
 	/**
 	 * The layout factory that will be used to create the executable archive if no
 	 * explicit layout is set. Alternative layouts implementations can be provided by 3rd
@@ -206,6 +214,11 @@ protected LayoutType getLayout() {
 		return this.layout;
 	}
 
+	@Override
+	protected LoaderImplementation getLoaderImplementation() {
+		return this.loaderImplementation;
+	}
+
 	/**
 	 * Return the layout factory that will be used to determine the
 	 * {@link AbstractPackagerMojo.LayoutType} if no explicit layout is set.
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CacheInfo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CacheInfo.java
index 491deabe28eb..a64c0387073d 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CacheInfo.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CacheInfo.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -32,8 +32,8 @@ public class CacheInfo {
 	public CacheInfo() {
 	}
 
-	CacheInfo(VolumeCacheInfo volumeCacheInfo) {
-		this.cache = Cache.volume(volumeCacheInfo.getName());
+	private CacheInfo(Cache cache) {
+		this.cache = cache;
 	}
 
 	public void setVolume(VolumeCacheInfo info) {
@@ -41,10 +41,23 @@ public void setVolume(VolumeCacheInfo info) {
 		this.cache = Cache.volume(info.getName());
 	}
 
+	public void setBind(BindCacheInfo info) {
+		Assert.state(this.cache == null, "Each image building cache can be configured only once");
+		this.cache = Cache.bind(info.getSource());
+	}
+
 	Cache asCache() {
 		return this.cache;
 	}
 
+	static CacheInfo fromVolume(VolumeCacheInfo cacheInfo) {
+		return new CacheInfo(Cache.volume(cacheInfo.getName()));
+	}
+
+	static CacheInfo fromBind(BindCacheInfo cacheInfo) {
+		return new CacheInfo(Cache.bind(cacheInfo.getSource()));
+	}
+
 	/**
 	 * Encapsulates configuration of an image building cache stored in a volume.
 	 */
@@ -69,4 +82,28 @@ void setName(String name) {
 
 	}
 
+	/**
+	 * Encapsulates configuration of an image building cache stored in a bind mount.
+	 */
+	public static class BindCacheInfo {
+
+		private String source;
+
+		public BindCacheInfo() {
+		}
+
+		BindCacheInfo(String name) {
+			this.source = name;
+		}
+
+		public String getSource() {
+			return this.source;
+		}
+
+		void setSource(String source) {
+			this.source = source;
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CustomLayersProvider.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CustomLayersProvider.java
index e78d817e34aa..5f3d6e6c87b6 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CustomLayersProvider.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CustomLayersProvider.java
@@ -123,7 +123,7 @@ private <T> ContentSelector<T> getSelector(Element element, Function<String, Con
 		return new IncludeExcludeContentSelector<>(layer, includes, excludes, filterFactory);
 	}
 
-	private <T> ContentSelector<Library> getLibrarySelector(Element element,
+	private ContentSelector<Library> getLibrarySelector(Element element,
 			Function<String, ContentFilter<Library>> filterFactory) {
 		Layer layer = new Layer(element.getAttribute("layer"));
 		List<String> includes = getChildNodeTextContent(element, "include");
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Docker.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Docker.java
index 78e8b5b89b98..53618609d4d7 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Docker.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Docker.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -29,6 +29,8 @@ public class Docker {
 
 	private String host;
 
+	private String context;
+
 	private boolean tlsVerify;
 
 	private String certPath;
@@ -51,6 +53,18 @@ void setHost(String host) {
 		this.host = host;
 	}
 
+	/**
+	 * The Docker context to use to retrieve host configuration.
+	 * @return the Docker context
+	 */
+	public String getContext() {
+		return this.context;
+	}
+
+	public void setContext(String context) {
+		this.context = context;
+	}
+
 	/**
 	 * Whether the Docker daemon requires TLS communication.
 	 * @return {@code true} to enable TLS
@@ -138,6 +152,13 @@ DockerConfiguration asDockerConfiguration() {
 	}
 
 	private DockerConfiguration customizeHost(DockerConfiguration dockerConfiguration) {
+		if (this.context != null && this.host != null) {
+			throw new IllegalArgumentException(
+					"Invalid Docker configuration, either context or host can be provided but not both");
+		}
+		if (this.context != null) {
+			return dockerConfiguration.withContext(this.context);
+		}
 		if (this.host != null) {
 			return dockerConfiguration.withHost(this.host, this.tlsVerify, this.certPath);
 		}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Image.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Image.java
index 2d5cf6b24728..c19ac62465a4 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Image.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Image.java
@@ -69,6 +69,8 @@ public class Image {
 
 	List<String> tags;
 
+	CacheInfo buildWorkspace;
+
 	CacheInfo buildCache;
 
 	CacheInfo launchCache;
@@ -77,6 +79,8 @@ public class Image {
 
 	String applicationDirectory;
 
+	List<String> securityOptions;
+
 	/**
 	 * The name of the created image.
 	 * @return the image name
@@ -243,6 +247,9 @@ private BuildRequest customize(BuildRequest request) {
 		if (!CollectionUtils.isEmpty(this.tags)) {
 			request = request.withTags(this.tags.stream().map(ImageReference::of).toList());
 		}
+		if (this.buildWorkspace != null) {
+			request = request.withBuildWorkspace(this.buildWorkspace.asCache());
+		}
 		if (this.buildCache != null) {
 			request = request.withBuildCache(this.buildCache.asCache());
 		}
@@ -255,6 +262,9 @@ private BuildRequest customize(BuildRequest request) {
 		if (StringUtils.hasText(this.applicationDirectory)) {
 			request = request.withApplicationDirectory(this.applicationDirectory);
 		}
+		if (this.securityOptions != null) {
+			request = request.withSecurityOptions(this.securityOptions);
+		}
 		return request;
 	}
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/MavenBuildOutputTimestamp.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/MavenBuildOutputTimestamp.java
new file mode 100644
index 000000000000..702677bba986
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/MavenBuildOutputTimestamp.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.maven;
+
+import java.nio.file.attribute.FileTime;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeParseException;
+import java.time.temporal.ChronoUnit;
+
+import org.springframework.util.StringUtils;
+
+/**
+ * Parse output timestamp configured for Reproducible Builds' archive entries.
+ * <p>
+ * Either as {@link java.time.format.DateTimeFormatter#ISO_OFFSET_DATE_TIME} or as a
+ * number representing seconds since the epoch (like <a href=
+ * "https://reproducible-builds.org/docs/source-date-epoch/">SOURCE_DATE_EPOCH</a>).
+ * Implementation inspired by <a href=
+ * "https://github.com/apache/maven-archiver/blob/cc2f6a219f6563f450b0c00e8ccd651520b67406/src/main/java/org/apache/maven/archiver/MavenArchiver.java#L768">MavenArchiver</a>.
+ *
+ * @author Niels Basjes
+ * @author Moritz Halbritter
+ */
+class MavenBuildOutputTimestamp {
+
+	private static final Instant DATE_MIN = Instant.parse("1980-01-01T00:00:02Z");
+
+	private static final Instant DATE_MAX = Instant.parse("2099-12-31T23:59:59Z");
+
+	private final String timestamp;
+
+	/**
+	 * Creates a new {@link MavenBuildOutputTimestamp}.
+	 * @param timestamp timestamp or {@code null}
+	 */
+	MavenBuildOutputTimestamp(String timestamp) {
+		this.timestamp = timestamp;
+	}
+
+	/**
+	 * Returns the parsed timestamp as an {@code FileTime}.
+	 * @return the parsed timestamp as an {@code FileTime}, or {@code null}
+	 * @throws IllegalArgumentException if the outputTimestamp is neither ISO 8601 nor an
+	 * integer, or it's not within the valid range 1980-01-01T00:00:02Z to
+	 * 2099-12-31T23:59:59Z
+	 */
+	FileTime toFileTime() {
+		Instant instant = toInstant();
+		if (instant == null) {
+			return null;
+		}
+		return FileTime.from(instant);
+	}
+
+	/**
+	 * Returns the parsed timestamp as an {@code Instant}.
+	 * @return the parsed timestamp as an {@code Instant}, or {@code null}
+	 * @throws IllegalArgumentException if the outputTimestamp is neither ISO 8601 nor an
+	 * integer, or it's not within the valid range 1980-01-01T00:00:02Z to
+	 * 2099-12-31T23:59:59Z
+	 */
+	Instant toInstant() {
+		if (!StringUtils.hasLength(this.timestamp)) {
+			return null;
+		}
+		if (isNumeric(this.timestamp)) {
+			return Instant.ofEpochSecond(Long.parseLong(this.timestamp));
+		}
+		if (this.timestamp.length() < 2) {
+			return null;
+		}
+		try {
+			Instant instant = OffsetDateTime.parse(this.timestamp)
+				.withOffsetSameInstant(ZoneOffset.UTC)
+				.truncatedTo(ChronoUnit.SECONDS)
+				.toInstant();
+			if (instant.isBefore(DATE_MIN) || instant.isAfter(DATE_MAX)) {
+				throw new IllegalArgumentException(String
+					.format(String.format("'%s' is not within the valid range %s to %s", instant, DATE_MIN, DATE_MAX)));
+			}
+			return instant;
+		}
+		catch (DateTimeParseException pe) {
+			throw new IllegalArgumentException(String.format("Can't parse '%s' to instant", this.timestamp));
+		}
+	}
+
+	private static boolean isNumeric(String str) {
+		for (char c : str.toCharArray()) {
+			if (!Character.isDigit(c)) {
+				return false;
+			}
+		}
+		return true;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ProcessAotMojo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ProcessAotMojo.java
index 44cd248c4a36..9f75cf1e5a37 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ProcessAotMojo.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ProcessAotMojo.java
@@ -88,6 +88,10 @@ public class ProcessAotMojo extends AbstractAotMojo {
 
 	@Override
 	protected void executeAot() throws Exception {
+		if (this.project.getPackaging().equals("pom")) {
+			getLog().debug("process-aot goal could not be applied to pom project.");
+			return;
+		}
 		String applicationClass = (this.mainClass != null) ? this.mainClass
 				: SpringBootApplicationClassFinder.findSingleClass(this.classesDirectory);
 		URL[] classPath = getClassPath();
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ProcessTestAotMojo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ProcessTestAotMojo.java
index ea888c599230..399e4561b414 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ProcessTestAotMojo.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ProcessTestAotMojo.java
@@ -26,13 +26,12 @@
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.stream.Collectors;
 
+import org.apache.maven.RepositoryUtils;
 import org.apache.maven.artifact.Artifact;
 import org.apache.maven.artifact.DefaultArtifact;
 import org.apache.maven.artifact.handler.DefaultArtifactHandler;
-import org.apache.maven.artifact.repository.ArtifactRepository;
-import org.apache.maven.artifact.resolver.ArtifactResolutionRequest;
-import org.apache.maven.artifact.resolver.ArtifactResolutionResult;
 import org.apache.maven.artifact.resolver.ResolutionErrorHandler;
 import org.apache.maven.plugin.MojoExecutionException;
 import org.apache.maven.plugins.annotations.Component;
@@ -40,7 +39,13 @@
 import org.apache.maven.plugins.annotations.Mojo;
 import org.apache.maven.plugins.annotations.Parameter;
 import org.apache.maven.plugins.annotations.ResolutionScope;
-import org.apache.maven.repository.RepositorySystem;
+import org.eclipse.aether.RepositorySystem;
+import org.eclipse.aether.collection.CollectRequest;
+import org.eclipse.aether.resolution.ArtifactResult;
+import org.eclipse.aether.resolution.DependencyRequest;
+import org.eclipse.aether.resolution.DependencyResult;
+import org.eclipse.aether.util.artifact.JavaScopes;
+import org.eclipse.aether.util.filter.DependencyFilterUtils;
 
 /**
  * Invoke the AOT engine on tests.
@@ -98,18 +103,6 @@ public class ProcessTestAotMojo extends AbstractAotMojo {
 	@Parameter(defaultValue = "${project.build.directory}/spring-aot/main/classes", required = true)
 	private File generatedClasses;
 
-	/**
-	 * Local artifact repository used to resolve JUnit platform launcher jars.
-	 */
-	@Parameter(defaultValue = "${localRepository}", required = true, readonly = true)
-	private ArtifactRepository localRepository;
-
-	/**
-	 * Remote artifact repositories used to resolve JUnit platform launcher jars.
-	 */
-	@Parameter(defaultValue = "${project.remoteArtifactRepositories}", required = true, readonly = true)
-	private List<ArtifactRepository> remoteRepositories;
-
 	@Component
 	private RepositorySystem repositorySystem;
 
@@ -118,6 +111,10 @@ public class ProcessTestAotMojo extends AbstractAotMojo {
 
 	@Override
 	protected void executeAot() throws Exception {
+		if (this.project.getPackaging().equals("pom")) {
+			getLog().debug("process-test-aot goal could not be applied to pom project.");
+			return;
+		}
 		if (Boolean.getBoolean("skipTests") || Boolean.getBoolean("maven.test.skip")) {
 			getLog().info("Skipping AOT test processing since tests are skipped");
 			return;
@@ -160,10 +157,10 @@ private URL[] addJUnitPlatformLauncher(URL[] classPath) throws Exception {
 		String version = getJUnitPlatformVersion();
 		DefaultArtifactHandler handler = new DefaultArtifactHandler("jar");
 		handler.setIncludesDependencies(true);
-		ArtifactResolutionResult resolutionResult = resolveArtifact(new DefaultArtifact(JUNIT_PLATFORM_GROUP_ID,
+		Set<Artifact> artifacts = resolveArtifact(new DefaultArtifact(JUNIT_PLATFORM_GROUP_ID,
 				JUNIT_PLATFORM_LAUNCHER_ARTIFACT_ID, version, null, "jar", null, handler));
 		Set<URL> fullClassPath = new LinkedHashSet<>(Arrays.asList(classPath));
-		for (Artifact artifact : resolutionResult.getArtifacts()) {
+		for (Artifact artifact : artifacts) {
 			fullClassPath.add(artifact.getFile().toURI().toURL());
 		}
 		return fullClassPath.toArray(URL[]::new);
@@ -175,21 +172,25 @@ private String getJUnitPlatformVersion() throws MojoExecutionException {
 		String version = (platformCommonsArtifact != null) ? platformCommonsArtifact.getBaseVersion() : null;
 		if (version == null) {
 			throw new MojoExecutionException(
-					"Unable to find '%s' dependnecy. Please ensure JUnit is correctly configured.".formatted(id));
+					"Unable to find '%s' dependency. Please ensure JUnit is correctly configured.".formatted(id));
 		}
 		return version;
 	}
 
-	private ArtifactResolutionResult resolveArtifact(Artifact artifact) throws Exception {
-		ArtifactResolutionRequest request = new ArtifactResolutionRequest();
-		request.setArtifact(artifact);
-		request.setLocalRepository(this.localRepository);
-		request.setResolveTransitively(true);
-		request.setCollectionFilter(new RuntimeArtifactFilter());
-		request.setRemoteRepositories(this.remoteRepositories);
-		ArtifactResolutionResult result = this.repositorySystem.resolve(request);
-		this.resolutionErrorHandler.throwErrors(request, result);
-		return result;
+	private Set<Artifact> resolveArtifact(Artifact artifact) throws Exception {
+		CollectRequest collectRequest = new CollectRequest();
+		collectRequest.setRoot(RepositoryUtils.toDependency(artifact, null));
+		collectRequest.setRepositories(this.project.getRemotePluginRepositories());
+		DependencyRequest request = new DependencyRequest();
+		request.setCollectRequest(collectRequest);
+		request.setFilter(DependencyFilterUtils.classpathFilter(JavaScopes.RUNTIME));
+		DependencyResult dependencyResult = this.repositorySystem
+			.resolveDependencies(getSession().getRepositorySession(), request);
+		return dependencyResult.getArtifactResults()
+			.stream()
+			.map(ArtifactResult::getArtifact)
+			.map(RepositoryUtils::toArtifact)
+			.collect(Collectors.toSet());
 	}
 
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/RepackageMojo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/RepackageMojo.java
index ec1540b7112c..13a16c2a144a 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/RepackageMojo.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/RepackageMojo.java
@@ -19,10 +19,8 @@
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.attribute.FileTime;
-import java.time.OffsetDateTime;
 import java.util.List;
 import java.util.Properties;
-import java.util.concurrent.TimeUnit;
 import java.util.regex.Pattern;
 
 import org.apache.maven.artifact.Artifact;
@@ -38,6 +36,7 @@
 import org.springframework.boot.loader.tools.LaunchScript;
 import org.springframework.boot.loader.tools.LayoutFactory;
 import org.springframework.boot.loader.tools.Libraries;
+import org.springframework.boot.loader.tools.LoaderImplementation;
 import org.springframework.boot.loader.tools.Repackager;
 
 /**
@@ -108,7 +107,7 @@ public class RepackageMojo extends AbstractPackagerMojo {
 	private boolean attach = true;
 
 	/**
-	 * A list of the libraries that must be unpacked from fat jars in order to run.
+	 * A list of the libraries that must be unpacked from uber jars in order to run.
 	 * Specify each library as a {@code <dependency>} with a {@code <groupId>} and a
 	 * {@code <artifactId>} and they will be unpacked at runtime.
 	 * @since 1.1.0
@@ -163,6 +162,13 @@ public class RepackageMojo extends AbstractPackagerMojo {
 	@Parameter(property = "spring-boot.repackage.layout")
 	private LayoutType layout;
 
+	/**
+	 * The loader implementation that should be used.
+	 * @since 3.2.0
+	 */
+	@Parameter
+	private LoaderImplementation loaderImplementation;
+
 	/**
 	 * The layout factory that will be used to create the executable archive if no
 	 * explicit layout is set. Alternative layouts implementations can be provided by 3rd
@@ -182,6 +188,11 @@ protected LayoutType getLayout() {
 		return this.layout;
 	}
 
+	@Override
+	protected LoaderImplementation getLoaderImplementation() {
+		return this.loaderImplementation;
+	}
+
 	/**
 	 * Return the layout factory that will be used to determine the
 	 * {@link AbstractPackagerMojo.LayoutType} if no explicit layout is set.
@@ -221,21 +232,12 @@ private void repackage() throws MojoExecutionException {
 		updateArtifact(source, target, repackager.getBackupFile());
 	}
 
-	private FileTime parseOutputTimestamp() {
-		// Maven ignores a single-character timestamp as it is "useful to override a full
-		// value during pom inheritance"
-		if (this.outputTimestamp == null || this.outputTimestamp.length() < 2) {
-			return null;
-		}
-		return FileTime.from(getOutputTimestampEpochSeconds(), TimeUnit.SECONDS);
-	}
-
-	private long getOutputTimestampEpochSeconds() {
+	private FileTime parseOutputTimestamp() throws MojoExecutionException {
 		try {
-			return Long.parseLong(this.outputTimestamp);
+			return new MavenBuildOutputTimestamp(this.outputTimestamp).toFileTime();
 		}
-		catch (NumberFormatException ex) {
-			return OffsetDateTime.parse(this.outputTimestamp).toInstant().getEpochSecond();
+		catch (IllegalArgumentException ex) {
+			throw new MojoExecutionException("Invalid value for parameter 'outputTimestamp'", ex);
 		}
 	}
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/xsd/layers-3.2.xsd b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/xsd/layers-3.2.xsd
new file mode 100644
index 000000000000..20219b9bd8b1
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/xsd/layers-3.2.xsd
@@ -0,0 +1,100 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<xsd:schema elementFormDefault="qualified"
+	xmlns="http://www.springframework.org/schema/boot/layers"
+	xmlns:xsd="http://www.w3.org/2001/XMLSchema"
+	targetNamespace="http://www.springframework.org/schema/boot/layers">
+	<xsd:element name="layers" type="layersType" />
+	<xsd:complexType name="layersType">
+		<xsd:sequence>
+			<xsd:element name="application" type="applicationType" minOccurs="0"/>
+			<xsd:element name="dependencies" type="dependenciesType" minOccurs="0"/>
+			<xsd:element name="layerOrder" type="layerOrderType" minOccurs="0"/>
+		</xsd:sequence>
+	</xsd:complexType>
+	<xsd:complexType name="applicationType">
+		<xsd:annotation>
+			<xsd:documentation><![CDATA[
+	The 'into layer' selections that should be applied to application classes and resources.
+				]]></xsd:documentation>
+		</xsd:annotation>
+		<xsd:sequence maxOccurs="unbounded">
+			<xsd:element name="into" type="intoType" />
+		</xsd:sequence>
+	</xsd:complexType>
+	<xsd:complexType name="dependenciesType">
+		<xsd:annotation>
+			<xsd:documentation><![CDATA[
+	The 'into layer' selections that should be applied to dependencies.
+				]]></xsd:documentation>
+		</xsd:annotation>
+		<xsd:sequence maxOccurs="unbounded">
+			<xsd:element name="into" type="dependenciesIntoType" />
+		</xsd:sequence>
+	</xsd:complexType>
+	<xsd:complexType name="layerOrderType">
+		<xsd:annotation>
+			<xsd:documentation><![CDATA[
+	The order that layers should be added (starting with the least frequently changed layer).
+				]]></xsd:documentation>
+		</xsd:annotation>
+		<xsd:sequence>
+			<xsd:element name="layer" maxOccurs="unbounded">
+				<xsd:annotation>
+					<xsd:documentation><![CDATA[
+	The layer name.
+				]]></xsd:documentation>
+				</xsd:annotation>
+				<xsd:simpleType>
+					<xsd:restriction base="xsd:string">
+						<xsd:minLength value="1" />
+					</xsd:restriction>
+				</xsd:simpleType>
+			</xsd:element>
+		</xsd:sequence>
+	</xsd:complexType>
+	<xsd:complexType name="intoType">
+		<xsd:choice maxOccurs="unbounded">
+			<xsd:element type="xsd:string" name="include"
+				minOccurs="0" maxOccurs="unbounded">
+				<xsd:annotation>
+					<xsd:documentation><![CDATA[
+	Pattern of the elements to include.
+			]]></xsd:documentation>
+				</xsd:annotation>
+			</xsd:element>
+			<xsd:element type="xsd:string" name="exclude"
+				minOccurs="0" maxOccurs="unbounded">
+				<xsd:annotation>
+					<xsd:documentation><![CDATA[
+	Pattern of the elements to exclude.
+			]]></xsd:documentation>
+				</xsd:annotation>
+			</xsd:element>
+		</xsd:choice>
+		<xsd:attribute type="xsd:string" name="layer"
+			use="required" />
+	</xsd:complexType>
+	<xsd:complexType name="dependenciesIntoType">
+		<xsd:complexContent>
+			<xsd:extension base="intoType">
+				<xsd:choice minOccurs="0">
+					<xsd:element type="xsd:string" name="includeModuleDependencies" minOccurs="0">
+						<xsd:annotation>
+							<xsd:documentation><![CDATA[
+	Include dependencies on other modules in the build.
+							]]></xsd:documentation>
+						</xsd:annotation>
+					</xsd:element>
+					<xsd:element type="xsd:string" name="excludeModuleDependencies" minOccurs="0">
+						<xsd:annotation>
+							<xsd:documentation><![CDATA[
+	Exclude dependencies on other modules in the build.
+							]]></xsd:documentation>
+						</xsd:annotation>
+					</xsd:element>
+				</xsd:choice>
+			</xsd:extension>
+		</xsd:complexContent>
+	</xsd:complexType>
+
+</xsd:schema>
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/DependencyFilterMojoTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/DependencyFilterMojoTests.java
index 39aa828ea2c6..55dca3cf7712 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/DependencyFilterMojoTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/DependencyFilterMojoTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -137,7 +137,7 @@ private static Artifact createArtifact(String groupId, String artifactId, String
 	}
 
 	private static File createArtifactFile(String jarType) {
-		Path jarPath = temp.resolve(UUID.randomUUID().toString() + ".jar");
+		Path jarPath = temp.resolve(UUID.randomUUID() + ".jar");
 		Manifest manifest = new Manifest();
 		manifest.getMainAttributes().putValue("Manifest-Version", "1.0");
 		if (jarType != null) {
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/DockerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/DockerTests.java
index 1fd381ee28de..f2258b915dc3 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/DockerTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/DockerTests.java
@@ -21,7 +21,7 @@
 import org.junit.jupiter.api.Test;
 
 import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration;
-import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost;
+import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
@@ -54,10 +54,11 @@ void asDockerConfigurationWithHostConfiguration() {
 		docker.setTlsVerify(true);
 		docker.setCertPath("/tmp/ca-cert");
 		DockerConfiguration dockerConfiguration = docker.asDockerConfiguration();
-		DockerHost host = dockerConfiguration.getHost();
+		DockerHostConfiguration host = dockerConfiguration.getHost();
 		assertThat(host.getAddress()).isEqualTo("docker.example.com");
 		assertThat(host.isSecure()).isTrue();
 		assertThat(host.getCertificatePath()).isEqualTo("/tmp/ca-cert");
+		assertThat(host.getContext()).isNull();
 		assertThat(dockerConfiguration.isBindHostToBuilder()).isFalse();
 		assertThat(docker.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull();
 		assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader()))
@@ -67,6 +68,34 @@ void asDockerConfigurationWithHostConfiguration() {
 			.contains("\"serveraddress\" : \"\"");
 	}
 
+	@Test
+	void asDockerConfigurationWithContextConfiguration() {
+		Docker docker = new Docker();
+		docker.setContext("test-context");
+		DockerConfiguration dockerConfiguration = docker.asDockerConfiguration();
+		DockerHostConfiguration host = dockerConfiguration.getHost();
+		assertThat(host.getContext()).isEqualTo("test-context");
+		assertThat(host.getAddress()).isNull();
+		assertThat(host.isSecure()).isFalse();
+		assertThat(host.getCertificatePath()).isNull();
+		assertThat(dockerConfiguration.isBindHostToBuilder()).isFalse();
+		assertThat(docker.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull();
+		assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader()))
+			.contains("\"username\" : \"\"")
+			.contains("\"password\" : \"\"")
+			.contains("\"email\" : \"\"")
+			.contains("\"serveraddress\" : \"\"");
+	}
+
+	@Test
+	void asDockerConfigurationWithHostAndContextFails() {
+		Docker docker = new Docker();
+		docker.setContext("test-context");
+		docker.setHost("docker.example.com");
+		assertThatIllegalArgumentException().isThrownBy(docker::asDockerConfiguration)
+			.withMessageContaining("Invalid Docker configuration");
+	}
+
 	@Test
 	void asDockerConfigurationWithBindHostToBuilder() {
 		Docker docker = new Docker();
@@ -75,7 +104,7 @@ void asDockerConfigurationWithBindHostToBuilder() {
 		docker.setCertPath("/tmp/ca-cert");
 		docker.setBindHostToBuilder(true);
 		DockerConfiguration dockerConfiguration = docker.asDockerConfiguration();
-		DockerHost host = dockerConfiguration.getHost();
+		DockerHostConfiguration host = dockerConfiguration.getHost();
 		assertThat(host.getAddress()).isEqualTo("docker.example.com");
 		assertThat(host.isSecure()).isTrue();
 		assertThat(host.getCertificatePath()).isEqualTo("/tmp/ca-cert");
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ImageTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ImageTests.java
index 8f3558701c46..1ec018db8608 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ImageTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ImageTests.java
@@ -18,6 +18,7 @@
 
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.List;
 import java.util.function.Function;
 
 import org.apache.maven.artifact.Artifact;
@@ -34,6 +35,7 @@
 import org.springframework.boot.buildpack.platform.docker.type.ImageReference;
 import org.springframework.boot.buildpack.platform.io.Owner;
 import org.springframework.boot.buildpack.platform.io.TarArchive;
+import org.springframework.boot.maven.CacheInfo.BindCacheInfo;
 import org.springframework.boot.maven.CacheInfo.VolumeCacheInfo;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -67,7 +69,7 @@ void getBuildRequestWhenNameIsSetUsesName() {
 	void getBuildRequestWhenNoCustomizationsUsesDefaults() {
 		BuildRequest request = new Image().getBuildRequest(createArtifact(), mockApplicationContent());
 		assertThat(request.getName()).hasToString("docker.io/library/my-app:0.0.1-SNAPSHOT");
-		assertThat(request.getBuilder().toString()).contains("paketobuildpacks/builder");
+		assertThat(request.getBuilder().toString()).contains("paketobuildpacks/builder-jammy-base");
 		assertThat(request.getRunImage()).isNull();
 		assertThat(request.getEnv()).isEmpty();
 		assertThat(request.isCleanCache()).isFalse();
@@ -170,21 +172,53 @@ void getBuildRequestWhenHasTagsUsesTags() {
 	}
 
 	@Test
-	void getBuildRequestWhenHasBuildVolumeCacheUsesCache() {
+	void getBuildRequestWhenHasBuildWorkspaceVolumeUsesWorkspace() {
 		Image image = new Image();
-		image.buildCache = new CacheInfo(new VolumeCacheInfo("build-cache-vol"));
+		image.buildWorkspace = CacheInfo.fromVolume(new VolumeCacheInfo("build-work-vol"));
+		BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent());
+		assertThat(request.getBuildWorkspace()).isEqualTo(Cache.volume("build-work-vol"));
+	}
+
+	@Test
+	void getBuildRequestWhenHasBuildCacheVolumeUsesCache() {
+		Image image = new Image();
+		image.buildCache = CacheInfo.fromVolume(new VolumeCacheInfo("build-cache-vol"));
 		BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent());
 		assertThat(request.getBuildCache()).isEqualTo(Cache.volume("build-cache-vol"));
 	}
 
 	@Test
-	void getBuildRequestWhenHasLaunchVolumeCacheUsesCache() {
+	void getBuildRequestWhenHasLaunchCacheVolumeUsesCache() {
 		Image image = new Image();
-		image.launchCache = new CacheInfo(new VolumeCacheInfo("launch-cache-vol"));
+		image.launchCache = CacheInfo.fromVolume(new VolumeCacheInfo("launch-cache-vol"));
 		BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent());
 		assertThat(request.getLaunchCache()).isEqualTo(Cache.volume("launch-cache-vol"));
 	}
 
+	@Test
+	void getBuildRequestWhenHasBuildWorkspaceBindUsesWorkspace() {
+		Image image = new Image();
+		image.buildWorkspace = CacheInfo.fromBind(new BindCacheInfo("build-work-dir"));
+		BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent());
+		assertThat(request.getBuildWorkspace()).isEqualTo(Cache.bind("build-work-dir"));
+	}
+
+	@Test
+	void getBuildRequestWhenHasBuildCacheBindUsesCache() {
+		Image image = new Image();
+		image.buildCache = CacheInfo.fromBind(new BindCacheInfo("build-cache-dir"));
+		BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent());
+		assertThat(request.getBuildCache()).isEqualTo(Cache.bind("build-cache-dir"));
+	}
+
+	@Test
+	void getBuildRequestWhenHasLaunchCacheBindUsesCache() {
+		Image image = new Image();
+		image.launchCache = CacheInfo.fromBind(new BindCacheInfo("launch-cache-dir"));
+		BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent());
+		assertThat(request.getLaunchCache()).isEqualTo(Cache.bind("launch-cache-dir"));
+	}
+
 	@Test
 	void getBuildRequestWhenHasCreatedDateUsesCreatedDate() {
 		Image image = new Image();
@@ -201,6 +235,22 @@ void getBuildRequestWhenHasApplicationDirectoryUsesApplicationDirectory() {
 		assertThat(request.getApplicationDirectory()).isEqualTo("/application");
 	}
 
+	@Test
+	void getBuildRequestWhenHasSecurityOptionsUsesSecurityOptions() {
+		Image image = new Image();
+		image.securityOptions = List.of("label=user:USER", "label=role:ROLE");
+		BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent());
+		assertThat(request.getSecurityOptions()).containsExactly("label=user:USER", "label=role:ROLE");
+	}
+
+	@Test
+	void getBuildRequestWhenHasEmptySecurityOptionsUsesSecurityOptions() {
+		Image image = new Image();
+		image.securityOptions = Collections.emptyList();
+		BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent());
+		assertThat(request.getSecurityOptions()).isEmpty();
+	}
+
 	private Artifact createArtifact() {
 		return new DefaultArtifact("com.example", "my-app", VersionRange.createFromVersion("0.0.1-SNAPSHOT"), "compile",
 				"jar", null, new DefaultArtifactHandler());
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/MavenBuildOutputTimestampTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/MavenBuildOutputTimestampTests.java
new file mode 100644
index 000000000000..23641fef4618
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/MavenBuildOutputTimestampTests.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.maven;
+
+import java.nio.file.attribute.FileTime;
+import java.time.Instant;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link MavenBuildOutputTimestamp}.
+ *
+ * @author Moritz Halbritter
+ */
+class MavenBuildOutputTimestampTests {
+
+	@Test
+	void shouldParseNull() {
+		assertThat(parse(null)).isNull();
+	}
+
+	@Test
+	void shouldParseSingleDigit() {
+		assertThat(parse("0")).isEqualTo(Instant.parse("1970-01-01T00:00:00Z"));
+	}
+
+	@Test
+	void shouldNotParseSingleCharacter() {
+		assertThat(parse("a")).isNull();
+	}
+
+	@Test
+	void shouldParseIso8601() {
+		assertThat(parse("2011-12-03T10:15:30Z")).isEqualTo(Instant.parse("2011-12-03T10:15:30Z"));
+	}
+
+	@Test
+	void shouldParseIso8601WithMilliseconds() {
+		assertThat(parse("2011-12-03T10:15:30.12345Z")).isEqualTo(Instant.parse("2011-12-03T10:15:30Z"));
+	}
+
+	@Test
+	void shouldFailIfIso8601BeforeMin() {
+		assertThatIllegalArgumentException().isThrownBy(() -> parse("1970-01-01T00:00:00Z"))
+			.withMessage(
+					"'1970-01-01T00:00:00Z' is not within the valid range 1980-01-01T00:00:02Z to 2099-12-31T23:59:59Z");
+	}
+
+	@Test
+	void shouldFailIfIso8601AfterMax() {
+		assertThatIllegalArgumentException().isThrownBy(() -> parse("2100-01-01T00:00:00Z"))
+			.withMessage(
+					"'2100-01-01T00:00:00Z' is not within the valid range 1980-01-01T00:00:02Z to 2099-12-31T23:59:59Z");
+	}
+
+	@Test
+	void shouldFailIfNotIso8601() {
+		assertThatIllegalArgumentException().isThrownBy(() -> parse("dummy"))
+			.withMessage("Can't parse 'dummy' to instant");
+	}
+
+	@Test
+	void shouldParseIso8601WithOffset() {
+		assertThat(parse("2019-10-05T20:37:42+06:00")).isEqualTo(Instant.parse("2019-10-05T14:37:42Z"));
+	}
+
+	@Test
+	void shouldParseToFileTime() {
+		assertThat(parseFileTime(null)).isEqualTo(null);
+		assertThat(parseFileTime("0")).isEqualTo(FileTime.fromMillis(0));
+		assertThat(parseFileTime("2019-10-05T14:37:42Z")).isEqualTo(FileTime.fromMillis(1570286262000L));
+	}
+
+	private static Instant parse(String timestamp) {
+		return new MavenBuildOutputTimestamp(timestamp).toInstant();
+	}
+
+	private static FileTime parseFileTime(String timestamp) {
+		return new MavenBuildOutputTimestamp(timestamp).toFileTime();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/dependencies-layer-no-filter.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/dependencies-layer-no-filter.xml
index 1398e8320206..b6e9af44d621 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/dependencies-layer-no-filter.xml
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/dependencies-layer-no-filter.xml
@@ -1,7 +1,7 @@
 <layers xmlns="http://www.springframework.org/schema/boot/layers"
 	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 	xsi:schemaLocation="http://www.springframework.org/schema/boot/layers
-					  https://www.springframework.org/schema/boot/layers/layers-3.1.xsd">
+					  https://www.springframework.org/schema/boot/layers/layers-3.2.xsd">
 	<dependencies>
 		<into layer="my-deps" />
 	</dependencies>
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/layers.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/layers.xml
index b7427f83a79b..81f10e26311c 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/layers.xml
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/layers.xml
@@ -1,7 +1,7 @@
 <layers xmlns="http://www.springframework.org/schema/boot/layers"
 	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 	xsi:schemaLocation="http://www.springframework.org/schema/boot/layers
-					  https://www.springframework.org/schema/boot/layers/layers-3.1.xsd">
+					  https://www.springframework.org/schema/boot/layers/layers-3.2.xsd">
 	<application>
 		<into layer="my-resources">
 			<include>META-INF/resources/**</include>
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/resource-layer-no-filter.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/resource-layer-no-filter.xml
index 0614492c4982..ebfb721fb7c2 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/resource-layer-no-filter.xml
+++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/resource-layer-no-filter.xml
@@ -1,7 +1,7 @@
 <layers xmlns="http://www.springframework.org/schema/boot/layers"
 	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 	xsi:schemaLocation="http://www.springframework.org/schema/boot/layers
-					  https://www.springframework.org/schema/boot/layers/layers-3.1.xsd">
+					  https://www.springframework.org/schema/boot/layers/layers-3.2.xsd">
 	<application>
 		<into layer="my-layer" />
 	</application>
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/BuildOutput.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/BuildOutput.java
index f283aeebfb49..1221bf470fdc 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/BuildOutput.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/BuildOutput.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -23,7 +23,6 @@
  * Provides access to build output locations in a build system and IDE agnostic manner.
  *
  * @author Andy Wilkinson
- * @since 2.2.0
  */
 public class BuildOutput {
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/assertj/SimpleAsyncTaskExecutorAssert.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/assertj/SimpleAsyncTaskExecutorAssert.java
new file mode 100644
index 000000000000..8b6b25e7a543
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/assertj/SimpleAsyncTaskExecutorAssert.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testsupport.assertj;
+
+import java.lang.reflect.Field;
+
+import org.assertj.core.api.AbstractAssert;
+import org.assertj.core.api.Assert;
+
+import org.springframework.core.task.SimpleAsyncTaskExecutor;
+import org.springframework.util.ReflectionUtils;
+
+/**
+ * AssertJ {@link Assert} for {@link SimpleAsyncTaskExecutor}.
+ *
+ * @author Moritz Halbritter
+ */
+public final class SimpleAsyncTaskExecutorAssert
+		extends AbstractAssert<SimpleAsyncTaskExecutorAssert, SimpleAsyncTaskExecutor> {
+
+	private SimpleAsyncTaskExecutorAssert(SimpleAsyncTaskExecutor actual) {
+		super(actual, SimpleAsyncTaskExecutorAssert.class);
+	}
+
+	/**
+	 * Verifies that the actual executor uses platform threads.
+	 * @return {@code this} assertion object
+	 * @throws AssertionError if the actual executor doesn't use platform threads
+	 */
+	public SimpleAsyncTaskExecutorAssert usesPlatformThreads() {
+		isNotNull();
+		if (producesVirtualThreads()) {
+			failWithMessage("Expected executor to use platform threads, but it uses virtual threads");
+		}
+		return this;
+	}
+
+	/**
+	 * Verifies that the actual executor uses virtual threads.
+	 * @return {@code this} assertion object
+	 * @throws AssertionError if the actual executor doesn't use virtual threads
+	 */
+	public SimpleAsyncTaskExecutorAssert usesVirtualThreads() {
+		isNotNull();
+		if (!producesVirtualThreads()) {
+			failWithMessage("Expected executor to use virtual threads, but it uses platform threads");
+		}
+		return this;
+	}
+
+	private boolean producesVirtualThreads() {
+		Field field = ReflectionUtils.findField(SimpleAsyncTaskExecutor.class, "virtualThreadDelegate");
+		if (field == null) {
+			throw new IllegalStateException("Field SimpleAsyncTaskExecutor.virtualThreadDelegate not found");
+		}
+		ReflectionUtils.makeAccessible(field);
+		Object virtualThreadDelegate = ReflectionUtils.getField(field, this.actual);
+		return virtualThreadDelegate != null;
+	}
+
+	/**
+	 * Creates a new assertion class with the given {@link SimpleAsyncTaskExecutor}.
+	 * @param actual the {@link SimpleAsyncTaskExecutor}
+	 * @return the assertion class
+	 */
+	public static SimpleAsyncTaskExecutorAssert assertThat(SimpleAsyncTaskExecutor actual) {
+		return new SimpleAsyncTaskExecutorAssert(actual);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/assertj/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/assertj/package-info.java
new file mode 100644
index 000000000000..9eb3d15f8081
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/assertj/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Custom AssertJ assertions.
+ */
+package org.springframework.boot.testsupport.assertj;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ClassPathExclusions.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ClassPathExclusions.java
index f7c809f94704..bc27172eb4cc 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ClassPathExclusions.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ClassPathExclusions.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -25,11 +25,12 @@
 
 import org.junit.jupiter.api.extension.ExtendWith;
 
+import org.springframework.core.annotation.AliasFor;
+
 /**
  * Annotation used to exclude entries from the classpath.
  *
  * @author Andy Wilkinson
- * @since 1.5.0
  */
 @Retention(RetentionPolicy.RUNTIME)
 @Target({ ElementType.TYPE, ElementType.METHOD })
@@ -37,6 +38,18 @@
 @ExtendWith(ModifiedClassPathExtension.class)
 public @interface ClassPathExclusions {
 
+	/**
+	 * Alias for {@code files}.
+	 * <p>
+	 * One or more Ant-style patterns that identify entries to be excluded from the class
+	 * path. Matching is performed against an entry's {@link File#getName() file name}.
+	 * For example, to exclude Hibernate Validator from the classpath,
+	 * {@code "hibernate-validator-*.jar"} can be used.
+	 * @return the exclusion patterns
+	 */
+	@AliasFor("files")
+	String[] value() default {};
+
 	/**
 	 * One or more Ant-style patterns that identify entries to be excluded from the class
 	 * path. Matching is performed against an entry's {@link File#getName() file name}.
@@ -44,6 +57,13 @@
 	 * {@code "hibernate-validator-*.jar"} can be used.
 	 * @return the exclusion patterns
 	 */
-	String[] value();
+	@AliasFor("value")
+	String[] files() default {};
+
+	/**
+	 * One or more packages that should be excluded from the classpath.
+	 * @return the excluded packages
+	 */
+	String[] packages() default {};
 
 }
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ClassPathOverrides.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ClassPathOverrides.java
index 8c45e6e53d6b..17d48b8e6bfc 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ClassPathOverrides.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ClassPathOverrides.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -28,7 +28,6 @@
  * Annotation used to override entries on the classpath.
  *
  * @author Andy Wilkinson
- * @since 1.5.0
  */
 @Retention(RetentionPolicy.RUNTIME)
 @Target({ ElementType.TYPE, ElementType.METHOD })
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ForkedClassPath.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ForkedClassPath.java
index 50884c10a520..1923f097ec07 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ForkedClassPath.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ForkedClassPath.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -30,7 +30,6 @@
  * {@link ClassPathOverrides} are needed, but just a copy of the classpath.
  *
  * @author Christoph Dreis
- * @since 2.4.0
  */
 @Retention(RetentionPolicy.RUNTIME)
 @Target({ ElementType.TYPE, ElementType.METHOD })
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathClassLoader.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathClassLoader.java
index b32890d790c0..8f3bbd14e0ac 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathClassLoader.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathClassLoader.java
@@ -20,12 +20,14 @@
 import java.lang.management.ManagementFactory;
 import java.lang.reflect.AnnotatedElement;
 import java.lang.reflect.Method;
+import java.net.URI;
 import java.net.URISyntaxException;
 import java.net.URL;
 import java.net.URLClassLoader;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.HashSet;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
@@ -42,7 +44,6 @@
 import org.eclipse.aether.collection.CollectRequest;
 import org.eclipse.aether.connector.basic.BasicRepositoryConnectorFactory;
 import org.eclipse.aether.graph.Dependency;
-import org.eclipse.aether.impl.DefaultServiceLocator;
 import org.eclipse.aether.repository.LocalRepository;
 import org.eclipse.aether.repository.RemoteRepository;
 import org.eclipse.aether.resolution.ArtifactResult;
@@ -55,6 +56,7 @@
 import org.springframework.core.annotation.MergedAnnotation;
 import org.springframework.core.annotation.MergedAnnotations;
 import org.springframework.util.AntPathMatcher;
+import org.springframework.util.ClassUtils;
 import org.springframework.util.ConcurrentReferenceHashMap;
 import org.springframework.util.ObjectUtils;
 import org.springframework.util.StringUtils;
@@ -73,10 +75,14 @@ final class ModifiedClassPathClassLoader extends URLClassLoader {
 
 	private static final int MAX_RESOLUTION_ATTEMPTS = 5;
 
+	private final Set<String> excludedPackages;
+
 	private final ClassLoader junitLoader;
 
-	ModifiedClassPathClassLoader(URL[] urls, ClassLoader parent, ClassLoader junitLoader) {
+	ModifiedClassPathClassLoader(URL[] urls, Set<String> excludedPackages, ClassLoader parent,
+			ClassLoader junitLoader) {
 		super(urls, parent);
+		this.excludedPackages = excludedPackages;
 		this.junitLoader = junitLoader;
 	}
 
@@ -86,6 +92,10 @@ public Class<?> loadClass(String name) throws ClassNotFoundException {
 				|| name.startsWith("io.netty.internal.tcnative")) {
 			return Class.forName(name, false, this.junitLoader);
 		}
+		String packageName = ClassUtils.getPackageName(name);
+		if (this.excludedPackages.contains(packageName)) {
+			throw new ClassNotFoundException();
+		}
 		return super.loadClass(name);
 	}
 
@@ -129,7 +139,7 @@ private static ModifiedClassPathClassLoader compute(ClassLoader classLoader,
 			.map((source) -> MergedAnnotations.from(source, MergedAnnotations.SearchStrategy.TYPE_HIERARCHY))
 			.toList();
 		return new ModifiedClassPathClassLoader(processUrls(extractUrls(classLoader), annotations),
-				classLoader.getParent(), classLoader);
+				excludedPackages(annotations), classLoader.getParent(), classLoader);
 	}
 
 	private static URL[] extractUrls(ClassLoader classLoader) {
@@ -230,11 +240,9 @@ private static List<URL> getAdditionalUrls(List<MergedAnnotations> annotations)
 
 	private static List<URL> resolveCoordinates(String[] coordinates) {
 		Exception latestFailure = null;
-		DefaultServiceLocator serviceLocator = MavenRepositorySystemUtils.newServiceLocator();
-		serviceLocator.addService(RepositoryConnectorFactory.class, BasicRepositoryConnectorFactory.class);
-		serviceLocator.addService(TransporterFactory.class, HttpTransporterFactory.class);
-		RepositorySystem repositorySystem = serviceLocator.getService(RepositorySystem.class);
+		RepositorySystem repositorySystem = createRepositorySystem();
 		DefaultRepositorySystemSession session = MavenRepositorySystemUtils.newSession();
+		session.setSystemProperties(System.getProperties());
 		LocalRepository localRepository = new LocalRepository(System.getProperty("user.home") + "/.m2/repository");
 		RemoteRepository remoteRepository = new RemoteRepository.Builder("central", "default",
 				"https://repo.maven.apache.org/maven2")
@@ -260,6 +268,15 @@ private static List<URL> resolveCoordinates(String[] coordinates) {
 				latestFailure);
 	}
 
+	@SuppressWarnings("deprecation")
+	private static RepositorySystem createRepositorySystem() {
+		org.eclipse.aether.impl.DefaultServiceLocator serviceLocator = MavenRepositorySystemUtils.newServiceLocator();
+		serviceLocator.addService(RepositoryConnectorFactory.class, BasicRepositoryConnectorFactory.class);
+		serviceLocator.addService(TransporterFactory.class, HttpTransporterFactory.class);
+		RepositorySystem repositorySystem = serviceLocator.getService(RepositorySystem.class);
+		return repositorySystem;
+	}
+
 	private static List<Dependency> createDependencies(String[] allCoordinates) {
 		List<Dependency> dependencies = new ArrayList<>();
 		for (String coordinate : allCoordinates) {
@@ -268,6 +285,17 @@ private static List<Dependency> createDependencies(String[] allCoordinates) {
 		return dependencies;
 	}
 
+	private static Set<String> excludedPackages(List<MergedAnnotations> annotations) {
+		Set<String> excludedPackages = new HashSet<>();
+		for (MergedAnnotations candidate : annotations) {
+			MergedAnnotation<ClassPathExclusions> annotation = candidate.get(ClassPathExclusions.class);
+			if (annotation.isPresent()) {
+				excludedPackages.addAll(Arrays.asList(annotation.getStringArray("packages")));
+			}
+		}
+		return excludedPackages;
+	}
+
 	/**
 	 * Filter for class path entries.
 	 */
@@ -291,7 +319,10 @@ private ClassPathEntryFilter(List<MergedAnnotations> annotations) {
 		private boolean isExcluded(URL url) {
 			if ("file".equals(url.getProtocol())) {
 				try {
-					String name = new File(url.toURI()).getName();
+					URI uri = url.toURI();
+					File file = new File(uri);
+					String name = (!uri.toString().endsWith("/")) ? file.getName()
+							: file.getParentFile().getParentFile().getName();
 					for (String exclusion : this.exclusions) {
 						if (this.matcher.match(exclusion, name)) {
 							return true;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/junit/DisabledOnOs.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/junit/DisabledOnOs.java
index c7c2763cb27c..2e7113d33ba0 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/junit/DisabledOnOs.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/junit/DisabledOnOs.java
@@ -29,7 +29,6 @@
  * architecture check.
  *
  * @author Moritz Halbritter
- * @since 2.5.11
  */
 @Target({ ElementType.TYPE, ElementType.METHOD })
 @Retention(RetentionPolicy.RUNTIME)
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/process/DisabledIfProcessUnavailable.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/process/DisabledIfProcessUnavailable.java
index 4dc48e7a37de..faac6707bbc3 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/process/DisabledIfProcessUnavailable.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/process/DisabledIfProcessUnavailable.java
@@ -29,7 +29,6 @@
  * Disables test execution if a process is unavailable.
  *
  * @author Phillip Webb
- * @since 3.1.0
  */
 @Target({ ElementType.TYPE, ElementType.METHOD })
 @Retention(RetentionPolicy.RUNTIME)
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/process/DisabledIfProcessUnavailables.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/process/DisabledIfProcessUnavailables.java
index 62a04d08c468..5c946f6c0594 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/process/DisabledIfProcessUnavailables.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/process/DisabledIfProcessUnavailables.java
@@ -28,7 +28,6 @@
  * Repeatable container for {@link DisabledIfProcessUnavailable}.
  *
  * @author Phillip Webb
- * @since 3.1.0
  */
 @Target({ ElementType.TYPE, ElementType.METHOD })
 @Retention(RetentionPolicy.RUNTIME)
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/system/CapturedOutput.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/system/CapturedOutput.java
index 77925cca61aa..ec03a66e41f4 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/system/CapturedOutput.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/system/CapturedOutput.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -23,7 +23,6 @@
  * @author Madhura Bhave
  * @author Phillip Webb
  * @author Andy Wilkinson
- * @since 2.2.0
  */
 public interface CapturedOutput extends CharSequence {
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/system/OutputCaptureExtension.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/system/OutputCaptureExtension.java
index 5fbff8139484..615c356cd8ba 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/system/OutputCaptureExtension.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/system/OutputCaptureExtension.java
@@ -62,7 +62,6 @@
  * @author Phillip Webb
  * @author Andy Wilkinson
  * @author Sam Brannen
- * @since 2.2.0
  * @see CapturedOutput
  */
 public class OutputCaptureExtension
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/ActiveMQContainer.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/ActiveMQContainer.java
index 6fc7dc5c1572..024594b2c04f 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/ActiveMQContainer.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/ActiveMQContainer.java
@@ -22,7 +22,6 @@
  * A {@link GenericContainer} for ActiveMQ.
  *
  * @author Stephane Nicoll
- * @since 3.1.0
  */
 public class ActiveMQContainer extends GenericContainer<ActiveMQContainer> {
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/CassandraContainer.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/CassandraContainer.java
index adf9c451a3a2..33f113842a3d 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/CassandraContainer.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/CassandraContainer.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -23,7 +23,6 @@
  * heavily contended environments such as CI.
  *
  * @author Andy Wilkinson
- * @since 2.4.10
  */
 public class CassandraContainer extends org.testcontainers.containers.CassandraContainer<CassandraContainer> {
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/DisabledIfDockerUnavailable.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/DisabledIfDockerUnavailable.java
index d148b50047c4..dba1e49c511e 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/DisabledIfDockerUnavailable.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/DisabledIfDockerUnavailable.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -29,7 +29,6 @@
  *
  * @author Andy Wilkinson
  * @author Phillip Webb
- * @since 2.3.0
  */
 @Target({ ElementType.TYPE, ElementType.METHOD })
 @Retention(RetentionPolicy.RUNTIME)
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/DockerImageNames.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/DockerImageNames.java
index 4ef13942d996..4bb98d22018d 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/DockerImageNames.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/DockerImageNames.java
@@ -24,7 +24,7 @@
  * @author Stephane Nicoll
  * @author EddĂș MelĂ©ndez
  * @author Moritz Halbritter
- * @since 2.3.6
+ * @author Chris Bono
  */
 public final class DockerImageNames {
 
@@ -36,14 +36,26 @@ public final class DockerImageNames {
 
 	private static final String ELASTICSEARCH_VERSION = "7.17.5";
 
+	private static final String ELASTICSEARCH_8_VERSION = "8.6.1";
+
 	private static final String KAFKA_VERSION = "7.4.0";
 
+	private static final String MARIADB_VERSION = "10.10";
+
 	private static final String MONGO_VERSION = "5.0.17";
 
+	private static final String MYSQL_VERSION = "8.0";
+
 	private static final String NEO4J_VERSION = "4.4.11";
 
+	private static final String ORACLE_FREE_VERSION = "23.3-slim";
+
 	private static final String ORACLE_XE_VERSION = "18.4.0-slim";
 
+	private static final String OPENTELEMETRY_VERSION = "0.75.0";
+
+	private static final String PULSAR_VERSION = "3.1.0";
+
 	private static final String POSTGRESQL_VERSION = "14.0";
 
 	private static final String RABBIT_VERSION = "3.11-alpine";
@@ -84,13 +96,21 @@ public static DockerImageName couchbase() {
 	}
 
 	/**
-	 * Return a {@link DockerImageName} suitable for running Elasticsearch.
+	 * Return a {@link DockerImageName} suitable for running Elasticsearch 7.
 	 * @return a docker image name for running elasticsearch
 	 */
 	public static DockerImageName elasticsearch() {
 		return DockerImageName.parse("docker.elastic.co/elasticsearch/elasticsearch").withTag(ELASTICSEARCH_VERSION);
 	}
 
+	/**
+	 * Return a {@link DockerImageName} suitable for running Elasticsearch 8.
+	 * @return a docker image name for running elasticsearch
+	 */
+	public static DockerImageName elasticsearch8() {
+		return DockerImageName.parse("elasticsearch").withTag(ELASTICSEARCH_8_VERSION);
+	}
+
 	/**
 	 * Return a {@link DockerImageName} suitable for running Kafka.
 	 * @return a docker image name for running Kafka
@@ -99,6 +119,14 @@ public static DockerImageName kafka() {
 		return DockerImageName.parse("confluentinc/cp-kafka").withTag(KAFKA_VERSION);
 	}
 
+	/**
+	 * Return a {@link DockerImageName} suitable for running MariaDB.
+	 * @return a docker image name for running MariaDB
+	 */
+	public static DockerImageName mariadb() {
+		return DockerImageName.parse("mariadb").withTag(MARIADB_VERSION);
+	}
+
 	/**
 	 * Return a {@link DockerImageName} suitable for running Mongo.
 	 * @return a docker image name for running mongo
@@ -107,6 +135,14 @@ public static DockerImageName mongo() {
 		return DockerImageName.parse("mongo").withTag(MONGO_VERSION);
 	}
 
+	/**
+	 * Return a {@link DockerImageName} suitable for running MySQL.
+	 * @return a docker image name for running MySQL
+	 */
+	public static DockerImageName mysql() {
+		return DockerImageName.parse("mysql").withTag(MYSQL_VERSION);
+	}
+
 	/**
 	 * Return a {@link DockerImageName} suitable for running Neo4j.
 	 * @return a docker image name for running neo4j
@@ -115,6 +151,14 @@ public static DockerImageName neo4j() {
 		return DockerImageName.parse("neo4j").withTag(NEO4J_VERSION);
 	}
 
+	/**
+	 * Return a {@link DockerImageName} suitable for running the Oracle database.
+	 * @return a docker image name for running the Oracle database
+	 */
+	public static DockerImageName oracleFree() {
+		return DockerImageName.parse("gvenzl/oracle-free").withTag(ORACLE_FREE_VERSION);
+	}
+
 	/**
 	 * Return a {@link DockerImageName} suitable for running the Oracle database.
 	 * @return a docker image name for running the Oracle database
@@ -123,6 +167,22 @@ public static DockerImageName oracleXe() {
 		return DockerImageName.parse("gvenzl/oracle-xe").withTag(ORACLE_XE_VERSION);
 	}
 
+	/**
+	 * Return a {@link DockerImageName} suitable for running OpenTelemetry.
+	 * @return a docker image name for running OpenTelemetry
+	 */
+	public static DockerImageName opentelemetry() {
+		return DockerImageName.parse("otel/opentelemetry-collector-contrib").withTag(OPENTELEMETRY_VERSION);
+	}
+
+	/**
+	 * Return a {@link DockerImageName} suitable for running Apache Pulsar.
+	 * @return a docker image name for running pulsar
+	 */
+	public static DockerImageName pulsar() {
+		return DockerImageName.parse("apachepulsar/pulsar").withTag(PULSAR_VERSION);
+	}
+
 	/**
 	 * Return a {@link DockerImageName} suitable for running PostgreSQL.
 	 * @return a docker image name for running postgresql
@@ -133,7 +193,7 @@ public static DockerImageName postgresql() {
 
 	/**
 	 * Return a {@link DockerImageName} suitable for running RabbitMQ.
-	 * @return a docker image name for running redis
+	 * @return a docker image name for running RabbitMQ
 	 */
 	public static DockerImageName rabbit() {
 		return DockerImageName.parse("rabbitmq").withTag(RABBIT_VERSION);
@@ -157,10 +217,17 @@ public static DockerImageName redpanda() {
 			.asCompatibleSubstituteFor("docker.redpanda.com/redpandadata/redpanda");
 	}
 
+	/**
+	 * Return a {@link DockerImageName} suitable for running Microsoft SQLServer.
+	 * @return a docker image name for running SQLServer
+	 */
+	public static DockerImageName sqlserver() {
+		return DockerImageName.parse("mcr.microsoft.com/mssql/server");
+	}
+
 	/**
 	 * Return a {@link DockerImageName} suitable for running a Docker registry.
 	 * @return a docker image name for running a registry
-	 * @since 2.4.0
 	 */
 	public static DockerImageName registry() {
 		return DockerImageName.parse("registry").withTag(REGISTRY_VERSION);
@@ -169,7 +236,6 @@ public static DockerImageName registry() {
 	/**
 	 * Return a {@link DockerImageName} suitable for running Zipkin.
 	 * @return a docker image name for running Zipkin
-	 * @since 3.1.0
 	 */
 	public static DockerImageName zipkin() {
 		return DockerImageName.parse("openzipkin/zipkin").withTag(ZIPKIN_VERSION);
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/RedisContainer.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/RedisContainer.java
index bc53a13135fc..1837ceac2286 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/RedisContainer.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/RedisContainer.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -23,7 +23,6 @@
  *
  * @author Andy Wilkinson
  * @author Madhura Bhave
- * @since 2.0.0
  */
 public class RedisContainer extends GenericContainer<RedisContainer> {
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/web/servlet/DirtiesUrlFactories.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/web/servlet/DirtiesUrlFactories.java
index deb5b59e7617..4aa8d94c4d5f 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/web/servlet/DirtiesUrlFactories.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/web/servlet/DirtiesUrlFactories.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -28,7 +28,6 @@
  * Indicates that a test pollutes URL factories.
  *
  * @author Phillip Webb
- * @since 2.6.14
  */
 @Retention(RetentionPolicy.RUNTIME)
 @Target({ ElementType.TYPE, ElementType.METHOD })
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/web/servlet/ExampleFilter.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/web/servlet/ExampleFilter.java
index 0df0692c8289..7066c43d3953 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/web/servlet/ExampleFilter.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/web/servlet/ExampleFilter.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -29,7 +29,6 @@
  * Simple example Filter used for testing.
  *
  * @author Phillip Webb
- * @since 2.0.0
  */
 public class ExampleFilter implements Filter {
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/web/servlet/ExampleServlet.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/web/servlet/ExampleServlet.java
index 6a9a349184e9..f31f67009fff 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/web/servlet/ExampleServlet.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/web/servlet/ExampleServlet.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -30,7 +30,6 @@
  * Simple example Servlet used for testing.
  *
  * @author Phillip Webb
- * @since 2.0.0
  */
 @SuppressWarnings("serial")
 public class ExampleServlet extends GenericServlet {
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/web/servlet/MockServletWebServer.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/web/servlet/MockServletWebServer.java
index 44e3473755b9..9f5886ab852a 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/web/servlet/MockServletWebServer.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/web/servlet/MockServletWebServer.java
@@ -46,7 +46,6 @@
  *
  * @author Phillip Webb
  * @author Andy Wilkinson
- * @since 2.0.0
  */
 public abstract class MockServletWebServer {
 
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/web/servlet/Servlet5ClassPathOverrides.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/web/servlet/Servlet5ClassPathOverrides.java
deleted file mode 100644
index 2fc8544d79ad..000000000000
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/web/servlet/Servlet5ClassPathOverrides.java
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.testsupport.web.servlet;
-
-import java.lang.annotation.Documented;
-import java.lang.annotation.ElementType;
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-import java.lang.annotation.Target;
-
-import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
-import org.springframework.boot.testsupport.classpath.ClassPathOverrides;
-
-/**
- * Annotation to downgrade to Servlet 5.0.
- *
- * @author Phillip Webb
- * @since 3.0.0
- */
-@Retention(RetentionPolicy.RUNTIME)
-@Target({ ElementType.TYPE, ElementType.METHOD })
-@Documented
-@ClassPathExclusions("jakarta.servlet-api-6*.jar")
-@ClassPathOverrides("jakarta.servlet:jakarta.servlet-api:5.0.0")
-public @interface Servlet5ClassPathOverrides {
-
-}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/assertj/SimpleAsyncTaskExecutorAssertTests.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/assertj/SimpleAsyncTaskExecutorAssertTests.java
new file mode 100644
index 000000000000..a3775298f84b
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/assertj/SimpleAsyncTaskExecutorAssertTests.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.testsupport.assertj;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.api.condition.JRE;
+
+import org.springframework.core.task.SimpleAsyncTaskExecutor;
+
+/**
+ * Tests for {@link SimpleAsyncTaskExecutorAssert}.
+ *
+ * @author Moritz Halbritter
+ */
+class SimpleAsyncTaskExecutorAssertTests {
+
+	@Test
+	void usesPlatformThreads() {
+		SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor();
+		executor.setVirtualThreads(false);
+		SimpleAsyncTaskExecutorAssert.assertThat(executor).usesPlatformThreads();
+	}
+
+	@Test
+	@EnabledForJreRange(min = JRE.JAVA_21)
+	void usesVirtualThreads() {
+		SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor();
+		executor.setVirtualThreads(true);
+		SimpleAsyncTaskExecutorAssert.assertThat(executor).usesVirtualThreads();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathExtensionExclusionsTests.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathExtensionExclusionsTests.java
index 5cfdd53c0af0..0e0741bd2ace 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathExtensionExclusionsTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathExtensionExclusionsTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -19,6 +19,8 @@
 import org.hamcrest.Matcher;
 import org.junit.jupiter.api.Test;
 
+import org.springframework.util.ClassUtils;
+
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.hamcrest.Matchers.isA;
 
@@ -26,22 +28,34 @@
  * Tests for {@link ModifiedClassPathExtension} excluding entries from the class path.
  *
  * @author Christoph Dreis
+ * @author Andy Wilkinson
  */
-@ClassPathExclusions("hibernate-validator-*.jar")
+@ClassPathExclusions(files = "hibernate-validator-*.jar", packages = "java.net.http")
 class ModifiedClassPathExtensionExclusionsTests {
 
 	private static final String EXCLUDED_RESOURCE = "META-INF/services/jakarta.validation.spi.ValidationProvider";
 
 	@Test
-	void entriesAreFilteredFromTestClassClassLoader() {
+	void fileExclusionsAreFilteredFromTestClassClassLoader() {
 		assertThat(getClass().getClassLoader().getResource(EXCLUDED_RESOURCE)).isNull();
 	}
 
 	@Test
-	void entriesAreFilteredFromThreadContextClassLoader() {
+	void fileExclusionsAreFilteredFromThreadContextClassLoader() {
 		assertThat(Thread.currentThread().getContextClassLoader().getResource(EXCLUDED_RESOURCE)).isNull();
 	}
 
+	@Test
+	void packageExclusionsAreFilteredFromTestClassClassLoader() {
+		assertThat(ClassUtils.isPresent("java.net.http.HttpClient", getClass().getClassLoader())).isFalse();
+	}
+
+	@Test
+	void packageExclusionsAreFilteredFromThreadContextClassLoader() {
+		assertThat(ClassUtils.isPresent("java.net.http.HttpClient", Thread.currentThread().getContextClassLoader()))
+			.isFalse();
+	}
+
 	@Test
 	void testsThatUseHamcrestWorkCorrectly() {
 		Matcher<IllegalStateException> matcher = isA(IllegalStateException.class);
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/process/DisabledIfProcessUnavailableTests.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/process/DisabledIfProcessUnavailableTests.java
index 633ad6d847a5..e004812f03bd 100644
--- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/process/DisabledIfProcessUnavailableTests.java
+++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/process/DisabledIfProcessUnavailableTests.java
@@ -18,7 +18,7 @@
 
 import org.junit.jupiter.api.Test;
 
-import static org.junit.jupiter.api.Assertions.fail;
+import static org.assertj.core.api.Assertions.fail;
 
 /**
  * Tests for {@link DisabledIfProcessUnavailable}.
diff --git a/spring-boot-project/spring-boot/build.gradle b/spring-boot-project/spring-boot/build.gradle
index f0831197439b..fcb00b66480a 100644
--- a/spring-boot-project/spring-boot/build.gradle
+++ b/spring-boot-project/spring-boot/build.gradle
@@ -56,17 +56,13 @@ dependencies {
 	optional("org.assertj:assertj-core")
 	optional("org.apache.groovy:groovy")
 	optional("org.apache.groovy:groovy-xml")
-	optional("org.eclipse.jetty:jetty-servlets")
+	optional("org.crac:crac")
+	optional("org.eclipse.jetty:jetty-alpn-conscrypt-server")
+	optional("org.eclipse.jetty:jetty-client")
 	optional("org.eclipse.jetty:jetty-util")
-	optional("org.eclipse.jetty:jetty-webapp") {
-		exclude(group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-servlet-api")
-	}
-	optional("org.eclipse.jetty:jetty-alpn-conscrypt-server") {
-		exclude(group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-servlet-api")
-	}
-	optional("org.eclipse.jetty.http2:http2-server") {
-		exclude(group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-servlet-api")
-	}
+	optional("org.eclipse.jetty.ee10:jetty-ee10-servlets")
+	optional("org.eclipse.jetty.ee10:jetty-ee10-webapp")
+	optional("org.eclipse.jetty.http2:jetty-http2-server")
 	optional("org.flywaydb:flyway-core")
 	optional("org.hamcrest:hamcrest-library")
 	optional("org.hibernate.orm:hibernate-core")
@@ -118,8 +114,8 @@ dependencies {
 	testImplementation("org.awaitility:awaitility")
 	testImplementation("org.codehaus.janino:janino")
 	testImplementation("org.eclipse.jetty:jetty-client")
-	testImplementation("org.eclipse.jetty.http2:http2-client")
-	testImplementation("org.eclipse.jetty.http2:http2-http-client-transport")
+	testImplementation("org.eclipse.jetty.http2:jetty-http2-client")
+	testImplementation("org.eclipse.jetty.http2:jetty-http2-client-transport")
 	testImplementation("org.firebirdsql.jdbc:jaybird") {
 		exclude group: "javax.resource", module: "connector-api"
 	}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ApplicationRunner.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ApplicationRunner.java
index 679d1600cf6a..c07e6091ba72 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ApplicationRunner.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ApplicationRunner.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -30,7 +30,7 @@
  * @see CommandLineRunner
  */
 @FunctionalInterface
-public interface ApplicationRunner {
+public interface ApplicationRunner extends Runner {
 
 	/**
 	 * Callback used to run the bean.
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/BeanDefinitionLoader.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/BeanDefinitionLoader.java
index ceecb07bc1f0..32a59063509a 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/BeanDefinitionLoader.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/BeanDefinitionLoader.java
@@ -265,7 +265,7 @@ private Package findPackage(CharSequence source) {
 				.getResources(ClassUtils.convertClassNameToResourcePath(source.toString()) + "/*.class");
 			for (Resource resource : resources) {
 				String className = StringUtils.stripFilenameExtension(resource.getFilename());
-				load(Class.forName(source.toString() + "." + className));
+				load(Class.forName(source + "." + className));
 				break;
 			}
 		}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/CommandLineRunner.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/CommandLineRunner.java
index f6765fbf1b3b..87fda64a689d 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/CommandLineRunner.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/CommandLineRunner.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -33,7 +33,7 @@
  * @see ApplicationRunner
  */
 @FunctionalInterface
-public interface CommandLineRunner {
+public interface CommandLineRunner extends Runner {
 
 	/**
 	 * Callback used to run the bean.
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/Runner.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/Runner.java
new file mode 100644
index 000000000000..5c0c2edd127c
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/Runner.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot;
+
+/**
+ * Marker interface for runners.
+ *
+ * @author Tadaya Tsuyukubo
+ * @see ApplicationRunner
+ * @see CommandLineRunner
+ */
+interface Runner {
+
+}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java
index fe2d6ac64d8f..b542dc464962 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java
@@ -17,6 +17,7 @@
 package org.springframework.boot;
 
 import java.lang.StackWalker.StackFrame;
+import java.lang.management.ManagementFactory;
 import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -35,6 +36,7 @@
 
 import org.apache.commons.logging.Log;
 import org.apache.commons.logging.LogFactory;
+import org.crac.management.CRaCMXBean;
 
 import org.springframework.aot.AotDetector;
 import org.springframework.beans.BeansException;
@@ -65,6 +67,7 @@
 import org.springframework.context.annotation.ClassPathBeanDefinitionScanner;
 import org.springframework.context.annotation.ConfigurationClassPostProcessor;
 import org.springframework.context.aot.AotApplicationContextInitializer;
+import org.springframework.context.event.ContextClosedEvent;
 import org.springframework.context.support.AbstractApplicationContext;
 import org.springframework.context.support.GenericApplicationContext;
 import org.springframework.core.GenericTypeResolver;
@@ -163,6 +166,9 @@
  * @author Brian Clozel
  * @author Ethan Rubinson
  * @author Chris Bono
+ * @author Moritz Halbritter
+ * @author Tadaya Tsuyukubo
+ * @author Yanming Zhou
  * @since 1.0.0
  * @see #run(Class, String[])
  * @see #run(Class[], String[])
@@ -240,6 +246,8 @@ public class SpringApplication {
 
 	private ApplicationStartup applicationStartup = ApplicationStartup.DEFAULT;
 
+	private boolean keepAlive;
+
 	/**
 	 * Create a new {@link SpringApplication} instance. The application context will load
 	 * beans from the specified primary sources (see {@link SpringApplication class-level}
@@ -296,7 +304,10 @@ private Optional<Class<?>> findMainClass(Stream<StackFrame> stack) {
 	 * @return a running {@link ApplicationContext}
 	 */
 	public ConfigurableApplicationContext run(String... args) {
-		long startTime = System.nanoTime();
+		Startup startup = Startup.create();
+		if (this.registerShutdownHook) {
+			SpringApplication.shutdownHook.enableShutdownHookAddition();
+		}
 		DefaultBootstrapContext bootstrapContext = createBootstrapContext();
 		ConfigurableApplicationContext context = null;
 		configureHeadlessProperty();
@@ -311,11 +322,11 @@ public ConfigurableApplicationContext run(String... args) {
 			prepareContext(bootstrapContext, context, environment, listeners, applicationArguments, printedBanner);
 			refreshContext(context);
 			afterRefresh(context, applicationArguments);
-			Duration timeTakenToStartup = Duration.ofNanos(System.nanoTime() - startTime);
+			startup.started();
 			if (this.logStartupInfo) {
-				new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), timeTakenToStartup);
+				new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), startup);
 			}
-			listeners.started(context, timeTakenToStartup);
+			listeners.started(context, startup.timeTakenToStarted());
 			callRunners(context, applicationArguments);
 		}
 		catch (Throwable ex) {
@@ -327,8 +338,7 @@ public ConfigurableApplicationContext run(String... args) {
 		}
 		try {
 			if (context.isRunning()) {
-				Duration timeTakenToReady = Duration.ofNanos(System.nanoTime() - startTime);
-				listeners.ready(context, timeTakenToReady);
+				listeners.ready(context, startup.ready());
 			}
 		}
 		catch (Throwable ex) {
@@ -406,6 +416,11 @@ private void prepareContext(DefaultBootstrapContext bootstrapContext, Configurab
 		if (this.lazyInitialization) {
 			context.addBeanFactoryPostProcessor(new LazyInitializationBeanFactoryPostProcessor());
 		}
+		if (this.keepAlive) {
+			KeepAlive keepAlive = new KeepAlive();
+			keepAlive.start();
+			context.addApplicationListener(keepAlive);
+		}
 		context.addBeanFactoryPostProcessor(new PropertySourceOrderingBeanFactoryPostProcessor(context));
 		if (!AotDetector.useGeneratedArtifacts()) {
 			// Load the sources
@@ -422,6 +437,10 @@ private void addAotGeneratedInitializerIfNecessary(List<ApplicationContextInitia
 					initializers.stream().filter(AotApplicationContextInitializer.class::isInstance).toList());
 			if (aotInitializers.isEmpty()) {
 				String initializerClassName = this.mainApplicationClass.getName() + "__ApplicationContextInitializer";
+				Assert.state(ClassUtils.isPresent(initializerClassName, getClassLoader()),
+						"You are starting the application with AOT mode enabled but AOT processing hasn't happened. "
+								+ "Please build your application with enabled AOT processing first, "
+								+ "or remove the system property 'spring.aot.enabled' to run the application in regular mode");
 				aotInitializers.add(AotApplicationContextInitializer.forInitializerClasses(initializerClassName));
 			}
 			initializers.removeAll(aotInitializers);
@@ -570,8 +589,8 @@ protected ConfigurableApplicationContext createApplicationContext() {
 	}
 
 	/**
-	 * Apply any relevant post processing the {@link ApplicationContext}. Subclasses can
-	 * apply additional processing as required.
+	 * Apply any relevant post-processing to the {@link ApplicationContext}. Subclasses
+	 * can apply additional processing as required.
 	 * @param context the application context
 	 */
 	protected void postProcessApplicationContext(ConfigurableApplicationContext context) {
@@ -743,18 +762,14 @@ protected void afterRefresh(ConfigurableApplicationContext context, ApplicationA
 	}
 
 	private void callRunners(ApplicationContext context, ApplicationArguments args) {
-		List<Object> runners = new ArrayList<>();
-		runners.addAll(context.getBeansOfType(ApplicationRunner.class).values());
-		runners.addAll(context.getBeansOfType(CommandLineRunner.class).values());
-		AnnotationAwareOrderComparator.sort(runners);
-		for (Object runner : new LinkedHashSet<>(runners)) {
+		context.getBeanProvider(Runner.class).orderedStream().forEach((runner) -> {
 			if (runner instanceof ApplicationRunner applicationRunner) {
 				callRunner(applicationRunner, args);
 			}
 			if (runner instanceof CommandLineRunner commandLineRunner) {
 				callRunner(commandLineRunner, args);
 			}
-		}
+		});
 	}
 
 	private void callRunner(ApplicationRunner runner, ApplicationArguments args) {
@@ -1274,6 +1289,27 @@ public ApplicationStartup getApplicationStartup() {
 		return this.applicationStartup;
 	}
 
+	/**
+	 * Whether to keep the application alive even if there are no more non-daemon threads.
+	 * @return whether to keep the application alive even if there are no more non-daemon
+	 * threads
+	 * @since 3.2.0
+	 */
+	public boolean isKeepAlive() {
+		return this.keepAlive;
+	}
+
+	/**
+	 * Set whether to keep the application alive even if there are no more non-daemon
+	 * threads.
+	 * @param keepAlive whether to keep the application alive even if there are no more
+	 * non-daemon threads
+	 * @since 3.2.0
+	 */
+	public void setKeepAlive(boolean keepAlive) {
+		this.keepAlive = keepAlive;
+	}
+
 	/**
 	 * Return a {@link SpringApplicationShutdownHandlers} instance that can be used to add
 	 * or remove handlers that perform actions before the JVM is shutdown.
@@ -1598,4 +1634,127 @@ public SpringApplicationRunListener getRunListener(SpringApplication springAppli
 
 	}
 
+	/**
+	 * A non-daemon thread to keep the JVM alive. Reacts to {@link ContextClosedEvent} to
+	 * stop itself when the application context is closed.
+	 */
+	private static final class KeepAlive extends Thread implements ApplicationListener<ContextClosedEvent> {
+
+		KeepAlive() {
+			setName("keep-alive");
+			setDaemon(false);
+		}
+
+		@Override
+		public void onApplicationEvent(ContextClosedEvent event) {
+			interrupt();
+		}
+
+		@Override
+		public void run() {
+			while (true) {
+				try {
+					Thread.sleep(Long.MAX_VALUE);
+				}
+				catch (InterruptedException ex) {
+					break;
+				}
+			}
+		}
+
+	}
+
+	abstract static class Startup {
+
+		private Duration timeTakenToStarted;
+
+		abstract long startTime();
+
+		abstract Long processUptime();
+
+		abstract String action();
+
+		final Duration started() {
+			long now = System.currentTimeMillis();
+			this.timeTakenToStarted = Duration.ofMillis(now - startTime());
+			return this.timeTakenToStarted;
+		}
+
+		private Duration ready() {
+			long now = System.currentTimeMillis();
+			return Duration.ofMillis(now - startTime());
+		}
+
+		Duration timeTakenToStarted() {
+			return this.timeTakenToStarted;
+		}
+
+		static Startup create() {
+			ClassLoader classLoader = Startup.class.getClassLoader();
+			return (ClassUtils.isPresent("jdk.crac.management.CRaCMXBean", classLoader)
+					&& ClassUtils.isPresent("org.crac.management.CRaCMXBean", classLoader))
+							? new CoordinatedRestoreAtCheckpointStartup() : new StandardStartup();
+		}
+
+	}
+
+	private static class CoordinatedRestoreAtCheckpointStartup extends Startup {
+
+		private final StandardStartup fallback = new StandardStartup();
+
+		@Override
+		Long processUptime() {
+			long uptime = CRaCMXBean.getCRaCMXBean().getUptimeSinceRestore();
+			return (uptime >= 0) ? uptime : this.fallback.processUptime();
+		}
+
+		@Override
+		String action() {
+			if (restoreTime() >= 0) {
+				return "Restored";
+			}
+			return this.fallback.action();
+		}
+
+		private long restoreTime() {
+			return CRaCMXBean.getCRaCMXBean().getRestoreTime();
+		}
+
+		@Override
+		long startTime() {
+			long restoreTime = restoreTime();
+			if (restoreTime >= 0) {
+				return restoreTime;
+			}
+			return this.fallback.startTime();
+		}
+
+	}
+
+	private static class StandardStartup extends Startup {
+
+		private final Long startTime = System.currentTimeMillis();
+
+		@Override
+		long startTime() {
+			return this.startTime;
+		}
+
+		@Override
+		Long processUptime() {
+			try {
+				return ManagementFactory.getRuntimeMXBean().getUptime();
+			}
+			catch (Throwable ex) {
+				return null;
+			}
+		}
+
+		@Override
+		String action() {
+			return "Started";
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/SpringApplicationShutdownHook.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/SpringApplicationShutdownHook.java
index afa1ea89e906..1b24d5c0b4e4 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/SpringApplicationShutdownHook.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/SpringApplicationShutdownHook.java
@@ -62,12 +62,18 @@ class SpringApplicationShutdownHook implements Runnable {
 
 	private final AtomicBoolean shutdownHookAdded = new AtomicBoolean();
 
+	private volatile boolean shutdownHookAdditionEnabled = false;
+
 	private boolean inProgress;
 
 	SpringApplicationShutdownHandlers getHandlers() {
 		return this.handlers;
 	}
 
+	void enableShutdownHookAddition() {
+		this.shutdownHookAdditionEnabled = true;
+	}
+
 	void registerApplicationContext(ConfigurableApplicationContext context) {
 		addRuntimeShutdownHookIfNecessary();
 		synchronized (SpringApplicationShutdownHook.class) {
@@ -78,7 +84,7 @@ void registerApplicationContext(ConfigurableApplicationContext context) {
 	}
 
 	private void addRuntimeShutdownHookIfNecessary() {
-		if (this.shutdownHookAdded.compareAndSet(false, true)) {
+		if (this.shutdownHookAdditionEnabled && this.shutdownHookAdded.compareAndSet(false, true)) {
 			addRuntimeShutdownHook();
 		}
 	}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/StartupInfoLogger.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/StartupInfoLogger.java
index 1f3104bb60b1..98e4ac32a64a 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/StartupInfoLogger.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/StartupInfoLogger.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,13 +16,12 @@
 
 package org.springframework.boot;
 
-import java.lang.management.ManagementFactory;
-import java.time.Duration;
 import java.util.concurrent.Callable;
 
 import org.apache.commons.logging.Log;
 
 import org.springframework.aot.AotDetector;
+import org.springframework.boot.SpringApplication.Startup;
 import org.springframework.boot.system.ApplicationHome;
 import org.springframework.boot.system.ApplicationPid;
 import org.springframework.context.ApplicationContext;
@@ -52,9 +51,9 @@ void logStarting(Log applicationLog) {
 		applicationLog.debug(LogMessage.of(this::getRunningMessage));
 	}
 
-	void logStarted(Log applicationLog, Duration timeTakenToStartup) {
+	void logStarted(Log applicationLog, Startup startup) {
 		if (applicationLog.isInfoEnabled()) {
-			applicationLog.info(getStartedMessage(timeTakenToStartup));
+			applicationLog.info(getStartedMessage(startup));
 		}
 	}
 
@@ -79,20 +78,18 @@ private CharSequence getRunningMessage() {
 		return message;
 	}
 
-	private CharSequence getStartedMessage(Duration timeTakenToStartup) {
+	private CharSequence getStartedMessage(Startup startup) {
 		StringBuilder message = new StringBuilder();
-		message.append("Started");
+		message.append(startup.action());
 		appendApplicationName(message);
 		message.append(" in ");
-		message.append(timeTakenToStartup.toMillis() / 1000.0);
+		message.append(startup.timeTakenToStarted().toMillis() / 1000.0);
 		message.append(" seconds");
-		try {
-			double uptime = ManagementFactory.getRuntimeMXBean().getUptime() / 1000.0;
+		Long uptimeMs = startup.processUptime();
+		if (uptimeMs != null) {
+			double uptime = uptimeMs / 1000.0;
 			message.append(" (process running for ").append(uptime).append(")");
 		}
-		catch (Throwable ex) {
-			// No JVM time available
-		}
 		return message;
 	}
 
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/builder/SpringApplicationBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/builder/SpringApplicationBuilder.java
index 712caa5e6f15..b479568fcb53 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/builder/SpringApplicationBuilder.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/builder/SpringApplicationBuilder.java
@@ -76,7 +76,7 @@ public class SpringApplicationBuilder {
 
 	private final SpringApplication application;
 
-	private ConfigurableApplicationContext context;
+	private volatile ConfigurableApplicationContext context;
 
 	private SpringApplicationBuilder parent;
 
@@ -145,10 +145,8 @@ public ConfigurableApplicationContext run(String... args) {
 		}
 		configureAsChildIfNecessary(args);
 		if (this.running.compareAndSet(false, true)) {
-			synchronized (this.running) {
-				// If not already running copy the sources over and then run.
-				this.context = build().run(args);
-			}
+			// If not already running copy the sources over and then run.
+			this.context = build().run(args);
 		}
 		return this.context;
 	}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataLoaders.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataLoaders.java
index 2857c534648f..2276d440b37e 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataLoaders.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataLoaders.java
@@ -100,15 +100,14 @@ <R extends ConfigDataResource> ConfigData load(ConfigDataLoaderContext context,
 	private <R extends ConfigDataResource> ConfigDataLoader<R> getLoader(ConfigDataLoaderContext context, R resource) {
 		ConfigDataLoader<R> result = null;
 		for (int i = 0; i < this.loaders.size(); i++) {
-			ConfigDataLoader<?> candidate = this.loaders.get(i);
+			ConfigDataLoader<R> candidate = this.loaders.get(i);
 			if (this.resourceTypes.get(i).isInstance(resource)) {
-				ConfigDataLoader<R> loader = (ConfigDataLoader<R>) candidate;
-				if (loader.isLoadable(context, resource)) {
+				if (candidate.isLoadable(context, resource)) {
 					if (result != null) {
 						throw new IllegalStateException("Multiple loaders found for resource '" + resource + "' ["
 								+ candidate.getClass().getName() + "," + result.getClass().getName() + "]");
 					}
-					result = loader;
+					result = candidate;
 				}
 			}
 		}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataLocationBindHandler.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataLocationBindHandler.java
index 76628b1973f1..28ea06922219 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataLocationBindHandler.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataLocationBindHandler.java
@@ -39,31 +39,22 @@
 class ConfigDataLocationBindHandler extends AbstractBindHandler {
 
 	@Override
-	@SuppressWarnings("unchecked")
 	public Object onSuccess(ConfigurationPropertyName name, Bindable<?> target, BindContext context, Object result) {
 		if (result instanceof ConfigDataLocation location) {
 			return withOrigin(context, location);
 		}
-		if (result instanceof List) {
-			List<Object> list = ((List<Object>) result).stream()
+		if (result instanceof List<?> list) {
+			return list.stream()
 				.filter(Objects::nonNull)
+				.map((element) -> (element instanceof ConfigDataLocation location) ? withOrigin(context, location)
+						: element)
 				.collect(Collectors.toCollection(ArrayList::new));
-			for (int i = 0; i < list.size(); i++) {
-				Object element = list.get(i);
-				if (element instanceof ConfigDataLocation location) {
-					list.set(i, withOrigin(context, location));
-				}
-			}
-			return list;
 		}
 		if (result instanceof ConfigDataLocation[] unfilteredLocations) {
-			ConfigDataLocation[] locations = Arrays.stream(unfilteredLocations)
+			return Arrays.stream(unfilteredLocations)
 				.filter(Objects::nonNull)
+				.map((element) -> withOrigin(context, element))
 				.toArray(ConfigDataLocation[]::new);
-			for (int i = 0; i < locations.length; i++) {
-				locations[i] = withOrigin(context, locations[i]);
-			}
-			return locations;
 		}
 		return result;
 	}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataLocationRuntimeHints.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataLocationRuntimeHints.java
index a3920df086a4..7998837e0b6b 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataLocationRuntimeHints.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataLocationRuntimeHints.java
@@ -49,8 +49,10 @@ public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
 			logger.debug("Registering application configuration hints for " + fileNames + "(" + extensions + ") at "
 					+ locations);
 		}
-		new FilePatternResourceHintsRegistrar(fileNames, locations, extensions).registerHints(hints.resources(),
-				classLoader);
+		FilePatternResourceHintsRegistrar.forClassPathLocations(locations)
+			.withFilePrefixes(fileNames)
+			.withFileExtensions(extensions)
+			.registerHints(hints.resources(), classLoader);
 	}
 
 	/**
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/DelegatingApplicationContextInitializer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/DelegatingApplicationContextInitializer.java
index 5f0cf707a7ee..3cf1721734bf 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/DelegatingApplicationContextInitializer.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/DelegatingApplicationContextInitializer.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -38,7 +38,10 @@
  * @author Dave Syer
  * @author Phillip Webb
  * @since 1.0.0
+ * @deprecated since 3.2.0 for removal in 3.4.0 as property based initialization is no
+ * longer recommended
  */
+@Deprecated(since = "3.2.0", forRemoval = true)
 public class DelegatingApplicationContextInitializer
 		implements ApplicationContextInitializer<ConfigurableApplicationContext>, Ordered {
 
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/DelegatingApplicationListener.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/DelegatingApplicationListener.java
index 634962b0b6fa..41c85cc354bc 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/DelegatingApplicationListener.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/DelegatingApplicationListener.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -40,7 +40,10 @@
  * @author Dave Syer
  * @author Phillip Webb
  * @since 1.0.0
+ * @deprecated since 3.2.0 for removal in 3.4.0 as property based initialization is no
+ * longer recommended
  */
+@Deprecated(since = "3.2.0", forRemoval = true)
 public class DelegatingApplicationListener implements ApplicationListener<ApplicationEvent>, Ordered {
 
 	// NOTE: Similar to org.springframework.web.context.ContextLoader
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/StandardConfigDataLocationResolver.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/StandardConfigDataLocationResolver.java
index e5f9db7c4460..3b3140329974 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/StandardConfigDataLocationResolver.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/StandardConfigDataLocationResolver.java
@@ -228,6 +228,9 @@ private Set<StandardConfigDataReference> getReferencesForFile(ConfigDataLocation
 				return Collections.singleton(reference);
 			}
 		}
+		if (configDataLocation.isOptional()) {
+			return Collections.emptySet();
+		}
 		throw new IllegalStateException("File extension is not known to any PropertySourceLoader. "
 				+ "If the location is meant to reference a directory, it must end in '/' or File.separator");
 	}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/StandardConfigDataResource.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/StandardConfigDataResource.java
index ccf175f555a4..aac0897413eb 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/StandardConfigDataResource.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/StandardConfigDataResource.java
@@ -106,7 +106,7 @@ private boolean isSameUnderlyingResource(Resource ours, Resource other) {
 	}
 
 	private boolean isSameFile(File ours, File other) {
-		return (ours != null) && (other != null) && ours.equals(other);
+		return (ours != null) && ours.equals(other);
 	}
 
 	@Override
@@ -119,9 +119,10 @@ public int hashCode() {
 	public String toString() {
 		if (this.resource instanceof FileSystemResource || this.resource instanceof FileUrlResource) {
 			try {
-				return "file [" + this.resource.getFile().toString() + "]";
+				return "file [" + this.resource.getFile() + "]";
 			}
 			catch (IOException ex) {
+				// Ignore
 			}
 		}
 		return this.resource.toString();
@@ -131,11 +132,11 @@ private File getUnderlyingFile(Resource resource) {
 		try {
 			if (resource instanceof ClassPathResource || resource instanceof FileSystemResource
 					|| resource instanceof FileUrlResource) {
-				File file = resource.getFile();
-				return (file != null) ? file.getAbsoluteFile() : null;
+				return resource.getFile().getAbsoluteFile();
 			}
 		}
 		catch (IOException ex) {
+			// Ignore
 		}
 		return null;
 	}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBean.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBean.java
index 186184b7c8b1..3fa38d84391b 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBean.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBean.java
@@ -23,7 +23,6 @@
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.Map;
-import java.util.concurrent.atomic.AtomicReference;
 
 import org.springframework.aop.support.AopUtils;
 import org.springframework.beans.factory.NoSuchBeanDefinitionException;
@@ -42,8 +41,6 @@
 import org.springframework.core.annotation.MergedAnnotations;
 import org.springframework.core.annotation.MergedAnnotations.SearchStrategy;
 import org.springframework.util.Assert;
-import org.springframework.util.ClassUtils;
-import org.springframework.util.ReflectionUtils;
 import org.springframework.validation.annotation.Validated;
 
 /**
@@ -237,36 +234,12 @@ private static Method findFactoryMethod(ConfigurableListableBeanFactory beanFact
 		if (beanFactory.containsBeanDefinition(beanName)) {
 			BeanDefinition beanDefinition = beanFactory.getMergedBeanDefinition(beanName);
 			if (beanDefinition instanceof RootBeanDefinition rootBeanDefinition) {
-				Method resolvedFactoryMethod = rootBeanDefinition.getResolvedFactoryMethod();
-				if (resolvedFactoryMethod != null) {
-					return resolvedFactoryMethod;
-				}
+				return rootBeanDefinition.getResolvedFactoryMethod();
 			}
-			return findFactoryMethodUsingReflection(beanFactory, beanDefinition);
 		}
 		return null;
 	}
 
-	private static Method findFactoryMethodUsingReflection(ConfigurableListableBeanFactory beanFactory,
-			BeanDefinition beanDefinition) {
-		String factoryMethodName = beanDefinition.getFactoryMethodName();
-		String factoryBeanName = beanDefinition.getFactoryBeanName();
-		if (factoryMethodName == null || factoryBeanName == null) {
-			return null;
-		}
-		Class<?> factoryType = beanFactory.getType(factoryBeanName);
-		if (factoryType.getName().contains(ClassUtils.CGLIB_CLASS_SEPARATOR)) {
-			factoryType = factoryType.getSuperclass();
-		}
-		AtomicReference<Method> factoryMethod = new AtomicReference<>();
-		ReflectionUtils.doWithMethods(factoryType, (method) -> {
-			if (method.getName().equals(factoryMethodName)) {
-				factoryMethod.set(method);
-			}
-		});
-		return factoryMethod.get();
-	}
-
 	static ConfigurationPropertiesBean forValueObject(Class<?> beanType, String beanName) {
 		Bindable<Object> bindTarget = createBindTarget(null, beanType, null);
 		Assert.state(bindTarget != null && deduceBindMethod(bindTarget) == VALUE_OBJECT_BIND_METHOD,
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanRegistrationAotProcessor.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanRegistrationAotProcessor.java
index 623626154b5c..5e96c1ae963f 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanRegistrationAotProcessor.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanRegistrationAotProcessor.java
@@ -16,7 +16,6 @@
 
 package org.springframework.boot.context.properties;
 
-import java.lang.reflect.Executable;
 import java.util.function.Predicate;
 
 import javax.lang.model.element.Modifier;
@@ -34,6 +33,7 @@
 import org.springframework.beans.factory.support.RegisteredBean;
 import org.springframework.beans.factory.support.RootBeanDefinition;
 import org.springframework.boot.context.properties.bind.BindMethod;
+import org.springframework.javapoet.ClassName;
 import org.springframework.javapoet.CodeBlock;
 
 /**
@@ -80,10 +80,14 @@ public CodeBlock generateSetBeanDefinitionPropertiesCode(GenerationContext gener
 					beanDefinition, attributeFilter.or(BindMethodAttribute.NAME::equals));
 		}
 
+		@Override
+		public ClassName getTarget(RegisteredBean registeredBean) {
+			return ClassName.get(this.registeredBean.getBeanClass());
+		}
+
 		@Override
 		public CodeBlock generateInstanceSupplierCode(GenerationContext generationContext,
-				BeanRegistrationCode beanRegistrationCode, Executable constructorOrFactoryMethod,
-				boolean allowDirectSupplierShortcut) {
+				BeanRegistrationCode beanRegistrationCode, boolean allowDirectSupplierShortcut) {
 			GeneratedMethod generatedMethod = beanRegistrationCode.getMethods().add("getInstance", (method) -> {
 				Class<?> beanClass = this.registeredBean.getBeanClass();
 				method.addJavadoc("Get the bean instance for '$L'.", this.registeredBean.getBeanName())
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConstructorBinding.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConstructorBinding.java
deleted file mode 100644
index 49c95249ee97..000000000000
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConstructorBinding.java
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.context.properties;
-
-import java.lang.annotation.Documented;
-import java.lang.annotation.ElementType;
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-import java.lang.annotation.Target;
-
-/**
- * Annotation that can be used to indicate which constructor to use when binding
- * configuration properties using constructor arguments rather than by calling setters. A
- * single parameterized constructor implicitly indicates that constructor binding should
- * be used unless the constructor is annotated with `@Autowired`.
- * <p>
- * Note: To use constructor binding the class must be enabled using
- * {@link EnableConfigurationProperties @EnableConfigurationProperties} or configuration
- * property scanning. Constructor binding cannot be used with beans that are created by
- * the regular Spring mechanisms (e.g.
- * {@link org.springframework.stereotype.Component @Component} beans, beans created via
- * {@link org.springframework.context.annotation.Bean @Bean} methods or beans loaded using
- * {@link org.springframework.context.annotation.Import @Import}).
- *
- * @author Phillip Webb
- * @since 2.2.0
- * @see ConfigurationProperties
- * @deprecated since 3.0.0 for removal in 3.2.0 in favor of
- * {@link org.springframework.boot.context.properties.bind.ConstructorBinding}
- */
-@Target({ ElementType.CONSTRUCTOR, ElementType.ANNOTATION_TYPE })
-@Retention(RetentionPolicy.RUNTIME)
-@Documented
-@Deprecated(since = "3.0.0", forRemoval = true)
-@org.springframework.boot.context.properties.bind.ConstructorBinding
-public @interface ConstructorBinding {
-
-}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConversionServiceDeducer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConversionServiceDeducer.java
index 3593faf4230a..775680c794ae 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConversionServiceDeducer.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConversionServiceDeducer.java
@@ -31,6 +31,7 @@
 import org.springframework.core.convert.converter.GenericConverter;
 import org.springframework.format.Formatter;
 import org.springframework.format.FormatterRegistry;
+import org.springframework.format.support.FormattingConversionService;
 
 /**
  * Utility to deduce the {@link ConversionService} to use for configuration properties
@@ -59,15 +60,22 @@ List<ConversionService> getConversionServices() {
 
 	private List<ConversionService> getConversionServices(ConfigurableApplicationContext applicationContext) {
 		List<ConversionService> conversionServices = new ArrayList<>();
-		if (applicationContext.getBeanFactory().getConversionService() != null) {
-			conversionServices.add(applicationContext.getBeanFactory().getConversionService());
-		}
 		ConverterBeans converterBeans = new ConverterBeans(applicationContext);
 		if (!converterBeans.isEmpty()) {
-			ApplicationConversionService beansConverterService = new ApplicationConversionService();
+			FormattingConversionService beansConverterService = new FormattingConversionService();
 			converterBeans.addTo(beansConverterService);
 			conversionServices.add(beansConverterService);
 		}
+		if (applicationContext.getBeanFactory().getConversionService() != null) {
+			conversionServices.add(applicationContext.getBeanFactory().getConversionService());
+		}
+		if (!converterBeans.isEmpty()) {
+			// Converters beans used to be added to a custom ApplicationConversionService
+			// after the BeanFactory's ConversionService. For backwards compatibility, we
+			// add an ApplicationConversationService as a fallback in the same place in
+			// the list.
+			conversionServices.add(ApplicationConversionService.getSharedInstance());
+		}
 		return conversionServices;
 	}
 
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/DeprecatedConfigurationProperty.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/DeprecatedConfigurationProperty.java
index 23940cde1271..f3962b551515 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/DeprecatedConfigurationProperty.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/DeprecatedConfigurationProperty.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -31,6 +31,7 @@
  * This annotation <strong>must</strong> be used on the getter of the deprecated element.
  *
  * @author Phillip Webb
+ * @author Scott Frederick
  * @since 1.3.0
  */
 @Target(ElementType.METHOD)
@@ -50,4 +51,10 @@
 	 */
 	String replacement() default "";
 
+	/**
+	 * The version in which the property became deprecated.
+	 * @return the version
+	 */
+	String since() default "";
+
 }
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/NotConstructorBoundInjectionFailureAnalyzer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/NotConstructorBoundInjectionFailureAnalyzer.java
index 2e4fc05a4885..228aa5ed48cc 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/NotConstructorBoundInjectionFailureAnalyzer.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/NotConstructorBoundInjectionFailureAnalyzer.java
@@ -49,9 +49,9 @@ protected FailureAnalysis analyze(Throwable rootFailure, NoSuchBeanDefinitionExc
 		InjectionPoint injectionPoint = findInjectionPoint(rootFailure);
 		if (isConstructorBindingConfigurationProperties(injectionPoint)) {
 			String simpleName = injectionPoint.getMember().getDeclaringClass().getSimpleName();
-			String action = String.format("Update your configuration so that " + simpleName + " is defined via @"
+			String action = "Update your configuration so that " + simpleName + " is defined via @"
 					+ ConfigurationPropertiesScan.class.getSimpleName() + " or @"
-					+ EnableConfigurationProperties.class.getSimpleName() + ".", simpleName);
+					+ EnableConfigurationProperties.class.getSimpleName() + ".";
 			return new FailureAnalysis(
 					simpleName + " is annotated with @" + ConstructorBinding.class.getSimpleName()
 							+ " but it is defined as a regular bean which caused dependency injection to fail.",
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindConverter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindConverter.java
index cdabe1d990d5..d56c03e312d2 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindConverter.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindConverter.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -42,6 +42,7 @@
 import org.springframework.core.convert.TypeDescriptor;
 import org.springframework.core.convert.converter.ConditionalGenericConverter;
 import org.springframework.core.convert.support.GenericConversionService;
+import org.springframework.core.io.Resource;
 import org.springframework.util.CollectionUtils;
 
 /**
@@ -154,8 +155,8 @@ private static class ResolvableTypeDescriptor extends TypeDescriptor {
 	private static class TypeConverterConversionService extends GenericConversionService {
 
 		TypeConverterConversionService(Consumer<PropertyEditorRegistry> initializer) {
-			addConverter(new TypeConverterConverter(initializer));
 			ApplicationConversionService.addDelimitedStringConverters(this);
+			addConverter(new TypeConverterConverter(initializer));
 		}
 
 		@Override
@@ -196,16 +197,23 @@ private static class TypeConverterConverter implements ConditionalGenericConvert
 
 		@Override
 		public Set<ConvertiblePair> getConvertibleTypes() {
-			return Collections.singleton(new ConvertiblePair(String.class, Object.class));
+			return Set.of(new ConvertiblePair(String.class, Object.class),
+					new ConvertiblePair(String.class, Resource[].class),
+					new ConvertiblePair(String.class, Collection.class));
 		}
 
 		@Override
 		public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) {
 			Class<?> type = targetType.getType();
-			if (type == null || type == Object.class || Collection.class.isAssignableFrom(type)
-					|| Map.class.isAssignableFrom(type)) {
+			if (type == null || type == Object.class || Map.class.isAssignableFrom(type)) {
 				return false;
 			}
+			if (Collection.class.isAssignableFrom(type)) {
+				TypeDescriptor elementType = targetType.getElementTypeDescriptor();
+				if (elementType == null || (!Resource.class.isAssignableFrom(elementType.getType()))) {
+					return false;
+				}
+			}
 			PropertyEditor editor = this.matchesOnlyTypeConverter.getDefaultEditor(type);
 			if (editor == null) {
 				editor = this.matchesOnlyTypeConverter.findCustomEditor(type, null);
@@ -218,7 +226,7 @@ public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) {
 
 		@Override
 		public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
-			return createTypeConverter().convertIfNecessary(source, targetType.getType());
+			return createTypeConverter().convertIfNecessary(source, targetType.getType(), targetType);
 		}
 
 		private SimpleTypeConverter createTypeConverter() {
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Bindable.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Bindable.java
index d40937abb32d..9e76a84e82c1 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Bindable.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Bindable.java
@@ -156,13 +156,7 @@ public boolean equals(Object obj) {
 
 	@Override
 	public int hashCode() {
-		final int prime = 31;
-		int result = 1;
-		result = prime * result + ObjectUtils.nullSafeHashCode(this.type);
-		result = prime * result + ObjectUtils.nullSafeHashCode(this.annotations);
-		result = prime * result + ObjectUtils.nullSafeHashCode(this.bindRestrictions);
-		result = prime * result + ObjectUtils.nullSafeHashCode(this.bindMethod);
-		return result;
+		return ObjectUtils.nullSafeHash(this.type, this.annotations, this.bindRestrictions, this.bindMethod);
 	}
 
 	@Override
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindableRuntimeHintsRegistrar.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindableRuntimeHintsRegistrar.java
index 40fbba7d7a9f..c2ce25d84290 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindableRuntimeHintsRegistrar.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindableRuntimeHintsRegistrar.java
@@ -318,13 +318,20 @@ private boolean isMap(ResolvableType type) {
 		 */
 		private boolean isNestedType(String propertyName, Class<?> propertyType) {
 			Class<?> declaringClass = propertyType.getDeclaringClass();
-			if (declaringClass != null && declaringClass.isAssignableFrom(this.type)) {
+			if (declaringClass != null && isNested(declaringClass, this.type)) {
 				return true;
 			}
 			Field field = ReflectionUtils.findField(this.type, propertyName);
 			return (field != null) && MergedAnnotations.from(field).isPresent(Nested.class);
 		}
 
+		private static boolean isNested(Class<?> type, Class<?> candidate) {
+			if (type.isAssignableFrom(candidate)) {
+				return true;
+			}
+			return (candidate.getDeclaringClass() != null && isNested(type, candidate.getDeclaringClass()));
+		}
+
 		private boolean isJavaType(Class<?> candidate) {
 			return candidate.getPackageName().startsWith("java.");
 		}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/CollectionBinder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/CollectionBinder.java
index 7ddee6aaa262..4c9d10ec58d7 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/CollectionBinder.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/CollectionBinder.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -40,12 +40,11 @@ class CollectionBinder extends IndexedElementsBinder<Collection<Object>> {
 	@Override
 	protected Object bindAggregate(ConfigurationPropertyName name, Bindable<?> target,
 			AggregateElementBinder elementBinder) {
-		Class<?> collectionType = (target.getValue() != null) ? List.class : target.getType().resolve(Object.class);
 		ResolvableType aggregateType = ResolvableType.forClassWithGenerics(List.class,
 				target.getType().asCollection().getGenerics());
 		ResolvableType elementType = target.getType().asCollection().getGeneric();
 		IndexedCollectionSupplier result = new IndexedCollectionSupplier(
-				() -> CollectionFactory.createCollection(collectionType, elementType.resolve(), 0));
+				() -> CollectionFactory.createCollection(List.class, elementType.resolve(), 0));
 		bindIndexed(name, target, elementBinder, aggregateType, elementType, result);
 		if (result.wasSupplied()) {
 			return result.get();
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DefaultBindConstructorProvider.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DefaultBindConstructorProvider.java
index 54b454ebcafe..609b8ebccc3d 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DefaultBindConstructorProvider.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DefaultBindConstructorProvider.java
@@ -135,7 +135,7 @@ private static Constructor<?>[] getCandidateConstructors(Class<?> type) {
 				return new Constructor<?>[0];
 			}
 			return Arrays.stream(type.getDeclaredConstructors())
-				.filter((constructor) -> isNonSynthetic(constructor, type))
+				.filter(Constructors::isNonSynthetic)
 				.toArray(Constructor[]::new);
 		}
 
@@ -148,7 +148,7 @@ private static boolean isInnerClass(Class<?> type) {
 			}
 		}
 
-		private static boolean isNonSynthetic(Constructor<?> constructor, Class<?> type) {
+		private static boolean isNonSynthetic(Constructor<?> constructor) {
 			return !constructor.isSynthetic();
 		}
 
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/ValueObjectBinder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/ValueObjectBinder.java
index 7a68330b7b28..5f51e91f3410 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/ValueObjectBinder.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/ValueObjectBinder.java
@@ -31,6 +31,8 @@
 import kotlin.reflect.KFunction;
 import kotlin.reflect.KParameter;
 import kotlin.reflect.jvm.ReflectJvmMapping;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
 
 import org.springframework.beans.BeanUtils;
 import org.springframework.boot.context.properties.source.ConfigurationPropertyName;
@@ -43,6 +45,7 @@
 import org.springframework.core.annotation.MergedAnnotation;
 import org.springframework.core.annotation.MergedAnnotations;
 import org.springframework.core.convert.ConversionException;
+import org.springframework.core.log.LogMessage;
 import org.springframework.util.Assert;
 
 /**
@@ -55,6 +58,8 @@
  */
 class ValueObjectBinder implements DataObjectBinder {
 
+	private static final Log logger = LogFactory.getLog(ValueObjectBinder.class);
+
 	private final BindConstructorProvider constructorProvider;
 
 	ValueObjectBinder(BindConstructorProvider constructorProvider) {
@@ -261,15 +266,31 @@ private static final class DefaultValueObject<T> extends ValueObject<T> {
 
 		private final List<ConstructorParameter> constructorParameters;
 
-		private DefaultValueObject(Constructor<T> constructor, ResolvableType type) {
+		private DefaultValueObject(Constructor<T> constructor, List<ConstructorParameter> constructorParameters) {
 			super(constructor);
-			this.constructorParameters = parseConstructorParameters(constructor, type);
+			this.constructorParameters = constructorParameters;
+		}
+
+		@Override
+		List<ConstructorParameter> getConstructorParameters() {
+			return this.constructorParameters;
+		}
+
+		@SuppressWarnings("unchecked")
+		static <T> ValueObject<T> get(Constructor<?> bindConstructor, ResolvableType type) {
+			String[] names = PARAMETER_NAME_DISCOVERER.getParameterNames(bindConstructor);
+			if (names == null) {
+				logger.debug(LogMessage.format(
+						"Unable to use value object binding with %s as parameter names cannot be discovered",
+						bindConstructor));
+				return null;
+			}
+			List<ConstructorParameter> constructorParameters = parseConstructorParameters(bindConstructor, type, names);
+			return new DefaultValueObject<>((Constructor<T>) bindConstructor, constructorParameters);
 		}
 
 		private static List<ConstructorParameter> parseConstructorParameters(Constructor<?> constructor,
-				ResolvableType type) {
-			String[] names = PARAMETER_NAME_DISCOVERER.getParameterNames(constructor);
-			Assert.state(names != null, () -> "Failed to extract parameter names for " + constructor);
+				ResolvableType type, String[] names) {
 			Parameter[] parameters = constructor.getParameters();
 			List<ConstructorParameter> result = new ArrayList<>(parameters.length);
 			for (int i = 0; i < parameters.length; i++) {
@@ -285,16 +306,6 @@ private static List<ConstructorParameter> parseConstructorParameters(Constructor
 			return Collections.unmodifiableList(result);
 		}
 
-		@Override
-		List<ConstructorParameter> getConstructorParameters() {
-			return this.constructorParameters;
-		}
-
-		@SuppressWarnings("unchecked")
-		static <T> ValueObject<T> get(Constructor<?> bindConstructor, ResolvableType type) {
-			return new DefaultValueObject<>((Constructor<T>) bindConstructor, type);
-		}
-
 	}
 
 	/**
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/source/ConfigurationPropertyName.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/source/ConfigurationPropertyName.java
index 4f8e98e1e6ff..85a76d37263f 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/source/ConfigurationPropertyName.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/source/ConfigurationPropertyName.java
@@ -493,7 +493,7 @@ private boolean remainderIsDashes(Elements elements, int element, int index) {
 		}
 		int length = elements.getLength(element);
 		do {
-			char c = Character.toLowerCase(elements.charAt(element, index++));
+			char c = elements.charAt(element, index++);
 			if (c != '-') {
 				return false;
 			}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/BeanDefinitionOverrideFailureAnalyzer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/BeanDefinitionOverrideFailureAnalyzer.java
index ca536fcc9a7b..439f9d23a5ed 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/BeanDefinitionOverrideFailureAnalyzer.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/BeanDefinitionOverrideFailureAnalyzer.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -46,11 +46,11 @@ private String getDescription(BeanDefinitionOverrideException ex) {
 		if (ex.getBeanDefinition().getResourceDescription() != null) {
 			printer.printf(", defined in %s,", ex.getBeanDefinition().getResourceDescription());
 		}
-		printer.printf(" could not be registered. A bean with that name has already been defined ");
+		printer.print(" could not be registered. A bean with that name has already been defined ");
 		if (ex.getExistingDefinition().getResourceDescription() != null) {
 			printer.printf("in %s ", ex.getExistingDefinition().getResourceDescription());
 		}
-		printer.printf("and overriding is disabled.");
+		printer.print("and overriding is disabled.");
 		return description.toString();
 	}
 
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/MutuallyExclusiveConfigurationPropertiesFailureAnalyzer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/MutuallyExclusiveConfigurationPropertiesFailureAnalyzer.java
index e3d95d3658f0..324c02083740 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/MutuallyExclusiveConfigurationPropertiesFailureAnalyzer.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/MutuallyExclusiveConfigurationPropertiesFailureAnalyzer.java
@@ -99,7 +99,7 @@ private void appendDetails(StringBuilder message, MutuallyExclusiveConfiguration
 		configuredDescriptions.forEach(message::append);
 	}
 
-	private <S> Set<String> sortedStrings(Collection<String> input) {
+	private Set<String> sortedStrings(Collection<String> input) {
 		return sortedStrings(input, Function.identity());
 	}
 
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/env/ConfigTreePropertySource.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/env/ConfigTreePropertySource.java
index 866404b46674..ae3737c547b3 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/env/ConfigTreePropertySource.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/env/ConfigTreePropertySource.java
@@ -29,6 +29,8 @@
 import java.util.Map;
 import java.util.Set;
 import java.util.TreeMap;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
 import java.util.stream.Stream;
 
 import org.springframework.boot.convert.ApplicationConversionService;
@@ -258,6 +260,8 @@ private static final class PropertyFileContent implements Value, OriginProvider
 
 		private final Path path;
 
+		private final Lock resourceLock = new ReentrantLock();
+
 		private final Resource resource;
 
 		private final Origin origin;
@@ -341,11 +345,15 @@ private byte[] getBytes() {
 				}
 				if (this.content == null) {
 					assertStillExists();
-					synchronized (this.resource) {
+					this.resourceLock.lock();
+					try {
 						if (this.content == null) {
 							this.content = FileCopyUtils.copyToByteArray(this.resource.getInputStream());
 						}
 					}
+					finally {
+						this.resourceLock.unlock();
+					}
 				}
 				return this.content;
 			}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/env/RandomValuePropertySource.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/env/RandomValuePropertySource.java
index 7c2f23f63153..b01f16fb3d6f 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/env/RandomValuePropertySource.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/env/RandomValuePropertySource.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,6 +16,7 @@
 
 package org.springframework.boot.env;
 
+import java.util.HexFormat;
 import java.util.OptionalInt;
 import java.util.OptionalLong;
 import java.util.Random;
@@ -31,7 +32,6 @@
 import org.springframework.core.env.StandardEnvironment;
 import org.springframework.core.log.LogMessage;
 import org.springframework.util.Assert;
-import org.springframework.util.DigestUtils;
 import org.springframework.util.StringUtils;
 
 /**
@@ -58,6 +58,7 @@
  * @author Dave Syer
  * @author Matt Benson
  * @author Madhura Bhave
+ * @author Moritz Halbritter
  * @since 1.0.0
  */
 public class RandomValuePropertySource extends PropertySource<Random> {
@@ -136,9 +137,9 @@ private void assertPresent(boolean present, Range<?> range) {
 	}
 
 	private Object getRandomBytes() {
-		byte[] bytes = new byte[32];
+		byte[] bytes = new byte[16];
 		getSource().nextBytes(bytes);
-		return DigestUtils.md5DigestAsHex(bytes);
+		return HexFormat.of().withLowerCase().formatHex(bytes);
 	}
 
 	public static void addToEnvironment(ConfigurableEnvironment environment) {
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinModule.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinModule.java
index 09ed12dd40b7..9364c73bf70a 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinModule.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinModule.java
@@ -16,14 +16,9 @@
 
 package org.springframework.boot.jackson;
 
-import java.util.Collection;
-
 import com.fasterxml.jackson.databind.Module;
 import com.fasterxml.jackson.databind.module.SimpleModule;
 
-import org.springframework.context.ApplicationContext;
-import org.springframework.util.Assert;
-
 /**
  * Spring Bean and Jackson {@link Module} to find and
  * {@link SimpleModule#setMixInAnnotation(Class, Class) register}
@@ -36,22 +31,6 @@
  */
 public class JsonMixinModule extends SimpleModule {
 
-	public JsonMixinModule() {
-	}
-
-	/**
-	 * Create a new {@link JsonMixinModule} instance.
-	 * @param context the source application context
-	 * @param basePackages the packages to check for annotated classes
-	 * @deprecated since 3.0.0 in favor of
-	 * {@link #registerEntries(JsonMixinModuleEntries, ClassLoader)}
-	 */
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	public JsonMixinModule(ApplicationContext context, Collection<String> basePackages) {
-		Assert.notNull(context, "Context must not be null");
-		registerEntries(JsonMixinModuleEntries.scan(context, basePackages), context.getClassLoader());
-	}
-
 	/**
 	 * Register the specified {@link JsonMixinModuleEntries entries}.
 	 * @param entries the entries to register to this instance
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinModuleEntriesBeanRegistrationAotProcessor.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinModuleEntriesBeanRegistrationAotProcessor.java
index 980928d762ee..93749dd160a2 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinModuleEntriesBeanRegistrationAotProcessor.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinModuleEntriesBeanRegistrationAotProcessor.java
@@ -16,7 +16,6 @@
 
 package org.springframework.boot.jackson;
 
-import java.lang.reflect.Executable;
 import java.util.LinkedHashSet;
 import java.util.Set;
 
@@ -33,6 +32,7 @@
 import org.springframework.beans.factory.aot.BeanRegistrationCodeFragments;
 import org.springframework.beans.factory.aot.BeanRegistrationCodeFragmentsDecorator;
 import org.springframework.beans.factory.support.RegisteredBean;
+import org.springframework.javapoet.ClassName;
 import org.springframework.javapoet.CodeBlock;
 
 /**
@@ -54,6 +54,8 @@ public BeanRegistrationAotContribution processAheadOfTime(RegisteredBean registe
 
 	static class AotContribution extends BeanRegistrationCodeFragmentsDecorator {
 
+		private static final Class<?> BEAN_TYPE = JsonMixinModuleEntries.class;
+
 		private final RegisteredBean registeredBean;
 
 		private final ClassLoader classLoader;
@@ -64,18 +66,21 @@ static class AotContribution extends BeanRegistrationCodeFragmentsDecorator {
 			this.classLoader = registeredBean.getBeanFactory().getBeanClassLoader();
 		}
 
+		@Override
+		public ClassName getTarget(RegisteredBean registeredBean) {
+			return ClassName.get(BEAN_TYPE);
+		}
+
 		@Override
 		public CodeBlock generateInstanceSupplierCode(GenerationContext generationContext,
-				BeanRegistrationCode beanRegistrationCode, Executable constructorOrFactoryMethod,
-				boolean allowDirectSupplierShortcut) {
+				BeanRegistrationCode beanRegistrationCode, boolean allowDirectSupplierShortcut) {
 			JsonMixinModuleEntries entries = this.registeredBean.getBeanFactory()
 				.getBean(this.registeredBean.getBeanName(), JsonMixinModuleEntries.class);
 			contributeHints(generationContext.getRuntimeHints(), entries);
 			GeneratedMethod generatedMethod = beanRegistrationCode.getMethods().add("getInstance", (method) -> {
-				Class<?> beanType = JsonMixinModuleEntries.class;
 				method.addJavadoc("Get the bean instance for '$L'.", this.registeredBean.getBeanName());
 				method.addModifiers(Modifier.PRIVATE, Modifier.STATIC);
-				method.returns(beanType);
+				method.returns(BEAN_TYPE);
 				CodeBlock.Builder code = CodeBlock.builder();
 				code.add("return $T.create(", JsonMixinModuleEntries.class).beginControlFlow("(mixins) ->");
 				entries.doWithEntry(this.classLoader, (type, mixin) -> addEntryCode(code, type, mixin));
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/DatabaseDriver.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/DatabaseDriver.java
index b391ee9236dc..573cdf48c48b 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/DatabaseDriver.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/DatabaseDriver.java
@@ -320,7 +320,9 @@ public static DatabaseDriver fromProductName(String productName) {
 	 * @param dataSource data source to inspect
 	 * @return the database driver of {@link #UNKNOWN} if not found
 	 * @since 2.6.0
+	 * @deprecated since 2.7.15 for removal in 3.3.0 with no replacement
 	 */
+	@Deprecated(since = "2.7.15", forRemoval = true)
 	public static DatabaseDriver fromDataSource(DataSource dataSource) {
 		try {
 			String productName = JdbcUtils.commonDatabaseName(
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/HikariCheckpointRestoreLifecycle.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/HikariCheckpointRestoreLifecycle.java
new file mode 100644
index 000000000000..40585e4885d1
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/HikariCheckpointRestoreLifecycle.java
@@ -0,0 +1,153 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.jdbc;
+
+import java.lang.reflect.Field;
+import java.time.Duration;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.function.Function;
+
+import javax.sql.DataSource;
+
+import com.zaxxer.hikari.HikariConfigMXBean;
+import com.zaxxer.hikari.HikariDataSource;
+import com.zaxxer.hikari.HikariPoolMXBean;
+import com.zaxxer.hikari.pool.HikariPool;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.context.Lifecycle;
+import org.springframework.core.log.LogMessage;
+import org.springframework.util.Assert;
+import org.springframework.util.ReflectionUtils;
+
+/**
+ * {@link Lifecycle} for a {@link HikariDataSource} allowing it to participate in
+ * checkpoint-restore. When {@link #stop() stopped}, and the data source
+ * {@link HikariDataSource#isAllowPoolSuspension() allows it}, its pool is suspended,
+ * blocking any attempts to borrow connections. Open and idle connections are then
+ * evicted. When subsequently {@link #start() started}, the pool is
+ * {@link HikariPoolMXBean#resumePool() resumed} if necessary.
+ *
+ * @author Christoph Strobl
+ * @author Andy Wilkinson
+ * @since 3.2.0
+ */
+public class HikariCheckpointRestoreLifecycle implements Lifecycle {
+
+	private static final Log logger = LogFactory.getLog(HikariCheckpointRestoreLifecycle.class);
+
+	private static final Field CLOSE_CONNECTION_EXECUTOR;
+
+	static {
+		Field closeConnectionExecutor = ReflectionUtils.findField(HikariPool.class, "closeConnectionExecutor");
+		Assert.notNull(closeConnectionExecutor, "Unable to locate closeConnectionExecutor for HikariPool");
+		Assert.isAssignable(ThreadPoolExecutor.class, closeConnectionExecutor.getType(),
+				"Expected ThreadPoolExecutor for closeConnectionExecutor but found %s"
+					.formatted(closeConnectionExecutor.getType()));
+		ReflectionUtils.makeAccessible(closeConnectionExecutor);
+		CLOSE_CONNECTION_EXECUTOR = closeConnectionExecutor;
+	}
+
+	private final Function<HikariPool, Boolean> hasOpenConnections;
+
+	private final HikariDataSource dataSource;
+
+	/**
+	 * Creates a new {@code HikariCheckpointRestoreLifecycle} that will allow the given
+	 * {@code dataSource} to participate in checkpoint-restore. The {@code dataSource} is
+	 * {@link DataSourceUnwrapper#unwrap unwrapped} to a {@link HikariDataSource}. If such
+	 * unwrapping is not possible, the lifecycle will have no effect.
+	 * @param dataSource the checkpoint-restore participant
+	 */
+	public HikariCheckpointRestoreLifecycle(DataSource dataSource) {
+		this.dataSource = DataSourceUnwrapper.unwrap(dataSource, HikariConfigMXBean.class, HikariDataSource.class);
+		this.hasOpenConnections = (pool) -> {
+			ThreadPoolExecutor closeConnectionExecutor = (ThreadPoolExecutor) ReflectionUtils
+				.getField(CLOSE_CONNECTION_EXECUTOR, pool);
+			Assert.notNull(closeConnectionExecutor, "CloseConnectionExecutor was null");
+			return closeConnectionExecutor.getActiveCount() > 0;
+		};
+	}
+
+	@Override
+	public void start() {
+		if (this.dataSource == null || this.dataSource.isRunning()) {
+			return;
+		}
+		Assert.state(!this.dataSource.isClosed(), "DataSource has been closed and cannot be restarted");
+		if (this.dataSource.isAllowPoolSuspension()) {
+			logger.info("Resuming Hikari pool");
+			this.dataSource.getHikariPoolMXBean().resumePool();
+		}
+	}
+
+	@Override
+	public void stop() {
+		if (this.dataSource == null || !this.dataSource.isRunning()) {
+			return;
+		}
+		if (this.dataSource.isAllowPoolSuspension()) {
+			logger.info("Suspending Hikari pool");
+			this.dataSource.getHikariPoolMXBean().suspendPool();
+		}
+		closeConnections(Duration.ofMillis(this.dataSource.getConnectionTimeout() + 250));
+	}
+
+	private void closeConnections(Duration shutdownTimeout) {
+		logger.info("Evicting Hikari connections");
+		this.dataSource.getHikariPoolMXBean().softEvictConnections();
+		logger.debug("Waiting for Hikari connections to be closed");
+		CompletableFuture<Void> allConnectionsClosed = CompletableFuture.runAsync(this::waitForConnectionsToClose);
+		try {
+			allConnectionsClosed.get(shutdownTimeout.toMillis(), TimeUnit.MILLISECONDS);
+			logger.debug("Hikari connections closed");
+		}
+		catch (InterruptedException ex) {
+			logger.warn("Interrupted while waiting for connections to be closed", ex);
+			Thread.currentThread().interrupt();
+		}
+		catch (TimeoutException ex) {
+			logger.warn(LogMessage.format("Hikari connections could not be closed within %s", shutdownTimeout), ex);
+		}
+		catch (ExecutionException ex) {
+			throw new RuntimeException("Failed to close Hikari connections", ex);
+		}
+	}
+
+	private void waitForConnectionsToClose() {
+		while (this.hasOpenConnections.apply((HikariPool) this.dataSource.getHikariPoolMXBean())) {
+			try {
+				TimeUnit.MILLISECONDS.sleep(50);
+			}
+			catch (InterruptedException ex) {
+				logger.error("Interrupted while waiting for datasource connections to be closed", ex);
+				Thread.currentThread().interrupt();
+			}
+		}
+	}
+
+	@Override
+	public boolean isRunning() {
+		return this.dataSource != null && this.dataSource.isRunning();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/SpringJdbcDependsOnDatabaseInitializationDetector.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/SpringJdbcDependsOnDatabaseInitializationDetector.java
index 13ed613745a6..dedfa0004070 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/SpringJdbcDependsOnDatabaseInitializationDetector.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/SpringJdbcDependsOnDatabaseInitializationDetector.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,14 +16,13 @@
 
 package org.springframework.boot.jdbc;
 
-import java.util.Arrays;
-import java.util.HashSet;
 import java.util.Set;
 
 import org.springframework.boot.sql.init.dependency.AbstractBeansOfTypeDependsOnDatabaseInitializationDetector;
 import org.springframework.boot.sql.init.dependency.DependsOnDatabaseInitializationDetector;
 import org.springframework.jdbc.core.JdbcOperations;
 import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations;
+import org.springframework.jdbc.core.simple.JdbcClient;
 
 /**
  * {@link DependsOnDatabaseInitializationDetector} for Spring Framework's JDBC support.
@@ -35,7 +34,7 @@ class SpringJdbcDependsOnDatabaseInitializationDetector
 
 	@Override
 	protected Set<Class<?>> getDependsOnDatabaseInitializationBeanTypes() {
-		return new HashSet<>(Arrays.asList(JdbcOperations.class, NamedParameterJdbcOperations.class));
+		return Set.of(JdbcClient.class, JdbcOperations.class, NamedParameterJdbcOperations.class);
 	}
 
 }
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/init/PlatformPlaceholderDatabaseDriverResolver.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/init/PlatformPlaceholderDatabaseDriverResolver.java
index 67fb0edb1510..bd1121b49d7d 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/init/PlatformPlaceholderDatabaseDriverResolver.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/init/PlatformPlaceholderDatabaseDriverResolver.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,6 +16,7 @@
 
 package org.springframework.boot.jdbc.init;
 
+import java.sql.DatabaseMetaData;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.LinkedHashMap;
@@ -26,6 +27,7 @@
 import javax.sql.DataSource;
 
 import org.springframework.boot.jdbc.DatabaseDriver;
+import org.springframework.jdbc.support.JdbcUtils;
 import org.springframework.util.Assert;
 import org.springframework.util.ObjectUtils;
 import org.springframework.util.StringUtils;
@@ -89,7 +91,6 @@ public PlatformPlaceholderDatabaseDriverResolver withDriverPlatform(DatabaseDriv
 	 * @param dataSource the DataSource from which the {@link DatabaseDriver} is derived
 	 * @param values the values in which placeholders are resolved
 	 * @return the values with their placeholders resolved
-	 * @see DatabaseDriver#fromDataSource(DataSource)
 	 */
 	public List<String> resolveAll(DataSource dataSource, String... values) {
 		Assert.notNull(dataSource, "DataSource must not be null");
@@ -134,7 +135,14 @@ private String determinePlatform(DataSource dataSource) {
 	}
 
 	DatabaseDriver getDatabaseDriver(DataSource dataSource) {
-		return DatabaseDriver.fromDataSource(dataSource);
+		try {
+			String productName = JdbcUtils.commonDatabaseName(
+					JdbcUtils.extractDatabaseMetaData(dataSource, DatabaseMetaData::getDatabaseProductName));
+			return DatabaseDriver.fromProductName(productName);
+		}
+		catch (Exception ex) {
+			throw new IllegalStateException("Failed to determine DatabaseDriver", ex);
+		}
 	}
 
 }
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/AbstractLoggingSystem.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/AbstractLoggingSystem.java
index 48be4c91f46a..1d22b9aee8c6 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/AbstractLoggingSystem.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/AbstractLoggingSystem.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -22,6 +22,7 @@
 import java.util.LinkedHashSet;
 import java.util.Map;
 import java.util.Set;
+import java.util.function.Function;
 
 import org.springframework.core.env.Environment;
 import org.springframework.core.io.ClassPathResource;
@@ -174,7 +175,35 @@ protected final String getPackagedConfigFile(String fileName) {
 	}
 
 	protected final void applySystemProperties(Environment environment, LogFile logFile) {
-		new LoggingSystemProperties(environment).apply(logFile);
+		new LoggingSystemProperties(environment, getDefaultValueResolver(environment), null).apply(logFile);
+	}
+
+	/**
+	 * Return the default value resolver to use when resolving system properties.
+	 * @param environment the environment
+	 * @return the default value resolver
+	 * @since 3.2.0
+	 */
+	protected Function<String, String> getDefaultValueResolver(Environment environment) {
+		String defaultLogCorrelationPattern = getDefaultLogCorrelationPattern();
+		return (name) -> {
+			if (StringUtils.hasLength(defaultLogCorrelationPattern)
+					&& LoggingSystemProperty.CORRELATION_PATTERN.getApplicationPropertyName().equals(name)
+					&& environment.getProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, Boolean.class, false)) {
+				return defaultLogCorrelationPattern;
+			}
+			return null;
+		};
+	}
+
+	/**
+	 * Return the default log correlation pattern or {@code null} if log correlation
+	 * patterns are not supported.
+	 * @return the default log correlation pattern
+	 * @since 3.2.0
+	 */
+	protected String getDefaultLogCorrelationPattern() {
+		return null;
 	}
 
 	/**
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/CorrelationIdFormatter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/CorrelationIdFormatter.java
new file mode 100644
index 000000000000..e701fba05cff
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/CorrelationIdFormatter.java
@@ -0,0 +1,200 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.logging;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.function.Predicate;
+import java.util.function.UnaryOperator;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+
+/**
+ * Utility class that can be used to format a correlation identifier for logging based on
+ * <a href=
+ * "https://www.w3.org/TR/trace-context/#examples-of-http-traceparent-headers">W3C</a>
+ * recommendations.
+ * <p>
+ * The formatter can be configured with a comma-separated list of names and the expected
+ * length of their resolved value. Each item should be specified in the form
+ * {@code "<name>(length)"}. For example, {@code "traceId(32),spanId(16)"} specifies the
+ * names {@code "traceId"} and {@code "spanId"} with expected lengths of {@code 32} and
+ * {@code 16} respectively.
+ * <p>
+ * Correlation IDs are formatted as dash separated strings surrounded in square brackets.
+ * Formatted output is always of a fixed width and with trailing space. Dashes are omitted
+ * if none of the named items can be resolved.
+ * <p>
+ * The following example would return a formatted result of
+ * {@code "[01234567890123456789012345678901-0123456789012345] "}: <pre class="code">
+ * CorrelationIdFormatter formatter = CorrelationIdFormatter.of("traceId(32),spanId(16)");
+ * Map&lt;String, String&gt; mdc = Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345");
+ * return formatter.format(mdc::get);
+ * </pre>
+ * <p>
+ * If {@link #of(String)} is called with an empty spec the {@link #DEFAULT} formatter will
+ * be used.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ * @see #of(String)
+ * @see #of(Collection)
+ */
+public final class CorrelationIdFormatter {
+
+	/**
+	 * Default {@link CorrelationIdFormatter}.
+	 */
+	public static final CorrelationIdFormatter DEFAULT = CorrelationIdFormatter.of("traceId(32),spanId(16)");
+
+	private final List<Part> parts;
+
+	private final String blank;
+
+	private CorrelationIdFormatter(List<Part> parts) {
+		this.parts = parts;
+		this.blank = String.format("[%s] ", parts.stream().map(Part::blank).collect(Collectors.joining(" ")));
+	}
+
+	/**
+	 * Format a correlation from the values in the given resolver.
+	 * @param resolver the resolver used to resolve named values
+	 * @return a formatted correlation id
+	 */
+	public String format(UnaryOperator<String> resolver) {
+		StringBuilder result = new StringBuilder();
+		formatTo(resolver, result);
+		return result.toString();
+	}
+
+	/**
+	 * Format a correlation from the values in the given resolver and append it to the
+	 * given {@link Appendable}.
+	 * @param resolver the resolver used to resolve named values
+	 * @param appendable the appendable for the formatted correlation id
+	 */
+	public void formatTo(UnaryOperator<String> resolver, Appendable appendable) {
+		Predicate<Part> canResolve = (part) -> StringUtils.hasLength(resolver.apply(part.name()));
+		try {
+			if (this.parts.stream().anyMatch(canResolve)) {
+				appendable.append('[');
+				for (Iterator<Part> iterator = this.parts.iterator(); iterator.hasNext();) {
+					appendable.append(iterator.next().resolve(resolver));
+					if (iterator.hasNext()) {
+						appendable.append('-');
+					}
+				}
+				appendable.append("] ");
+			}
+			else {
+				appendable.append(this.blank);
+			}
+		}
+		catch (IOException ex) {
+			throw new UncheckedIOException(ex);
+		}
+	}
+
+	@Override
+	public String toString() {
+		return this.parts.stream().map(Part::toString).collect(Collectors.joining(","));
+	}
+
+	/**
+	 * Create a new {@link CorrelationIdFormatter} instance from the given specification.
+	 * @param spec a comma-separated specification
+	 * @return a new {@link CorrelationIdFormatter} instance
+	 */
+	public static CorrelationIdFormatter of(String spec) {
+		try {
+			return (!StringUtils.hasText(spec)) ? DEFAULT : of(List.of(spec.split(",")));
+		}
+		catch (Exception ex) {
+			throw new IllegalStateException("Unable to parse correlation formatter spec '%s'".formatted(spec), ex);
+		}
+	}
+
+	/**
+	 * Create a new {@link CorrelationIdFormatter} instance from the given specification.
+	 * @param spec a pre-separated specification
+	 * @return a new {@link CorrelationIdFormatter} instance
+	 */
+	public static CorrelationIdFormatter of(String[] spec) {
+		return of((spec != null) ? List.of(spec) : Collections.emptyList());
+	}
+
+	/**
+	 * Create a new {@link CorrelationIdFormatter} instance from the given specification.
+	 * @param spec a pre-separated specification
+	 * @return a new {@link CorrelationIdFormatter} instance
+	 */
+	public static CorrelationIdFormatter of(Collection<String> spec) {
+		if (CollectionUtils.isEmpty(spec)) {
+			return DEFAULT;
+		}
+		List<Part> parts = spec.stream().map(Part::of).toList();
+		return new CorrelationIdFormatter(parts);
+	}
+
+	/**
+	 * A part of the correlation id.
+	 *
+	 * @param name the name of the correlation part
+	 * @param length the expected length of the correlation part
+	 */
+	record Part(String name, int length) {
+
+		private static final Pattern pattern = Pattern.compile("^(.+?)\\((\\d+)\\)$");
+
+		String resolve(UnaryOperator<String> resolver) {
+			String resolved = resolver.apply(name());
+			if (resolved == null) {
+				return blank();
+			}
+			int padding = length() - resolved.length();
+			return (padding <= 0) ? resolved : resolved + " ".repeat(padding);
+		}
+
+		String blank() {
+			return " ".repeat(this.length);
+		}
+
+		@Override
+		public String toString() {
+			return "%s(%s)".formatted(name(), length());
+		}
+
+		static Part of(String part) {
+			Matcher matcher = pattern.matcher(part.trim());
+			Assert.state(matcher.matches(), () -> "Invalid specification part '%s'".formatted(part));
+			String name = matcher.group(1);
+			int length = Integer.parseInt(matcher.group(2));
+			return new Part(name, length);
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/DeferredLog.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/DeferredLog.java
index cf5f7b4713f1..05026a362d62 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/DeferredLog.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/DeferredLog.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -35,7 +35,7 @@
  */
 public class DeferredLog implements Log {
 
-	private volatile Log destination;
+	private Log destination;
 
 	private final Supplier<Log> destinationSupplier;
 
@@ -175,7 +175,9 @@ private void log(LogLevel level, Object message, Throwable t) {
 	}
 
 	void switchOver() {
-		this.destination = this.destinationSupplier.get();
+		synchronized (this.lines) {
+			this.destination = this.destinationSupplier.get();
+		}
 	}
 
 	/**
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LogFile.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LogFile.java
index b973bc90019d..a1a201202e72 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LogFile.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LogFile.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -86,13 +86,13 @@ public void applyToSystemProperties() {
 	 * @param properties the properties to apply to
 	 */
 	public void applyTo(Properties properties) {
-		put(properties, LoggingSystemProperties.LOG_PATH, this.path);
-		put(properties, LoggingSystemProperties.LOG_FILE, toString());
+		put(properties, LoggingSystemProperty.LOG_PATH, this.path);
+		put(properties, LoggingSystemProperty.LOG_FILE, toString());
 	}
 
-	private void put(Properties properties, String key, String value) {
+	private void put(Properties properties, LoggingSystemProperty property, String value) {
 		if (StringUtils.hasLength(value)) {
-			properties.put(key, value);
+			properties.put(property.getEnvironmentVariableName(), value);
 		}
 	}
 
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggerConfiguration.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggerConfiguration.java
index 170244b9c68d..c1f67d81ea70 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggerConfiguration.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggerConfiguration.java
@@ -60,7 +60,7 @@ public LoggerConfiguration(String name, LogLevel configuredLevel, LogLevel effec
 	public LoggerConfiguration(String name, LevelConfiguration levelConfiguration,
 			LevelConfiguration inheritedLevelConfiguration) {
 		Assert.notNull(name, "Name must not be null");
-		Assert.notNull(inheritedLevelConfiguration, "EffectiveLevelConfiguration must not be null");
+		Assert.notNull(inheritedLevelConfiguration, "InheritedLevelConfiguration must not be null");
 		this.name = name;
 		this.levelConfiguration = levelConfiguration;
 		this.inheritedLevelConfiguration = inheritedLevelConfiguration;
@@ -140,15 +140,15 @@ public String toString() {
 	}
 
 	/**
-	 * Supported logger configurations scopes.
+	 * Supported logger configuration scopes.
 	 *
 	 * @since 2.7.13
 	 */
 	public enum ConfigurationScope {
 
 		/**
-		 * Only return configuration that has been applied directly applied. Often
-		 * referred to as 'configured' or 'assigned' configuration.
+		 * Only return configuration that has been applied directly. Often referred to as
+		 * 'configured' or 'assigned' configuration.
 		 */
 		DIRECT,
 
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystem.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystem.java
index d35b5ba4c64b..1fd3a398378d 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystem.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystem.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -23,6 +23,7 @@
 import java.util.Set;
 
 import org.springframework.core.env.ConfigurableEnvironment;
+import org.springframework.core.env.Environment;
 import org.springframework.util.Assert;
 import org.springframework.util.ClassUtils;
 import org.springframework.util.StringUtils;
@@ -58,6 +59,13 @@ public abstract class LoggingSystem {
 
 	private static final LoggingSystemFactory SYSTEM_FACTORY = LoggingSystemFactory.fromSpringFactories();
 
+	/**
+	 * The name of an {@link Environment} property used to indicate that a correlation ID
+	 * is expected to be logged at some point.
+	 * @since 3.2.0
+	 */
+	public static final String EXPECT_CORRELATION_ID_PROPERTY = "logging.expect-correlation-id";
+
 	/**
 	 * Return the {@link LoggingSystemProperties} that should be applied.
 	 * @param environment the {@link ConfigurableEnvironment} used to obtain value
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystemProperties.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystemProperties.java
index ff4c6c7eebe1..17b2e1a3aec9 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystemProperties.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystemProperties.java
@@ -19,6 +19,7 @@
 import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
 import java.util.function.BiConsumer;
+import java.util.function.Function;
 
 import org.springframework.boot.system.ApplicationPid;
 import org.springframework.core.env.ConfigurableEnvironment;
@@ -26,6 +27,7 @@
 import org.springframework.core.env.PropertyResolver;
 import org.springframework.core.env.PropertySourcesPropertyResolver;
 import org.springframework.util.Assert;
+import org.springframework.util.StringUtils;
 
 /**
  * Utility to set system properties that can later be used by log configuration files.
@@ -36,69 +38,122 @@
  * @author Vedran Pavic
  * @author Robert Thornton
  * @author EddĂș MelĂ©ndez
+ * @author Jonatan Ivanov
  * @since 2.0.0
+ * @see LoggingSystemProperty
  */
 public class LoggingSystemProperties {
 
 	/**
 	 * The name of the System property that contains the process ID.
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling
+	 * {@link LoggingSystemProperty#getEnvironmentVariableName()} on
+	 * {@link LoggingSystemProperty#PID}
 	 */
-	public static final String PID_KEY = "PID";
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public static final String PID_KEY = LoggingSystemProperty.PID.getEnvironmentVariableName();
 
 	/**
 	 * The name of the System property that contains the exception conversion word.
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling
+	 * {@link LoggingSystemProperty#getEnvironmentVariableName()} on
+	 * {@link LoggingSystemProperty#EXCEPTION_CONVERSION_WORD}
 	 */
-	public static final String EXCEPTION_CONVERSION_WORD = "LOG_EXCEPTION_CONVERSION_WORD";
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public static final String EXCEPTION_CONVERSION_WORD = LoggingSystemProperty.EXCEPTION_CONVERSION_WORD
+		.getEnvironmentVariableName();
 
 	/**
 	 * The name of the System property that contains the log file.
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling
+	 * {@link LoggingSystemProperty#getEnvironmentVariableName()} on
+	 * {@link LoggingSystemProperty#LOG_FILE}
 	 */
-	public static final String LOG_FILE = "LOG_FILE";
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public static final String LOG_FILE = LoggingSystemProperty.LOG_FILE.getEnvironmentVariableName();
 
 	/**
 	 * The name of the System property that contains the log path.
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling
+	 * {@link LoggingSystemProperty#getEnvironmentVariableName()} on
+	 * {@link LoggingSystemProperty#LOG_PATH}
 	 */
-	public static final String LOG_PATH = "LOG_PATH";
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public static final String LOG_PATH = LoggingSystemProperty.LOG_PATH.getEnvironmentVariableName();
 
 	/**
 	 * The name of the System property that contains the console log pattern.
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling
+	 * {@link LoggingSystemProperty#getEnvironmentVariableName()} on
+	 * {@link LoggingSystemProperty#CONSOLE_PATTERN}
 	 */
-	public static final String CONSOLE_LOG_PATTERN = "CONSOLE_LOG_PATTERN";
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public static final String CONSOLE_LOG_PATTERN = LoggingSystemProperty.CONSOLE_PATTERN.getEnvironmentVariableName();
 
 	/**
 	 * The name of the System property that contains the console log charset.
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling
+	 * {@link LoggingSystemProperty#getEnvironmentVariableName()} on
+	 * {@link LoggingSystemProperty#CONSOLE_CHARSET}
 	 */
-	public static final String CONSOLE_LOG_CHARSET = "CONSOLE_LOG_CHARSET";
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public static final String CONSOLE_LOG_CHARSET = LoggingSystemProperty.CONSOLE_CHARSET.getEnvironmentVariableName();
 
 	/**
 	 * The log level threshold for console log.
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling
+	 * {@link LoggingSystemProperty#getEnvironmentVariableName()} on
+	 * {@link LoggingSystemProperty#CONSOLE_THRESHOLD}
 	 */
-	public static final String CONSOLE_LOG_THRESHOLD = "CONSOLE_LOG_THRESHOLD";
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public static final String CONSOLE_LOG_THRESHOLD = LoggingSystemProperty.CONSOLE_THRESHOLD
+		.getEnvironmentVariableName();
 
 	/**
 	 * The name of the System property that contains the file log pattern.
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling
+	 * {@link LoggingSystemProperty#getEnvironmentVariableName()} on
+	 * {@link LoggingSystemProperty#FILE_PATTERN}
 	 */
-	public static final String FILE_LOG_PATTERN = "FILE_LOG_PATTERN";
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public static final String FILE_LOG_PATTERN = LoggingSystemProperty.FILE_PATTERN.getEnvironmentVariableName();
 
 	/**
 	 * The name of the System property that contains the file log charset.
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling
+	 * {@link LoggingSystemProperty#getEnvironmentVariableName()} on
+	 * {@link LoggingSystemProperty#FILE_CHARSET}
 	 */
-	public static final String FILE_LOG_CHARSET = "FILE_LOG_CHARSET";
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public static final String FILE_LOG_CHARSET = LoggingSystemProperty.FILE_CHARSET.getEnvironmentVariableName();
 
 	/**
 	 * The log level threshold for file log.
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling
+	 * {@link LoggingSystemProperty#getEnvironmentVariableName()} on
+	 * {@link LoggingSystemProperty#FILE_THRESHOLD}
 	 */
-	public static final String FILE_LOG_THRESHOLD = "FILE_LOG_THRESHOLD";
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public static final String FILE_LOG_THRESHOLD = LoggingSystemProperty.FILE_THRESHOLD.getEnvironmentVariableName();
 
 	/**
 	 * The name of the System property that contains the log level pattern.
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling
+	 * {@link LoggingSystemProperty#getEnvironmentVariableName()} on
+	 * {@link LoggingSystemProperty#LEVEL_PATTERN}
 	 */
-	public static final String LOG_LEVEL_PATTERN = "LOG_LEVEL_PATTERN";
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public static final String LOG_LEVEL_PATTERN = LoggingSystemProperty.LEVEL_PATTERN.getEnvironmentVariableName();
 
 	/**
 	 * The name of the System property that contains the log date-format pattern.
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling
+	 * {@link LoggingSystemProperty#getEnvironmentVariableName()} on
+	 * {@link LoggingSystemProperty#DATEFORMAT_PATTERN}
 	 */
-	public static final String LOG_DATEFORMAT_PATTERN = "LOG_DATEFORMAT_PATTERN";
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public static final String LOG_DATEFORMAT_PATTERN = LoggingSystemProperty.DATEFORMAT_PATTERN
+		.getEnvironmentVariableName();
 
 	private static final BiConsumer<String, String> systemPropertySetter = (name, value) -> {
 		if (System.getProperty(name) == null && value != null) {
@@ -108,6 +163,8 @@ public class LoggingSystemProperties {
 
 	private final Environment environment;
 
+	private final Function<String, String> defaultValueResolver;
+
 	private final BiConsumer<String, String> setter;
 
 	/**
@@ -115,20 +172,34 @@ public class LoggingSystemProperties {
 	 * @param environment the source environment
 	 */
 	public LoggingSystemProperties(Environment environment) {
-		this(environment, systemPropertySetter);
+		this(environment, null);
 	}
 
 	/**
 	 * Create a new {@link LoggingSystemProperties} instance.
 	 * @param environment the source environment
-	 * @param setter setter used to apply the property
+	 * @param setter setter used to apply the property or {@code null} for system
+	 * properties
 	 * @since 2.4.2
 	 */
 	public LoggingSystemProperties(Environment environment, BiConsumer<String, String> setter) {
+		this(environment, null, setter);
+	}
+
+	/**
+	 * Create a new {@link LoggingSystemProperties} instance.
+	 * @param environment the source environment
+	 * @param defaultValueResolver function used to resolve default values or {@code null}
+	 * @param setter setter used to apply the property or {@code null} for system
+	 * properties
+	 * @since 3.2.0
+	 */
+	public LoggingSystemProperties(Environment environment, Function<String, String> defaultValueResolver,
+			BiConsumer<String, String> setter) {
 		Assert.notNull(environment, "Environment must not be null");
-		Assert.notNull(setter, "Setter must not be null");
 		this.environment = environment;
-		this.setter = setter;
+		this.defaultValueResolver = (defaultValueResolver != null) ? defaultValueResolver : (name) -> null;
+		this.setter = (setter != null) ? setter : systemPropertySetter;
 	}
 
 	protected Charset getDefaultCharset() {
@@ -144,22 +215,6 @@ public final void apply(LogFile logFile) {
 		apply(logFile, resolver);
 	}
 
-	protected void apply(LogFile logFile, PropertyResolver resolver) {
-		setSystemProperty(resolver, EXCEPTION_CONVERSION_WORD, "logging.exception-conversion-word");
-		setSystemProperty(PID_KEY, new ApplicationPid().toString());
-		setSystemProperty(resolver, CONSOLE_LOG_PATTERN, "logging.pattern.console");
-		setSystemProperty(resolver, CONSOLE_LOG_CHARSET, "logging.charset.console", getDefaultCharset().name());
-		setSystemProperty(resolver, CONSOLE_LOG_THRESHOLD, "logging.threshold.console");
-		setSystemProperty(resolver, LOG_DATEFORMAT_PATTERN, "logging.pattern.dateformat");
-		setSystemProperty(resolver, FILE_LOG_PATTERN, "logging.pattern.file");
-		setSystemProperty(resolver, FILE_LOG_CHARSET, "logging.charset.file", getDefaultCharset().name());
-		setSystemProperty(resolver, FILE_LOG_THRESHOLD, "logging.threshold.file");
-		setSystemProperty(resolver, LOG_LEVEL_PATTERN, "logging.pattern.level");
-		if (logFile != null) {
-			logFile.applyToSystemProperties();
-		}
-	}
-
 	private PropertyResolver getPropertyResolver() {
 		if (this.environment instanceof ConfigurableEnvironment configurableEnvironment) {
 			PropertySourcesPropertyResolver resolver = new PropertySourcesPropertyResolver(
@@ -171,17 +226,85 @@ private PropertyResolver getPropertyResolver() {
 		return this.environment;
 	}
 
+	protected void apply(LogFile logFile, PropertyResolver resolver) {
+		String defaultCharsetName = getDefaultCharset().name();
+		setApplicationNameSystemProperty(resolver);
+		setSystemProperty(LoggingSystemProperty.PID, new ApplicationPid().toString());
+		setSystemProperty(LoggingSystemProperty.CONSOLE_CHARSET, resolver, defaultCharsetName);
+		setSystemProperty(LoggingSystemProperty.FILE_CHARSET, resolver, defaultCharsetName);
+		setSystemProperty(LoggingSystemProperty.CONSOLE_THRESHOLD, resolver);
+		setSystemProperty(LoggingSystemProperty.FILE_THRESHOLD, resolver);
+		setSystemProperty(LoggingSystemProperty.EXCEPTION_CONVERSION_WORD, resolver);
+		setSystemProperty(LoggingSystemProperty.CONSOLE_PATTERN, resolver);
+		setSystemProperty(LoggingSystemProperty.FILE_PATTERN, resolver);
+		setSystemProperty(LoggingSystemProperty.LEVEL_PATTERN, resolver);
+		setSystemProperty(LoggingSystemProperty.DATEFORMAT_PATTERN, resolver);
+		setSystemProperty(LoggingSystemProperty.CORRELATION_PATTERN, resolver);
+		if (logFile != null) {
+			logFile.applyToSystemProperties();
+		}
+	}
+
+	private void setApplicationNameSystemProperty(PropertyResolver resolver) {
+		if (resolver.getProperty("logging.include-application-name", Boolean.class, Boolean.TRUE)) {
+			String applicationName = resolver.getProperty("spring.application.name");
+			if (StringUtils.hasText(applicationName)) {
+				setSystemProperty(LoggingSystemProperty.APPLICATION_NAME.getEnvironmentVariableName(),
+						"[%s] ".formatted(applicationName));
+			}
+		}
+	}
+
+	private void setSystemProperty(LoggingSystemProperty property, PropertyResolver resolver) {
+		setSystemProperty(property, resolver, null);
+	}
+
+	private void setSystemProperty(LoggingSystemProperty property, PropertyResolver resolver, String defaultValue) {
+		String value = (property.getApplicationPropertyName() != null)
+				? resolver.getProperty(property.getApplicationPropertyName()) : null;
+		value = (value != null) ? value : this.defaultValueResolver.apply(property.getApplicationPropertyName());
+		value = (value != null) ? value : defaultValue;
+		setSystemProperty(property.getEnvironmentVariableName(), value);
+	}
+
+	private void setSystemProperty(LoggingSystemProperty property, String value) {
+		setSystemProperty(property.getEnvironmentVariableName(), value);
+	}
+
+	/**
+	 * Set a system property.
+	 * @param resolver the resolver used to get the property value
+	 * @param systemPropertyName the system property name
+	 * @param propertyName the application property name
+	 * @deprecated since 3.2.0 for removal in 3.4.0 with no replacement
+	 */
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	protected final void setSystemProperty(PropertyResolver resolver, String systemPropertyName, String propertyName) {
 		setSystemProperty(resolver, systemPropertyName, propertyName, null);
 	}
 
+	/**
+	 * Set a system property.
+	 * @param resolver the resolver used to get the property value
+	 * @param systemPropertyName the system property name
+	 * @param propertyName the application property name
+	 * @param defaultValue the default value if none can be resolved
+	 * @deprecated since 3.2.0 for removal in 3.4.0 with no replacement
+	 */
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	protected final void setSystemProperty(PropertyResolver resolver, String systemPropertyName, String propertyName,
 			String defaultValue) {
 		String value = resolver.getProperty(propertyName);
+		value = (value != null) ? value : this.defaultValueResolver.apply(systemPropertyName);
 		value = (value != null) ? value : defaultValue;
 		setSystemProperty(systemPropertyName, value);
 	}
 
+	/**
+	 * Set a system property.
+	 * @param name the property name
+	 * @param value the value
+	 */
 	protected final void setSystemProperty(String name, String value) {
 		this.setter.accept(name, value);
 	}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystemProperty.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystemProperty.java
new file mode 100644
index 000000000000..489ebec89feb
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystemProperty.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.logging;
+
+/**
+ * Logging system properties that can later be used by log configuration files.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ * @see LoggingSystemProperties
+ */
+public enum LoggingSystemProperty {
+
+	/**
+	 * Logging system property for the application name that should be logged.
+	 */
+	APPLICATION_NAME("LOGGED_APPLICATION_NAME"),
+
+	/**
+	 * Logging system property for the process ID.
+	 */
+	PID("PID"),
+
+	/**
+	 * Logging system property for the log file.
+	 */
+	LOG_FILE("LOG_FILE"),
+
+	/**
+	 * Logging system property for the log path.
+	 */
+	LOG_PATH("LOG_PATH"),
+
+	/**
+	 * Logging system property for the console log charset.
+	 */
+	CONSOLE_CHARSET("CONSOLE_LOG_CHARSET", "logging.charset.console"),
+
+	/**
+	 * Logging system property for the file log charset.
+	 */
+	FILE_CHARSET("FILE_LOG_CHARSET", "logging.charset.file"),
+
+	/**
+	 * Logging system property for the console log.
+	 */
+	CONSOLE_THRESHOLD("CONSOLE_LOG_THRESHOLD", "logging.threshold.console"),
+
+	/**
+	 * Logging system property for the file log.
+	 */
+	FILE_THRESHOLD("FILE_LOG_THRESHOLD", "logging.threshold.file"),
+
+	/**
+	 * Logging system property for the exception conversion word.
+	 */
+	EXCEPTION_CONVERSION_WORD("LOG_EXCEPTION_CONVERSION_WORD", "logging.exception-conversion-word"),
+
+	/**
+	 * Logging system property for the console log pattern.
+	 */
+	CONSOLE_PATTERN("CONSOLE_LOG_PATTERN", "logging.pattern.console"),
+
+	/**
+	 * Logging system property for the file log pattern.
+	 */
+	FILE_PATTERN("FILE_LOG_PATTERN", "logging.pattern.file"),
+
+	/**
+	 * Logging system property for the log level pattern.
+	 */
+	LEVEL_PATTERN("LOG_LEVEL_PATTERN", "logging.pattern.level"),
+
+	/**
+	 * Logging system property for the date-format pattern.
+	 */
+	DATEFORMAT_PATTERN("LOG_DATEFORMAT_PATTERN", "logging.pattern.dateformat"),
+
+	/**
+	 * Logging system property for the correlation pattern.
+	 */
+	CORRELATION_PATTERN("LOG_CORRELATION_PATTERN", "logging.pattern.correlation");
+
+	private final String environmentVariableName;
+
+	private final String applicationPropertyName;
+
+	LoggingSystemProperty(String environmentVariableName) {
+		this(environmentVariableName, null);
+	}
+
+	LoggingSystemProperty(String environmentVariableName, String applicationPropertyName) {
+		this.environmentVariableName = environmentVariableName;
+		this.applicationPropertyName = applicationPropertyName;
+	}
+
+	/**
+	 * Return the name of environment variable that can be used to access this property.
+	 * @return the environment variable name
+	 */
+	public String getEnvironmentVariableName() {
+		return this.environmentVariableName;
+	}
+
+	String getApplicationPropertyName() {
+		return this.applicationPropertyName;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/java/SimpleFormatter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/java/SimpleFormatter.java
index f92e5eae0edb..fc9dbfbcaf29 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/java/SimpleFormatter.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/java/SimpleFormatter.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -22,7 +22,7 @@
 import java.util.logging.Formatter;
 import java.util.logging.LogRecord;
 
-import org.springframework.boot.logging.LoggingSystemProperties;
+import org.springframework.boot.logging.LoggingSystemProperty;
 
 /**
  * Simple 'Java Logging' {@link Formatter}.
@@ -36,19 +36,17 @@ public class SimpleFormatter extends Formatter {
 
 	private final String format = getOrUseDefault("LOG_FORMAT", DEFAULT_FORMAT);
 
-	private final String pid = getOrUseDefault(LoggingSystemProperties.PID_KEY, "????");
-
-	private final Date date = new Date();
+	private final String pid = getOrUseDefault(LoggingSystemProperty.PID.getEnvironmentVariableName(), "????");
 
 	@Override
-	public synchronized String format(LogRecord record) {
-		this.date.setTime(record.getMillis());
+	public String format(LogRecord record) {
+		Date date = new Date(record.getMillis());
 		String source = record.getLoggerName();
 		String message = formatMessage(record);
 		String throwable = getThrowable(record);
 		String thread = getThreadName();
-		return String.format(this.format, this.date, source, record.getLoggerName(),
-				record.getLevel().getLocalizedName(), message, throwable, thread, this.pid);
+		return String.format(this.format, date, source, record.getLoggerName(), record.getLevel().getLocalizedName(),
+				message, throwable, thread, this.pid);
 	}
 
 	private String getThrowable(LogRecord record) {
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/CorrelationIdConverter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/CorrelationIdConverter.java
new file mode 100644
index 000000000000..5dcf9195da48
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/CorrelationIdConverter.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.logging.log4j2;
+
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.config.plugins.Plugin;
+import org.apache.logging.log4j.core.pattern.ConverterKeys;
+import org.apache.logging.log4j.core.pattern.LogEventPatternConverter;
+import org.apache.logging.log4j.core.pattern.MdcPatternConverter;
+import org.apache.logging.log4j.core.pattern.PatternConverter;
+import org.apache.logging.log4j.util.PerformanceSensitive;
+import org.apache.logging.log4j.util.ReadOnlyStringMap;
+
+import org.springframework.boot.logging.CorrelationIdFormatter;
+import org.springframework.util.ObjectUtils;
+
+/**
+ * Log4j2 {@link LogEventPatternConverter} to convert a {@link CorrelationIdFormatter}
+ * pattern into formatted output using data from the {@link LogEvent#getContextData()
+ * MDC}.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ * @see MdcPatternConverter
+ */
+@Plugin(name = "CorrelationIdConverter", category = PatternConverter.CATEGORY)
+@ConverterKeys("correlationId")
+@PerformanceSensitive("allocation")
+public final class CorrelationIdConverter extends LogEventPatternConverter {
+
+	private final CorrelationIdFormatter formatter;
+
+	private CorrelationIdConverter(CorrelationIdFormatter formatter) {
+		super("correlationId{%s}".formatted(formatter), "mdc");
+		this.formatter = formatter;
+	}
+
+	@Override
+	public void format(LogEvent event, StringBuilder toAppendTo) {
+		ReadOnlyStringMap contextData = event.getContextData();
+		this.formatter.formatTo(contextData::getValue, toAppendTo);
+	}
+
+	/**
+	 * Factory method to create a new {@link CorrelationIdConverter}.
+	 * @param options options, may be null or first element contains name of property to
+	 * format.
+	 * @return instance of PropertiesPatternConverter.
+	 */
+	public static CorrelationIdConverter newInstance(String[] options) {
+		String pattern = (!ObjectUtils.isEmpty(options)) ? options[0] : null;
+		return new CorrelationIdConverter(CorrelationIdFormatter.of(pattern));
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/Log4J2LoggingSystem.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/Log4J2LoggingSystem.java
index 1ff7c840ed07..10152f88c398 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/Log4J2LoggingSystem.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/Log4J2LoggingSystem.java
@@ -249,27 +249,28 @@ public void initialize(LoggingInitializationContext initializationContext, Strin
 
 	@Override
 	protected void loadDefaults(LoggingInitializationContext initializationContext, LogFile logFile) {
-		if (logFile != null) {
-			loadConfiguration(getPackagedConfigFile("log4j2-file.xml"), logFile, getOverrides(initializationContext));
-		}
-		else {
-			loadConfiguration(getPackagedConfigFile("log4j2.xml"), logFile, getOverrides(initializationContext));
-		}
-	}
-
-	private List<String> getOverrides(LoggingInitializationContext initializationContext) {
-		BindResult<List<String>> overrides = Binder.get(initializationContext.getEnvironment())
-			.bind("logging.log4j2.config.override", Bindable.listOf(String.class));
-		return overrides.orElse(Collections.emptyList());
+		String location = getPackagedConfigFile((logFile != null) ? "log4j2-file.xml" : "log4j2.xml");
+		load(initializationContext, location, logFile);
 	}
 
 	@Override
 	protected void loadConfiguration(LoggingInitializationContext initializationContext, String location,
 			LogFile logFile) {
+		load(initializationContext, location, logFile);
+	}
+
+	private void load(LoggingInitializationContext initializationContext, String location, LogFile logFile) {
+		List<String> overrides = getOverrides(initializationContext);
 		if (initializationContext != null) {
 			applySystemProperties(initializationContext.getEnvironment(), logFile);
 		}
-		loadConfiguration(location, logFile, getOverrides(initializationContext));
+		loadConfiguration(location, logFile, overrides);
+	}
+
+	private List<String> getOverrides(LoggingInitializationContext initializationContext) {
+		BindResult<List<String>> overrides = Binder.get(initializationContext.getEnvironment())
+			.bind("logging.log4j2.config.override", Bindable.listOf(String.class));
+		return overrides.orElse(Collections.emptyList());
 	}
 
 	/**
@@ -492,6 +493,11 @@ private void markAsUninitialized(LoggerContext loggerContext) {
 		loggerContext.setExternalContext(null);
 	}
 
+	@Override
+	protected String getDefaultLogCorrelationPattern() {
+		return "%correlationId";
+	}
+
 	/**
 	 * Get the Spring {@link Environment} attached to the given {@link LoggerContext} or
 	 * {@code null} if no environment is available.
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/CorrelationIdConverter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/CorrelationIdConverter.java
new file mode 100644
index 000000000000..87b1e792b6c8
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/CorrelationIdConverter.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.logging.logback;
+
+import java.util.Map;
+
+import ch.qos.logback.classic.pattern.MDCConverter;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.pattern.DynamicConverter;
+
+import org.springframework.boot.logging.CorrelationIdFormatter;
+import org.springframework.core.env.Environment;
+
+/**
+ * Logback {@link DynamicConverter} to convert a {@link CorrelationIdFormatter} pattern
+ * into formatted output using data from the {@link ILoggingEvent#getMDCPropertyMap() MDC}
+ * and {@link Environment}.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ * @see MDCConverter
+ */
+public class CorrelationIdConverter extends DynamicConverter<ILoggingEvent> {
+
+	private CorrelationIdFormatter formatter;
+
+	@Override
+	public void start() {
+		this.formatter = CorrelationIdFormatter.of(getOptionList());
+		super.start();
+	}
+
+	@Override
+	public void stop() {
+		this.formatter = null;
+		super.stop();
+	}
+
+	@Override
+	public String convert(ILoggingEvent event) {
+		if (this.formatter == null) {
+			return "";
+		}
+		Map<String, String> mdc = event.getMDCPropertyMap();
+		return this.formatter.format(mdc::get);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/DefaultLogbackConfiguration.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/DefaultLogbackConfiguration.java
index 0329653359da..dfce5601214b 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/DefaultLogbackConfiguration.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/DefaultLogbackConfiguration.java
@@ -43,6 +43,7 @@
  * @author Vedran Pavic
  * @author Robert Thornton
  * @author Scott Frederick
+ * @author Jonatan Ivanov
  */
 class DefaultLogbackConfiguration {
 
@@ -68,12 +69,14 @@ void apply(LogbackConfigurator config) {
 
 	private void defaults(LogbackConfigurator config) {
 		config.conversionRule("clr", ColorConverter.class);
+		config.conversionRule("correlationId", CorrelationIdConverter.class);
 		config.conversionRule("wex", WhitespaceThrowableProxyConverter.class);
 		config.conversionRule("wEx", ExtendedWhitespaceThrowableProxyConverter.class);
 		config.getContext()
 			.putProperty("CONSOLE_LOG_PATTERN", resolve(config, "${CONSOLE_LOG_PATTERN:-"
 					+ "%clr(%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd'T'HH:mm:ss.SSSXXX}}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) "
-					+ "%clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} "
+					+ "%clr(${PID:- }){magenta} %clr(---){faint} %clr(${LOGGED_APPLICATION_NAME:-}[%15.15t]){faint} "
+					+ "%clr(${LOG_CORRELATION_PATTERN:-}){faint}%clr(%-40.40logger{39}){cyan} "
 					+ "%clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"));
 		String defaultCharset = Charset.defaultCharset().name();
 		config.getContext()
@@ -81,7 +84,8 @@ private void defaults(LogbackConfigurator config) {
 		config.getContext().putProperty("CONSOLE_LOG_THRESHOLD", resolve(config, "${CONSOLE_LOG_THRESHOLD:-TRACE}"));
 		config.getContext()
 			.putProperty("FILE_LOG_PATTERN", resolve(config, "${FILE_LOG_PATTERN:-"
-					+ "%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd'T'HH:mm:ss.SSSXXX}} ${LOG_LEVEL_PATTERN:-%5p} ${PID:- } --- [%t] "
+					+ "%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd'T'HH:mm:ss.SSSXXX}} ${LOG_LEVEL_PATTERN:-%5p} ${PID:- } --- ${LOGGED_APPLICATION_NAME:-}[%t] "
+					+ "${LOG_CORRELATION_PATTERN:-}"
 					+ "%-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"));
 		config.getContext()
 			.putProperty("FILE_LOG_CHARSET", resolve(config, "${FILE_LOG_CHARSET:-" + defaultCharset + "}"));
@@ -100,6 +104,7 @@ private Appender<ILoggingEvent> consoleAppender(LogbackConfigurator config) {
 		ConsoleAppender<ILoggingEvent> appender = new ConsoleAppender<>();
 		ThresholdFilter filter = new ThresholdFilter();
 		filter.setLevel(resolve(config, "${CONSOLE_LOG_THRESHOLD}"));
+		filter.start();
 		appender.addFilter(filter);
 		PatternLayoutEncoder encoder = new PatternLayoutEncoder();
 		encoder.setPattern(resolve(config, "${CONSOLE_LOG_PATTERN}"));
@@ -114,6 +119,7 @@ private Appender<ILoggingEvent> fileAppender(LogbackConfigurator config, String
 		RollingFileAppender<ILoggingEvent> appender = new RollingFileAppender<>();
 		ThresholdFilter filter = new ThresholdFilter();
 		filter.setLevel(resolve(config, "${FILE_LOG_THRESHOLD}"));
+		filter.start();
 		appender.addFilter(filter);
 		PatternLayoutEncoder encoder = new PatternLayoutEncoder();
 		encoder.setPattern(resolve(config, "${FILE_LOG_PATTERN}"));
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackLoggingSystem.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackLoggingSystem.java
index ee31b9b07a45..e531e74ded60 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackLoggingSystem.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackLoggingSystem.java
@@ -30,6 +30,7 @@
 import ch.qos.logback.classic.LoggerContext;
 import ch.qos.logback.classic.joran.JoranConfigurator;
 import ch.qos.logback.classic.jul.LevelChangePropagator;
+import ch.qos.logback.classic.spi.TurboFilterList;
 import ch.qos.logback.classic.turbo.TurboFilter;
 import ch.qos.logback.core.joran.spi.JoranException;
 import ch.qos.logback.core.spi.FilterReply;
@@ -43,6 +44,7 @@
 import org.slf4j.LoggerFactory;
 import org.slf4j.Marker;
 import org.slf4j.bridge.SLF4JBridgeHandler;
+import org.slf4j.helpers.SubstituteLoggerFactory;
 
 import org.springframework.aot.AotDetector;
 import org.springframework.beans.factory.aot.BeanFactoryInitializationAotContribution;
@@ -109,7 +111,7 @@ public LogbackLoggingSystem(ClassLoader classLoader) {
 
 	@Override
 	public LoggingSystemProperties getSystemProperties(ConfigurableEnvironment environment) {
-		return new LogbackLoggingSystemProperties(environment);
+		return new LogbackLoggingSystemProperties(environment, getDefaultValueResolver(environment), null);
 	}
 
 	@Override
@@ -186,6 +188,7 @@ public void initialize(LoggingInitializationContext initializationContext, Strin
 		if (!initializeFromAotGeneratedArtifactsIfPossible(initializationContext, logFile)) {
 			super.initialize(initializationContext, configLocation, logFile);
 		}
+		loggerContext.putObject(Environment.class.getName(), initializationContext.getEnvironment());
 		loggerContext.getTurboFilterList().remove(FILTER);
 		markAsInitialized(loggerContext);
 		if (StringUtils.hasText(System.getProperty(CONFIGURATION_FILE_PROPERTY))) {
@@ -217,56 +220,68 @@ private boolean initializeFromAotGeneratedArtifactsIfPossible(LoggingInitializat
 	protected void loadDefaults(LoggingInitializationContext initializationContext, LogFile logFile) {
 		LoggerContext context = getLoggerContext();
 		stopAndReset(context);
-		boolean debug = Boolean.getBoolean("logback.debug");
-		if (debug) {
-			StatusListenerConfigHelper.addOnConsoleListenerInstance(context, new OnConsoleStatusListener());
-		}
-		Environment environment = initializationContext.getEnvironment();
-		// Apply system properties directly in case the same JVM runs multiple apps
-		new LogbackLoggingSystemProperties(environment, context::putProperty).apply(logFile);
-		LogbackConfigurator configurator = debug ? new DebugLogbackConfigurator(context)
-				: new LogbackConfigurator(context);
-		new DefaultLogbackConfiguration(logFile).apply(configurator);
-		context.setPackagingDataEnabled(true);
+		withLoggingSuppressed(() -> {
+			boolean debug = Boolean.getBoolean("logback.debug");
+			if (debug) {
+				StatusListenerConfigHelper.addOnConsoleListenerInstance(context, new OnConsoleStatusListener());
+			}
+			Environment environment = initializationContext.getEnvironment();
+			// Apply system properties directly in case the same JVM runs multiple apps
+			new LogbackLoggingSystemProperties(environment, getDefaultValueResolver(environment), context::putProperty)
+				.apply(logFile);
+			LogbackConfigurator configurator = debug ? new DebugLogbackConfigurator(context)
+					: new LogbackConfigurator(context);
+			new DefaultLogbackConfiguration(logFile).apply(configurator);
+			context.setPackagingDataEnabled(true);
+		});
 	}
 
 	@Override
 	protected void loadConfiguration(LoggingInitializationContext initializationContext, String location,
 			LogFile logFile) {
-		if (initializationContext != null) {
-			applySystemProperties(initializationContext.getEnvironment(), logFile);
-		}
 		LoggerContext loggerContext = getLoggerContext();
 		stopAndReset(loggerContext);
-		try {
-			configureByResourceUrl(initializationContext, loggerContext, ResourceUtils.getURL(location));
-		}
-		catch (Exception ex) {
-			throw new IllegalStateException("Could not initialize Logback logging from " + location, ex);
-		}
+		withLoggingSuppressed(() -> {
+			if (initializationContext != null) {
+				applySystemProperties(initializationContext.getEnvironment(), logFile);
+			}
+			try {
+				configureByResourceUrl(initializationContext, loggerContext, ResourceUtils.getURL(location));
+			}
+			catch (Exception ex) {
+				throw new IllegalStateException("Could not initialize Logback logging from " + location, ex);
+			}
+		});
 		reportConfigurationErrorsIfNecessary(loggerContext);
 	}
 
 	private void reportConfigurationErrorsIfNecessary(LoggerContext loggerContext) {
-		List<Status> statuses = loggerContext.getStatusManager().getCopyOfStatusList();
 		StringBuilder errors = new StringBuilder();
-		for (Status status : statuses) {
+		List<Throwable> suppressedExceptions = new ArrayList<>();
+		for (Status status : loggerContext.getStatusManager().getCopyOfStatusList()) {
 			if (status.getLevel() == Status.ERROR) {
 				errors.append((errors.length() > 0) ? String.format("%n") : "");
 				errors.append(status.toString());
+				if (status.getThrowable() != null) {
+					suppressedExceptions.add(status.getThrowable());
+				}
 			}
 		}
-		if (errors.length() > 0) {
-			throw new IllegalStateException(String.format("Logback configuration error detected: %n%s", errors));
-		}
-		if (!StatusUtil.contextHasStatusListener(loggerContext)) {
-			StatusPrinter.printInCaseOfErrorsOrWarnings(loggerContext);
+		if (errors.length() == 0) {
+			if (!StatusUtil.contextHasStatusListener(loggerContext)) {
+				StatusPrinter.printInCaseOfErrorsOrWarnings(loggerContext);
+			}
+			return;
 		}
+		IllegalStateException ex = new IllegalStateException(
+				String.format("Logback configuration error detected: %n%s", errors));
+		suppressedExceptions.forEach(ex::addSuppressed);
+		throw ex;
 	}
 
 	private void configureByResourceUrl(LoggingInitializationContext initializationContext, LoggerContext loggerContext,
 			URL url) throws JoranException {
-		if (url.toString().endsWith(".xml")) {
+		if (url.getPath().endsWith(".xml")) {
 			JoranConfigurator configurator = new SpringBootJoranConfigurator(initializationContext);
 			configurator.setContext(loggerContext);
 			configurator.doConfigure(url);
@@ -377,7 +392,7 @@ private ch.qos.logback.classic.Logger getLogger(String name) {
 	}
 
 	private LoggerContext getLoggerContext() {
-		ILoggerFactory factory = LoggerFactory.getILoggerFactory();
+		ILoggerFactory factory = getLoggerFactory();
 		Assert.isInstanceOf(LoggerContext.class, factory,
 				() -> String.format(
 						"LoggerFactory is not a Logback LoggerContext but Logback is on "
@@ -389,6 +404,21 @@ private LoggerContext getLoggerContext() {
 		return (LoggerContext) factory;
 	}
 
+	private ILoggerFactory getLoggerFactory() {
+		ILoggerFactory factory = LoggerFactory.getILoggerFactory();
+		while (factory instanceof SubstituteLoggerFactory) {
+			try {
+				Thread.sleep(50);
+			}
+			catch (InterruptedException ex) {
+				Thread.currentThread().interrupt();
+				throw new IllegalStateException("Interrupted while waiting for non-substitute logger factory", ex);
+			}
+			factory = LoggerFactory.getILoggerFactory();
+		}
+		return factory;
+	}
+
 	private Object getLocation(ILoggerFactory factory) {
 		try {
 			ProtectionDomain protectionDomain = factory.getClass().getProtectionDomain();
@@ -415,6 +445,11 @@ private void markAsUninitialized(LoggerContext loggerContext) {
 		loggerContext.removeObject(LoggingSystem.class.getName());
 	}
 
+	@Override
+	protected String getDefaultLogCorrelationPattern() {
+		return "%correlationId";
+	}
+
 	@Override
 	public BeanFactoryInitializationAotContribution processAheadOfTime(ConfigurableListableBeanFactory beanFactory) {
 		String key = BeanFactoryInitializationAotContribution.class.getName();
@@ -425,6 +460,17 @@ public BeanFactoryInitializationAotContribution processAheadOfTime(ConfigurableL
 		return contribution;
 	}
 
+	private void withLoggingSuppressed(Runnable action) {
+		TurboFilterList turboFilters = getLoggerContext().getTurboFilterList();
+		turboFilters.add(FILTER);
+		try {
+			action.run();
+		}
+		finally {
+			turboFilters.remove(FILTER);
+		}
+	}
+
 	/**
 	 * {@link LoggingSystemFactory} that returns {@link LogbackLoggingSystem} if possible.
 	 */
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackLoggingSystemProperties.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackLoggingSystemProperties.java
index f9d97937d757..df821a473394 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackLoggingSystemProperties.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackLoggingSystemProperties.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -18,6 +18,7 @@
 
 import java.nio.charset.Charset;
 import java.util.function.BiConsumer;
+import java.util.function.Function;
 
 import ch.qos.logback.core.util.FileSize;
 
@@ -35,6 +36,7 @@
  *
  * @author Phillip Webb
  * @since 2.4.0
+ * @see RollingPolicySystemProperty
  */
 public class LogbackLoggingSystemProperties extends LoggingSystemProperties {
 
@@ -44,28 +46,53 @@ public class LogbackLoggingSystemProperties extends LoggingSystemProperties {
 	/**
 	 * The name of the System property that contains the rolled-over log file name
 	 * pattern.
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling
+	 * {@link RollingPolicySystemProperty#getEnvironmentVariableName()} on
+	 * {@link RollingPolicySystemProperty#FILE_NAME_PATTERN}
 	 */
-	public static final String ROLLINGPOLICY_FILE_NAME_PATTERN = "LOGBACK_ROLLINGPOLICY_FILE_NAME_PATTERN";
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public static final String ROLLINGPOLICY_FILE_NAME_PATTERN = RollingPolicySystemProperty.FILE_NAME_PATTERN
+		.getEnvironmentVariableName();
 
 	/**
 	 * The name of the System property that contains the clean history on start flag.
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling
+	 * {@link RollingPolicySystemProperty#getEnvironmentVariableName()} on
+	 * {@link RollingPolicySystemProperty#CLEAN_HISTORY_ON_START}
 	 */
-	public static final String ROLLINGPOLICY_CLEAN_HISTORY_ON_START = "LOGBACK_ROLLINGPOLICY_CLEAN_HISTORY_ON_START";
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public static final String ROLLINGPOLICY_CLEAN_HISTORY_ON_START = RollingPolicySystemProperty.CLEAN_HISTORY_ON_START
+		.getEnvironmentVariableName();
 
 	/**
 	 * The name of the System property that contains the file log max size.
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling
+	 * {@link RollingPolicySystemProperty#getEnvironmentVariableName()} on
+	 * {@link RollingPolicySystemProperty#MAX_FILE_SIZE}
 	 */
-	public static final String ROLLINGPOLICY_MAX_FILE_SIZE = "LOGBACK_ROLLINGPOLICY_MAX_FILE_SIZE";
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public static final String ROLLINGPOLICY_MAX_FILE_SIZE = RollingPolicySystemProperty.MAX_FILE_SIZE
+		.getEnvironmentVariableName();
 
 	/**
 	 * The name of the System property that contains the file total size cap.
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling
+	 * {@link RollingPolicySystemProperty#getEnvironmentVariableName()} on
+	 * {@link RollingPolicySystemProperty#TOTAL_SIZE_CAP}
 	 */
-	public static final String ROLLINGPOLICY_TOTAL_SIZE_CAP = "LOGBACK_ROLLINGPOLICY_TOTAL_SIZE_CAP";
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public static final String ROLLINGPOLICY_TOTAL_SIZE_CAP = RollingPolicySystemProperty.TOTAL_SIZE_CAP
+		.getEnvironmentVariableName();
 
 	/**
 	 * The name of the System property that contains the file log max history.
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling
+	 * {@link RollingPolicySystemProperty#getEnvironmentVariableName()} on
+	 * {@link RollingPolicySystemProperty#MAX_HISTORY}
 	 */
-	public static final String ROLLINGPOLICY_MAX_HISTORY = "LOGBACK_ROLLINGPOLICY_MAX_HISTORY";
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public static final String ROLLINGPOLICY_MAX_HISTORY = RollingPolicySystemProperty.MAX_HISTORY
+		.getEnvironmentVariableName();
 
 	public LogbackLoggingSystemProperties(Environment environment) {
 		super(environment);
@@ -81,6 +108,19 @@ public LogbackLoggingSystemProperties(Environment environment, BiConsumer<String
 		super(environment, setter);
 	}
 
+	/**
+	 * Create a new {@link LoggingSystemProperties} instance.
+	 * @param environment the source environment
+	 * @param defaultValueResolver function used to resolve default values or {@code null}
+	 * @param setter setter used to apply the property or {@code null} for system
+	 * properties
+	 * @since 3.2.0
+	 */
+	public LogbackLoggingSystemProperties(Environment environment, Function<String, String> defaultValueResolver,
+			BiConsumer<String, String> setter) {
+		super(environment, defaultValueResolver, setter);
+	}
+
 	@Override
 	protected Charset getDefaultCharset() {
 		return Charset.defaultCharset();
@@ -100,32 +140,24 @@ private void applyJBossLoggingProperties() {
 	}
 
 	private void applyRollingPolicyProperties(PropertyResolver resolver) {
-		applyRollingPolicy(resolver, ROLLINGPOLICY_FILE_NAME_PATTERN, "logging.logback.rollingpolicy.file-name-pattern",
-				"logging.pattern.rolling-file-name");
-		applyRollingPolicy(resolver, ROLLINGPOLICY_CLEAN_HISTORY_ON_START,
-				"logging.logback.rollingpolicy.clean-history-on-start", "logging.file.clean-history-on-start");
-		applyRollingPolicy(resolver, ROLLINGPOLICY_MAX_FILE_SIZE, "logging.logback.rollingpolicy.max-file-size",
-				"logging.file.max-size", DataSize.class);
-		applyRollingPolicy(resolver, ROLLINGPOLICY_TOTAL_SIZE_CAP, "logging.logback.rollingpolicy.total-size-cap",
-				"logging.file.total-size-cap", DataSize.class);
-		applyRollingPolicy(resolver, ROLLINGPOLICY_MAX_HISTORY, "logging.logback.rollingpolicy.max-history",
-				"logging.file.max-history");
+		applyRollingPolicy(RollingPolicySystemProperty.FILE_NAME_PATTERN, resolver);
+		applyRollingPolicy(RollingPolicySystemProperty.CLEAN_HISTORY_ON_START, resolver);
+		applyRollingPolicy(RollingPolicySystemProperty.MAX_FILE_SIZE, resolver, DataSize.class);
+		applyRollingPolicy(RollingPolicySystemProperty.TOTAL_SIZE_CAP, resolver, DataSize.class);
+		applyRollingPolicy(RollingPolicySystemProperty.MAX_HISTORY, resolver);
 	}
 
-	private void applyRollingPolicy(PropertyResolver resolver, String systemPropertyName, String propertyName,
-			String deprecatedPropertyName) {
-		applyRollingPolicy(resolver, systemPropertyName, propertyName, deprecatedPropertyName, String.class);
+	private void applyRollingPolicy(RollingPolicySystemProperty property, PropertyResolver resolver) {
+		applyRollingPolicy(property, resolver, String.class);
 	}
 
-	private <T> void applyRollingPolicy(PropertyResolver resolver, String systemPropertyName, String propertyName,
-			String deprecatedPropertyName, Class<T> type) {
-		T value = getProperty(resolver, propertyName, type);
-		if (value == null) {
-			value = getProperty(resolver, deprecatedPropertyName, type);
-		}
+	private <T> void applyRollingPolicy(RollingPolicySystemProperty property, PropertyResolver resolver,
+			Class<T> type) {
+		T value = getProperty(resolver, property.getApplicationPropertyName(), type);
+		value = (value != null) ? value : getProperty(resolver, property.getDeprecatedApplicationPropertyName(), type);
 		if (value != null) {
 			String stringValue = String.valueOf((value instanceof DataSize dataSize) ? dataSize.toBytes() : value);
-			setSystemProperty(systemPropertyName, stringValue);
+			setSystemProperty(property.getEnvironmentVariableName(), stringValue);
 		}
 	}
 
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackRuntimeHints.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackRuntimeHints.java
index 532a2adf78d4..44e50d50900d 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackRuntimeHints.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackRuntimeHints.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -59,7 +59,8 @@ private void registerHintsForBuiltInLogbackConverters(ReflectionHints reflection
 
 	private void registerHintsForSpringBootConverters(ReflectionHints reflection) {
 		registerForPublicConstructorInvocation(reflection, ColorConverter.class,
-				ExtendedWhitespaceThrowableProxyConverter.class, WhitespaceThrowableProxyConverter.class);
+				ExtendedWhitespaceThrowableProxyConverter.class, WhitespaceThrowableProxyConverter.class,
+				CorrelationIdConverter.class);
 	}
 
 	private void registerForPublicConstructorInvocation(ReflectionHints reflection, Class<?>... classes) {
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/RollingPolicySystemProperty.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/RollingPolicySystemProperty.java
new file mode 100644
index 000000000000..f75db8f2386f
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/RollingPolicySystemProperty.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.logging.logback;
+
+/**
+ * Logback rolling policy system properties that can later be used by log configuration
+ * files.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ * @see LogbackLoggingSystemProperties
+ */
+public enum RollingPolicySystemProperty {
+
+	/**
+	 * Logging system property for the rolled-over log file name pattern.
+	 */
+	FILE_NAME_PATTERN("file-name-pattern", "logging.pattern.rolling-file-name"),
+
+	/**
+	 * Logging system property for the clean history on start flag.
+	 */
+	CLEAN_HISTORY_ON_START("clean-history-on-start", "logging.file.clean-history-on-start"),
+
+	/**
+	 * Logging system property for the file log max size.
+	 */
+	MAX_FILE_SIZE("max-file-size", "logging.file.max-size"),
+
+	/**
+	 * Logging system property for the file total size cap.
+	 */
+	TOTAL_SIZE_CAP("total-size-cap", "logging.file.total-size-cap"),
+
+	/**
+	 * Logging system property for the file log max history.
+	 */
+	MAX_HISTORY("max-history", "logging.file.max-history");
+
+	private final String environmentVariableName;
+
+	private final String applicationPropertyName;
+
+	private final String deprecatedApplicationPropertyName;
+
+	RollingPolicySystemProperty(String applicationPropertyName, String deprecatedApplicationPropertyName) {
+		this.environmentVariableName = "LOGBACK_ROLLINGPOLICY_" + name();
+		this.applicationPropertyName = "logging.logback.rollingpolicy." + applicationPropertyName;
+		this.deprecatedApplicationPropertyName = deprecatedApplicationPropertyName;
+	}
+
+	/**
+	 * Return the name of environment variable that can be used to access this property.
+	 * @return the environment variable name
+	 */
+	public String getEnvironmentVariableName() {
+		return this.environmentVariableName;
+	}
+
+	String getApplicationPropertyName() {
+		return this.applicationPropertyName;
+	}
+
+	String getDeprecatedApplicationPropertyName() {
+		return this.deprecatedApplicationPropertyName;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/r2dbc/ConnectionFactoryBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/r2dbc/ConnectionFactoryBuilder.java
index e69eca5d33bd..0e7880d7a2ea 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/r2dbc/ConnectionFactoryBuilder.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/r2dbc/ConnectionFactoryBuilder.java
@@ -17,6 +17,8 @@
 package org.springframework.boot.r2dbc;
 
 import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Locale;
 import java.util.function.Consumer;
 import java.util.function.Function;
@@ -43,6 +45,7 @@
  * @author Tadaya Tsuyukubo
  * @author Stephane Nicoll
  * @author Andy Wilkinson
+ * @author Moritz Halbritter
  * @since 2.5.0
  */
 public final class ConnectionFactoryBuilder {
@@ -62,6 +65,8 @@ public final class ConnectionFactoryBuilder {
 
 	private final Builder optionsBuilder;
 
+	private final List<ConnectionFactoryDecorator> decorators = new ArrayList<>();
+
 	private ConnectionFactoryBuilder(Builder optionsBuilder) {
 		this.optionsBuilder = optionsBuilder;
 	}
@@ -168,13 +173,41 @@ public ConnectionFactoryBuilder database(String database) {
 		return configure((options) -> options.option(ConnectionFactoryOptions.DATABASE, database));
 	}
 
+	/**
+	 * Add a {@link ConnectionFactoryDecorator decorator}.
+	 * @param decorator the decorator to add
+	 * @return this for method chaining
+	 * @since 3.2.0
+	 */
+	public ConnectionFactoryBuilder decorator(ConnectionFactoryDecorator decorator) {
+		this.decorators.add(decorator);
+		return this;
+	}
+
+	/**
+	 * Add {@link ConnectionFactoryDecorator decorators}.
+	 * @param decorators the decorators to add
+	 * @return this for method chaining
+	 * @since 3.2.0
+	 */
+	public ConnectionFactoryBuilder decorators(Iterable<ConnectionFactoryDecorator> decorators) {
+		for (ConnectionFactoryDecorator decorator : decorators) {
+			this.decorators.add(decorator);
+		}
+		return this;
+	}
+
 	/**
 	 * Build a {@link ConnectionFactory} based on the state of this builder.
 	 * @return a connection factory
 	 */
 	public ConnectionFactory build() {
 		ConnectionFactoryOptions options = buildOptions();
-		return optionsCapableWrapper.buildAndWrap(options);
+		ConnectionFactory connectionFactory = optionsCapableWrapper.buildAndWrap(options);
+		for (ConnectionFactoryDecorator decorator : this.decorators) {
+			connectionFactory = decorator.decorate(connectionFactory);
+		}
+		return connectionFactory;
 	}
 
 	/**
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/r2dbc/ConnectionFactoryDecorator.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/r2dbc/ConnectionFactoryDecorator.java
new file mode 100644
index 000000000000..f4885ec6c632
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/r2dbc/ConnectionFactoryDecorator.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.r2dbc;
+
+import io.r2dbc.spi.ConnectionFactory;
+
+/**
+ * Decorator for {@link ConnectionFactory connection factories}.
+ *
+ * @author Moritz Halbritter
+ * @since 3.2.0
+ * @see ConnectionFactoryBuilder
+ */
+@FunctionalInterface
+public interface ConnectionFactoryDecorator {
+
+	/**
+	 * Decorates the given {@link ConnectionFactory}.
+	 * @param delegate the connection factory which should be decorated
+	 * @return the decorated connection factory
+	 */
+	ConnectionFactory decorate(ConnectionFactory delegate);
+
+}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/reactor/DebugAgentEnvironmentPostProcessor.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/reactor/DebugAgentEnvironmentPostProcessor.java
deleted file mode 100644
index 6268e8b67c93..000000000000
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/reactor/DebugAgentEnvironmentPostProcessor.java
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * Copyright 2012-2020 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.reactor;
-
-import org.springframework.boot.SpringApplication;
-import org.springframework.boot.env.EnvironmentPostProcessor;
-import org.springframework.core.Ordered;
-import org.springframework.core.env.ConfigurableEnvironment;
-import org.springframework.util.ClassUtils;
-
-/**
- * {@link EnvironmentPostProcessor} to enable the Reactor Debug Agent if available.
- * <p>
- * The debug agent is enabled by default, unless the
- * {@code "spring.reactor.debug-agent.enabled"} configuration property is set to false. We
- * are using here an {@link EnvironmentPostProcessor} instead of an auto-configuration
- * class to enable the agent as soon as possible during the startup process.
- *
- * @author Brian Clozel
- * @since 2.2.0
- */
-public class DebugAgentEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered {
-
-	private static final String REACTOR_DEBUGAGENT_CLASS = "reactor.tools.agent.ReactorDebugAgent";
-
-	private static final String DEBUGAGENT_ENABLED_CONFIG_KEY = "spring.reactor.debug-agent.enabled";
-
-	@Override
-	public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
-		if (ClassUtils.isPresent(REACTOR_DEBUGAGENT_CLASS, null)) {
-			Boolean agentEnabled = environment.getProperty(DEBUGAGENT_ENABLED_CONFIG_KEY, Boolean.class);
-			if (agentEnabled != Boolean.FALSE) {
-				try {
-					Class<?> debugAgent = Class.forName(REACTOR_DEBUGAGENT_CLASS);
-					debugAgent.getMethod("init").invoke(null);
-				}
-				catch (Exception ex) {
-					throw new RuntimeException("Failed to init Reactor's debug agent", ex);
-				}
-			}
-		}
-	}
-
-	@Override
-	public int getOrder() {
-		return Ordered.LOWEST_PRECEDENCE;
-	}
-
-}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/reactor/ReactorEnvironmentPostProcessor.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/reactor/ReactorEnvironmentPostProcessor.java
new file mode 100644
index 000000000000..4b3e1c143fb7
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/reactor/ReactorEnvironmentPostProcessor.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.reactor;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.env.EnvironmentPostProcessor;
+import org.springframework.boot.system.JavaVersion;
+import org.springframework.core.Ordered;
+import org.springframework.core.env.ConfigurableEnvironment;
+import org.springframework.util.ClassUtils;
+
+/**
+ * {@link EnvironmentPostProcessor} to enable the Reactor global features as early as
+ * possible in the startup process.
+ * <p>
+ * If the "reactor-tools" dependency is available, the debug agent is enabled by default,
+ * unless the {@code "spring.reactor.debug-agent.enabled"} configuration property is set
+ * to false.
+ * <p>
+ * If the {@code "spring.threads.virtual.enabled"} property is enabled and the current JVM
+ * is 21 or later, then the Reactor System property is set to configure the Bounded
+ * Elastic Scheduler to use Virtual Threads globally.
+ *
+ * @author Brian Clozel
+ * @since 3.2.0
+ */
+public class ReactorEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered {
+
+	private static final String REACTOR_DEBUGAGENT_CLASS = "reactor.tools.agent.ReactorDebugAgent";
+
+	@Override
+	public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
+		if (ClassUtils.isPresent(REACTOR_DEBUGAGENT_CLASS, null)) {
+			Boolean agentEnabled = environment.getProperty("spring.reactor.debug-agent.enabled", Boolean.class);
+			if (agentEnabled != Boolean.FALSE) {
+				try {
+					Class<?> debugAgent = Class.forName(REACTOR_DEBUGAGENT_CLASS);
+					debugAgent.getMethod("init").invoke(null);
+				}
+				catch (Exception ex) {
+					throw new RuntimeException("Failed to init Reactor's debug agent", ex);
+				}
+			}
+		}
+		if (environment.getProperty("spring.threads.virtual.enabled", boolean.class, false)
+				&& JavaVersion.getJavaVersion().isEqualOrNewerThan(JavaVersion.TWENTY_ONE)) {
+			System.setProperty("reactor.schedulers.defaultBoundedElasticOnVirtualThreads", "true");
+		}
+	}
+
+	@Override
+	public int getOrder() {
+		return Ordered.LOWEST_PRECEDENCE;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/context/RSocketPortInfoApplicationContextInitializer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/context/RSocketPortInfoApplicationContextInitializer.java
index da497d9ab1d6..dd94ba58204a 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/context/RSocketPortInfoApplicationContextInitializer.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/context/RSocketPortInfoApplicationContextInitializer.java
@@ -55,6 +55,8 @@ private static class Listener implements ApplicationListener<RSocketServerInitia
 
 		private static final String PROPERTY_NAME = "local.rsocket.server.port";
 
+		private static final String PROPERTY_SOURCE_NAME = "server.ports";
+
 		private final ConfigurableApplicationContext applicationContext;
 
 		Listener(ConfigurableApplicationContext applicationContext) {
@@ -79,9 +81,9 @@ private void setPortProperty(ApplicationContext context, int port) {
 
 		private void setPortProperty(ConfigurableEnvironment environment, int port) {
 			MutablePropertySources sources = environment.getPropertySources();
-			PropertySource<?> source = sources.get("server.ports");
+			PropertySource<?> source = sources.get(PROPERTY_SOURCE_NAME);
 			if (source == null) {
-				source = new MapPropertySource("server.ports", new HashMap<>());
+				source = new MapPropertySource(PROPERTY_SOURCE_NAME, new HashMap<>());
 				sources.addFirst(source);
 			}
 			setPortProperty(port, source);
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServer.java
index cb2df6779f62..6c21b070367a 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServer.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServer.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -62,7 +62,7 @@ public InetSocketAddress address() {
 	@Override
 	public void start() throws RSocketServerException {
 		this.channel = block(this.starter, this.lifecycleTimeout);
-		logger.info("Netty RSocket started on port(s): " + address().getPort());
+		logger.info("Netty RSocket started on port " + address().getPort());
 		startDaemonAwaitThread(this.channel);
 	}
 
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactory.java
index bd1d64283515..e291716cac71 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactory.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactory.java
@@ -45,7 +45,7 @@
 import org.springframework.boot.web.server.Ssl;
 import org.springframework.boot.web.server.SslStoreProvider;
 import org.springframework.boot.web.server.WebServerSslBundle;
-import org.springframework.http.client.reactive.ReactorResourceFactory;
+import org.springframework.http.client.ReactorResourceFactory;
 import org.springframework.util.Assert;
 import org.springframework.util.unit.DataSize;
 
@@ -219,12 +219,15 @@ private InetSocketAddress getListenAddress() {
 	private static final class TcpSslServerCustomizer
 			extends org.springframework.boot.web.embedded.netty.SslServerCustomizer {
 
+		private final SslBundle sslBundle;
+
 		private TcpSslServerCustomizer(Ssl.ClientAuth clientAuth, SslBundle sslBundle) {
 			super(null, clientAuth, sslBundle);
+			this.sslBundle = sslBundle;
 		}
 
 		private TcpServer apply(TcpServer server) {
-			AbstractProtocolSslContextSpec<?> sslContextSpec = createSslContextSpec();
+			AbstractProtocolSslContextSpec<?> sslContextSpec = createSslContextSpec(this.sslBundle);
 			return server.secure((spec) -> spec.sslContext(sslContextSpec));
 		}
 
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/security/reactive/ApplicationContextServerWebExchangeMatcher.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/security/reactive/ApplicationContextServerWebExchangeMatcher.java
index 59264b353ff2..88ea38ca5614 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/security/reactive/ApplicationContextServerWebExchangeMatcher.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/security/reactive/ApplicationContextServerWebExchangeMatcher.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/sql/init/AbstractScriptDatabaseInitializer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/sql/init/AbstractScriptDatabaseInitializer.java
index 2955439bf620..830e47a2acdd 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/sql/init/AbstractScriptDatabaseInitializer.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/sql/init/AbstractScriptDatabaseInitializer.java
@@ -122,7 +122,7 @@ private List<Resource> getScripts(List<String> locations, String type, ScriptLoc
 				location = location.substring(OPTIONAL_LOCATION_PREFIX.length());
 			}
 			for (Resource resource : doGetResources(location, locationResolver)) {
-				if (resource.exists()) {
+				if (resource.isReadable()) {
 					resources.add(resource);
 				}
 				else if (!optional) {
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslBundleRegistry.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslBundleRegistry.java
index fa79265755c2..8c999e5ccf4f 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslBundleRegistry.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslBundleRegistry.java
@@ -16,20 +16,31 @@
 
 package org.springframework.boot.ssl;
 
+import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.function.Consumer;
 
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.core.log.LogMessage;
 import org.springframework.util.Assert;
 
 /**
  * Default {@link SslBundleRegistry} implementation.
  *
  * @author Scott Frederick
+ * @author Moritz Halbritter
+ * @author Phillip Webb
  * @since 3.1.0
  */
 public class DefaultSslBundleRegistry implements SslBundleRegistry, SslBundles {
 
-	private final Map<String, SslBundle> bundles = new ConcurrentHashMap<>();
+	private static final Log logger = LogFactory.getLog(DefaultSslBundleRegistry.class);
+
+	private final Map<String, RegisteredSslBundle> registeredBundles = new ConcurrentHashMap<>();
 
 	public DefaultSslBundleRegistry() {
 	}
@@ -42,18 +53,67 @@ public DefaultSslBundleRegistry(String name, SslBundle bundle) {
 	public void registerBundle(String name, SslBundle bundle) {
 		Assert.notNull(name, "Name must not be null");
 		Assert.notNull(bundle, "Bundle must not be null");
-		SslBundle previous = this.bundles.putIfAbsent(name, bundle);
+		RegisteredSslBundle previous = this.registeredBundles.putIfAbsent(name, new RegisteredSslBundle(name, bundle));
 		Assert.state(previous == null, () -> "Cannot replace existing SSL bundle '%s'".formatted(name));
 	}
 
+	@Override
+	public void updateBundle(String name, SslBundle updatedBundle) {
+		getRegistered(name).update(updatedBundle);
+	}
+
 	@Override
 	public SslBundle getBundle(String name) {
+		return getRegistered(name).getBundle();
+	}
+
+	@Override
+	public void addBundleUpdateHandler(String name, Consumer<SslBundle> updateHandler) throws NoSuchSslBundleException {
+		getRegistered(name).addUpdateHandler(updateHandler);
+	}
+
+	private RegisteredSslBundle getRegistered(String name) throws NoSuchSslBundleException {
 		Assert.notNull(name, "Name must not be null");
-		SslBundle bundle = this.bundles.get(name);
-		if (bundle == null) {
+		RegisteredSslBundle registered = this.registeredBundles.get(name);
+		if (registered == null) {
 			throw new NoSuchSslBundleException(name, "SSL bundle name '%s' cannot be found".formatted(name));
 		}
-		return bundle;
+		return registered;
+	}
+
+	private static class RegisteredSslBundle {
+
+		private final String name;
+
+		private final List<Consumer<SslBundle>> updateHandlers = new CopyOnWriteArrayList<>();
+
+		private volatile SslBundle bundle;
+
+		RegisteredSslBundle(String name, SslBundle bundle) {
+			this.name = name;
+			this.bundle = bundle;
+		}
+
+		void update(SslBundle updatedBundle) {
+			Assert.notNull(updatedBundle, "UpdatedBundle must not be null");
+			this.bundle = updatedBundle;
+			if (this.updateHandlers.isEmpty()) {
+				logger.warn(LogMessage.format(
+						"SSL bundle '%s' has been updated but may be in use by a technology that doesn't support SSL reloading",
+						this.name));
+			}
+			this.updateHandlers.forEach((handler) -> handler.accept(updatedBundle));
+		}
+
+		SslBundle getBundle() {
+			return this.bundle;
+		}
+
+		void addUpdateHandler(Consumer<SslBundle> updateHandler) {
+			Assert.notNull(updateHandler, "UpdateHandler must not be null");
+			this.updateHandlers.add(updateHandler);
+		}
+
 	}
 
 }
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleRegistry.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleRegistry.java
index 990a481066be..e1c0a4c64179 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleRegistry.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleRegistry.java
@@ -20,6 +20,7 @@
  * Interface that can be used to register an {@link SslBundle} for a given name.
  *
  * @author Scott Frederick
+ * @author Moritz Halbritter
  * @since 3.1.0
  */
 public interface SslBundleRegistry {
@@ -31,4 +32,13 @@ public interface SslBundleRegistry {
 	 */
 	void registerBundle(String name, SslBundle bundle);
 
+	/**
+	 * Updates an {@link SslBundle}.
+	 * @param name the bundle name
+	 * @param updatedBundle the updated bundle
+	 * @throws NoSuchSslBundleException if the bundle cannot be found
+	 * @since 3.2.0
+	 */
+	void updateBundle(String name, SslBundle updatedBundle) throws NoSuchSslBundleException;
+
 }
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundles.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundles.java
index ed8a0ea9cda4..21afc4346a61 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundles.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundles.java
@@ -16,20 +16,32 @@
 
 package org.springframework.boot.ssl;
 
+import java.util.function.Consumer;
+
 /**
  * A managed set of {@link SslBundle} instances that can be retrieved by name.
  *
  * @author Scott Frederick
+ * @author Moritz Halbritter
  * @since 3.1.0
  */
 public interface SslBundles {
 
 	/**
 	 * Return an {@link SslBundle} with the provided name.
-	 * @param bundleName the bundle name
+	 * @param name the bundle name
 	 * @return the bundle
 	 * @throws NoSuchSslBundleException if a bundle with the provided name does not exist
 	 */
-	SslBundle getBundle(String bundleName) throws NoSuchSslBundleException;
+	SslBundle getBundle(String name) throws NoSuchSslBundleException;
+
+	/**
+	 * Add a handler that will be called each time the named bundle is updated.
+	 * @param name the bundle name
+	 * @param updateHandler the handler that should be called
+	 * @throws NoSuchSslBundleException if a bundle with the provided name does not exist
+	 * @since 3.2.0
+	 */
+	void addBundleUpdateHandler(String name, Consumer<SslBundle> updateHandler) throws NoSuchSslBundleException;
 
 }
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/jks/JksSslStoreBundle.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/jks/JksSslStoreBundle.java
index 8deb079a5c61..ef0b924ffb2d 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/jks/JksSslStoreBundle.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/jks/JksSslStoreBundle.java
@@ -35,13 +35,16 @@
  *
  * @author Scott Frederick
  * @author Phillip Webb
+ * @author Moritz Halbritter
  * @since 3.1.0
  */
 public class JksSslStoreBundle implements SslStoreBundle {
 
 	private final JksSslStoreDetails keyStoreDetails;
 
-	private final JksSslStoreDetails trustStoreDetails;
+	private final KeyStore keyStore;
+
+	private final KeyStore trustStore;
 
 	/**
 	 * Create a new {@link JksSslStoreBundle} instance.
@@ -50,12 +53,13 @@ public class JksSslStoreBundle implements SslStoreBundle {
 	 */
 	public JksSslStoreBundle(JksSslStoreDetails keyStoreDetails, JksSslStoreDetails trustStoreDetails) {
 		this.keyStoreDetails = keyStoreDetails;
-		this.trustStoreDetails = trustStoreDetails;
+		this.keyStore = createKeyStore("key", this.keyStoreDetails);
+		this.trustStore = createKeyStore("trust", trustStoreDetails);
 	}
 
 	@Override
 	public KeyStore getKeyStore() {
-		return createKeyStore("key", this.keyStoreDetails);
+		return this.keyStore;
 	}
 
 	@Override
@@ -65,7 +69,7 @@ public String getKeyStorePassword() {
 
 	@Override
 	public KeyStore getTrustStore() {
-		return createKeyStore("trust", this.trustStoreDetails);
+		return this.trustStore;
 	}
 
 	private KeyStore createKeyStore(String name, JksSslStoreDetails details) {
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/LoadedPemSslStore.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/LoadedPemSslStore.java
new file mode 100644
index 000000000000..07f457a1b1f8
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/LoadedPemSslStore.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.ssl.pem;
+
+import java.io.IOException;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.util.List;
+
+import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
+
+/**
+ * {@link PemSslStore} loaded from {@link PemSslStoreDetails}.
+ *
+ * @author Phillip Webb
+ * @see PemSslStore#load(PemSslStoreDetails)
+ */
+final class LoadedPemSslStore implements PemSslStore {
+
+	private final PemSslStoreDetails details;
+
+	private final List<X509Certificate> certificates;
+
+	private final PrivateKey privateKey;
+
+	LoadedPemSslStore(PemSslStoreDetails details) throws IOException {
+		Assert.notNull(details, "Details must not be null");
+		this.details = details;
+		this.certificates = loadCertificates(details);
+		this.privateKey = loadPrivateKey(details);
+	}
+
+	private static List<X509Certificate> loadCertificates(PemSslStoreDetails details) throws IOException {
+		PemContent pemContent = PemContent.load(details.certificates());
+		if (pemContent == null) {
+			return null;
+		}
+		List<X509Certificate> certificates = pemContent.getCertificates();
+		Assert.state(!CollectionUtils.isEmpty(certificates), "Loaded certificates are empty");
+		return certificates;
+	}
+
+	private static PrivateKey loadPrivateKey(PemSslStoreDetails details) throws IOException {
+		PemContent pemContent = PemContent.load(details.privateKey());
+		return (pemContent != null) ? pemContent.getPrivateKey(details.privateKeyPassword()) : null;
+	}
+
+	@Override
+	public String type() {
+		return this.details.type();
+	}
+
+	@Override
+	public String alias() {
+		return this.details.alias();
+	}
+
+	@Override
+	public String password() {
+		return this.details.password();
+	}
+
+	@Override
+	public List<X509Certificate> certificates() {
+		return this.certificates;
+	}
+
+	@Override
+	public PrivateKey privateKey() {
+		return this.privateKey;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemCertificateParser.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemCertificateParser.java
index 327dcc94a1ff..8e07b0740bc3 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemCertificateParser.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemCertificateParser.java
@@ -27,6 +27,9 @@
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
+import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
+
 /**
  * Parser for X.509 certificates in PEM format.
  *
@@ -48,17 +51,18 @@ private PemCertificateParser() {
 
 	/**
 	 * Parse certificates from the specified string.
-	 * @param certificates the certificates to parse
+	 * @param text the text to parse
 	 * @return the parsed certificates
 	 */
-	static X509Certificate[] parse(String certificates) {
-		if (certificates == null) {
+	static List<X509Certificate> parse(String text) {
+		if (text == null) {
 			return null;
 		}
 		CertificateFactory factory = getCertificateFactory();
 		List<X509Certificate> certs = new ArrayList<>();
-		readCertificates(certificates, factory, certs::add);
-		return (!certs.isEmpty()) ? certs.toArray(X509Certificate[]::new) : null;
+		readCertificates(text, factory, certs::add);
+		Assert.state(!CollectionUtils.isEmpty(certs), "Missing certificates or unrecognized format");
+		return List.copyOf(certs);
 	}
 
 	private static CertificateFactory getCertificateFactory() {
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemContent.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemContent.java
index 317828575064..d3013bcb6f3a 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemContent.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemContent.java
@@ -17,48 +17,155 @@
 package org.springframework.boot.ssl.pem;
 
 import java.io.IOException;
-import java.io.InputStreamReader;
-import java.io.Reader;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
 import java.net.URL;
 import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.util.List;
+import java.util.Objects;
 import java.util.regex.Pattern;
 
-import org.springframework.util.FileCopyUtils;
+import org.springframework.util.Assert;
 import org.springframework.util.ResourceUtils;
+import org.springframework.util.StreamUtils;
 
 /**
- * Utility to load PEM content.
+ * PEM encoded content that can provide {@link X509Certificate certificates} and
+ * {@link PrivateKey private keys}.
  *
  * @author Scott Frederick
  * @author Phillip Webb
+ * @since 3.2.0
  */
-final class PemContent {
+public final class PemContent {
 
 	private static final Pattern PEM_HEADER = Pattern.compile("-+BEGIN\\s+[^-]*-+", Pattern.CASE_INSENSITIVE);
 
 	private static final Pattern PEM_FOOTER = Pattern.compile("-+END\\s+[^-]*-+", Pattern.CASE_INSENSITIVE);
 
-	private PemContent() {
+	private final String text;
+
+	private PemContent(String text) {
+		this.text = text;
+	}
+
+	/**
+	 * Parse and return all {@link X509Certificate certificates} from the PEM content.
+	 * Most PEM files either contain a single certificate or a certificate chain.
+	 * @return the certificates
+	 * @throws IllegalStateException if no certificates could be loaded
+	 */
+	public List<X509Certificate> getCertificates() {
+		return PemCertificateParser.parse(this.text);
+	}
+
+	/**
+	 * Parse and return the {@link PrivateKey private keys} from the PEM content.
+	 * @return the private keys
+	 * @throws IllegalStateException if no private key could be loaded
+	 */
+	public PrivateKey getPrivateKey() {
+		return getPrivateKey(null);
+	}
+
+	/**
+	 * Parse and return the {@link PrivateKey private keys} from the PEM content or
+	 * {@code null} if there is no private key.
+	 * @param password the password to decrypt the private keys or {@code null}
+	 * @return the private keys
+	 */
+	public PrivateKey getPrivateKey(String password) {
+		return PemPrivateKeyParser.parse(this.text, password);
+	}
+
+	@Override
+	public boolean equals(Object obj) {
+		if (this == obj) {
+			return true;
+		}
+		if (obj == null || getClass() != obj.getClass()) {
+			return false;
+		}
+		return Objects.equals(this.text, ((PemContent) obj).text);
+	}
+
+	@Override
+	public int hashCode() {
+		return Objects.hash(this.text);
 	}
 
-	static String load(String content) {
-		if (content == null || isPemContent(content)) {
-			return content;
+	@Override
+	public String toString() {
+		return this.text;
+	}
+
+	/**
+	 * Load {@link PemContent} from the given content (either the PEM content itself or a
+	 * reference to the resource to load).
+	 * @param content the content to load
+	 * @return a new {@link PemContent} instance
+	 * @throws IOException on IO error
+	 */
+	static PemContent load(String content) throws IOException {
+		if (content == null) {
+			return null;
+		}
+		if (isPresentInText(content)) {
+			return new PemContent(content);
 		}
 		try {
-			URL url = ResourceUtils.getURL(content);
-			try (Reader reader = new InputStreamReader(url.openStream(), StandardCharsets.UTF_8)) {
-				return FileCopyUtils.copyToString(reader);
-			}
+			return load(ResourceUtils.getURL(content));
 		}
-		catch (IOException ex) {
-			throw new IllegalStateException(
-					"Error reading certificate or key from file '" + content + "':" + ex.getMessage(), ex);
+		catch (IOException | UncheckedIOException ex) {
+			throw new IOException("Error reading certificate or key from file '%s'".formatted(content), ex);
 		}
 	}
 
-	private static boolean isPemContent(String content) {
-		return content != null && PEM_HEADER.matcher(content).find() && PEM_FOOTER.matcher(content).find();
+	/**
+	 * Load {@link PemContent} from the given {@link Path}.
+	 * @param path a path to load the content from
+	 * @return the loaded PEM content
+	 * @throws IOException on IO error
+	 */
+	public static PemContent load(Path path) throws IOException {
+		Assert.notNull(path, "Path must not be null");
+		try (InputStream in = Files.newInputStream(path, StandardOpenOption.READ)) {
+			return load(in);
+		}
+	}
+
+	private static PemContent load(URL url) throws IOException {
+		Assert.notNull(url, "Url must not be null");
+		try (InputStream in = url.openStream()) {
+			return load(in);
+		}
+	}
+
+	private static PemContent load(InputStream in) throws IOException {
+		return of(StreamUtils.copyToString(in, StandardCharsets.UTF_8));
+	}
+
+	/**
+	 * Return a new {@link PemContent} instance containing the given text.
+	 * @param text the text containing PEM encoded content
+	 * @return a new {@link PemContent} instance
+	 */
+	public static PemContent of(String text) {
+		return (text != null) ? new PemContent(text) : null;
+	}
+
+	/**
+	 * Return if PEM content is present in the given text.
+	 * @param text the text to check
+	 * @return if the text includes PEM encoded content.
+	 */
+	public static boolean isPresentInText(String text) {
+		return text != null && PEM_HEADER.matcher(text).find() && PEM_FOOTER.matcher(text).find();
 	}
 
 }
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemPrivateKeyParser.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemPrivateKeyParser.java
index 7f3e6c330447..113d490ea18c 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemPrivateKeyParser.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemPrivateKeyParser.java
@@ -18,9 +18,11 @@
 
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.nio.ByteBuffer;
 import java.security.AlgorithmParameters;
 import java.security.GeneralSecurityException;
 import java.security.KeyFactory;
+import java.security.NoSuchAlgorithmException;
 import java.security.PrivateKey;
 import java.security.spec.InvalidKeySpecException;
 import java.security.spec.PKCS8EncodedKeySpec;
@@ -38,6 +40,8 @@
 import javax.crypto.SecretKeyFactory;
 import javax.crypto.spec.PBEKeySpec;
 
+import org.springframework.boot.ssl.pem.PemPrivateKeyParser.DerElement.TagType;
+import org.springframework.boot.ssl.pem.PemPrivateKeyParser.DerElement.ValueType;
 import org.springframework.util.Assert;
 
 /**
@@ -49,9 +53,9 @@
  */
 final class PemPrivateKeyParser {
 
-	private static final String PKCS1_HEADER = "-+BEGIN\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+";
+	private static final String PKCS1_RSA_HEADER = "-+BEGIN\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+";
 
-	private static final String PKCS1_FOOTER = "-+END\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+";
+	private static final String PKCS1_RSA_FOOTER = "-+END\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+";
 
 	private static final String PKCS8_HEADER = "-+BEGIN\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+";
 
@@ -61,9 +65,9 @@ final class PemPrivateKeyParser {
 
 	private static final String PKCS8_ENCRYPTED_FOOTER = "-+END\\s+ENCRYPTED\\s+PRIVATE\\s+KEY[^-]*-+";
 
-	private static final String EC_HEADER = "-+BEGIN\\s+EC\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+";
+	private static final String SEC1_EC_HEADER = "-+BEGIN\\s+EC\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+";
 
-	private static final String EC_FOOTER = "-+END\\s+EC\\s+PRIVATE\\s+KEY[^-]*-+";
+	private static final String SEC1_EC_FOOTER = "-+END\\s+EC\\s+PRIVATE\\s+KEY[^-]*-+";
 
 	private static final String BASE64_TEXT = "([a-z0-9+/=\\r\\n]+)";
 
@@ -72,12 +76,13 @@ final class PemPrivateKeyParser {
 	private static final List<PemParser> PEM_PARSERS;
 	static {
 		List<PemParser> parsers = new ArrayList<>();
-		parsers.add(new PemParser(PKCS1_HEADER, PKCS1_FOOTER, PemPrivateKeyParser::createKeySpecForPkcs1, "RSA"));
-		parsers.add(new PemParser(EC_HEADER, EC_FOOTER, PemPrivateKeyParser::createKeySpecForEc, "EC"));
-		parsers.add(new PemParser(PKCS8_HEADER, PKCS8_FOOTER, PemPrivateKeyParser::createKeySpecForPkcs8, "RSA", "EC",
-				"DSA", "Ed25519"));
+		parsers.add(new PemParser(PKCS1_RSA_HEADER, PKCS1_RSA_FOOTER, PemPrivateKeyParser::createKeySpecForPkcs1Rsa,
+				"RSA"));
+		parsers.add(new PemParser(SEC1_EC_HEADER, SEC1_EC_FOOTER, PemPrivateKeyParser::createKeySpecForSec1Ec, "EC"));
+		parsers.add(new PemParser(PKCS8_HEADER, PKCS8_FOOTER, PemPrivateKeyParser::createKeySpecForPkcs8, "RSA",
+				"RSASSA-PSS", "EC", "DSA", "EdDSA", "XDH"));
 		parsers.add(new PemParser(PKCS8_ENCRYPTED_HEADER, PKCS8_ENCRYPTED_FOOTER,
-				PemPrivateKeyParser::createKeySpecForPkcs8Encrypted, "RSA", "EC", "DSA", "Ed25519"));
+				PemPrivateKeyParser::createKeySpecForPkcs8Encrypted, "RSA", "RSASSA-PSS", "EC", "DSA", "EdDSA", "XDH"));
 		PEM_PARSERS = Collections.unmodifiableList(parsers);
 	}
 
@@ -99,12 +104,43 @@ final class PemPrivateKeyParser {
 	private PemPrivateKeyParser() {
 	}
 
-	private static PKCS8EncodedKeySpec createKeySpecForPkcs1(byte[] bytes, String password) {
+	private static PKCS8EncodedKeySpec createKeySpecForPkcs1Rsa(byte[] bytes, String password) {
 		return createKeySpecForAlgorithm(bytes, RSA_ALGORITHM, null);
 	}
 
-	private static PKCS8EncodedKeySpec createKeySpecForEc(byte[] bytes, String password) {
-		return createKeySpecForAlgorithm(bytes, EC_ALGORITHM, EC_PARAMETERS);
+	private static PKCS8EncodedKeySpec createKeySpecForSec1Ec(byte[] bytes, String password) {
+		DerElement ecPrivateKey = DerElement.of(bytes);
+		Assert.state(ecPrivateKey.isType(ValueType.ENCODED, TagType.SEQUENCE),
+				"Key spec should be an ASN.1 encoded sequence");
+		DerElement version = DerElement.of(ecPrivateKey.getContents());
+		Assert.state(version != null && version.isType(ValueType.PRIMITIVE, TagType.INTEGER),
+				"Key spec should start with version");
+		Assert.state(version.getContents().remaining() == 1 && version.getContents().get() == 1,
+				"Key spec version must be 1");
+		DerElement privateKey = DerElement.of(ecPrivateKey.getContents());
+		Assert.state(privateKey != null && privateKey.isType(ValueType.PRIMITIVE, TagType.OCTET_STRING),
+				"Key spec should contain private key");
+		DerElement parameters = DerElement.of(ecPrivateKey.getContents());
+		return createKeySpecForAlgorithm(bytes, EC_ALGORITHM, getEcParameters(parameters));
+	}
+
+	private static int[] getEcParameters(DerElement parameters) {
+		if (parameters == null) {
+			return EC_PARAMETERS;
+		}
+		Assert.state(parameters.isType(ValueType.ENCODED), "Key spec should contain encoded parameters");
+		DerElement contents = DerElement.of(parameters.getContents());
+		Assert.state(contents != null && contents.isType(ValueType.PRIMITIVE, TagType.OBJECT_IDENTIFIER),
+				"Key spec parameters should contain object identifier");
+		return getEcParameters(contents.getContents());
+	}
+
+	private static int[] getEcParameters(ByteBuffer bytes) {
+		int[] result = new int[bytes.remaining()];
+		for (int i = 0; i < result.length; i++) {
+			result[i] = bytes.get() & 0xFF;
+		}
+		return result;
 	}
 
 	private static PKCS8EncodedKeySpec createKeySpecForAlgorithm(byte[] bytes, int[] algorithm, int[] parameters) {
@@ -114,8 +150,7 @@ private static PKCS8EncodedKeySpec createKeySpecForAlgorithm(byte[] bytes, int[]
 			DerEncoder algorithmIdentifier = new DerEncoder();
 			algorithmIdentifier.objectIdentifier(algorithm);
 			algorithmIdentifier.objectIdentifier(parameters);
-			byte[] byteArray = algorithmIdentifier.toByteArray();
-			encoder.sequence(byteArray);
+			encoder.sequence(algorithmIdentifier.toByteArray());
 			encoder.octetString(bytes);
 			return new PKCS8EncodedKeySpec(encoder.toSequence());
 		}
@@ -134,36 +169,36 @@ private static PKCS8EncodedKeySpec createKeySpecForPkcs8Encrypted(byte[] bytes,
 
 	/**
 	 * Parse a private key from the specified string.
-	 * @param key the private key to parse
+	 * @param text the text to parse
 	 * @return the parsed private key
 	 */
-	static PrivateKey parse(String key) {
-		return parse(key, null);
+	static PrivateKey parse(String text) {
+		return parse(text, null);
 	}
 
 	/**
 	 * Parse a private key from the specified string, using the provided password for
 	 * decryption if necessary.
-	 * @param key the private key to parse
+	 * @param text the text to parse
 	 * @param password the password used to decrypt an encrypted private key
 	 * @return the parsed private key
 	 */
-	static PrivateKey parse(String key, String password) {
-		if (key == null) {
+	static PrivateKey parse(String text, String password) {
+		if (text == null) {
 			return null;
 		}
 		try {
 			for (PemParser pemParser : PEM_PARSERS) {
-				PrivateKey privateKey = pemParser.parse(key, password);
+				PrivateKey privateKey = pemParser.parse(text, password);
 				if (privateKey != null) {
 					return privateKey;
 				}
 			}
-			throw new IllegalStateException("Unrecognized private key format");
 		}
 		catch (Exception ex) {
 			throw new IllegalStateException("Error loading private key file: " + ex.getMessage(), ex);
 		}
+		throw new IllegalStateException("Missing private key or unrecognized format");
 	}
 
 	/**
@@ -195,21 +230,17 @@ private static byte[] decodeBase64(String content) {
 		}
 
 		private PrivateKey parse(byte[] bytes, String password) {
-			try {
-				PKCS8EncodedKeySpec keySpec = this.keySpecFactory.apply(bytes, password);
-				for (String algorithm : this.algorithms) {
+			PKCS8EncodedKeySpec keySpec = this.keySpecFactory.apply(bytes, password);
+			for (String algorithm : this.algorithms) {
+				try {
 					KeyFactory keyFactory = KeyFactory.getInstance(algorithm);
-					try {
-						return keyFactory.generatePrivate(keySpec);
-					}
-					catch (InvalidKeySpecException ex) {
-					}
+					return keyFactory.generatePrivate(keySpec);
+				}
+				catch (InvalidKeySpecException | NoSuchAlgorithmException ex) {
+					// Ignore
 				}
-				return null;
-			}
-			catch (GeneralSecurityException ex) {
-				throw new IllegalArgumentException("Unexpected key format", ex);
 			}
+			return null;
 		}
 
 	}
@@ -234,10 +265,6 @@ void octetString(byte[] bytes) throws IOException {
 			codeLengthBytes(0x04, bytes);
 		}
 
-		void sequence(int... elements) throws IOException {
-			sequence(bytes(elements));
-		}
-
 		void sequence(byte[] bytes) throws IOException {
 			codeLengthBytes(0x30, bytes);
 		}
@@ -288,6 +315,107 @@ byte[] toByteArray() {
 
 	}
 
+	/**
+	 * An ASN.1 DER encoded element.
+	 */
+	static final class DerElement {
+
+		private final ValueType valueType;
+
+		private final long tagType;
+
+		private final ByteBuffer contents;
+
+		private DerElement(ByteBuffer bytes) {
+			byte b = bytes.get();
+			this.valueType = ((b & 0x20) == 0) ? ValueType.PRIMITIVE : ValueType.ENCODED;
+			this.tagType = decodeTagType(b, bytes);
+			int length = decodeLength(bytes);
+			bytes.limit(bytes.position() + length);
+			this.contents = bytes.slice();
+			bytes.limit(bytes.capacity());
+			bytes.position(bytes.position() + length);
+		}
+
+		private long decodeTagType(byte b, ByteBuffer bytes) {
+			long tagType = (b & 0x1F);
+			if (tagType != 0x1F) {
+				return tagType;
+			}
+			tagType = 0;
+			b = bytes.get();
+			while ((b & 0x80) != 0) {
+				tagType <<= 7;
+				tagType = tagType | (b & 0x7F);
+				b = bytes.get();
+			}
+			return tagType;
+		}
+
+		private int decodeLength(ByteBuffer bytes) {
+			byte b = bytes.get();
+			if ((b & 0x80) == 0) {
+				return b & 0x7F;
+			}
+			int numberOfLengthBytes = (b & 0x7F);
+			Assert.state(numberOfLengthBytes != 0, "Infinite length encoding is not supported");
+			Assert.state(numberOfLengthBytes != 0x7F, "Reserved length encoding is not supported");
+			Assert.state(numberOfLengthBytes <= 4, "Length overflow");
+			int length = 0;
+			for (int i = 0; i < numberOfLengthBytes; i++) {
+				length <<= 8;
+				length |= (bytes.get() & 0xFF);
+			}
+			return length;
+		}
+
+		boolean isType(ValueType valueType) {
+			return this.valueType == valueType;
+		}
+
+		boolean isType(ValueType valueType, TagType tagType) {
+			return this.valueType == valueType && this.tagType == tagType.getNumber();
+		}
+
+		ByteBuffer getContents() {
+			return this.contents;
+		}
+
+		static DerElement of(byte[] bytes) {
+			return of(ByteBuffer.wrap(bytes));
+		}
+
+		static DerElement of(ByteBuffer bytes) {
+			return (bytes.remaining() > 0) ? new DerElement(bytes) : null;
+		}
+
+		enum ValueType {
+
+			PRIMITIVE, ENCODED
+
+		}
+
+		enum TagType {
+
+			INTEGER(0x02), OCTET_STRING(0x04), OBJECT_IDENTIFIER(0x06), SEQUENCE(0x10);
+
+			private final int number;
+
+			TagType(int number) {
+				this.number = number;
+			}
+
+			int getNumber() {
+				return this.number;
+			}
+
+		}
+
+	}
+
+	/**
+	 * Decryptor for PKCS8 encoded private keys.
+	 */
 	static class Pkcs8PrivateKeyDecryptor {
 
 		public static final String PBES2_ALGORITHM = "PBES2";
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStore.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStore.java
new file mode 100644
index 000000000000..e1ed146f3cdb
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStore.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.ssl.pem;
+
+import java.io.IOException;
+import java.security.KeyStore;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.util.List;
+
+import org.springframework.util.Assert;
+
+/**
+ * An individual trust or key store that has been loaded from PEM content.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ * @see PemSslStoreDetails
+ * @see PemContent
+ */
+public interface PemSslStore {
+
+	/**
+	 * The key store type, for example {@code JKS} or {@code PKCS11}. A {@code null} value
+	 * will use {@link KeyStore#getDefaultType()}).
+	 * @return the key store type
+	 */
+	String type();
+
+	/**
+	 * The alias used when setting entries in the {@link KeyStore}.
+	 * @return the alias
+	 */
+	String alias();
+
+	/**
+	 * The password used when
+	 * {@link KeyStore#setKeyEntry(String, java.security.Key, char[], java.security.cert.Certificate[])
+	 * setting key entries} in the {@link KeyStore}.
+	 * @return the password
+	 */
+	String password();
+
+	/**
+	 * The certificates for this store. When a {@link #privateKey() private key} is
+	 * present the returned value is treated as a certificate chain, otherwise it is
+	 * treated a list of certificates that should all be registered.
+	 * @return the X509 certificates
+	 */
+	List<X509Certificate> certificates();
+
+	/**
+	 * The private key for this store or {@code null}.
+	 * @return the private key
+	 */
+	PrivateKey privateKey();
+
+	/**
+	 * Return a new {@link PemSslStore} instance with a new alias.
+	 * @param alias the new alias
+	 * @return a new {@link PemSslStore} instance
+	 */
+	default PemSslStore withAlias(String alias) {
+		return of(type(), alias, password(), certificates(), privateKey());
+	}
+
+	/**
+	 * Return a new {@link PemSslStore} instance with a new password.
+	 * @param password the new password
+	 * @return a new {@link PemSslStore} instance
+	 */
+	default PemSslStore withPassword(String password) {
+		return of(type(), alias(), password, certificates(), privateKey());
+	}
+
+	/**
+	 * Return a {@link PemSslStore} instance loaded using the given
+	 * {@link PemSslStoreDetails}.
+	 * @param details the PEM store details
+	 * @return a loaded {@link PemSslStore} or {@code null}.
+	 * @throws IOException on IO error
+	 */
+	static PemSslStore load(PemSslStoreDetails details) throws IOException {
+		if (details == null || details.isEmpty()) {
+			return null;
+		}
+		return new LoadedPemSslStore(details);
+	}
+
+	/**
+	 * Factory method that can be used to create a new {@link PemSslStore} with the given
+	 * values.
+	 * @param type the key store type
+	 * @param certificates the certificates for this store
+	 * @param privateKey the private key
+	 * @return a new {@link PemSslStore} instance
+	 */
+	static PemSslStore of(String type, List<X509Certificate> certificates, PrivateKey privateKey) {
+		return of(type, null, null, certificates, privateKey);
+	}
+
+	/**
+	 * Factory method that can be used to create a new {@link PemSslStore} with the given
+	 * values.
+	 * @param certificates the certificates for this store
+	 * @param privateKey the private key
+	 * @return a new {@link PemSslStore} instance
+	 */
+	static PemSslStore of(List<X509Certificate> certificates, PrivateKey privateKey) {
+		return of(null, null, null, certificates, privateKey);
+	}
+
+	/**
+	 * Factory method that can be used to create a new {@link PemSslStore} with the given
+	 * values.
+	 * @param type the key store type
+	 * @param alias the alias used when setting entries in the {@link KeyStore}
+	 * @param password the password used
+	 * {@link KeyStore#setKeyEntry(String, java.security.Key, char[], java.security.cert.Certificate[])
+	 * setting key entries} in the {@link KeyStore}
+	 * @param certificates the certificates for this store
+	 * @param privateKey the private key
+	 * @return a new {@link PemSslStore} instance
+	 */
+	static PemSslStore of(String type, String alias, String password, List<X509Certificate> certificates,
+			PrivateKey privateKey) {
+		Assert.notEmpty(certificates, "Certificates must not be empty");
+		return new PemSslStore() {
+
+			@Override
+			public String type() {
+				return type;
+			}
+
+			@Override
+			public String alias() {
+				return alias;
+			}
+
+			@Override
+			public String password() {
+				return password;
+			}
+
+			@Override
+			public List<X509Certificate> certificates() {
+				return certificates;
+			}
+
+			@Override
+			public PrivateKey privateKey() {
+				return privateKey;
+			}
+
+		};
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreBundle.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreBundle.java
index dee4651852af..44c6e0fbff47 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreBundle.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreBundle.java
@@ -16,10 +16,15 @@
 
 package org.springframework.boot.ssl.pem;
 
+import java.io.IOException;
+import java.io.UncheckedIOException;
 import java.security.KeyStore;
 import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
 import java.security.PrivateKey;
+import java.security.cert.CertificateException;
 import java.security.cert.X509Certificate;
+import java.util.List;
 
 import org.springframework.boot.ssl.SslStoreBundle;
 import org.springframework.util.Assert;
@@ -30,17 +35,16 @@
  *
  * @author Scott Frederick
  * @author Phillip Webb
+ * @author Moritz Halbritter
  * @since 3.1.0
  */
 public class PemSslStoreBundle implements SslStoreBundle {
 
-	private static final String DEFAULT_KEY_ALIAS = "ssl";
+	private static final String DEFAULT_ALIAS = "ssl";
 
-	private final PemSslStoreDetails keyStoreDetails;
+	private final KeyStore keyStore;
 
-	private final PemSslStoreDetails trustStoreDetails;
-
-	private final String keyAlias;
+	private final KeyStore trustStore;
 
 	/**
 	 * Create a new {@link PemSslStoreBundle} instance.
@@ -55,18 +59,40 @@ public PemSslStoreBundle(PemSslStoreDetails keyStoreDetails, PemSslStoreDetails
 	 * Create a new {@link PemSslStoreBundle} instance.
 	 * @param keyStoreDetails the key store details
 	 * @param trustStoreDetails the trust store details
-	 * @param keyAlias the key alias to use or {@code null} to use a default alias
+	 * @param alias the alias to use or {@code null} to use a default alias
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of
+	 * {@link PemSslStoreDetails#alias()} in the {@code keyStoreDetails} and
+	 * {@code trustStoreDetails}
+	 */
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public PemSslStoreBundle(PemSslStoreDetails keyStoreDetails, PemSslStoreDetails trustStoreDetails, String alias) {
+		try {
+			this.keyStore = createKeyStore("key", PemSslStore.load(keyStoreDetails), alias);
+			this.trustStore = createKeyStore("trust", PemSslStore.load(trustStoreDetails), alias);
+		}
+		catch (IOException ex) {
+			throw new UncheckedIOException(ex);
+		}
+	}
+
+	/**
+	 * Create a new {@link PemSslStoreBundle} instance.
+	 * @param pemKeyStore the PEM key store
+	 * @param pemTrustStore the PEM trust store
+	 * @since 3.2.0
 	 */
-	public PemSslStoreBundle(PemSslStoreDetails keyStoreDetails, PemSslStoreDetails trustStoreDetails,
-			String keyAlias) {
-		this.keyAlias = keyAlias;
-		this.keyStoreDetails = keyStoreDetails;
-		this.trustStoreDetails = trustStoreDetails;
+	public PemSslStoreBundle(PemSslStore pemKeyStore, PemSslStore pemTrustStore) {
+		this(pemKeyStore, pemTrustStore, null);
+	}
+
+	private PemSslStoreBundle(PemSslStore pemKeyStore, PemSslStore pemTrustStore, String alias) {
+		this.keyStore = createKeyStore("key", pemKeyStore, alias);
+		this.trustStore = createKeyStore("trust", pemTrustStore, alias);
 	}
 
 	@Override
 	public KeyStore getKeyStore() {
-		return createKeyStore("key", this.keyStoreDetails);
+		return this.keyStore;
 	}
 
 	@Override
@@ -76,23 +102,26 @@ public String getKeyStorePassword() {
 
 	@Override
 	public KeyStore getTrustStore() {
-		return createKeyStore("trust", this.trustStoreDetails);
+		return this.trustStore;
 	}
 
-	private KeyStore createKeyStore(String name, PemSslStoreDetails details) {
-		if (details == null || details.isEmpty()) {
+	private static KeyStore createKeyStore(String name, PemSslStore pemSslStore, String alias) {
+		if (pemSslStore == null) {
 			return null;
 		}
 		try {
-			Assert.notNull(details.certificate(), "Certificate content must not be null");
-			String type = (!StringUtils.hasText(details.type())) ? KeyStore.getDefaultType() : details.type();
-			KeyStore store = KeyStore.getInstance(type);
-			store.load(null);
-			String certificateContent = PemContent.load(details.certificate());
-			String privateKeyContent = PemContent.load(details.privateKey());
-			X509Certificate[] certificates = PemCertificateParser.parse(certificateContent);
-			PrivateKey privateKey = PemPrivateKeyParser.parse(privateKeyContent, details.privateKeyPassword());
-			addCertificates(store, certificates, privateKey);
+			Assert.notEmpty(pemSslStore.certificates(), "Certificates must not be empty");
+			alias = (pemSslStore.alias() != null) ? pemSslStore.alias() : alias;
+			alias = (alias != null) ? alias : DEFAULT_ALIAS;
+			KeyStore store = createKeyStore(pemSslStore.type());
+			List<X509Certificate> certificates = pemSslStore.certificates();
+			PrivateKey privateKey = pemSslStore.privateKey();
+			if (privateKey != null) {
+				addPrivateKey(store, privateKey, alias, pemSslStore.password(), certificates);
+			}
+			else {
+				addCertificates(store, certificates, alias);
+			}
 			return store;
 		}
 		catch (Exception ex) {
@@ -100,16 +129,25 @@ private KeyStore createKeyStore(String name, PemSslStoreDetails details) {
 		}
 	}
 
-	private void addCertificates(KeyStore keyStore, X509Certificate[] certificates, PrivateKey privateKey)
+	private static KeyStore createKeyStore(String type)
+			throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException {
+		KeyStore store = KeyStore.getInstance(StringUtils.hasText(type) ? type : KeyStore.getDefaultType());
+		store.load(null);
+		return store;
+	}
+
+	private static void addPrivateKey(KeyStore keyStore, PrivateKey privateKey, String alias, String keyPassword,
+			List<X509Certificate> certificateChain) throws KeyStoreException {
+		keyStore.setKeyEntry(alias, privateKey, (keyPassword != null) ? keyPassword.toCharArray() : null,
+				certificateChain.toArray(X509Certificate[]::new));
+	}
+
+	private static void addCertificates(KeyStore keyStore, List<X509Certificate> certificates, String alias)
 			throws KeyStoreException {
-		String alias = (this.keyAlias != null) ? this.keyAlias : DEFAULT_KEY_ALIAS;
-		if (privateKey != null) {
-			keyStore.setKeyEntry(alias, privateKey, null, certificates);
-		}
-		else {
-			for (int index = 0; index < certificates.length; index++) {
-				keyStore.setCertificateEntry(alias + "-" + index, certificates[index]);
-			}
+		for (int index = 0; index < certificates.size(); index++) {
+			String entryAlias = alias + ((certificates.size() == 1) ? "" : "-" + index);
+			X509Certificate certificate = certificates.get(index);
+			keyStore.setCertificateEntry(entryAlias, certificate);
 		}
 	}
 
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreDetails.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreDetails.java
index 81d68eb69594..2f7dfff29c13 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreDetails.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreDetails.java
@@ -18,7 +18,6 @@
 
 import java.security.KeyStore;
 
-import org.springframework.util.ResourceUtils;
 import org.springframework.util.StringUtils;
 
 /**
@@ -26,41 +25,124 @@
  *
  * @param type the key store type, for example {@code JKS} or {@code PKCS11}. A
  * {@code null} value will use {@link KeyStore#getDefaultType()}).
- * @param certificate the certificate content (either the PEM content itself or something
- * that can be loaded by {@link ResourceUtils#getURL})
- * @param privateKey the private key content (either the PEM content itself or something
- * that can be loaded by {@link ResourceUtils#getURL})
+ * @param alias the alias used when setting entries in the {@link KeyStore}
+ * @param password the password used
+ * {@link KeyStore#setKeyEntry(String, java.security.Key, char[], java.security.cert.Certificate[])
+ * setting key entries} in the {@link KeyStore}
+ * @param certificates the certificates content (either the PEM content itself or or a
+ * reference to the resource to load). When a {@link #privateKey() private key} is present
+ * this value is treated as a certificate chain, otherwise it is treated a list of
+ * certificates that should all be registered.
+ * @param privateKey the private key content (either the PEM content itself or a reference
+ * to the resource to load)
  * @param privateKeyPassword a password used to decrypt an encrypted private key
  * @author Scott Frederick
  * @author Phillip Webb
  * @since 3.1.0
+ * @see PemSslStore#load(PemSslStoreDetails)
  */
-public record PemSslStoreDetails(String type, String certificate, String privateKey, String privateKeyPassword) {
+public record PemSslStoreDetails(String type, String alias, String password, String certificates, String privateKey,
+		String privateKeyPassword) {
 
+	/**
+	 * Create a new {@link PemSslStoreDetails} instance.
+	 * @param type the key store type, for example {@code JKS} or {@code PKCS11}. A
+	 * {@code null} value will use {@link KeyStore#getDefaultType()}).
+	 * @param alias the alias used when setting entries in the {@link KeyStore}
+	 * @param password the password used
+	 * {@link KeyStore#setKeyEntry(String, java.security.Key, char[], java.security.cert.Certificate[])
+	 * setting key entries} in the {@link KeyStore}
+	 * @param certificates the certificate content (either the PEM content itself or a
+	 * reference to the resource to load)
+	 * @param privateKey the private key content (either the PEM content itself or a
+	 * reference to the resource to load)
+	 * @param privateKeyPassword a password used to decrypt an encrypted private key
+	 * @since 3.2.0
+	 */
+	public PemSslStoreDetails {
+	}
+
+	/**
+	 * Create a new {@link PemSslStoreDetails} instance.
+	 * @param type the key store type, for example {@code JKS} or {@code PKCS11}. A
+	 * {@code null} value will use {@link KeyStore#getDefaultType()}).
+	 * @param certificate the certificate content (either the PEM content itself or a
+	 * reference to the resource to load)
+	 * @param privateKey the private key content (either the PEM content itself or a
+	 * reference to the resource to load)
+	 * @param privateKeyPassword a password used to decrypt an encrypted private key
+	 */
+	public PemSslStoreDetails(String type, String certificate, String privateKey, String privateKeyPassword) {
+		this(type, null, null, certificate, privateKey, privateKeyPassword);
+	}
+
+	/**
+	 * Create a new {@link PemSslStoreDetails} instance.
+	 * @param type the key store type, for example {@code JKS} or {@code PKCS11}. A
+	 * {@code null} value will use {@link KeyStore#getDefaultType()}).
+	 * @param certificate the certificate content (either the PEM content itself or a
+	 * reference to the resource to load)
+	 * @param privateKey the private key content (either the PEM content itself or a
+	 * reference to the resource to load)
+	 */
 	public PemSslStoreDetails(String type, String certificate, String privateKey) {
 		this(type, certificate, privateKey, null);
 	}
 
+	/**
+	 * Return the certificate content.
+	 * @return the certificate content
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of {@link #certificates()}
+	 */
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public String certificate() {
+		return certificates();
+	}
+
+	/**
+	 * Return a new {@link PemSslStoreDetails} instance with a new alias.
+	 * @param alias the new alias
+	 * @return a new {@link PemSslStoreDetails} instance
+	 * @since 3.2.0
+	 */
+	public PemSslStoreDetails withAlias(String alias) {
+		return new PemSslStoreDetails(this.type, alias, this.password, this.certificates, this.privateKey,
+				this.privateKeyPassword);
+	}
+
+	/**
+	 * Return a new {@link PemSslStoreDetails} instance with a new password.
+	 * @param password the new password
+	 * @return a new {@link PemSslStoreDetails} instance
+	 * @since 3.2.0
+	 */
+	public PemSslStoreDetails withPassword(String password) {
+		return new PemSslStoreDetails(this.type, this.alias, password, this.certificates, this.privateKey,
+				this.privateKeyPassword);
+	}
+
 	/**
 	 * Return a new {@link PemSslStoreDetails} instance with a new private key.
 	 * @param privateKey the new private key
 	 * @return a new {@link PemSslStoreDetails} instance
 	 */
 	public PemSslStoreDetails withPrivateKey(String privateKey) {
-		return new PemSslStoreDetails(this.type, this.certificate, privateKey, this.privateKeyPassword);
+		return new PemSslStoreDetails(this.type, this.alias, this.password, this.certificates, privateKey,
+				this.privateKeyPassword);
 	}
 
 	/**
 	 * Return a new {@link PemSslStoreDetails} instance with a new private key password.
-	 * @param password the new private key password
+	 * @param privateKeyPassword the new private key password
 	 * @return a new {@link PemSslStoreDetails} instance
 	 */
-	public PemSslStoreDetails withPrivateKeyPassword(String password) {
-		return new PemSslStoreDetails(this.type, this.certificate, this.privateKey, password);
+	public PemSslStoreDetails withPrivateKeyPassword(String privateKeyPassword) {
+		return new PemSslStoreDetails(this.type, this.alias, this.password, this.certificates, this.privateKey,
+				privateKeyPassword);
 	}
 
 	boolean isEmpty() {
-		return isEmpty(this.type) && isEmpty(this.certificate) && isEmpty(this.privateKey);
+		return isEmpty(this.type) && isEmpty(this.certificates) && isEmpty(this.privateKey);
 	}
 
 	private boolean isEmpty(String value) {
@@ -69,12 +151,27 @@ private boolean isEmpty(String value) {
 
 	/**
 	 * Factory method to create a new {@link PemSslStoreDetails} instance for the given
-	 * certificate.
-	 * @param certificate the certificate
+	 * certificate. <b>Note:</b> This method doesn't actually check if the provided value
+	 * only contains a single certificate. It is functionally equivalent to
+	 * {@link #forCertificates(String)}.
+	 * @param certificate the certificate content (either the PEM content itself or a
+	 * reference to the resource to load)
 	 * @return a new {@link PemSslStoreDetails} instance.
 	 */
 	public static PemSslStoreDetails forCertificate(String certificate) {
-		return new PemSslStoreDetails(null, certificate, null);
+		return forCertificates(certificate);
+	}
+
+	/**
+	 * Factory method to create a new {@link PemSslStoreDetails} instance for the given
+	 * certificates.
+	 * @param certificates the certificates content (either the PEM content itself or a
+	 * reference to the resource to load)
+	 * @return a new {@link PemSslStoreDetails} instance.
+	 * @since 3.2.0
+	 */
+	public static PemSslStoreDetails forCertificates(String certificates) {
+		return new PemSslStoreDetails(null, certificates, null);
 	}
 
 }
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/system/ApplicationTemp.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/system/ApplicationTemp.java
index 9c413a0ab702..2ab9f745f574 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/system/ApplicationTemp.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/system/ApplicationTemp.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -28,6 +28,8 @@
 import java.security.MessageDigest;
 import java.util.EnumSet;
 import java.util.HexFormat;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
 
 import org.springframework.util.Assert;
 import org.springframework.util.StringUtils;
@@ -49,6 +51,8 @@ public class ApplicationTemp {
 
 	private final Class<?> sourceClass;
 
+	private final Lock pathLock = new ReentrantLock();
+
 	private volatile Path path;
 
 	/**
@@ -90,9 +94,15 @@ public File getDir(String subDir) {
 
 	private Path getPath() {
 		if (this.path == null) {
-			synchronized (this) {
-				String hash = HexFormat.of().withUpperCase().formatHex(generateHash(this.sourceClass));
-				this.path = createDirectory(getTempDirectory().resolve(hash));
+			this.pathLock.lock();
+			try {
+				if (this.path == null) {
+					String hash = HexFormat.of().withUpperCase().formatHex(generateHash(this.sourceClass));
+					this.path = createDirectory(getTempDirectory().resolve(hash));
+				}
+			}
+			finally {
+				this.pathLock.unlock();
 			}
 		}
 		return this.path;
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/system/JavaVersion.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/system/JavaVersion.java
index feda02e0099b..2c01b4ea7eb4 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/system/JavaVersion.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/system/JavaVersion.java
@@ -21,6 +21,7 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.SortedSet;
 import java.util.concurrent.Future;
 
 import org.springframework.util.ClassUtils;
@@ -37,23 +38,33 @@ public enum JavaVersion {
 
 	/**
 	 * Java 17.
+	 * @since 2.5.3
 	 */
 	SEVENTEEN("17", Console.class, "charset"),
 
 	/**
 	 * Java 18.
+	 * @since 2.5.11
 	 */
 	EIGHTEEN("18", Duration.class, "isPositive"),
 
 	/**
 	 * Java 19.
+	 * @since 2.6.12
 	 */
 	NINETEEN("19", Future.class, "state"),
 
 	/**
 	 * Java 20.
+	 * @since 2.7.13
 	 */
-	TWENTY("20", Class.class, "accessFlags");
+	TWENTY("20", Class.class, "accessFlags"),
+
+	/**
+	 * Java 21.
+	 * @since 2.7.16
+	 */
+	TWENTY_ONE("21", SortedSet.class, "getFirst");
 
 	private final String name;
 
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskExecutorBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskExecutorBuilder.java
new file mode 100644
index 000000000000..b43c8b6d0b73
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskExecutorBuilder.java
@@ -0,0 +1,218 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.task;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+import org.springframework.beans.BeanUtils;
+import org.springframework.boot.context.properties.PropertyMapper;
+import org.springframework.core.task.SimpleAsyncTaskExecutor;
+import org.springframework.core.task.TaskDecorator;
+import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
+
+/**
+ * Builder that can be used to configure and create a {@link SimpleAsyncTaskExecutor}.
+ * Provides convenience methods to set common {@link SimpleAsyncTaskExecutor} settings and
+ * register {@link #taskDecorator(TaskDecorator)}). For advanced configuration, consider
+ * using {@link SimpleAsyncTaskExecutorCustomizer}.
+ * <p>
+ * In a typical auto-configured Spring Boot application this builder is available as a
+ * bean and can be injected whenever a {@link SimpleAsyncTaskExecutor} is needed.
+ *
+ * @author Stephane Nicoll
+ * @author Filip Hrisafov
+ * @author Moritz Halbritter
+ * @since 3.2.0
+ */
+public class SimpleAsyncTaskExecutorBuilder {
+
+	private final Boolean virtualThreads;
+
+	private final String threadNamePrefix;
+
+	private final Integer concurrencyLimit;
+
+	private final TaskDecorator taskDecorator;
+
+	private final Set<SimpleAsyncTaskExecutorCustomizer> customizers;
+
+	public SimpleAsyncTaskExecutorBuilder() {
+		this(null, null, null, null, null);
+	}
+
+	private SimpleAsyncTaskExecutorBuilder(Boolean virtualThreads, String threadNamePrefix, Integer concurrencyLimit,
+			TaskDecorator taskDecorator, Set<SimpleAsyncTaskExecutorCustomizer> customizers) {
+		this.virtualThreads = virtualThreads;
+		this.threadNamePrefix = threadNamePrefix;
+		this.concurrencyLimit = concurrencyLimit;
+		this.taskDecorator = taskDecorator;
+		this.customizers = customizers;
+	}
+
+	/**
+	 * Set the prefix to use for the names of newly created threads.
+	 * @param threadNamePrefix the thread name prefix to set
+	 * @return a new builder instance
+	 */
+	public SimpleAsyncTaskExecutorBuilder threadNamePrefix(String threadNamePrefix) {
+		return new SimpleAsyncTaskExecutorBuilder(this.virtualThreads, threadNamePrefix, this.concurrencyLimit,
+				this.taskDecorator, this.customizers);
+	}
+
+	/**
+	 * Set whether to use virtual threads.
+	 * @param virtualThreads whether to use virtual threads
+	 * @return a new builder instance
+	 */
+	public SimpleAsyncTaskExecutorBuilder virtualThreads(Boolean virtualThreads) {
+		return new SimpleAsyncTaskExecutorBuilder(virtualThreads, this.threadNamePrefix, this.concurrencyLimit,
+				this.taskDecorator, this.customizers);
+	}
+
+	/**
+	 * Set the concurrency limit.
+	 * @param concurrencyLimit the concurrency limit
+	 * @return a new builder instance
+	 */
+	public SimpleAsyncTaskExecutorBuilder concurrencyLimit(Integer concurrencyLimit) {
+		return new SimpleAsyncTaskExecutorBuilder(this.virtualThreads, this.threadNamePrefix, concurrencyLimit,
+				this.taskDecorator, this.customizers);
+	}
+
+	/**
+	 * Set the {@link TaskDecorator} to use or {@code null} to not use any.
+	 * @param taskDecorator the task decorator to use
+	 * @return a new builder instance
+	 */
+	public SimpleAsyncTaskExecutorBuilder taskDecorator(TaskDecorator taskDecorator) {
+		return new SimpleAsyncTaskExecutorBuilder(this.virtualThreads, this.threadNamePrefix, this.concurrencyLimit,
+				taskDecorator, this.customizers);
+	}
+
+	/**
+	 * Set the {@link SimpleAsyncTaskExecutorCustomizer customizers} that should be
+	 * applied to the {@link SimpleAsyncTaskExecutor}. Customizers are applied in the
+	 * order that they were added after builder configuration has been applied. Setting
+	 * this value will replace any previously configured customizers.
+	 * @param customizers the customizers to set
+	 * @return a new builder instance
+	 * @see #additionalCustomizers(SimpleAsyncTaskExecutorCustomizer...)
+	 */
+	public SimpleAsyncTaskExecutorBuilder customizers(SimpleAsyncTaskExecutorCustomizer... customizers) {
+		Assert.notNull(customizers, "Customizers must not be null");
+		return customizers(Arrays.asList(customizers));
+	}
+
+	/**
+	 * Set the {@link SimpleAsyncTaskExecutorCustomizer customizers} that should be
+	 * applied to the {@link SimpleAsyncTaskExecutor}. Customizers are applied in the
+	 * order that they were added after builder configuration has been applied. Setting
+	 * this value will replace any previously configured customizers.
+	 * @param customizers the customizers to set
+	 * @return a new builder instance
+	 * @see #additionalCustomizers(Iterable)
+	 */
+	public SimpleAsyncTaskExecutorBuilder customizers(
+			Iterable<? extends SimpleAsyncTaskExecutorCustomizer> customizers) {
+		Assert.notNull(customizers, "Customizers must not be null");
+		return new SimpleAsyncTaskExecutorBuilder(this.virtualThreads, this.threadNamePrefix, this.concurrencyLimit,
+				this.taskDecorator, append(null, customizers));
+	}
+
+	/**
+	 * Add {@link SimpleAsyncTaskExecutorCustomizer customizers} that should be applied to
+	 * the {@link SimpleAsyncTaskExecutor}. Customizers are applied in the order that they
+	 * were added after builder configuration has been applied.
+	 * @param customizers the customizers to add
+	 * @return a new builder instance
+	 * @see #customizers(SimpleAsyncTaskExecutorCustomizer...)
+	 */
+	public SimpleAsyncTaskExecutorBuilder additionalCustomizers(SimpleAsyncTaskExecutorCustomizer... customizers) {
+		Assert.notNull(customizers, "Customizers must not be null");
+		return additionalCustomizers(Arrays.asList(customizers));
+	}
+
+	/**
+	 * Add {@link SimpleAsyncTaskExecutorCustomizer customizers} that should be applied to
+	 * the {@link SimpleAsyncTaskExecutor}. Customizers are applied in the order that they
+	 * were added after builder configuration has been applied.
+	 * @param customizers the customizers to add
+	 * @return a new builder instance
+	 * @see #customizers(Iterable)
+	 */
+	public SimpleAsyncTaskExecutorBuilder additionalCustomizers(
+			Iterable<? extends SimpleAsyncTaskExecutorCustomizer> customizers) {
+		Assert.notNull(customizers, "Customizers must not be null");
+		return new SimpleAsyncTaskExecutorBuilder(this.virtualThreads, this.threadNamePrefix, this.concurrencyLimit,
+				this.taskDecorator, append(this.customizers, customizers));
+	}
+
+	/**
+	 * Build a new {@link SimpleAsyncTaskExecutor} instance and configure it using this
+	 * builder.
+	 * @return a configured {@link SimpleAsyncTaskExecutor} instance.
+	 * @see #build(Class)
+	 * @see #configure(SimpleAsyncTaskExecutor)
+	 */
+	public SimpleAsyncTaskExecutor build() {
+		return configure(new SimpleAsyncTaskExecutor());
+	}
+
+	/**
+	 * Build a new {@link SimpleAsyncTaskExecutor} instance of the specified type and
+	 * configure it using this builder.
+	 * @param <T> the type of task executor
+	 * @param taskExecutorClass the template type to create
+	 * @return a configured {@link SimpleAsyncTaskExecutor} instance.
+	 * @see #build()
+	 * @see #configure(SimpleAsyncTaskExecutor)
+	 */
+	public <T extends SimpleAsyncTaskExecutor> T build(Class<T> taskExecutorClass) {
+		return configure(BeanUtils.instantiateClass(taskExecutorClass));
+	}
+
+	/**
+	 * Configure the provided {@link SimpleAsyncTaskExecutor} instance using this builder.
+	 * @param <T> the type of task executor
+	 * @param taskExecutor the {@link SimpleAsyncTaskExecutor} to configure
+	 * @return the task executor instance
+	 * @see #build()
+	 * @see #build(Class)
+	 */
+	public <T extends SimpleAsyncTaskExecutor> T configure(T taskExecutor) {
+		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+		map.from(this.virtualThreads).to(taskExecutor::setVirtualThreads);
+		map.from(this.threadNamePrefix).whenHasText().to(taskExecutor::setThreadNamePrefix);
+		map.from(this.concurrencyLimit).to(taskExecutor::setConcurrencyLimit);
+		map.from(this.taskDecorator).to(taskExecutor::setTaskDecorator);
+		if (!CollectionUtils.isEmpty(this.customizers)) {
+			this.customizers.forEach((customizer) -> customizer.customize(taskExecutor));
+		}
+		return taskExecutor;
+	}
+
+	private <T> Set<T> append(Set<T> set, Iterable<? extends T> additions) {
+		Set<T> result = new LinkedHashSet<>((set != null) ? set : Collections.emptySet());
+		additions.forEach(result::add);
+		return Collections.unmodifiableSet(result);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskExecutorCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskExecutorCustomizer.java
new file mode 100644
index 000000000000..0f4218ecb4bd
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskExecutorCustomizer.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.task;
+
+import org.springframework.core.task.SimpleAsyncTaskExecutor;
+
+/**
+ * Callback interface that can be used to customize a {@link SimpleAsyncTaskExecutor}.
+ *
+ * @author Stephane Nicoll
+ * @author Moritz Halbritter
+ * @since 3.2.0
+ * @see SimpleAsyncTaskExecutorBuilder
+ */
+@FunctionalInterface
+public interface SimpleAsyncTaskExecutorCustomizer {
+
+	/**
+	 * Callback to customize a {@link SimpleAsyncTaskExecutor} instance.
+	 * @param taskExecutor the task executor to customize
+	 */
+	void customize(SimpleAsyncTaskExecutor taskExecutor);
+
+}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskSchedulerBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskSchedulerBuilder.java
new file mode 100644
index 000000000000..e5dab60b1e80
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskSchedulerBuilder.java
@@ -0,0 +1,186 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.task;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+import org.springframework.boot.context.properties.PropertyMapper;
+import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler;
+import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
+
+/**
+ * Builder that can be used to configure and create a {@link SimpleAsyncTaskScheduler}.
+ * Provides convenience methods to set common {@link SimpleAsyncTaskScheduler} settings.
+ * For advanced configuration, consider using {@link SimpleAsyncTaskSchedulerCustomizer}.
+ * <p>
+ * In a typical auto-configured Spring Boot application this builder is available as a
+ * bean and can be injected whenever a {@link SimpleAsyncTaskScheduler} is needed.
+ *
+ * @author Stephane Nicoll
+ * @author Moritz Halbritter
+ * @since 3.2.0
+ */
+public class SimpleAsyncTaskSchedulerBuilder {
+
+	private final String threadNamePrefix;
+
+	private final Integer concurrencyLimit;
+
+	private final Boolean virtualThreads;
+
+	private final Set<SimpleAsyncTaskSchedulerCustomizer> customizers;
+
+	public SimpleAsyncTaskSchedulerBuilder() {
+		this(null, null, null, null);
+	}
+
+	private SimpleAsyncTaskSchedulerBuilder(String threadNamePrefix, Integer concurrencyLimit, Boolean virtualThreads,
+			Set<SimpleAsyncTaskSchedulerCustomizer> taskSchedulerCustomizers) {
+		this.threadNamePrefix = threadNamePrefix;
+		this.concurrencyLimit = concurrencyLimit;
+		this.virtualThreads = virtualThreads;
+		this.customizers = taskSchedulerCustomizers;
+	}
+
+	/**
+	 * Set the prefix to use for the names of newly created threads.
+	 * @param threadNamePrefix the thread name prefix to set
+	 * @return a new builder instance
+	 */
+	public SimpleAsyncTaskSchedulerBuilder threadNamePrefix(String threadNamePrefix) {
+		return new SimpleAsyncTaskSchedulerBuilder(threadNamePrefix, this.concurrencyLimit, this.virtualThreads,
+				this.customizers);
+	}
+
+	/**
+	 * Set the concurrency limit.
+	 * @param concurrencyLimit the concurrency limit
+	 * @return a new builder instance
+	 */
+	public SimpleAsyncTaskSchedulerBuilder concurrencyLimit(Integer concurrencyLimit) {
+		return new SimpleAsyncTaskSchedulerBuilder(this.threadNamePrefix, concurrencyLimit, this.virtualThreads,
+				this.customizers);
+	}
+
+	/**
+	 * Set whether to use virtual threads.
+	 * @param virtualThreads whether to use virtual threads
+	 * @return a new builder instance
+	 */
+	public SimpleAsyncTaskSchedulerBuilder virtualThreads(Boolean virtualThreads) {
+		return new SimpleAsyncTaskSchedulerBuilder(this.threadNamePrefix, this.concurrencyLimit, virtualThreads,
+				this.customizers);
+	}
+
+	/**
+	 * Set the {@link SimpleAsyncTaskSchedulerCustomizer customizers} that should be
+	 * applied to the {@link SimpleAsyncTaskScheduler}. Customizers are applied in the
+	 * order that they were added after builder configuration has been applied. Setting
+	 * this value will replace any previously configured customizers.
+	 * @param customizers the customizers to set
+	 * @return a new builder instance
+	 * @see #additionalCustomizers(SimpleAsyncTaskSchedulerCustomizer...)
+	 */
+	public SimpleAsyncTaskSchedulerBuilder customizers(SimpleAsyncTaskSchedulerCustomizer... customizers) {
+		Assert.notNull(customizers, "Customizers must not be null");
+		return customizers(Arrays.asList(customizers));
+	}
+
+	/**
+	 * Set the {@link SimpleAsyncTaskSchedulerCustomizer customizers} that should be
+	 * applied to the {@link SimpleAsyncTaskScheduler}. Customizers are applied in the
+	 * order that they were added after builder configuration has been applied. Setting
+	 * this value will replace any previously configured customizers.
+	 * @param customizers the customizers to set
+	 * @return a new builder instance
+	 * @see #additionalCustomizers(Iterable)
+	 */
+	public SimpleAsyncTaskSchedulerBuilder customizers(
+			Iterable<? extends SimpleAsyncTaskSchedulerCustomizer> customizers) {
+		Assert.notNull(customizers, "Customizers must not be null");
+		return new SimpleAsyncTaskSchedulerBuilder(this.threadNamePrefix, this.concurrencyLimit, this.virtualThreads,
+				append(null, customizers));
+	}
+
+	/**
+	 * Add {@link SimpleAsyncTaskSchedulerCustomizer customizers} that should be applied
+	 * to the {@link SimpleAsyncTaskScheduler}. Customizers are applied in the order that
+	 * they were added after builder configuration has been applied.
+	 * @param customizers the customizers to add
+	 * @return a new builder instance
+	 * @see #customizers(SimpleAsyncTaskSchedulerCustomizer...)
+	 */
+	public SimpleAsyncTaskSchedulerBuilder additionalCustomizers(SimpleAsyncTaskSchedulerCustomizer... customizers) {
+		Assert.notNull(customizers, "Customizers must not be null");
+		return additionalCustomizers(Arrays.asList(customizers));
+	}
+
+	/**
+	 * Add {@link SimpleAsyncTaskSchedulerCustomizer customizers} that should be applied
+	 * to the {@link SimpleAsyncTaskScheduler}. Customizers are applied in the order that
+	 * they were added after builder configuration has been applied.
+	 * @param customizers the customizers to add
+	 * @return a new builder instance
+	 * @see #customizers(Iterable)
+	 */
+	public SimpleAsyncTaskSchedulerBuilder additionalCustomizers(
+			Iterable<? extends SimpleAsyncTaskSchedulerCustomizer> customizers) {
+		Assert.notNull(customizers, "Customizers must not be null");
+		return new SimpleAsyncTaskSchedulerBuilder(this.threadNamePrefix, this.concurrencyLimit, this.virtualThreads,
+				append(this.customizers, customizers));
+	}
+
+	/**
+	 * Build a new {@link SimpleAsyncTaskScheduler} instance and configure it using this
+	 * builder.
+	 * @return a configured {@link SimpleAsyncTaskScheduler} instance.
+	 * @see #configure(SimpleAsyncTaskScheduler)
+	 */
+	public SimpleAsyncTaskScheduler build() {
+		return configure(new SimpleAsyncTaskScheduler());
+	}
+
+	/**
+	 * Configure the provided {@link SimpleAsyncTaskScheduler} instance using this
+	 * builder.
+	 * @param <T> the type of task scheduler
+	 * @param taskScheduler the {@link SimpleAsyncTaskScheduler} to configure
+	 * @return the task scheduler instance
+	 * @see #build()
+	 */
+	public <T extends SimpleAsyncTaskScheduler> T configure(T taskScheduler) {
+		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+		map.from(this.threadNamePrefix).to(taskScheduler::setThreadNamePrefix);
+		map.from(this.concurrencyLimit).to(taskScheduler::setConcurrencyLimit);
+		map.from(this.virtualThreads).to(taskScheduler::setVirtualThreads);
+		if (!CollectionUtils.isEmpty(this.customizers)) {
+			this.customizers.forEach((customizer) -> customizer.customize(taskScheduler));
+		}
+		return taskScheduler;
+	}
+
+	private <T> Set<T> append(Set<T> set, Iterable<? extends T> additions) {
+		Set<T> result = new LinkedHashSet<>((set != null) ? set : Collections.emptySet());
+		additions.forEach(result::add);
+		return Collections.unmodifiableSet(result);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskSchedulerCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskSchedulerCustomizer.java
new file mode 100644
index 000000000000..e66c627327d4
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskSchedulerCustomizer.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.task;
+
+import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler;
+
+/**
+ * Callback interface that can be used to customize a {@link SimpleAsyncTaskScheduler}.
+ *
+ * @author Moritz Halbritter
+ * @since 3.2.0
+ */
+@FunctionalInterface
+public interface SimpleAsyncTaskSchedulerCustomizer {
+
+	/**
+	 * Callback to customize a {@link SimpleAsyncTaskScheduler} instance.
+	 * @param taskScheduler the task scheduler to customize
+	 */
+	void customize(SimpleAsyncTaskScheduler taskScheduler);
+
+}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskExecutorBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskExecutorBuilder.java
index 34b45a83176b..304b9ce9320c 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskExecutorBuilder.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskExecutorBuilder.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -42,7 +42,11 @@
  * @author Stephane Nicoll
  * @author Filip Hrisafov
  * @since 2.1.0
+ * @deprecated since 3.2.0 for removal in 3.4.0 in favor of
+ * {@link ThreadPoolTaskExecutorBuilder}
  */
+@Deprecated(since = "3.2.0", forRemoval = true)
+@SuppressWarnings("removal")
 public class TaskExecutorBuilder {
 
 	private final Integer queueCapacity;
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskExecutorCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskExecutorCustomizer.java
index 4ceed9047b02..0ff969caaba7 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskExecutorCustomizer.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskExecutorCustomizer.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -24,8 +24,11 @@
  * @author Stephane Nicoll
  * @since 2.1.0
  * @see TaskExecutorBuilder
+ * @deprecated since 3.2.0 for removal in 3.4.0 in favor of
+ * {@link ThreadPoolTaskExecutorCustomizer}
  */
 @FunctionalInterface
+@Deprecated(since = "3.2.0", forRemoval = true)
 public interface TaskExecutorCustomizer {
 
 	/**
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskSchedulerBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskSchedulerBuilder.java
index 9ec2c3e6aeef..65c385d0c5a3 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskSchedulerBuilder.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskSchedulerBuilder.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -38,7 +38,11 @@
  *
  * @author Stephane Nicoll
  * @since 2.1.0
+ * @deprecated since 3.2.0 for removal in 3.4.0 in favor of
+ * {@link ThreadPoolTaskSchedulerBuilder}
  */
+@Deprecated(since = "3.2.0", forRemoval = true)
+@SuppressWarnings("removal")
 public class TaskSchedulerBuilder {
 
 	private final Integer poolSize;
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskSchedulerCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskSchedulerCustomizer.java
index 7c5252c68669..8acf391a42a8 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskSchedulerCustomizer.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskSchedulerCustomizer.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -23,8 +23,11 @@
  *
  * @author Stephane Nicoll
  * @since 2.1.0
+ * @deprecated since 3.2.0 for removal in 3.4.0 in favor of
+ * {@link ThreadPoolTaskSchedulerCustomizer}
  */
 @FunctionalInterface
+@Deprecated(since = "3.2.0", forRemoval = true)
 public interface TaskSchedulerCustomizer {
 
 	/**
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskExecutorBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskExecutorBuilder.java
new file mode 100644
index 000000000000..2609245832ad
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskExecutorBuilder.java
@@ -0,0 +1,326 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.task;
+
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+import org.springframework.beans.BeanUtils;
+import org.springframework.boot.context.properties.PropertyMapper;
+import org.springframework.core.task.TaskDecorator;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
+
+/**
+ * Builder that can be used to configure and create a {@link ThreadPoolTaskExecutor}.
+ * Provides convenience methods to set common {@link ThreadPoolTaskExecutor} settings and
+ * register {@link #taskDecorator(TaskDecorator)}). For advanced configuration, consider
+ * using {@link ThreadPoolTaskExecutorCustomizer}.
+ * <p>
+ * In a typical auto-configured Spring Boot application this builder is available as a
+ * bean and can be injected whenever a {@link ThreadPoolTaskExecutor} is needed.
+ *
+ * @author Stephane Nicoll
+ * @author Filip Hrisafov
+ * @since 3.2.0
+ */
+public class ThreadPoolTaskExecutorBuilder {
+
+	private final Integer queueCapacity;
+
+	private final Integer corePoolSize;
+
+	private final Integer maxPoolSize;
+
+	private final Boolean allowCoreThreadTimeOut;
+
+	private final Duration keepAlive;
+
+	private final Boolean awaitTermination;
+
+	private final Duration awaitTerminationPeriod;
+
+	private final String threadNamePrefix;
+
+	private final TaskDecorator taskDecorator;
+
+	private final Set<ThreadPoolTaskExecutorCustomizer> customizers;
+
+	public ThreadPoolTaskExecutorBuilder() {
+		this.queueCapacity = null;
+		this.corePoolSize = null;
+		this.maxPoolSize = null;
+		this.allowCoreThreadTimeOut = null;
+		this.keepAlive = null;
+		this.awaitTermination = null;
+		this.awaitTerminationPeriod = null;
+		this.threadNamePrefix = null;
+		this.taskDecorator = null;
+		this.customizers = null;
+	}
+
+	private ThreadPoolTaskExecutorBuilder(Integer queueCapacity, Integer corePoolSize, Integer maxPoolSize,
+			Boolean allowCoreThreadTimeOut, Duration keepAlive, Boolean awaitTermination,
+			Duration awaitTerminationPeriod, String threadNamePrefix, TaskDecorator taskDecorator,
+			Set<ThreadPoolTaskExecutorCustomizer> customizers) {
+		this.queueCapacity = queueCapacity;
+		this.corePoolSize = corePoolSize;
+		this.maxPoolSize = maxPoolSize;
+		this.allowCoreThreadTimeOut = allowCoreThreadTimeOut;
+		this.keepAlive = keepAlive;
+		this.awaitTermination = awaitTermination;
+		this.awaitTerminationPeriod = awaitTerminationPeriod;
+		this.threadNamePrefix = threadNamePrefix;
+		this.taskDecorator = taskDecorator;
+		this.customizers = customizers;
+	}
+
+	/**
+	 * Set the capacity of the queue. An unbounded capacity does not increase the pool and
+	 * therefore ignores {@link #maxPoolSize(int) maxPoolSize}.
+	 * @param queueCapacity the queue capacity to set
+	 * @return a new builder instance
+	 */
+	public ThreadPoolTaskExecutorBuilder queueCapacity(int queueCapacity) {
+		return new ThreadPoolTaskExecutorBuilder(queueCapacity, this.corePoolSize, this.maxPoolSize,
+				this.allowCoreThreadTimeOut, this.keepAlive, this.awaitTermination, this.awaitTerminationPeriod,
+				this.threadNamePrefix, this.taskDecorator, this.customizers);
+	}
+
+	/**
+	 * Set the core number of threads. Effectively that maximum number of threads as long
+	 * as the queue is not full.
+	 * <p>
+	 * Core threads can grow and shrink if {@link #allowCoreThreadTimeOut(boolean)} is
+	 * enabled.
+	 * @param corePoolSize the core pool size to set
+	 * @return a new builder instance
+	 */
+	public ThreadPoolTaskExecutorBuilder corePoolSize(int corePoolSize) {
+		return new ThreadPoolTaskExecutorBuilder(this.queueCapacity, corePoolSize, this.maxPoolSize,
+				this.allowCoreThreadTimeOut, this.keepAlive, this.awaitTermination, this.awaitTerminationPeriod,
+				this.threadNamePrefix, this.taskDecorator, this.customizers);
+	}
+
+	/**
+	 * Set the maximum allowed number of threads. When the {@link #queueCapacity(int)
+	 * queue} is full, the pool can expand up to that size to accommodate the load.
+	 * <p>
+	 * If the {@link #queueCapacity(int) queue capacity} is unbounded, this setting is
+	 * ignored.
+	 * @param maxPoolSize the max pool size to set
+	 * @return a new builder instance
+	 */
+	public ThreadPoolTaskExecutorBuilder maxPoolSize(int maxPoolSize) {
+		return new ThreadPoolTaskExecutorBuilder(this.queueCapacity, this.corePoolSize, maxPoolSize,
+				this.allowCoreThreadTimeOut, this.keepAlive, this.awaitTermination, this.awaitTerminationPeriod,
+				this.threadNamePrefix, this.taskDecorator, this.customizers);
+	}
+
+	/**
+	 * Set whether core threads are allowed to time out. When enabled, this enables
+	 * dynamic growing and shrinking of the pool.
+	 * @param allowCoreThreadTimeOut if core threads are allowed to time out
+	 * @return a new builder instance
+	 */
+	public ThreadPoolTaskExecutorBuilder allowCoreThreadTimeOut(boolean allowCoreThreadTimeOut) {
+		return new ThreadPoolTaskExecutorBuilder(this.queueCapacity, this.corePoolSize, this.maxPoolSize,
+				allowCoreThreadTimeOut, this.keepAlive, this.awaitTermination, this.awaitTerminationPeriod,
+				this.threadNamePrefix, this.taskDecorator, this.customizers);
+	}
+
+	/**
+	 * Set the time limit for which threads may remain idle before being terminated.
+	 * @param keepAlive the keep alive to set
+	 * @return a new builder instance
+	 */
+	public ThreadPoolTaskExecutorBuilder keepAlive(Duration keepAlive) {
+		return new ThreadPoolTaskExecutorBuilder(this.queueCapacity, this.corePoolSize, this.maxPoolSize,
+				this.allowCoreThreadTimeOut, keepAlive, this.awaitTermination, this.awaitTerminationPeriod,
+				this.threadNamePrefix, this.taskDecorator, this.customizers);
+	}
+
+	/**
+	 * Set whether the executor should wait for scheduled tasks to complete on shutdown,
+	 * not interrupting running tasks and executing all tasks in the queue.
+	 * @param awaitTermination whether the executor needs to wait for the tasks to
+	 * complete on shutdown
+	 * @return a new builder instance
+	 * @see #awaitTerminationPeriod(Duration)
+	 */
+	public ThreadPoolTaskExecutorBuilder awaitTermination(boolean awaitTermination) {
+		return new ThreadPoolTaskExecutorBuilder(this.queueCapacity, this.corePoolSize, this.maxPoolSize,
+				this.allowCoreThreadTimeOut, this.keepAlive, awaitTermination, this.awaitTerminationPeriod,
+				this.threadNamePrefix, this.taskDecorator, this.customizers);
+	}
+
+	/**
+	 * Set the maximum time the executor is supposed to block on shutdown. When set, the
+	 * executor blocks on shutdown in order to wait for remaining tasks to complete their
+	 * execution before the rest of the container continues to shut down. This is
+	 * particularly useful if your remaining tasks are likely to need access to other
+	 * resources that are also managed by the container.
+	 * @param awaitTerminationPeriod the await termination period to set
+	 * @return a new builder instance
+	 */
+	public ThreadPoolTaskExecutorBuilder awaitTerminationPeriod(Duration awaitTerminationPeriod) {
+		return new ThreadPoolTaskExecutorBuilder(this.queueCapacity, this.corePoolSize, this.maxPoolSize,
+				this.allowCoreThreadTimeOut, this.keepAlive, this.awaitTermination, awaitTerminationPeriod,
+				this.threadNamePrefix, this.taskDecorator, this.customizers);
+	}
+
+	/**
+	 * Set the prefix to use for the names of newly created threads.
+	 * @param threadNamePrefix the thread name prefix to set
+	 * @return a new builder instance
+	 */
+	public ThreadPoolTaskExecutorBuilder threadNamePrefix(String threadNamePrefix) {
+		return new ThreadPoolTaskExecutorBuilder(this.queueCapacity, this.corePoolSize, this.maxPoolSize,
+				this.allowCoreThreadTimeOut, this.keepAlive, this.awaitTermination, this.awaitTerminationPeriod,
+				threadNamePrefix, this.taskDecorator, this.customizers);
+	}
+
+	/**
+	 * Set the {@link TaskDecorator} to use or {@code null} to not use any.
+	 * @param taskDecorator the task decorator to use
+	 * @return a new builder instance
+	 */
+	public ThreadPoolTaskExecutorBuilder taskDecorator(TaskDecorator taskDecorator) {
+		return new ThreadPoolTaskExecutorBuilder(this.queueCapacity, this.corePoolSize, this.maxPoolSize,
+				this.allowCoreThreadTimeOut, this.keepAlive, this.awaitTermination, this.awaitTerminationPeriod,
+				this.threadNamePrefix, taskDecorator, this.customizers);
+	}
+
+	/**
+	 * Set the {@link ThreadPoolTaskExecutorCustomizer ThreadPoolTaskExecutorCustomizers}
+	 * that should be applied to the {@link ThreadPoolTaskExecutor}. Customizers are
+	 * applied in the order that they were added after builder configuration has been
+	 * applied. Setting this value will replace any previously configured customizers.
+	 * @param customizers the customizers to set
+	 * @return a new builder instance
+	 * @see #additionalCustomizers(ThreadPoolTaskExecutorCustomizer...)
+	 */
+	public ThreadPoolTaskExecutorBuilder customizers(ThreadPoolTaskExecutorCustomizer... customizers) {
+		Assert.notNull(customizers, "Customizers must not be null");
+		return customizers(Arrays.asList(customizers));
+	}
+
+	/**
+	 * Set the {@link ThreadPoolTaskExecutorCustomizer ThreadPoolTaskExecutorCustomizers}
+	 * that should be applied to the {@link ThreadPoolTaskExecutor}. Customizers are
+	 * applied in the order that they were added after builder configuration has been
+	 * applied. Setting this value will replace any previously configured customizers.
+	 * @param customizers the customizers to set
+	 * @return a new builder instance
+	 * @see #additionalCustomizers(ThreadPoolTaskExecutorCustomizer...)
+	 */
+	public ThreadPoolTaskExecutorBuilder customizers(Iterable<? extends ThreadPoolTaskExecutorCustomizer> customizers) {
+		Assert.notNull(customizers, "Customizers must not be null");
+		return new ThreadPoolTaskExecutorBuilder(this.queueCapacity, this.corePoolSize, this.maxPoolSize,
+				this.allowCoreThreadTimeOut, this.keepAlive, this.awaitTermination, this.awaitTerminationPeriod,
+				this.threadNamePrefix, this.taskDecorator, append(null, customizers));
+	}
+
+	/**
+	 * Add {@link ThreadPoolTaskExecutorCustomizer ThreadPoolTaskExecutorCustomizers} that
+	 * should be applied to the {@link ThreadPoolTaskExecutor}. Customizers are applied in
+	 * the order that they were added after builder configuration has been applied.
+	 * @param customizers the customizers to add
+	 * @return a new builder instance
+	 * @see #customizers(ThreadPoolTaskExecutorCustomizer...)
+	 */
+	public ThreadPoolTaskExecutorBuilder additionalCustomizers(ThreadPoolTaskExecutorCustomizer... customizers) {
+		Assert.notNull(customizers, "Customizers must not be null");
+		return additionalCustomizers(Arrays.asList(customizers));
+	}
+
+	/**
+	 * Add {@link ThreadPoolTaskExecutorCustomizer ThreadPoolTaskExecutorCustomizers} that
+	 * should be applied to the {@link ThreadPoolTaskExecutor}. Customizers are applied in
+	 * the order that they were added after builder configuration has been applied.
+	 * @param customizers the customizers to add
+	 * @return a new builder instance
+	 * @see #customizers(ThreadPoolTaskExecutorCustomizer...)
+	 */
+	public ThreadPoolTaskExecutorBuilder additionalCustomizers(
+			Iterable<? extends ThreadPoolTaskExecutorCustomizer> customizers) {
+		Assert.notNull(customizers, "Customizers must not be null");
+		return new ThreadPoolTaskExecutorBuilder(this.queueCapacity, this.corePoolSize, this.maxPoolSize,
+				this.allowCoreThreadTimeOut, this.keepAlive, this.awaitTermination, this.awaitTerminationPeriod,
+				this.threadNamePrefix, this.taskDecorator, append(this.customizers, customizers));
+	}
+
+	/**
+	 * Build a new {@link ThreadPoolTaskExecutor} instance and configure it using this
+	 * builder.
+	 * @return a configured {@link ThreadPoolTaskExecutor} instance.
+	 * @see #build(Class)
+	 * @see #configure(ThreadPoolTaskExecutor)
+	 */
+	public ThreadPoolTaskExecutor build() {
+		return configure(new ThreadPoolTaskExecutor());
+	}
+
+	/**
+	 * Build a new {@link ThreadPoolTaskExecutor} instance of the specified type and
+	 * configure it using this builder.
+	 * @param <T> the type of task executor
+	 * @param taskExecutorClass the template type to create
+	 * @return a configured {@link ThreadPoolTaskExecutor} instance.
+	 * @see #build()
+	 * @see #configure(ThreadPoolTaskExecutor)
+	 */
+	public <T extends ThreadPoolTaskExecutor> T build(Class<T> taskExecutorClass) {
+		return configure(BeanUtils.instantiateClass(taskExecutorClass));
+	}
+
+	/**
+	 * Configure the provided {@link ThreadPoolTaskExecutor} instance using this builder.
+	 * @param <T> the type of task executor
+	 * @param taskExecutor the {@link ThreadPoolTaskExecutor} to configure
+	 * @return the task executor instance
+	 * @see #build()
+	 * @see #build(Class)
+	 */
+	public <T extends ThreadPoolTaskExecutor> T configure(T taskExecutor) {
+		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+		map.from(this.queueCapacity).to(taskExecutor::setQueueCapacity);
+		map.from(this.corePoolSize).to(taskExecutor::setCorePoolSize);
+		map.from(this.maxPoolSize).to(taskExecutor::setMaxPoolSize);
+		map.from(this.keepAlive).asInt(Duration::getSeconds).to(taskExecutor::setKeepAliveSeconds);
+		map.from(this.allowCoreThreadTimeOut).to(taskExecutor::setAllowCoreThreadTimeOut);
+		map.from(this.awaitTermination).to(taskExecutor::setWaitForTasksToCompleteOnShutdown);
+		map.from(this.awaitTerminationPeriod).as(Duration::toMillis).to(taskExecutor::setAwaitTerminationMillis);
+		map.from(this.threadNamePrefix).whenHasText().to(taskExecutor::setThreadNamePrefix);
+		map.from(this.taskDecorator).to(taskExecutor::setTaskDecorator);
+		if (!CollectionUtils.isEmpty(this.customizers)) {
+			this.customizers.forEach((customizer) -> customizer.customize(taskExecutor));
+		}
+		return taskExecutor;
+	}
+
+	private <T> Set<T> append(Set<T> set, Iterable<? extends T> additions) {
+		Set<T> result = new LinkedHashSet<>((set != null) ? set : Collections.emptySet());
+		additions.forEach(result::add);
+		return Collections.unmodifiableSet(result);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskExecutorCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskExecutorCustomizer.java
new file mode 100644
index 000000000000..c81c5bfe7985
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskExecutorCustomizer.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.task;
+
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+/**
+ * Callback interface that can be used to customize a {@link ThreadPoolTaskExecutor}.
+ *
+ * @author Stephane Nicoll
+ * @since 3.2.0
+ * @see ThreadPoolTaskExecutorBuilder
+ */
+@FunctionalInterface
+public interface ThreadPoolTaskExecutorCustomizer {
+
+	/**
+	 * Callback to customize a {@link ThreadPoolTaskExecutor} instance.
+	 * @param taskExecutor the task executor to customize
+	 */
+	void customize(ThreadPoolTaskExecutor taskExecutor);
+
+}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskSchedulerBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskSchedulerBuilder.java
new file mode 100644
index 000000000000..a36e48308ee4
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskSchedulerBuilder.java
@@ -0,0 +1,214 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.task;
+
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+import org.springframework.boot.context.properties.PropertyMapper;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
+import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
+
+/**
+ * Builder that can be used to configure and create a {@link ThreadPoolTaskScheduler}.
+ * Provides convenience methods to set common {@link ThreadPoolTaskScheduler} settings.
+ * For advanced configuration, consider using {@link ThreadPoolTaskSchedulerCustomizer}.
+ * <p>
+ * In a typical auto-configured Spring Boot application this builder is available as a
+ * bean and can be injected whenever a {@link ThreadPoolTaskScheduler} is needed.
+ *
+ * @author Stephane Nicoll
+ * @since 3.2.0
+ */
+public class ThreadPoolTaskSchedulerBuilder {
+
+	private final Integer poolSize;
+
+	private final Boolean awaitTermination;
+
+	private final Duration awaitTerminationPeriod;
+
+	private final String threadNamePrefix;
+
+	private final Set<ThreadPoolTaskSchedulerCustomizer> customizers;
+
+	public ThreadPoolTaskSchedulerBuilder() {
+		this.poolSize = null;
+		this.awaitTermination = null;
+		this.awaitTerminationPeriod = null;
+		this.threadNamePrefix = null;
+		this.customizers = null;
+	}
+
+	public ThreadPoolTaskSchedulerBuilder(Integer poolSize, Boolean awaitTermination, Duration awaitTerminationPeriod,
+			String threadNamePrefix, Set<ThreadPoolTaskSchedulerCustomizer> taskSchedulerCustomizers) {
+		this.poolSize = poolSize;
+		this.awaitTermination = awaitTermination;
+		this.awaitTerminationPeriod = awaitTerminationPeriod;
+		this.threadNamePrefix = threadNamePrefix;
+		this.customizers = taskSchedulerCustomizers;
+	}
+
+	/**
+	 * Set the maximum allowed number of threads.
+	 * @param poolSize the pool size to set
+	 * @return a new builder instance
+	 */
+	public ThreadPoolTaskSchedulerBuilder poolSize(int poolSize) {
+		return new ThreadPoolTaskSchedulerBuilder(poolSize, this.awaitTermination, this.awaitTerminationPeriod,
+				this.threadNamePrefix, this.customizers);
+	}
+
+	/**
+	 * Set whether the executor should wait for scheduled tasks to complete on shutdown,
+	 * not interrupting running tasks and executing all tasks in the queue.
+	 * @param awaitTermination whether the executor needs to wait for the tasks to
+	 * complete on shutdown
+	 * @return a new builder instance
+	 * @see #awaitTerminationPeriod(Duration)
+	 */
+	public ThreadPoolTaskSchedulerBuilder awaitTermination(boolean awaitTermination) {
+		return new ThreadPoolTaskSchedulerBuilder(this.poolSize, awaitTermination, this.awaitTerminationPeriod,
+				this.threadNamePrefix, this.customizers);
+	}
+
+	/**
+	 * Set the maximum time the executor is supposed to block on shutdown. When set, the
+	 * executor blocks on shutdown in order to wait for remaining tasks to complete their
+	 * execution before the rest of the container continues to shut down. This is
+	 * particularly useful if your remaining tasks are likely to need access to other
+	 * resources that are also managed by the container.
+	 * @param awaitTerminationPeriod the await termination period to set
+	 * @return a new builder instance
+	 */
+	public ThreadPoolTaskSchedulerBuilder awaitTerminationPeriod(Duration awaitTerminationPeriod) {
+		return new ThreadPoolTaskSchedulerBuilder(this.poolSize, this.awaitTermination, awaitTerminationPeriod,
+				this.threadNamePrefix, this.customizers);
+	}
+
+	/**
+	 * Set the prefix to use for the names of newly created threads.
+	 * @param threadNamePrefix the thread name prefix to set
+	 * @return a new builder instance
+	 */
+	public ThreadPoolTaskSchedulerBuilder threadNamePrefix(String threadNamePrefix) {
+		return new ThreadPoolTaskSchedulerBuilder(this.poolSize, this.awaitTermination, this.awaitTerminationPeriod,
+				threadNamePrefix, this.customizers);
+	}
+
+	/**
+	 * Set the {@link ThreadPoolTaskSchedulerCustomizer
+	 * threadPoolTaskSchedulerCustomizers} that should be applied to the
+	 * {@link ThreadPoolTaskScheduler}. Customizers are applied in the order that they
+	 * were added after builder configuration has been applied. Setting this value will
+	 * replace any previously configured customizers.
+	 * @param customizers the customizers to set
+	 * @return a new builder instance
+	 * @see #additionalCustomizers(ThreadPoolTaskSchedulerCustomizer...)
+	 */
+	public ThreadPoolTaskSchedulerBuilder customizers(ThreadPoolTaskSchedulerCustomizer... customizers) {
+		Assert.notNull(customizers, "Customizers must not be null");
+		return customizers(Arrays.asList(customizers));
+	}
+
+	/**
+	 * Set the {@link ThreadPoolTaskSchedulerCustomizer
+	 * threadPoolTaskSchedulerCustomizers} that should be applied to the
+	 * {@link ThreadPoolTaskScheduler}. Customizers are applied in the order that they
+	 * were added after builder configuration has been applied. Setting this value will
+	 * replace any previously configured customizers.
+	 * @param customizers the customizers to set
+	 * @return a new builder instance
+	 * @see #additionalCustomizers(ThreadPoolTaskSchedulerCustomizer...)
+	 */
+	public ThreadPoolTaskSchedulerBuilder customizers(
+			Iterable<? extends ThreadPoolTaskSchedulerCustomizer> customizers) {
+		Assert.notNull(customizers, "Customizers must not be null");
+		return new ThreadPoolTaskSchedulerBuilder(this.poolSize, this.awaitTermination, this.awaitTerminationPeriod,
+				this.threadNamePrefix, append(null, customizers));
+	}
+
+	/**
+	 * Add {@link ThreadPoolTaskSchedulerCustomizer threadPoolTaskSchedulerCustomizers}
+	 * that should be applied to the {@link ThreadPoolTaskScheduler}. Customizers are
+	 * applied in the order that they were added after builder configuration has been
+	 * applied.
+	 * @param customizers the customizers to add
+	 * @return a new builder instance
+	 * @see #customizers(ThreadPoolTaskSchedulerCustomizer...)
+	 */
+	public ThreadPoolTaskSchedulerBuilder additionalCustomizers(ThreadPoolTaskSchedulerCustomizer... customizers) {
+		Assert.notNull(customizers, "Customizers must not be null");
+		return additionalCustomizers(Arrays.asList(customizers));
+	}
+
+	/**
+	 * Add {@link ThreadPoolTaskSchedulerCustomizer threadPoolTaskSchedulerCustomizers}
+	 * that should be applied to the {@link ThreadPoolTaskScheduler}. Customizers are
+	 * applied in the order that they were added after builder configuration has been
+	 * applied.
+	 * @param customizers the customizers to add
+	 * @return a new builder instance
+	 * @see #customizers(ThreadPoolTaskSchedulerCustomizer...)
+	 */
+	public ThreadPoolTaskSchedulerBuilder additionalCustomizers(
+			Iterable<? extends ThreadPoolTaskSchedulerCustomizer> customizers) {
+		Assert.notNull(customizers, "Customizers must not be null");
+		return new ThreadPoolTaskSchedulerBuilder(this.poolSize, this.awaitTermination, this.awaitTerminationPeriod,
+				this.threadNamePrefix, append(this.customizers, customizers));
+	}
+
+	/**
+	 * Build a new {@link ThreadPoolTaskScheduler} instance and configure it using this
+	 * builder.
+	 * @return a configured {@link ThreadPoolTaskScheduler} instance.
+	 * @see #configure(ThreadPoolTaskScheduler)
+	 */
+	public ThreadPoolTaskScheduler build() {
+		return configure(new ThreadPoolTaskScheduler());
+	}
+
+	/**
+	 * Configure the provided {@link ThreadPoolTaskScheduler} instance using this builder.
+	 * @param <T> the type of task scheduler
+	 * @param taskScheduler the {@link ThreadPoolTaskScheduler} to configure
+	 * @return the task scheduler instance
+	 * @see #build()
+	 */
+	public <T extends ThreadPoolTaskScheduler> T configure(T taskScheduler) {
+		PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+		map.from(this.poolSize).to(taskScheduler::setPoolSize);
+		map.from(this.awaitTermination).to(taskScheduler::setWaitForTasksToCompleteOnShutdown);
+		map.from(this.awaitTerminationPeriod).asInt(Duration::getSeconds).to(taskScheduler::setAwaitTerminationSeconds);
+		map.from(this.threadNamePrefix).to(taskScheduler::setThreadNamePrefix);
+		if (!CollectionUtils.isEmpty(this.customizers)) {
+			this.customizers.forEach((customizer) -> customizer.customize(taskScheduler));
+		}
+		return taskScheduler;
+	}
+
+	private <T> Set<T> append(Set<T> set, Iterable<? extends T> additions) {
+		Set<T> result = new LinkedHashSet<>((set != null) ? set : Collections.emptySet());
+		additions.forEach(result::add);
+		return Collections.unmodifiableSet(result);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskSchedulerCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskSchedulerCustomizer.java
new file mode 100644
index 000000000000..0e7cc44458e2
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskSchedulerCustomizer.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.task;
+
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
+
+/**
+ * Callback interface that can be used to customize a {@link ThreadPoolTaskScheduler}.
+ *
+ * @author Stephane Nicoll
+ * @since 3.2.0
+ */
+@FunctionalInterface
+public interface ThreadPoolTaskSchedulerCustomizer {
+
+	/**
+	 * Callback to customize a {@link ThreadPoolTaskScheduler} instance.
+	 * @param taskScheduler the task scheduler to customize
+	 */
+	void customize(ThreadPoolTaskScheduler taskScheduler);
+
+}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/util/Instantiator.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/util/Instantiator.java
index af23e1111559..05eb183cc679 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/util/Instantiator.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/util/Instantiator.java
@@ -17,7 +17,6 @@
 package org.springframework.boot.util;
 
 import java.lang.reflect.Constructor;
-import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
@@ -28,7 +27,6 @@
 import java.util.function.Consumer;
 import java.util.function.Function;
 import java.util.function.Supplier;
-import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 import org.springframework.core.annotation.AnnotationAwareOrderComparator;
@@ -139,9 +137,7 @@ public List<T> instantiateTypes(Collection<Class<?>> types) {
 	}
 
 	private List<T> instantiate(Stream<TypeSupplier> typeSuppliers) {
-		List<T> instances = typeSuppliers.map(this::instantiate).collect(Collectors.toCollection(ArrayList::new));
-		AnnotationAwareOrderComparator.sort(instances);
-		return Collections.unmodifiableList(instances);
+		return typeSuppliers.map(this::instantiate).sorted(AnnotationAwareOrderComparator.INSTANCE).toList();
 	}
 
 	private T instantiate(TypeSupplier typeSupplier) {
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactories.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactories.java
index a350a9092223..9f4aa4475487 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactories.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactories.java
@@ -26,6 +26,7 @@
 import java.util.function.Supplier;
 
 import javax.net.ssl.HttpsURLConnection;
+import javax.net.ssl.SSLContext;
 import javax.net.ssl.SSLSocketFactory;
 import javax.net.ssl.TrustManager;
 import javax.net.ssl.X509TrustManager;
@@ -38,6 +39,9 @@
 import org.apache.hc.client5.http.ssl.DefaultHostnameVerifier;
 import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
 import org.apache.hc.core5.http.io.SocketConfig;
+import org.eclipse.jetty.client.transport.HttpClientTransportDynamic;
+import org.eclipse.jetty.io.ClientConnector;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
 
 import org.springframework.boot.context.properties.PropertyMapper;
 import org.springframework.boot.ssl.SslBundle;
@@ -45,6 +49,8 @@
 import org.springframework.http.client.AbstractClientHttpRequestFactoryWrapper;
 import org.springframework.http.client.ClientHttpRequestFactory;
 import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
+import org.springframework.http.client.JdkClientHttpRequestFactory;
+import org.springframework.http.client.JettyClientHttpRequestFactory;
 import org.springframework.http.client.OkHttp3ClientHttpRequestFactory;
 import org.springframework.http.client.SimpleClientHttpRequestFactory;
 import org.springframework.util.Assert;
@@ -70,6 +76,10 @@ public final class ClientHttpRequestFactories {
 
 	private static final boolean OKHTTP_CLIENT_PRESENT = ClassUtils.isPresent(OKHTTP_CLIENT_CLASS, null);
 
+	static final String JETTY_CLIENT_CLASS = "org.eclipse.jetty.client.HttpClient";
+
+	private static final boolean JETTY_CLIENT_PRESENT = ClassUtils.isPresent(JETTY_CLIENT_CLASS, null);
+
 	private ClientHttpRequestFactories() {
 	}
 
@@ -79,17 +89,22 @@ private ClientHttpRequestFactories() {
 	 * dependencies {@link ClassUtils#isPresent are available} is returned:
 	 * <ol>
 	 * <li>{@link HttpComponentsClientHttpRequestFactory}</li>
-	 * <li>{@link OkHttp3ClientHttpRequestFactory}</li>
+	 * <li>{@link JettyClientHttpRequestFactory}</li>
+	 * <li>{@link OkHttp3ClientHttpRequestFactory} (deprecated)</li>
 	 * <li>{@link SimpleClientHttpRequestFactory}</li>
 	 * </ol>
 	 * @param settings the settings to apply
 	 * @return a new {@link ClientHttpRequestFactory}
 	 */
+	@SuppressWarnings("removal")
 	public static ClientHttpRequestFactory get(ClientHttpRequestFactorySettings settings) {
 		Assert.notNull(settings, "Settings must not be null");
 		if (APACHE_HTTP_CLIENT_PRESENT) {
 			return HttpComponents.get(settings);
 		}
+		if (JETTY_CLIENT_PRESENT) {
+			return Jetty.get(settings);
+		}
 		if (OKHTTP_CLIENT_PRESENT) {
 			return OkHttp.get(settings);
 		}
@@ -103,7 +118,9 @@ public static ClientHttpRequestFactory get(ClientHttpRequestFactorySettings sett
 	 * use of reflection:
 	 * <ul>
 	 * <li>{@link HttpComponentsClientHttpRequestFactory}</li>
-	 * <li>{@link OkHttp3ClientHttpRequestFactory}</li>
+	 * <li>{@link JdkClientHttpRequestFactory}</li>
+	 * <li>{@link JettyClientHttpRequestFactory}</li>
+	 * <li>{@link OkHttp3ClientHttpRequestFactory} (deprecated)</li>
 	 * <li>{@link SimpleClientHttpRequestFactory}</li>
 	 * </ul>
 	 * A {@code requestFactoryType} of {@link ClientHttpRequestFactory} is equivalent to
@@ -113,7 +130,7 @@ public static ClientHttpRequestFactory get(ClientHttpRequestFactorySettings sett
 	 * @param settings the settings to apply
 	 * @return a new {@link ClientHttpRequestFactory} instance
 	 */
-	@SuppressWarnings("unchecked")
+	@SuppressWarnings({ "unchecked", "removal" })
 	public static <T extends ClientHttpRequestFactory> T get(Class<T> requestFactoryType,
 			ClientHttpRequestFactorySettings settings) {
 		Assert.notNull(settings, "Settings must not be null");
@@ -123,12 +140,18 @@ public static <T extends ClientHttpRequestFactory> T get(Class<T> requestFactory
 		if (requestFactoryType == HttpComponentsClientHttpRequestFactory.class) {
 			return (T) HttpComponents.get(settings);
 		}
-		if (requestFactoryType == OkHttp3ClientHttpRequestFactory.class) {
-			return (T) OkHttp.get(settings);
+		if (requestFactoryType == JettyClientHttpRequestFactory.class) {
+			return (T) Jetty.get(settings);
+		}
+		if (requestFactoryType == JdkClientHttpRequestFactory.class) {
+			return (T) Jdk.get(settings);
 		}
 		if (requestFactoryType == SimpleClientHttpRequestFactory.class) {
 			return (T) Simple.get(settings);
 		}
+		if (requestFactoryType == OkHttp3ClientHttpRequestFactory.class) {
+			return (T) OkHttp.get(settings);
+		}
 		return get(() -> createRequestFactory(requestFactoryType), settings);
 	}
 
@@ -166,7 +189,6 @@ static HttpComponentsClientHttpRequestFactory get(ClientHttpRequestFactorySettin
 					settings.sslBundle());
 			PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
 			map.from(settings::connectTimeout).asInt(Duration::toMillis).to(requestFactory::setConnectTimeout);
-			map.from(settings::bufferRequestBody).to(requestFactory::setBufferRequestBody);
 			return requestFactory;
 		}
 
@@ -199,11 +221,11 @@ private static HttpClient createHttpClient(Duration readTimeout, SslBundle sslBu
 	/**
 	 * Support for {@link OkHttp3ClientHttpRequestFactory}.
 	 */
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	@SuppressWarnings("removal")
 	static class OkHttp {
 
 		static OkHttp3ClientHttpRequestFactory get(ClientHttpRequestFactorySettings settings) {
-			Assert.state(settings.bufferRequestBody() == null,
-					() -> "OkHttp3ClientHttpRequestFactory does not support request body buffering");
 			OkHttp3ClientHttpRequestFactory requestFactory = createRequestFactory(settings.sslBundle());
 			PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
 			map.from(settings::connectTimeout).asInt(Duration::toMillis).to(requestFactory::setConnectTimeout);
@@ -228,6 +250,61 @@ private static OkHttp3ClientHttpRequestFactory createRequestFactory(SslBundle ss
 
 	}
 
+	/**
+	 * Support for {@link JettyClientHttpRequestFactory}.
+	 */
+	static class Jetty {
+
+		static JettyClientHttpRequestFactory get(ClientHttpRequestFactorySettings settings) {
+			JettyClientHttpRequestFactory requestFactory = createRequestFactory(settings.sslBundle());
+			PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+			map.from(settings::connectTimeout).asInt(Duration::toMillis).to(requestFactory::setConnectTimeout);
+			map.from(settings::readTimeout).asInt(Duration::toMillis).to(requestFactory::setReadTimeout);
+			return requestFactory;
+		}
+
+		private static JettyClientHttpRequestFactory createRequestFactory(SslBundle sslBundle) {
+			if (sslBundle != null) {
+				SSLContext sslContext = sslBundle.createSslContext();
+				SslContextFactory.Client sslContextFactory = new SslContextFactory.Client();
+				sslContextFactory.setSslContext(sslContext);
+				ClientConnector connector = new ClientConnector();
+				connector.setSslContextFactory(sslContextFactory);
+				org.eclipse.jetty.client.HttpClient httpClient = new org.eclipse.jetty.client.HttpClient(
+						new HttpClientTransportDynamic(connector));
+				return new JettyClientHttpRequestFactory(httpClient);
+			}
+			return new JettyClientHttpRequestFactory();
+		}
+
+	}
+
+	/**
+	 * Support for {@link JdkClientHttpRequestFactory}.
+	 */
+	static class Jdk {
+
+		static JdkClientHttpRequestFactory get(ClientHttpRequestFactorySettings settings) {
+			java.net.http.HttpClient httpClient = createHttpClient(settings.connectTimeout(), settings.sslBundle());
+			JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
+			PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+			map.from(settings::readTimeout).to(requestFactory::setReadTimeout);
+			return requestFactory;
+		}
+
+		private static java.net.http.HttpClient createHttpClient(Duration connectTimeout, SslBundle sslBundle) {
+			java.net.http.HttpClient.Builder builder = java.net.http.HttpClient.newBuilder();
+			if (connectTimeout != null) {
+				builder.connectTimeout(connectTimeout);
+			}
+			if (sslBundle != null) {
+				builder.sslContext(sslBundle.createSslContext());
+			}
+			return builder.build();
+		}
+
+	}
+
 	/**
 	 * Support for {@link SimpleClientHttpRequestFactory}.
 	 */
@@ -242,7 +319,6 @@ static SimpleClientHttpRequestFactory get(ClientHttpRequestFactorySettings setti
 			PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
 			map.from(settings::readTimeout).asInt(Duration::toMillis).to(requestFactory::setReadTimeout);
 			map.from(settings::connectTimeout).asInt(Duration::toMillis).to(requestFactory::setConnectTimeout);
-			map.from(settings::bufferRequestBody).to(requestFactory::setBufferRequestBody);
 			return requestFactory;
 		}
 
@@ -290,8 +366,6 @@ private static void configure(ClientHttpRequestFactory requestFactory,
 			PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
 			map.from(settings::connectTimeout).to((connectTimeout) -> setConnectTimeout(unwrapped, connectTimeout));
 			map.from(settings::readTimeout).to((readTimeout) -> setReadTimeout(unwrapped, readTimeout));
-			map.from(settings::bufferRequestBody)
-				.to((bufferRequestBody) -> setBufferRequestBody(unwrapped, bufferRequestBody));
 		}
 
 		private static ClientHttpRequestFactory unwrapRequestFactoryIfNecessary(
@@ -321,11 +395,6 @@ private static void setReadTimeout(ClientHttpRequestFactory factory, Duration re
 			invoke(factory, method, timeout);
 		}
 
-		private static void setBufferRequestBody(ClientHttpRequestFactory factory, boolean bufferRequestBody) {
-			Method method = findMethod(factory, "setBufferRequestBody", boolean.class);
-			invoke(factory, method, bufferRequestBody);
-		}
-
 		private static Method findMethod(ClientHttpRequestFactory requestFactory, String methodName,
 				Class<?>... parameters) {
 			Method method = ReflectionUtils.findMethod(requestFactory.getClass(), methodName, parameters);
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesRuntimeHints.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesRuntimeHints.java
index c47ef109a64c..45a3744a4b82 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesRuntimeHints.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesRuntimeHints.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -28,6 +28,7 @@
 import org.springframework.http.client.AbstractClientHttpRequestFactoryWrapper;
 import org.springframework.http.client.ClientHttpRequestFactory;
 import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
+import org.springframework.http.client.JettyClientHttpRequestFactory;
 import org.springframework.http.client.OkHttp3ClientHttpRequestFactory;
 import org.springframework.http.client.SimpleClientHttpRequestFactory;
 import org.springframework.util.Assert;
@@ -55,21 +56,36 @@ private void registerHints(ReflectionHints hints, ClassLoader classLoader) {
 			typeHint.onReachableType(TypeReference.of(ClientHttpRequestFactories.APACHE_HTTP_CLIENT_CLASS));
 			registerReflectionHints(hints, HttpComponentsClientHttpRequestFactory.class);
 		});
-		hints.registerTypeIfPresent(classLoader, ClientHttpRequestFactories.OKHTTP_CLIENT_CLASS, (typeHint) -> {
-			typeHint.onReachableType(TypeReference.of(ClientHttpRequestFactories.OKHTTP_CLIENT_CLASS));
-			registerReflectionHints(hints, OkHttp3ClientHttpRequestFactory.class);
+		hints.registerTypeIfPresent(classLoader, ClientHttpRequestFactories.JETTY_CLIENT_CLASS, (typeHint) -> {
+			typeHint.onReachableType(TypeReference.of(ClientHttpRequestFactories.JETTY_CLIENT_CLASS));
+			registerReflectionHints(hints, JettyClientHttpRequestFactory.class, long.class);
 		});
 		hints.registerType(SimpleClientHttpRequestFactory.class, (typeHint) -> {
 			typeHint.onReachableType(HttpURLConnection.class);
 			registerReflectionHints(hints, SimpleClientHttpRequestFactory.class);
 		});
+		registerOkHttpHints(hints, classLoader);
+	}
+
+	@SuppressWarnings("removal")
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	private void registerOkHttpHints(ReflectionHints hints, ClassLoader classLoader) {
+		hints.registerTypeIfPresent(classLoader, ClientHttpRequestFactories.OKHTTP_CLIENT_CLASS, (typeHint) -> {
+			typeHint.onReachableType(TypeReference.of(ClientHttpRequestFactories.OKHTTP_CLIENT_CLASS));
+			registerReflectionHints(hints, OkHttp3ClientHttpRequestFactory.class);
+		});
+
 	}
 
 	private void registerReflectionHints(ReflectionHints hints,
 			Class<? extends ClientHttpRequestFactory> requestFactoryType) {
+		registerReflectionHints(hints, requestFactoryType, int.class);
+	}
+
+	private void registerReflectionHints(ReflectionHints hints,
+			Class<? extends ClientHttpRequestFactory> requestFactoryType, Class<?> readTimeoutType) {
 		registerMethod(hints, requestFactoryType, "setConnectTimeout", int.class);
-		registerMethod(hints, requestFactoryType, "setReadTimeout", int.class);
-		registerMethod(hints, requestFactoryType, "setBufferRequestBody", boolean.class);
+		registerMethod(hints, requestFactoryType, "setReadTimeout", readTimeoutType);
 	}
 
 	private void registerMethod(ReflectionHints hints, Class<? extends ClientHttpRequestFactory> requestFactoryType,
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactorySettings.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactorySettings.java
index 22deb5a4a16b..204acffb933d 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactorySettings.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactorySettings.java
@@ -26,7 +26,6 @@
  *
  * @param connectTimeout the connect timeout
  * @param readTimeout the read timeout
- * @param bufferRequestBody if request body buffering is used
  * @param sslBundle the SSL bundle providing SSL configuration
  * @author Andy Wilkinson
  * @author Phillip Webb
@@ -34,8 +33,7 @@
  * @since 3.0.0
  * @see ClientHttpRequestFactories
  */
-public record ClientHttpRequestFactorySettings(Duration connectTimeout, Duration readTimeout, Boolean bufferRequestBody,
-		SslBundle sslBundle) {
+public record ClientHttpRequestFactorySettings(Duration connectTimeout, Duration readTimeout, SslBundle sslBundle) {
 
 	/**
 	 * Use defaults for the {@link ClientHttpRequestFactory} which can differ depending on
@@ -48,15 +46,29 @@ public record ClientHttpRequestFactorySettings(Duration connectTimeout, Duration
 	 * Create a new {@link ClientHttpRequestFactorySettings} instance.
 	 * @param connectTimeout the connection timeout
 	 * @param readTimeout the read timeout
-	 * @param bufferRequestBody the bugger request body
-	 * @param sslBundle the ssl bundle
-	 * @since 3.1.0
+	 * @param bufferRequestBody if request body buffering is used
+	 * @deprecated since 3.2.0 for removal in 3.4.0 as support for buffering has been
+	 * removed in Spring Framework 6.1
 	 */
-	public ClientHttpRequestFactorySettings {
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public ClientHttpRequestFactorySettings(Duration connectTimeout, Duration readTimeout, Boolean bufferRequestBody) {
+		this(connectTimeout, readTimeout, (SslBundle) null);
 	}
 
-	public ClientHttpRequestFactorySettings(Duration connectTimeout, Duration readTimeout, Boolean bufferRequestBody) {
-		this(connectTimeout, readTimeout, bufferRequestBody, null);
+	/**
+	 * Create a new {@link ClientHttpRequestFactorySettings} instance.
+	 * @param connectTimeout the connection timeout
+	 * @param readTimeout the read timeout
+	 * @param bufferRequestBody if request body buffering is used
+	 * @param sslBundle the ssl bundle
+	 * @since 3.1.0
+	 * @deprecated since 3.2.0 for removal in 3.4.0 as support for buffering has been
+	 * removed in Spring Framework 6.1
+	 */
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public ClientHttpRequestFactorySettings(Duration connectTimeout, Duration readTimeout, Boolean bufferRequestBody,
+			SslBundle sslBundle) {
+		this(connectTimeout, readTimeout, sslBundle);
 	}
 
 	/**
@@ -66,8 +78,7 @@ public ClientHttpRequestFactorySettings(Duration connectTimeout, Duration readTi
 	 * @return a new {@link ClientHttpRequestFactorySettings} instance
 	 */
 	public ClientHttpRequestFactorySettings withConnectTimeout(Duration connectTimeout) {
-		return new ClientHttpRequestFactorySettings(connectTimeout, this.readTimeout, this.bufferRequestBody,
-				this.sslBundle);
+		return new ClientHttpRequestFactorySettings(connectTimeout, this.readTimeout, this.sslBundle);
 	}
 
 	/**
@@ -78,19 +89,19 @@ public ClientHttpRequestFactorySettings withConnectTimeout(Duration connectTimeo
 	 */
 
 	public ClientHttpRequestFactorySettings withReadTimeout(Duration readTimeout) {
-		return new ClientHttpRequestFactorySettings(this.connectTimeout, readTimeout, this.bufferRequestBody,
-				this.sslBundle);
+		return new ClientHttpRequestFactorySettings(this.connectTimeout, readTimeout, this.sslBundle);
 	}
 
 	/**
-	 * Return a new {@link ClientHttpRequestFactorySettings} instance with an updated
-	 * buffer request body setting.
+	 * Has no effect as support for buffering has been removed in Spring Framework 6.1.
 	 * @param bufferRequestBody the new buffer request body setting
 	 * @return a new {@link ClientHttpRequestFactorySettings} instance
+	 * @deprecated since 3.2.0 for removal in 3.4.0 as support for buffering has been
+	 * removed in Spring Framework 6.1
 	 */
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	public ClientHttpRequestFactorySettings withBufferRequestBody(Boolean bufferRequestBody) {
-		return new ClientHttpRequestFactorySettings(this.connectTimeout, this.readTimeout, bufferRequestBody,
-				this.sslBundle);
+		return this;
 	}
 
 	/**
@@ -101,8 +112,18 @@ public ClientHttpRequestFactorySettings withBufferRequestBody(Boolean bufferRequ
 	 * @since 3.1.0
 	 */
 	public ClientHttpRequestFactorySettings withSslBundle(SslBundle sslBundle) {
-		return new ClientHttpRequestFactorySettings(this.connectTimeout, this.readTimeout, this.bufferRequestBody,
-				sslBundle);
+		return new ClientHttpRequestFactorySettings(this.connectTimeout, this.readTimeout, sslBundle);
+	}
+
+	/**
+	 * Returns whether request body buffering is used.
+	 * @return whether request body buffering is used
+	 * @deprecated since 3.2.0 for removal in 3.4.0 as support for buffering has been
+	 * removed in Spring Framework 6.1
+	 */
+	@Deprecated(since = "3.2.0", forRemoval = true)
+	public Boolean bufferRequestBody() {
+		return null;
 	}
 
 }
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactorySupplier.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactorySupplier.java
deleted file mode 100644
index 7fce4e31cf0f..000000000000
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactorySupplier.java
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * Copyright 2012-2022 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.web.client;
-
-import java.util.function.Supplier;
-
-import org.springframework.http.client.ClientHttpRequestFactory;
-
-/**
- * A supplier for {@link ClientHttpRequestFactory} that detects the preferred candidate
- * based on the available implementations on the classpath.
- *
- * @author Stephane Nicoll
- * @author Moritz Halbritter
- * @since 2.1.0
- * @deprecated since 3.0.0 for removal in 3.2.0 in favor of
- * {@link ClientHttpRequestFactories}
- */
-@Deprecated(since = "3.0.0", forRemoval = true)
-public class ClientHttpRequestFactorySupplier implements Supplier<ClientHttpRequestFactory> {
-
-	@Override
-	public ClientHttpRequestFactory get() {
-		return ClientHttpRequestFactories.get(ClientHttpRequestFactorySettings.DEFAULTS);
-	}
-
-}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/RestClientCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/RestClientCustomizer.java
new file mode 100644
index 000000000000..e19b9f5a02c7
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/RestClientCustomizer.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.web.client;
+
+import org.springframework.web.client.RestClient;
+
+/**
+ * Callback interface that can be used to customize a
+ * {@link org.springframework.web.client.RestClient.Builder RestClient.Builder}.
+ *
+ * @author Arjen Poutsma
+ * @since 3.2.0
+ */
+@FunctionalInterface
+public interface RestClientCustomizer {
+
+	/**
+	 * Callback to customize a {@link org.springframework.web.client.RestClient.Builder
+	 * RestClient.Builder} instance.
+	 * @param restClientBuilder the client builder to customize
+	 */
+	void customize(RestClient.Builder restClientBuilder);
+
+}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/RestTemplateBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/RestTemplateBuilder.java
index 2430b697b694..a10a9ce10022 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/RestTemplateBuilder.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/RestTemplateBuilder.java
@@ -30,8 +30,6 @@
 import java.util.function.Function;
 import java.util.function.Supplier;
 
-import reactor.netty.http.client.HttpClientRequest;
-
 import org.springframework.beans.BeanUtils;
 import org.springframework.boot.ssl.SslBundle;
 import org.springframework.http.client.ClientHttpRequest;
@@ -399,7 +397,7 @@ this.errorHandler, new BasicAuthentication(username, password, charset), this.de
 
 	/**
 	 * Add a default header that will be set if not already present on the outgoing
-	 * {@link HttpClientRequest}.
+	 * {@link ClientHttpRequest}.
 	 * @param name the name of the header
 	 * @param values the header values
 	 * @return a new builder instance
@@ -441,19 +439,18 @@ public RestTemplateBuilder setReadTimeout(Duration readTimeout) {
 	}
 
 	/**
-	 * Sets if the underlying {@link ClientHttpRequestFactory} should buffer the
-	 * {@linkplain ClientHttpRequest#getBody() request body} internally.
+	 * Has no effect as support for buffering has been removed in Spring Framework 6.1.
 	 * @param bufferRequestBody value of the bufferRequestBody parameter
 	 * @return a new builder instance.
 	 * @since 2.2.0
+	 * @deprecated since 3.2.0 for removal in 3.4.0 as support for buffering has been
+	 * removed in Spring Framework 6.1
 	 * @see SimpleClientHttpRequestFactory#setBufferRequestBody(boolean)
 	 * @see HttpComponentsClientHttpRequestFactory#setBufferRequestBody(boolean)
 	 */
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	public RestTemplateBuilder setBufferRequestBody(boolean bufferRequestBody) {
-		return new RestTemplateBuilder(this.requestFactorySettings.withBufferRequestBody(bufferRequestBody),
-				this.detectRequestFactory, this.rootUri, this.messageConverters, this.interceptors, this.requestFactory,
-				this.uriTemplateHandler, this.errorHandler, this.basicAuthentication, this.defaultHeaders,
-				this.customizers, this.requestCustomizers);
+		return this;
 	}
 
 	/**
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/context/ServerPortInfoApplicationContextInitializer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/context/ServerPortInfoApplicationContextInitializer.java
index 7e3fafe9f410..2ec73ab57a12 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/context/ServerPortInfoApplicationContextInitializer.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/context/ServerPortInfoApplicationContextInitializer.java
@@ -52,6 +52,8 @@
 public class ServerPortInfoApplicationContextInitializer implements
 		ApplicationContextInitializer<ConfigurableApplicationContext>, ApplicationListener<WebServerInitializedEvent> {
 
+	private static final String PROPERTY_SOURCE_NAME = "server.ports";
+
 	@Override
 	public void initialize(ConfigurableApplicationContext applicationContext) {
 		applicationContext.addApplicationListener(this);
@@ -80,9 +82,9 @@ private void setPortProperty(ApplicationContext context, String propertyName, in
 	@SuppressWarnings("unchecked")
 	private void setPortProperty(ConfigurableEnvironment environment, String propertyName, int port) {
 		MutablePropertySources sources = environment.getPropertySources();
-		PropertySource<?> source = sources.get("server.ports");
+		PropertySource<?> source = sources.get(PROPERTY_SOURCE_NAME);
 		if (source == null) {
-			source = new MapPropertySource("server.ports", new HashMap<>());
+			source = new MapPropertySource(PROPERTY_SOURCE_NAME, new HashMap<>());
 			sources.addFirst(source);
 		}
 		((Map<String, Object>) source.getSource()).put(propertyName, port);
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/ConfigurableJettyWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/ConfigurableJettyWebServerFactory.java
index 5f8a927b403a..d92502ac9abb 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/ConfigurableJettyWebServerFactory.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/ConfigurableJettyWebServerFactory.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -25,6 +25,7 @@
  * {@link ConfigurableWebServerFactory} for Jetty-specific features.
  *
  * @author Brian Clozel
+ * @author Moritz Halbritter
  * @since 2.0.0
  * @see JettyServletWebServerFactory
  * @see JettyReactiveWebServerFactory
@@ -63,4 +64,11 @@ public interface ConfigurableJettyWebServerFactory extends ConfigurableWebServer
 	 */
 	void addServerCustomizers(JettyServerCustomizer... customizers);
 
+	/**
+	 * Sets the maximum number of concurrent connections.
+	 * @param maxConnections the maximum number of concurrent connections
+	 * @since 3.2.0
+	 */
+	void setMaxConnections(int maxConnections);
+
 }
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/GracefulShutdown.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/GracefulShutdown.java
index dc2580d25ea8..8ccd5d26e56c 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/GracefulShutdown.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/GracefulShutdown.java
@@ -99,6 +99,7 @@ private void awaitShutdown(GracefulShutdownCallback callback) {
 		while (this.shuttingDown && this.activeRequests.get() > 0) {
 			sleep(100);
 		}
+		System.out.println(this.activeRequests.get());
 		this.shuttingDown = false;
 		long activeRequests = this.activeRequests.get();
 		if (activeRequests == 0) {
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JasperInitializer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JasperInitializer.java
index 74b6b5a7561a..d370f56d9fdb 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JasperInitializer.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JasperInitializer.java
@@ -24,8 +24,8 @@
 import java.net.URLStreamHandlerFactory;
 
 import jakarta.servlet.ServletContainerInitializer;
+import org.eclipse.jetty.ee10.webapp.WebAppContext;
 import org.eclipse.jetty.util.component.AbstractLifeCycle;
-import org.eclipse.jetty.webapp.WebAppContext;
 
 import org.springframework.util.ClassUtils;
 
@@ -63,7 +63,6 @@ private ServletContainerInitializer newInitializer() {
 	}
 
 	@Override
-	@SuppressWarnings("deprecation")
 	protected void doStart() throws Exception {
 		if (this.initializer == null) {
 			return;
@@ -84,11 +83,11 @@ protected void doStart() throws Exception {
 		try {
 			Thread.currentThread().setContextClassLoader(this.context.getClassLoader());
 			try {
-				setExtendedListenerTypes(true);
+				this.context.getContext().setExtendedListenerTypes(true);
 				this.initializer.onStartup(null, this.context.getServletContext());
 			}
 			finally {
-				setExtendedListenerTypes(false);
+				this.context.getContext().setExtendedListenerTypes(false);
 			}
 		}
 		finally {
@@ -96,15 +95,6 @@ protected void doStart() throws Exception {
 		}
 	}
 
-	private void setExtendedListenerTypes(boolean extended) {
-		try {
-			this.context.getServletContext().setExtendedListenerTypes(extended);
-		}
-		catch (NoSuchMethodError ex) {
-			// Not available on Jetty 8
-		}
-	}
-
 	/**
 	 * {@link URLStreamHandlerFactory} to support {@literal war} protocol.
 	 */
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyEmbeddedErrorHandler.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyEmbeddedErrorHandler.java
index 5871fb668acf..924a53242139 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyEmbeddedErrorHandler.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyEmbeddedErrorHandler.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,17 +16,8 @@
 
 package org.springframework.boot.web.embedded.jetty;
 
-import java.io.IOException;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.Set;
-
-import jakarta.servlet.ServletException;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
+import org.eclipse.jetty.ee10.servlet.ErrorPageErrorHandler;
 import org.eclipse.jetty.http.HttpMethod;
-import org.eclipse.jetty.server.Request;
-import org.eclipse.jetty.servlet.ErrorPageErrorHandler;
 
 /**
  * Variation of Jetty's {@link ErrorPageErrorHandler} that supports all {@link HttpMethod
@@ -40,20 +31,9 @@
  */
 class JettyEmbeddedErrorHandler extends ErrorPageErrorHandler {
 
-	private static final Set<String> HANDLED_HTTP_METHODS = new HashSet<>(Arrays.asList("GET", "POST", "HEAD"));
-
 	@Override
 	public boolean errorPageForMethod(String method) {
 		return true;
 	}
 
-	@Override
-	public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response)
-			throws IOException, ServletException {
-		if (!HANDLED_HTTP_METHODS.contains(baseRequest.getMethod())) {
-			baseRequest.setMethod("GET");
-		}
-		super.handle(target, baseRequest, request, response);
-	}
-
 }
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyEmbeddedWebAppContext.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyEmbeddedWebAppContext.java
index 000c0acd79cb..d1caaf1d5a32 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyEmbeddedWebAppContext.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyEmbeddedWebAppContext.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,8 +16,9 @@
 
 package org.springframework.boot.web.embedded.jetty;
 
-import org.eclipse.jetty.servlet.ServletHandler;
-import org.eclipse.jetty.webapp.WebAppContext;
+import org.eclipse.jetty.ee10.servlet.ServletHandler;
+import org.eclipse.jetty.ee10.webapp.ClassMatcher;
+import org.eclipse.jetty.ee10.webapp.WebAppContext;
 
 /**
  * Jetty {@link WebAppContext} used by {@link JettyWebServer} to support deferred
@@ -27,6 +28,11 @@
  */
 class JettyEmbeddedWebAppContext extends WebAppContext {
 
+	JettyEmbeddedWebAppContext() {
+		setServerClassMatcher(new ClassMatcher("org.springframework.boot.loader."));
+		// setTempDirectory(WebInfConfiguration.getCanonicalNameForWebAppTmpDir(this));
+	}
+
 	@Override
 	protected ServletHandler newServletHandler() {
 		return new JettyEmbeddedServletHandler();
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyHandlerWrappers.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyHandlerWrappers.java
index 35a5286b81cb..bb61f81c6b79 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyHandlerWrappers.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyHandlerWrappers.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,15 +16,13 @@
 
 package org.springframework.boot.web.embedded.jetty;
 
-import java.io.IOException;
-
-import jakarta.servlet.ServletException;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
+import org.eclipse.jetty.http.HttpFields.Mutable;
 import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.server.Handler;
 import org.eclipse.jetty.server.Request;
-import org.eclipse.jetty.server.handler.HandlerWrapper;
+import org.eclipse.jetty.server.Response;
 import org.eclipse.jetty.server.handler.gzip.GzipHandler;
+import org.eclipse.jetty.util.Callback;
 
 import org.springframework.boot.web.server.Compression;
 
@@ -38,7 +36,7 @@ final class JettyHandlerWrappers {
 	private JettyHandlerWrappers() {
 	}
 
-	static HandlerWrapper createGzipHandlerWrapper(Compression compression) {
+	static Handler.Wrapper createGzipHandlerWrapper(Compression compression) {
 		GzipHandler handler = new GzipHandler();
 		handler.setMinGzipSize((int) compression.getMinResponseSize().toBytes());
 		handler.setIncludedMimeTypes(compression.getMimeTypes());
@@ -48,14 +46,14 @@ static HandlerWrapper createGzipHandlerWrapper(Compression compression) {
 		return handler;
 	}
 
-	static HandlerWrapper createServerHeaderHandlerWrapper(String header) {
+	static Handler.Wrapper createServerHeaderHandlerWrapper(String header) {
 		return new ServerHeaderHandler(header);
 	}
 
 	/**
-	 * {@link HandlerWrapper} to add a custom {@code server} header.
+	 * {@link Handler.Wrapper} to add a custom {@code server} header.
 	 */
-	private static class ServerHeaderHandler extends HandlerWrapper {
+	private static class ServerHeaderHandler extends Handler.Wrapper {
 
 		private static final String SERVER_HEADER = "server";
 
@@ -66,12 +64,12 @@ private static class ServerHeaderHandler extends HandlerWrapper {
 		}
 
 		@Override
-		public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response)
-				throws IOException, ServletException {
-			if (!response.getHeaderNames().contains(SERVER_HEADER)) {
-				response.setHeader(SERVER_HEADER, this.value);
+		public boolean handle(Request request, Response response, Callback callback) throws Exception {
+			Mutable headers = response.getHeaders();
+			if (!headers.contains(SERVER_HEADER)) {
+				headers.add(SERVER_HEADER, this.value);
 			}
-			super.handle(target, baseRequest, request, response);
+			return super.handle(request, response, callback);
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactory.java
index 3e8049f4d739..613514927381 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactory.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactory.java
@@ -26,18 +26,18 @@
 
 import org.apache.commons.logging.Log;
 import org.apache.commons.logging.LogFactory;
+import org.eclipse.jetty.ee10.servlet.ServletContextHandler;
+import org.eclipse.jetty.ee10.servlet.ServletHolder;
 import org.eclipse.jetty.http2.server.HTTP2CServerConnectionFactory;
 import org.eclipse.jetty.server.AbstractConnector;
 import org.eclipse.jetty.server.ConnectionFactory;
+import org.eclipse.jetty.server.ConnectionLimit;
 import org.eclipse.jetty.server.Handler;
 import org.eclipse.jetty.server.HttpConfiguration;
 import org.eclipse.jetty.server.HttpConnectionFactory;
 import org.eclipse.jetty.server.Server;
 import org.eclipse.jetty.server.ServerConnector;
-import org.eclipse.jetty.server.handler.HandlerWrapper;
 import org.eclipse.jetty.server.handler.StatisticsHandler;
-import org.eclipse.jetty.servlet.ServletContextHandler;
-import org.eclipse.jetty.servlet.ServletHolder;
 import org.eclipse.jetty.util.thread.ThreadPool;
 
 import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactory;
@@ -55,6 +55,7 @@
  * {@link ReactiveWebServerFactory} that can be used to create {@link JettyWebServer}s.
  *
  * @author Brian Clozel
+ * @author Moritz Halbritter
  * @since 2.0.0
  */
 public class JettyReactiveWebServerFactory extends AbstractReactiveWebServerFactory
@@ -80,6 +81,8 @@ public class JettyReactiveWebServerFactory extends AbstractReactiveWebServerFact
 
 	private ThreadPool threadPool;
 
+	private int maxConnections = -1;
+
 	/**
 	 * Create a new {@link JettyServletWebServerFactory} instance.
 	 */
@@ -118,6 +121,11 @@ public void addServerCustomizers(JettyServerCustomizer... customizers) {
 		this.jettyServerCustomizers.addAll(Arrays.asList(customizers));
 	}
 
+	@Override
+	public void setMaxConnections(int maxConnections) {
+		this.maxConnections = maxConnections;
+	}
+
 	/**
 	 * Sets {@link JettyServerCustomizer}s that will be applied to the {@link Server}
 	 * before it is started. Calling this method will replace any existing customizers.
@@ -176,10 +184,13 @@ protected Server createJettyServer(JettyHttpHandlerAdapter servlet) {
 		server.setStopTimeout(0);
 		ServletHolder servletHolder = new ServletHolder(servlet);
 		servletHolder.setAsyncSupported(true);
-		ServletContextHandler contextHandler = new ServletContextHandler(server, "/", false, false);
+		ServletContextHandler contextHandler = new ServletContextHandler("/", false, false);
 		contextHandler.addServlet(servletHolder, "/");
 		server.setHandler(addHandlerWrappers(contextHandler));
 		JettyReactiveWebServerFactory.logger.info("Server initialized with port: " + port);
+		if (this.maxConnections > -1) {
+			server.addBean(new ConnectionLimit(this.maxConnections, server));
+		}
 		if (Ssl.isEnabled(getSsl())) {
 			customizeSsl(server, address);
 		}
@@ -231,7 +242,7 @@ private Handler addHandlerWrappers(Handler handler) {
 		return handler;
 	}
 
-	private Handler applyWrapper(Handler handler, HandlerWrapper wrapper) {
+	private Handler applyWrapper(Handler handler, Handler.Wrapper wrapper) {
 		wrapper.setHandler(handler);
 		return wrapper;
 	}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactory.java
index e060a372f07c..a50ddc044802 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactory.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactory.java
@@ -20,55 +20,69 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.InetSocketAddress;
-import java.net.MalformedURLException;
 import java.net.URI;
 import java.net.URL;
 import java.nio.channels.ReadableByteChannel;
+import java.nio.file.Path;
 import java.time.Duration;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.EventListener;
+import java.util.Iterator;
 import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Objects;
 import java.util.Set;
+import java.util.Spliterator;
+import java.util.UUID;
+import java.util.function.Consumer;
 
-import jakarta.servlet.ServletException;
 import jakarta.servlet.http.Cookie;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-import jakarta.servlet.http.HttpServletResponseWrapper;
+import org.eclipse.jetty.ee10.servlet.ErrorHandler;
+import org.eclipse.jetty.ee10.servlet.ErrorPageErrorHandler;
+import org.eclipse.jetty.ee10.servlet.ListenerHolder;
+import org.eclipse.jetty.ee10.servlet.ServletHandler;
+import org.eclipse.jetty.ee10.servlet.ServletHolder;
+import org.eclipse.jetty.ee10.servlet.ServletMapping;
+import org.eclipse.jetty.ee10.servlet.SessionHandler;
+import org.eclipse.jetty.ee10.servlet.Source;
+import org.eclipse.jetty.ee10.webapp.AbstractConfiguration;
+import org.eclipse.jetty.ee10.webapp.Configuration;
+import org.eclipse.jetty.ee10.webapp.WebAppContext;
+import org.eclipse.jetty.ee10.webapp.WebInfConfiguration;
+import org.eclipse.jetty.http.CookieCompliance;
 import org.eclipse.jetty.http.HttpCookie;
+import org.eclipse.jetty.http.HttpField;
+import org.eclipse.jetty.http.HttpFields;
+import org.eclipse.jetty.http.HttpFields.Mutable;
+import org.eclipse.jetty.http.HttpHeader;
 import org.eclipse.jetty.http.MimeTypes;
+import org.eclipse.jetty.http.MimeTypes.Wrapper;
+import org.eclipse.jetty.http.SetCookieParser;
 import org.eclipse.jetty.http2.server.HTTP2CServerConnectionFactory;
 import org.eclipse.jetty.server.AbstractConnector;
 import org.eclipse.jetty.server.ConnectionFactory;
+import org.eclipse.jetty.server.ConnectionLimit;
 import org.eclipse.jetty.server.Connector;
 import org.eclipse.jetty.server.Handler;
 import org.eclipse.jetty.server.HttpConfiguration;
 import org.eclipse.jetty.server.HttpConnectionFactory;
+import org.eclipse.jetty.server.HttpCookieUtils;
 import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Response;
 import org.eclipse.jetty.server.Server;
 import org.eclipse.jetty.server.ServerConnector;
-import org.eclipse.jetty.server.handler.ErrorHandler;
-import org.eclipse.jetty.server.handler.HandlerWrapper;
 import org.eclipse.jetty.server.handler.StatisticsHandler;
-import org.eclipse.jetty.server.session.DefaultSessionCache;
-import org.eclipse.jetty.server.session.FileSessionDataStore;
-import org.eclipse.jetty.server.session.SessionHandler;
-import org.eclipse.jetty.servlet.ErrorPageErrorHandler;
-import org.eclipse.jetty.servlet.ListenerHolder;
-import org.eclipse.jetty.servlet.ServletHandler;
-import org.eclipse.jetty.servlet.ServletHolder;
-import org.eclipse.jetty.servlet.ServletMapping;
-import org.eclipse.jetty.servlet.Source;
-import org.eclipse.jetty.util.resource.JarResource;
+import org.eclipse.jetty.session.DefaultSessionCache;
+import org.eclipse.jetty.session.FileSessionDataStore;
+import org.eclipse.jetty.util.Callback;
+import org.eclipse.jetty.util.resource.CombinedResource;
 import org.eclipse.jetty.util.resource.Resource;
-import org.eclipse.jetty.util.resource.ResourceCollection;
+import org.eclipse.jetty.util.resource.ResourceFactory;
+import org.eclipse.jetty.util.resource.URLResourceFactory;
 import org.eclipse.jetty.util.thread.ThreadPool;
-import org.eclipse.jetty.webapp.AbstractConfiguration;
-import org.eclipse.jetty.webapp.Configuration;
-import org.eclipse.jetty.webapp.WebAppContext;
 
 import org.springframework.boot.web.server.Cookie.SameSite;
 import org.springframework.boot.web.server.ErrorPage;
@@ -101,6 +115,7 @@
  * @author EddĂș MelĂ©ndez
  * @author Venil Noronha
  * @author Henri Kerola
+ * @author Moritz Halbritter
  * @since 2.0.0
  * @see #setPort(int)
  * @see #setConfigurations(Collection)
@@ -129,6 +144,8 @@ public class JettyServletWebServerFactory extends AbstractServletWebServerFactor
 
 	private ThreadPool threadPool;
 
+	private int maxConnections = -1;
+
 	/**
 	 * Create a new {@link JettyServletWebServerFactory} instance.
 	 */
@@ -157,12 +174,17 @@ public JettyServletWebServerFactory(String contextPath, int port) {
 	@Override
 	public WebServer getWebServer(ServletContextInitializer... initializers) {
 		JettyEmbeddedWebAppContext context = new JettyEmbeddedWebAppContext();
+		context.getContext().getServletContext().setExtendedListenerTypes(true);
 		int port = Math.max(getPort(), 0);
 		InetSocketAddress address = new InetSocketAddress(getAddress(), port);
 		Server server = createServer(address);
+		context.setServer(server);
 		configureWebAppContext(context, initializers);
 		server.setHandler(addHandlerWrappers(context));
 		this.logger.info("Server initialized with port: " + port);
+		if (this.maxConnections > -1) {
+			server.addBean(new ConnectionLimit(this.maxConnections, server));
+		}
 		if (Ssl.isEnabled(getSsl())) {
 			customizeSsl(server, address);
 		}
@@ -184,12 +206,17 @@ private Server createServer(InetSocketAddress address) {
 		Server server = new Server(getThreadPool());
 		server.setConnectors(new Connector[] { createConnector(address, server) });
 		server.setStopTimeout(0);
+		MimeTypes.Mutable mimeTypes = server.getMimeTypes();
+		for (MimeMappings.Mapping mapping : getMimeMappings()) {
+			mimeTypes.addMimeMapping(mapping.getExtension(), mapping.getMimeType());
+		}
 		return server;
 	}
 
 	private AbstractConnector createConnector(InetSocketAddress address, Server server) {
 		HttpConfiguration httpConfiguration = new HttpConfiguration();
 		httpConfiguration.setSendServerVersion(false);
+		httpConfiguration.setIdleTimeout(30000);
 		List<ConnectionFactory> connectionFactories = new ArrayList<>();
 		connectionFactories.add(new HttpConnectionFactory(httpConfiguration));
 		if (getHttp2() != null && getHttp2().isEnabled()) {
@@ -215,7 +242,7 @@ private Handler addHandlerWrappers(Handler handler) {
 		return handler;
 	}
 
-	private Handler applyWrapper(Handler handler, HandlerWrapper wrapper) {
+	private Handler applyWrapper(Handler handler, Handler.Wrapper wrapper) {
 		wrapper.setHandler(handler);
 		return wrapper;
 	}
@@ -232,7 +259,6 @@ private void customizeSsl(Server server, InetSocketAddress address) {
 	protected final void configureWebAppContext(WebAppContext context, ServletContextInitializer... initializers) {
 		Assert.notNull(context, "Context must not be null");
 		context.clearAliasChecks();
-		context.setTempDirectory(getTempDirectory());
 		if (this.resourceLoader != null) {
 			context.setClassLoader(this.resourceLoader.getClassLoader());
 		}
@@ -253,6 +279,7 @@ protected final void configureWebAppContext(WebAppContext context, ServletContex
 		context.setConfigurations(configurations);
 		context.setThrowUnavailableOnStartupException(true);
 		configureSession(context);
+		context.setTempDirectory(getTempDirectory(context));
 		postProcessWebAppContext(context);
 	}
 
@@ -282,40 +309,49 @@ private void addLocaleMappings(WebAppContext context) {
 			.forEach((locale, charset) -> context.addLocaleEncoding(locale.toString(), charset.toString()));
 	}
 
-	private File getTempDirectory() {
+	private File getTempDirectory(WebAppContext context) {
 		String temp = System.getProperty("java.io.tmpdir");
-		return (temp != null) ? new File(temp) : null;
+		return (temp != null)
+				? new File(temp, WebInfConfiguration.getCanonicalNameForWebAppTmpDir(context) + UUID.randomUUID())
+				: null;
 	}
 
 	private void configureDocumentRoot(WebAppContext handler) {
 		File root = getValidDocumentRoot();
 		File docBase = (root != null) ? root : createTempDir("jetty-docbase");
 		try {
+			ResourceFactory resourceFactory = handler.getResourceFactory();
 			List<Resource> resources = new ArrayList<>();
-			Resource rootResource = (docBase.isDirectory() ? Resource.newResource(docBase.getCanonicalFile())
-					: JarResource.newJarResource(Resource.newResource(docBase)));
-			resources.add((root != null) ? new LoaderHidingResource(rootResource) : rootResource);
+			Resource rootResource = (docBase.isDirectory()
+					? resourceFactory.newResource(docBase.getCanonicalFile().toURI())
+					: resourceFactory.newJarFileResource(docBase.toURI()));
+			resources.add((root != null) ? new LoaderHidingResource(rootResource, rootResource) : rootResource);
+			URLResourceFactory urlResourceFactory = new URLResourceFactory();
 			for (URL resourceJarUrl : getUrlsOfJarsWithMetaInfResources()) {
-				Resource resource = createResource(resourceJarUrl);
-				if (resource.exists() && resource.isDirectory()) {
+				Resource resource = createResource(resourceJarUrl, resourceFactory, urlResourceFactory);
+				if (resource != null) {
 					resources.add(resource);
 				}
 			}
-			handler.setBaseResource(new ResourceCollection(resources.toArray(new Resource[0])));
+			handler.setBaseResource(ResourceFactory.combine(resources));
 		}
 		catch (Exception ex) {
 			throw new IllegalStateException(ex);
 		}
 	}
 
-	private Resource createResource(URL url) throws Exception {
+	private Resource createResource(URL url, ResourceFactory resourceFactory, URLResourceFactory urlResourceFactory)
+			throws Exception {
 		if ("file".equals(url.getProtocol())) {
 			File file = new File(url.toURI());
 			if (file.isFile()) {
-				return Resource.newResource("jar:" + url + "!/META-INF/resources");
+				return resourceFactory.newResource("jar:" + url + "!/META-INF/resources/");
+			}
+			if (file.isDirectory()) {
+				return resourceFactory.newResource(url).resolve("META-INF/resources/");
 			}
 		}
-		return Resource.newResource(url + "META-INF/resources");
+		return urlResourceFactory.newResource(url + "META-INF/resources/");
 	}
 
 	/**
@@ -326,7 +362,7 @@ protected final void addDefaultServlet(WebAppContext context) {
 		Assert.notNull(context, "Context must not be null");
 		ServletHolder holder = new ServletHolder();
 		holder.setName("default");
-		holder.setClassName("org.eclipse.jetty.servlet.DefaultServlet");
+		holder.setClassName("org.eclipse.jetty.ee10.servlet.DefaultServlet");
 		holder.setInitParameter("dirAllowed", "false");
 		holder.setInitOrder(1);
 		context.getServletHandler().addServletWithMapping(holder, "/");
@@ -375,7 +411,7 @@ protected Configuration[] getWebAppContextConfigurations(WebAppContext webAppCon
 	 * @return a configuration object for adding error pages
 	 */
 	private Configuration getErrorPageConfiguration() {
-		return new AbstractConfiguration() {
+		return new AbstractConfiguration(new AbstractConfiguration.Builder()) {
 
 			@Override
 			public void configure(WebAppContext context) throws Exception {
@@ -392,11 +428,12 @@ public void configure(WebAppContext context) throws Exception {
 	 * @return a configuration object for adding mime type mappings
 	 */
 	private Configuration getMimeTypeConfiguration() {
-		return new AbstractConfiguration() {
+		return new AbstractConfiguration(new AbstractConfiguration.Builder()) {
 
 			@Override
 			public void configure(WebAppContext context) throws Exception {
-				MimeTypes mimeTypes = context.getMimeTypes();
+				MimeTypes.Wrapper mimeTypes = (Wrapper) context.getMimeTypes();
+				mimeTypes.setWrapped(new MimeTypes(null));
 				for (MimeMappings.Mapping mapping : getMimeMappings()) {
 					mimeTypes.addMimeMapping(mapping.getExtension(), mapping.getMimeType());
 				}
@@ -458,6 +495,11 @@ public void setSelectors(int selectors) {
 		this.selectors = selectors;
 	}
 
+	@Override
+	public void setMaxConnections(int maxConnections) {
+		this.maxConnections = maxConnections;
+	}
+
 	/**
 	 * Sets {@link JettyServerCustomizer}s that will be applied to the {@link Server}
 	 * before it is started. Calling this method will replace any existing customizers.
@@ -546,28 +588,48 @@ private void addJettyErrorPages(ErrorHandler errorHandler, Collection<ErrorPage>
 
 	private static final class LoaderHidingResource extends Resource {
 
+		private static final String LOADER_RESOURCE_PATH_PREFIX = "/org/springframework/boot/";
+
+		private final Resource base;
+
 		private final Resource delegate;
 
-		private LoaderHidingResource(Resource delegate) {
+		private LoaderHidingResource(Resource base, Resource delegate) {
+			this.base = base;
 			this.delegate = delegate;
 		}
 
 		@Override
-		public Resource addPath(String path) throws IOException {
-			if (path.startsWith("/org/springframework/boot")) {
-				return null;
+		public void forEach(Consumer<? super Resource> action) {
+			this.delegate.forEach(action);
+		}
+
+		@Override
+		public Path getPath() {
+			return this.delegate.getPath();
+		}
+
+		@Override
+		public boolean isContainedIn(Resource r) {
+			return this.delegate.isContainedIn(r);
+		}
+
+		@Override
+		public Iterator<Resource> iterator() {
+			if (this.delegate instanceof CombinedResource) {
+				return list().iterator();
 			}
-			return this.delegate.addPath(path);
+			return List.<Resource>of(this).iterator();
 		}
 
 		@Override
-		public boolean isContainedIn(Resource resource) throws MalformedURLException {
-			return this.delegate.isContainedIn(resource);
+		public boolean equals(Object obj) {
+			return this.delegate.equals(obj);
 		}
 
 		@Override
-		public void close() {
-			this.delegate.close();
+		public int hashCode() {
+			return this.delegate.hashCode();
 		}
 
 		@Override
@@ -575,13 +637,23 @@ public boolean exists() {
 			return this.delegate.exists();
 		}
 
+		@Override
+		public Spliterator<Resource> spliterator() {
+			return this.delegate.spliterator();
+		}
+
 		@Override
 		public boolean isDirectory() {
 			return this.delegate.isDirectory();
 		}
 
 		@Override
-		public long lastModified() {
+		public boolean isReadable() {
+			return this.delegate.isReadable();
+		}
+
+		@Override
+		public Instant lastModified() {
 			return this.delegate.lastModified();
 		}
 
@@ -596,38 +668,67 @@ public URI getURI() {
 		}
 
 		@Override
-		public File getFile() throws IOException {
-			return this.delegate.getFile();
+		public String getName() {
+			return this.delegate.getName();
 		}
 
 		@Override
-		public String getName() {
-			return this.delegate.getName();
+		public String getFileName() {
+			return this.delegate.getFileName();
 		}
 
 		@Override
-		public InputStream getInputStream() throws IOException {
-			return this.delegate.getInputStream();
+		public InputStream newInputStream() throws IOException {
+			return this.delegate.newInputStream();
 		}
 
 		@Override
-		public ReadableByteChannel getReadableByteChannel() throws IOException {
-			return this.delegate.getReadableByteChannel();
+		public ReadableByteChannel newReadableByteChannel() throws IOException {
+			return this.delegate.newReadableByteChannel();
 		}
 
 		@Override
-		public boolean delete() throws SecurityException {
-			return this.delegate.delete();
+		public List<Resource> list() {
+			return this.delegate.list().stream().filter(this::nonLoaderResource).toList();
+		}
+
+		private boolean nonLoaderResource(Resource resource) {
+			Path prefix = this.base.getPath().resolve(Path.of("org", "springframework", "boot"));
+			return !resource.getPath().startsWith(prefix);
 		}
 
 		@Override
-		public boolean renameTo(Resource dest) throws SecurityException {
-			return this.delegate.renameTo(dest);
+		public Resource resolve(String subUriPath) {
+			if (subUriPath.startsWith(LOADER_RESOURCE_PATH_PREFIX)) {
+				return null;
+			}
+			Resource resolved = this.delegate.resolve(subUriPath);
+			return (resolved != null) ? new LoaderHidingResource(this.base, resolved) : null;
 		}
 
 		@Override
-		public String[] list() {
-			return this.delegate.list();
+		public boolean isAlias() {
+			return this.delegate.isAlias();
+		}
+
+		@Override
+		public URI getRealURI() {
+			return this.delegate.getRealURI();
+		}
+
+		@Override
+		public void copyTo(Path destination) throws IOException {
+			this.delegate.copyTo(destination);
+		}
+
+		@Override
+		public Collection<Resource> getAllResources() {
+			return this.delegate.getAllResources().stream().filter(this::nonLoaderResource).toList();
+		}
+
+		@Override
+		public String toString() {
+			return this.delegate.toString();
 		}
 
 	}
@@ -640,6 +741,7 @@ private static class WebListenersConfiguration extends AbstractConfiguration {
 		private final Set<String> classNames;
 
 		WebListenersConfiguration(Set<String> webListenerClassNames) {
+			super(new AbstractConfiguration.Builder());
 			this.classNames = webListenerClassNames;
 		}
 
@@ -669,10 +771,12 @@ private Class<? extends EventListener> loadClass(WebAppContext context, String c
 	}
 
 	/**
-	 * {@link HandlerWrapper} to apply {@link CookieSameSiteSupplier supplied}
+	 * {@link Handler.Wrapper} to apply {@link CookieSameSiteSupplier supplied}
 	 * {@link SameSite} cookie values.
 	 */
-	private static class SuppliedSameSiteCookieHandlerWrapper extends HandlerWrapper {
+	private static class SuppliedSameSiteCookieHandlerWrapper extends Handler.Wrapper {
+
+		private static final SetCookieParser setCookieParser = SetCookieParser.newInstance();
 
 		private final List<CookieSameSiteSupplier> suppliers;
 
@@ -681,46 +785,75 @@ private static class SuppliedSameSiteCookieHandlerWrapper extends HandlerWrapper
 		}
 
 		@Override
-		public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response)
-				throws IOException, ServletException {
-			HttpServletResponse wrappedResponse = new ResponseWrapper(response);
-			super.handle(target, baseRequest, request, wrappedResponse);
+		public boolean handle(Request request, Response response, Callback callback) throws Exception {
+			SuppliedSameSiteCookieResponse wrappedResponse = new SuppliedSameSiteCookieResponse(request, response);
+			return super.handle(request, wrappedResponse, callback);
+		}
+
+		private class SuppliedSameSiteCookieResponse extends Response.Wrapper {
+
+			private HttpFields.Mutable wrappedHeaders;
+
+			SuppliedSameSiteCookieResponse(Request request, Response wrapped) {
+				super(request, wrapped);
+				this.wrappedHeaders = new SuppliedSameSiteCookieHeaders(
+						request.getConnectionMetaData().getHttpConfiguration().getResponseCookieCompliance(),
+						wrapped.getHeaders());
+			}
+
+			@Override
+			public Mutable getHeaders() {
+				return this.wrappedHeaders;
+			}
+
 		}
 
-		class ResponseWrapper extends HttpServletResponseWrapper {
+		private class SuppliedSameSiteCookieHeaders extends HttpFields.Mutable.Wrapper {
 
-			ResponseWrapper(HttpServletResponse response) {
-				super(response);
+			private final CookieCompliance compliance;
+
+			SuppliedSameSiteCookieHeaders(CookieCompliance compliance, HttpFields.Mutable fields) {
+				super(fields);
+				this.compliance = compliance;
 			}
 
 			@Override
-			@SuppressWarnings("removal")
-			public void addCookie(Cookie cookie) {
-				SameSite sameSite = getSameSite(cookie);
-				if (sameSite != null) {
-					String comment = HttpCookie.getCommentWithoutAttributes(cookie.getComment());
-					String sameSiteComment = getSameSiteComment(sameSite);
-					cookie.setComment((comment != null) ? comment + sameSiteComment : sameSiteComment);
+			public HttpField onAddField(HttpField field) {
+				return (field.getHeader() != HttpHeader.SET_COOKIE) ? field : onAddSetCookieField(field);
+			}
+
+			private HttpField onAddSetCookieField(HttpField field) {
+				HttpCookie cookie = setCookieParser.parse(field.getValue());
+				SameSite sameSite = (cookie != null) ? getSameSite(cookie) : null;
+				if (sameSite == null) {
+					return field;
 				}
-				super.addCookie(cookie);
+				HttpCookie updatedCookie = buildCookieWithUpdatedSameSite(cookie, sameSite);
+				return new HttpCookieUtils.SetCookieHttpField(updatedCookie, this.compliance);
+			}
+
+			private HttpCookie buildCookieWithUpdatedSameSite(HttpCookie cookie, SameSite sameSite) {
+				return HttpCookie.build(cookie)
+					.sameSite(org.eclipse.jetty.http.HttpCookie.SameSite.from(sameSite.name()))
+					.build();
 			}
 
-			private String getSameSiteComment(SameSite sameSite) {
-				return switch (sameSite) {
-					case NONE -> HttpCookie.SAME_SITE_NONE_COMMENT;
-					case LAX -> HttpCookie.SAME_SITE_LAX_COMMENT;
-					case STRICT -> HttpCookie.SAME_SITE_STRICT_COMMENT;
-				};
+			private SameSite getSameSite(HttpCookie cookie) {
+				return getSameSite(asServletCookie(cookie));
 			}
 
 			private SameSite getSameSite(Cookie cookie) {
-				for (CookieSameSiteSupplier supplier : SuppliedSameSiteCookieHandlerWrapper.this.suppliers) {
-					SameSite sameSite = supplier.getSameSite(cookie);
-					if (sameSite != null) {
-						return sameSite;
-					}
-				}
-				return null;
+				return SuppliedSameSiteCookieHandlerWrapper.this.suppliers.stream()
+					.map((supplier) -> supplier.getSameSite(cookie))
+					.filter(Objects::nonNull)
+					.findFirst()
+					.orElse(null);
+			}
+
+			private Cookie asServletCookie(HttpCookie cookie) {
+				Cookie servletCookie = new Cookie(cookie.getName(), cookie.getValue());
+				cookie.getAttributes().forEach(servletCookie::setAttribute);
+				return servletCookie;
 			}
 
 		}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyWebServer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyWebServer.java
index 95ae2e3c2b67..bbcde54a773c 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyWebServer.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyWebServer.java
@@ -17,7 +17,6 @@
 package org.springframework.boot.web.embedded.jetty;
 
 import java.io.IOException;
-import java.util.Arrays;
 import java.util.List;
 import java.util.Objects;
 import java.util.stream.Collectors;
@@ -29,8 +28,6 @@
 import org.eclipse.jetty.server.NetworkConnector;
 import org.eclipse.jetty.server.Server;
 import org.eclipse.jetty.server.handler.ContextHandler;
-import org.eclipse.jetty.server.handler.HandlerCollection;
-import org.eclipse.jetty.server.handler.HandlerWrapper;
 import org.eclipse.jetty.server.handler.StatisticsHandler;
 
 import org.springframework.boot.web.server.GracefulShutdownCallback;
@@ -106,7 +103,7 @@ private StatisticsHandler findStatisticsHandler(Handler handler) {
 		if (handler instanceof StatisticsHandler statisticsHandler) {
 			return statisticsHandler;
 		}
-		if (handler instanceof HandlerWrapper handlerWrapper) {
+		if (handler instanceof Handler.Wrapper handlerWrapper) {
 			return findStatisticsHandler(handlerWrapper.getHandler());
 		}
 		return null;
@@ -168,8 +165,7 @@ public void start() throws WebServerException {
 					}
 				}
 				this.started = true;
-				logger.info("Jetty started on port(s) " + getActualPortsDescription() + " with context path '"
-						+ getContextPath() + "'");
+				logger.info(getStartedLogMessage());
 			}
 			catch (WebServerException ex) {
 				stopSilently();
@@ -182,15 +178,25 @@ public void start() throws WebServerException {
 		}
 	}
 
+	String getStartedLogMessage() {
+		return "Jetty started on " + getActualPortsDescription() + " with context path '" + getContextPath() + "'";
+	}
+
 	private String getActualPortsDescription() {
-		StringBuilder ports = new StringBuilder();
-		for (Connector connector : this.server.getConnectors()) {
-			if (ports.length() != 0) {
-				ports.append(", ");
+		StringBuilder description = new StringBuilder("port");
+		Connector[] connectors = this.server.getConnectors();
+		if (connectors.length != 1) {
+			description.append("s");
+		}
+		description.append(" ");
+		for (int i = 0; i < connectors.length; i++) {
+			if (i != 0) {
+				description.append(", ");
 			}
-			ports.append(getLocalPort(connector)).append(getProtocols(connector));
+			Connector connector = connectors[i];
+			description.append(getLocalPort(connector)).append(getProtocols(connector));
 		}
-		return ports.toString();
+		return description.toString();
 	}
 
 	private String getProtocols(Connector connector) {
@@ -199,7 +205,8 @@ private String getProtocols(Connector connector) {
 	}
 
 	private String getContextPath() {
-		return Arrays.stream(this.server.getHandlers())
+		return this.server.getHandlers()
+			.stream()
 			.map(this::findContextHandler)
 			.filter(Objects::nonNull)
 			.map(ContextHandler::getContextPath)
@@ -207,7 +214,7 @@ private String getContextPath() {
 	}
 
 	private ContextHandler findContextHandler(Handler handler) {
-		while (handler instanceof HandlerWrapper handlerWrapper) {
+		while (handler instanceof Handler.Wrapper handlerWrapper) {
 			if (handler instanceof ContextHandler contextHandler) {
 				return contextHandler;
 			}
@@ -216,17 +223,21 @@ private ContextHandler findContextHandler(Handler handler) {
 		return null;
 	}
 
-	private void handleDeferredInitialize(Handler... handlers) throws Exception {
+	private void handleDeferredInitialize(List<Handler> handlers) throws Exception {
 		for (Handler handler : handlers) {
-			if (handler instanceof JettyEmbeddedWebAppContext jettyEmbeddedWebAppContext) {
-				jettyEmbeddedWebAppContext.deferredInitialize();
-			}
-			else if (handler instanceof HandlerWrapper handlerWrapper) {
-				handleDeferredInitialize(handlerWrapper.getHandler());
-			}
-			else if (handler instanceof HandlerCollection handlerCollection) {
-				handleDeferredInitialize(handlerCollection.getHandlers());
-			}
+			handleDeferredInitialize(handler);
+		}
+	}
+
+	private void handleDeferredInitialize(Handler handler) throws Exception {
+		if (handler instanceof JettyEmbeddedWebAppContext jettyEmbeddedWebAppContext) {
+			jettyEmbeddedWebAppContext.deferredInitialize();
+		}
+		else if (handler instanceof Handler.Wrapper handlerWrapper) {
+			handleDeferredInitialize(handlerWrapper.getHandler());
+		}
+		else if (handler instanceof Handler.Collection handlerCollection) {
+			handleDeferredInitialize(handlerCollection.getHandlers());
 		}
 	}
 
@@ -238,7 +249,9 @@ public void stop() {
 				this.gracefulShutdown.abort();
 			}
 			try {
-				this.server.stop();
+				for (Connector connector : this.server.getConnectors()) {
+					connector.stop();
+				}
 			}
 			catch (InterruptedException ex) {
 				Thread.currentThread().interrupt();
@@ -249,19 +262,31 @@ public void stop() {
 		}
 	}
 
+	@Override
+	public void destroy() {
+		synchronized (this.monitor) {
+			try {
+				this.server.stop();
+			}
+			catch (Exception ex) {
+				throw new WebServerException("Unable to destroy embedded Jetty server", ex);
+			}
+		}
+	}
+
 	@Override
 	public int getPort() {
 		Connector[] connectors = this.server.getConnectors();
 		for (Connector connector : connectors) {
-			Integer localPort = getLocalPort(connector);
-			if (localPort != null && localPort > 0) {
+			int localPort = getLocalPort(connector);
+			if (localPort > 0) {
 				return localPort;
 			}
 		}
 		return -1;
 	}
 
-	private Integer getLocalPort(Connector connector) {
+	private int getLocalPort(Connector connector) {
 		if (connector instanceof NetworkConnector networkConnector) {
 			return networkConnector.getLocalPort();
 		}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/ServletContextInitializerConfiguration.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/ServletContextInitializerConfiguration.java
index 2b3ecbafa56f..98a86fb5ba4b 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/ServletContextInitializerConfiguration.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/ServletContextInitializerConfiguration.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -17,9 +17,9 @@
 package org.springframework.boot.web.embedded.jetty;
 
 import jakarta.servlet.ServletException;
-import org.eclipse.jetty.webapp.AbstractConfiguration;
-import org.eclipse.jetty.webapp.Configuration;
-import org.eclipse.jetty.webapp.WebAppContext;
+import org.eclipse.jetty.ee10.webapp.AbstractConfiguration;
+import org.eclipse.jetty.ee10.webapp.Configuration;
+import org.eclipse.jetty.ee10.webapp.WebAppContext;
 
 import org.springframework.boot.web.servlet.ServletContextInitializer;
 import org.springframework.util.Assert;
@@ -41,6 +41,7 @@ public class ServletContextInitializerConfiguration extends AbstractConfiguratio
 	 * @since 1.2.1
 	 */
 	public ServletContextInitializerConfiguration(ServletContextInitializer... initializers) {
+		super(new AbstractConfiguration.Builder());
 		Assert.notNull(initializers, "Initializers must not be null");
 		this.initializers = initializers;
 	}
@@ -59,22 +60,13 @@ public void configure(WebAppContext context) throws Exception {
 
 	private void callInitializers(WebAppContext context) throws ServletException {
 		try {
-			setExtendedListenerTypes(context, true);
+			context.getContext().setExtendedListenerTypes(true);
 			for (ServletContextInitializer initializer : this.initializers) {
 				initializer.onStartup(context.getServletContext());
 			}
 		}
 		finally {
-			setExtendedListenerTypes(context, false);
-		}
-	}
-
-	private void setExtendedListenerTypes(WebAppContext context, boolean extended) {
-		try {
-			context.getServletContext().setExtendedListenerTypes(extended);
-		}
-		catch (NoSuchMethodError ex) {
-			// Not available on Jetty 8
+			context.getContext().setExtendedListenerTypes(false);
 		}
 	}
 
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/SslServerCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/SslServerCustomizer.java
index cffefa6f0985..329b6a73f2fb 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/SslServerCustomizer.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/SslServerCustomizer.java
@@ -111,19 +111,7 @@ private ServerConnector createHttp11ServerConnector(HttpConfiguration config,
 
 	private SslConnectionFactory createSslConnectionFactory(SslContextFactory.Server sslContextFactory,
 			String protocol) {
-		try {
-			return new SslConnectionFactory(sslContextFactory, protocol);
-		}
-		catch (NoSuchMethodError ex) {
-			// Jetty 10
-			try {
-				return SslConnectionFactory.class.getConstructor(SslContextFactory.Server.class, String.class)
-					.newInstance(sslContextFactory, protocol);
-			}
-			catch (Exception ex2) {
-				throw new RuntimeException(ex2);
-			}
-		}
+		return new SslConnectionFactory(sslContextFactory, protocol);
 	}
 
 	private boolean isJettyAlpnPresent() {
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactory.java
index f351da622fd4..ee8c014ec3d9 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactory.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactory.java
@@ -27,22 +27,23 @@
 
 import reactor.netty.http.HttpProtocol;
 import reactor.netty.http.server.HttpServer;
-import reactor.netty.resources.LoopResources;
 
 import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactory;
 import org.springframework.boot.web.reactive.server.ReactiveWebServerFactory;
 import org.springframework.boot.web.server.Shutdown;
 import org.springframework.boot.web.server.Ssl;
 import org.springframework.boot.web.server.WebServer;
-import org.springframework.http.client.reactive.ReactorResourceFactory;
+import org.springframework.http.client.ReactorResourceFactory;
 import org.springframework.http.server.reactive.HttpHandler;
 import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
 import org.springframework.util.Assert;
+import org.springframework.util.StringUtils;
 
 /**
  * {@link ReactiveWebServerFactory} that can be used to create {@link NettyWebServer}s.
  *
  * @author Brian Clozel
+ * @author Moritz Halbritter
  * @since 2.0.0
  */
 public class NettyReactiveWebServerFactory extends AbstractReactiveWebServerFactory {
@@ -78,7 +79,7 @@ public WebServer getWebServer(HttpHandler httpHandler) {
 
 	NettyWebServer createNettyWebServer(HttpServer httpServer, ReactorHttpHandlerAdapter handlerAdapter,
 			Duration lifecycleTimeout, Shutdown shutdown) {
-		return new NettyWebServer(httpServer, handlerAdapter, lifecycleTimeout, shutdown);
+		return new NettyWebServer(httpServer, handlerAdapter, lifecycleTimeout, shutdown, this.resourceFactory);
 	}
 
 	/**
@@ -158,15 +159,7 @@ public Shutdown getShutdown() {
 	}
 
 	private HttpServer createHttpServer() {
-		HttpServer server = HttpServer.create();
-		if (this.resourceFactory != null) {
-			LoopResources resources = this.resourceFactory.getLoopResources();
-			Assert.notNull(resources, "No LoopResources: is ReactorResourceFactory not initialized yet?");
-			server = server.runOn(resources).bindAddress(this::getListenAddress);
-		}
-		else {
-			server = server.bindAddress(this::getListenAddress);
-		}
+		HttpServer server = HttpServer.create().bindAddress(this::getListenAddress);
 		if (Ssl.isEnabled(getSsl())) {
 			server = customizeSslConfiguration(server);
 		}
@@ -179,7 +172,12 @@ private HttpServer createHttpServer() {
 	}
 
 	private HttpServer customizeSslConfiguration(HttpServer httpServer) {
-		return new SslServerCustomizer(getHttp2(), getSsl().getClientAuth(), getSslBundle()).apply(httpServer);
+		SslServerCustomizer customizer = new SslServerCustomizer(getHttp2(), getSsl().getClientAuth(), getSslBundle());
+		String bundleName = getSsl().getBundle();
+		if (StringUtils.hasText(bundleName)) {
+			getSslBundles().addBundleUpdateHandler(bundleName, customizer::updateSslBundle);
+		}
+		return customizer.apply(httpServer);
 	}
 
 	private HttpProtocol[] listProtocols() {
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyWebServer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyWebServer.java
index 8b555f350f75..a9e6e6c2c953 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyWebServer.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyWebServer.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -35,6 +35,7 @@
 import reactor.netty.http.server.HttpServerRequest;
 import reactor.netty.http.server.HttpServerResponse;
 import reactor.netty.http.server.HttpServerRoutes;
+import reactor.netty.resources.LoopResources;
 
 import org.springframework.boot.web.server.GracefulShutdownCallback;
 import org.springframework.boot.web.server.GracefulShutdownResult;
@@ -42,6 +43,7 @@
 import org.springframework.boot.web.server.Shutdown;
 import org.springframework.boot.web.server.WebServer;
 import org.springframework.boot.web.server.WebServerException;
+import org.springframework.http.client.ReactorResourceFactory;
 import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
 import org.springframework.util.Assert;
 
@@ -74,12 +76,39 @@ public class NettyWebServer implements WebServer {
 
 	private final GracefulShutdown gracefulShutdown;
 
+	private final ReactorResourceFactory resourceFactory;
+
 	private List<NettyRouteProvider> routeProviders = Collections.emptyList();
 
 	private volatile DisposableServer disposableServer;
 
+	/**
+	 * Creates a new {@code NettyWebServer} instance.
+	 * @param httpServer the HTTP server
+	 * @param handlerAdapter the handler adapter
+	 * @param lifecycleTimeout the lifecycle timeout, may be {@code null}
+	 * @param shutdown the shutdown, may be {@code null}
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of
+	 * {@link #NettyWebServer(HttpServer, ReactorHttpHandlerAdapter, Duration, Shutdown, ReactorResourceFactory)}
+	 */
+	@Deprecated(since = "3.2.0", forRemoval = true)
 	public NettyWebServer(HttpServer httpServer, ReactorHttpHandlerAdapter handlerAdapter, Duration lifecycleTimeout,
 			Shutdown shutdown) {
+		this(httpServer, handlerAdapter, lifecycleTimeout, shutdown, null);
+	}
+
+	/**
+	 * Creates a new {@code NettyWebServer} instance.
+	 * @param httpServer the HTTP server
+	 * @param handlerAdapter the handler adapter
+	 * @param lifecycleTimeout the lifecycle timeout, may be {@code null}
+	 * @param shutdown the shutdown, may be {@code null}
+	 * @param resourceFactory the factory for the server's {@link LoopResources loop
+	 * resources}, may be {@code null}
+	 * @since 3.2.0
+	 */
+	public NettyWebServer(HttpServer httpServer, ReactorHttpHandlerAdapter handlerAdapter, Duration lifecycleTimeout,
+			Shutdown shutdown, ReactorResourceFactory resourceFactory) {
 		Assert.notNull(httpServer, "HttpServer must not be null");
 		Assert.notNull(handlerAdapter, "HandlerAdapter must not be null");
 		this.lifecycleTimeout = lifecycleTimeout;
@@ -87,6 +116,7 @@ public NettyWebServer(HttpServer httpServer, ReactorHttpHandlerAdapter handlerAd
 		this.httpServer = httpServer.channelGroup(new DefaultChannelGroup(new DefaultEventExecutor()));
 		this.gracefulShutdown = (shutdown == Shutdown.GRACEFUL) ? new GracefulShutdown(() -> this.disposableServer)
 				: null;
+		this.resourceFactory = resourceFactory;
 	}
 
 	public void setRouteProviders(List<NettyRouteProvider> routeProviders) {
@@ -108,7 +138,7 @@ public void start() throws WebServerException {
 				throw new WebServerException("Unable to start Netty", ex);
 			}
 			if (this.disposableServer != null) {
-				logger.info("Netty started" + getStartedOnMessage(this.disposableServer));
+				logger.info(getStartedOnMessage(this.disposableServer));
 			}
 			startDaemonAwaitThread(this.disposableServer);
 		}
@@ -118,16 +148,21 @@ private String getStartedOnMessage(DisposableServer server) {
 		StringBuilder message = new StringBuilder();
 		tryAppend(message, "port %s", server::port);
 		tryAppend(message, "path %s", server::path);
-		return (message.length() > 0) ? " on " + message : "";
+		return (!message.isEmpty()) ? "Netty started on " + message : "Netty started";
+	}
+
+	protected String getStartedLogMessage() {
+		return getStartedOnMessage(this.disposableServer);
 	}
 
 	private void tryAppend(StringBuilder message, String format, Supplier<Object> supplier) {
 		try {
 			Object value = supplier.get();
-			message.append((message.length() != 0) ? " " : "");
+			message.append((!message.isEmpty()) ? " " : "");
 			message.append(String.format(format, value));
 		}
 		catch (UnsupportedOperationException ex) {
+			// Ignore
 		}
 	}
 
@@ -139,6 +174,11 @@ DisposableServer startHttpServer() {
 		else {
 			server = server.route(this::applyRouteProviders);
 		}
+		if (this.resourceFactory != null) {
+			LoopResources resources = this.resourceFactory.getLoopResources();
+			Assert.notNull(resources, "No LoopResources: is ReactorResourceFactory not initialized yet?");
+			server = server.runOn(resources);
+		}
 		if (this.lifecycleTimeout != null) {
 			return server.bindNow(this.lifecycleTimeout);
 		}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/SslServerCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/SslServerCustomizer.java
index 5480c4d0c876..204868e06088 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/SslServerCustomizer.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/SslServerCustomizer.java
@@ -17,10 +17,14 @@
 package org.springframework.boot.web.embedded.netty;
 
 import io.netty.handler.ssl.ClientAuth;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
 import reactor.netty.http.Http11SslContextSpec;
 import reactor.netty.http.Http2SslContextSpec;
 import reactor.netty.http.server.HttpServer;
 import reactor.netty.tcp.AbstractProtocolSslContextSpec;
+import reactor.netty.tcp.SslProvider;
+import reactor.netty.tcp.SslProvider.SslContextSpec;
 
 import org.springframework.boot.ssl.SslBundle;
 import org.springframework.boot.ssl.SslOptions;
@@ -36,41 +40,77 @@
  * @author Chris Bono
  * @author Cyril Dangerville
  * @author Scott Frederick
+ * @author Moritz Halbritter
+ * @author Phillip Webb
  * @since 2.0.0
  */
 public class SslServerCustomizer implements NettyServerCustomizer {
 
+	private static final Log logger = LogFactory.getLog(SslServerCustomizer.class);
+
 	private final Http2 http2;
 
-	private final Ssl.ClientAuth clientAuth;
+	private final ClientAuth clientAuth;
+
+	private volatile SslProvider sslProvider;
 
-	private final SslBundle sslBundle;
+	private volatile SslBundle sslBundle;
 
 	public SslServerCustomizer(Http2 http2, Ssl.ClientAuth clientAuth, SslBundle sslBundle) {
 		this.http2 = http2;
-		this.clientAuth = clientAuth;
+		this.clientAuth = Ssl.ClientAuth.map(clientAuth, ClientAuth.NONE, ClientAuth.OPTIONAL, ClientAuth.REQUIRE);
 		this.sslBundle = sslBundle;
+		this.sslProvider = createSslProvider(sslBundle);
 	}
 
 	@Override
 	public HttpServer apply(HttpServer server) {
-		AbstractProtocolSslContextSpec<?> sslContextSpec = createSslContextSpec();
-		return server.secure((spec) -> spec.sslContext(sslContextSpec));
+		return server.secure(this::applySecurity);
+	}
+
+	private void applySecurity(SslContextSpec spec) {
+		spec.sslContext(this.sslProvider.getSslContext())
+			.setSniAsyncMappings((domainName, promise) -> promise.setSuccess(this.sslProvider));
 	}
 
+	void updateSslBundle(SslBundle sslBundle) {
+		logger.debug("SSL Bundle has been updated, reloading SSL configuration");
+		this.sslBundle = sslBundle;
+		this.sslProvider = createSslProvider(sslBundle);
+	}
+
+	private SslProvider createSslProvider(SslBundle sslBundle) {
+		return SslProvider.builder().sslContext(createSslContextSpec(sslBundle)).build();
+	}
+
+	/**
+	 * Factory method used to create an {@link AbstractProtocolSslContextSpec}.
+	 * @return the {@link AbstractProtocolSslContextSpec} to use
+	 * @deprecated since 3.2.0 for removal in 3.4.0 in favor of
+	 * {@link #createSslContextSpec(SslBundle)}
+	 */
+	@Deprecated(since = "3.2", forRemoval = true)
 	protected AbstractProtocolSslContextSpec<?> createSslContextSpec() {
+		return createSslContextSpec(this.sslBundle);
+	}
+
+	/**
+	 * Create an {@link AbstractProtocolSslContextSpec} for a given {@link SslBundle}.
+	 * @param sslBundle the {@link SslBundle} to use
+	 * @return an {@link AbstractProtocolSslContextSpec} instance
+	 * @since 3.2.0
+	 */
+	protected final AbstractProtocolSslContextSpec<?> createSslContextSpec(SslBundle sslBundle) {
 		AbstractProtocolSslContextSpec<?> sslContextSpec = (this.http2 != null && this.http2.isEnabled())
-				? Http2SslContextSpec.forServer(this.sslBundle.getManagers().getKeyManagerFactory())
-				: Http11SslContextSpec.forServer(this.sslBundle.getManagers().getKeyManagerFactory());
-		sslContextSpec.configure((builder) -> {
-			builder.trustManager(this.sslBundle.getManagers().getTrustManagerFactory());
-			SslOptions options = this.sslBundle.getOptions();
+				? Http2SslContextSpec.forServer(sslBundle.getManagers().getKeyManagerFactory())
+				: Http11SslContextSpec.forServer(sslBundle.getManagers().getKeyManagerFactory());
+		return sslContextSpec.configure((builder) -> {
+			builder.trustManager(sslBundle.getManagers().getTrustManagerFactory());
+			SslOptions options = sslBundle.getOptions();
 			builder.protocols(options.getEnabledProtocols());
 			builder.ciphers(SslOptions.asSet(options.getCiphers()));
-			builder.clientAuth(org.springframework.boot.web.server.Ssl.ClientAuth.map(this.clientAuth, ClientAuth.NONE,
-					ClientAuth.OPTIONAL, ClientAuth.REQUIRE));
+			builder.clientAuth(this.clientAuth);
 		});
-		return sslContextSpec;
 	}
 
 }
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/GracefulShutdown.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/GracefulShutdown.java
index c921cf5c94aa..3215a0de8609 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/GracefulShutdown.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/GracefulShutdown.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -60,14 +60,14 @@ private void doShutdown(GracefulShutdownCallback callback) {
 		try {
 			for (Container host : this.tomcat.getEngine().findChildren()) {
 				for (Container context : host.findChildren()) {
-					while (isActive(context)) {
-						if (this.aborted) {
-							logger.info("Graceful shutdown aborted with one or more requests still active");
-							callback.shutdownComplete(GracefulShutdownResult.REQUESTS_ACTIVE);
-							return;
-						}
+					while (!this.aborted && isActive(context)) {
 						Thread.sleep(50);
 					}
+					if (this.aborted) {
+						logger.info("Graceful shutdown aborted with one or more requests still active");
+						callback.shutdownComplete(GracefulShutdownResult.REQUESTS_ACTIVE);
+						return;
+					}
 				}
 			}
 
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/NestedJarResourceSet.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/NestedJarResourceSet.java
new file mode 100644
index 000000000000..0f1be9560a92
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/NestedJarResourceSet.java
@@ -0,0 +1,161 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.web.embedded.tomcat;
+
+import java.io.IOException;
+import java.net.JarURLConnection;
+import java.net.URL;
+import java.net.URLConnection;
+import java.util.jar.Attributes;
+import java.util.jar.Attributes.Name;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.jar.Manifest;
+
+import org.apache.catalina.LifecycleException;
+import org.apache.catalina.WebResource;
+import org.apache.catalina.WebResourceRoot;
+import org.apache.catalina.WebResourceSet;
+import org.apache.catalina.webresources.AbstractSingleArchiveResourceSet;
+import org.apache.catalina.webresources.JarResource;
+
+import org.springframework.util.Assert;
+import org.springframework.util.ResourceUtils;
+
+/**
+ * A {@link WebResourceSet} for a resource in a nested JAR.
+ *
+ * @author Phillip Webb
+ */
+class NestedJarResourceSet extends AbstractSingleArchiveResourceSet {
+
+	private static final Name MULTI_RELEASE = new Name("Multi-Release");
+
+	private final URL url;
+
+	private JarFile archive = null;
+
+	private long archiveUseCount = 0;
+
+	private boolean useCaches;
+
+	private volatile Boolean multiRelease;
+
+	NestedJarResourceSet(URL url, WebResourceRoot root, String webAppMount, String internalPath)
+			throws IllegalArgumentException {
+		this.url = url;
+		setRoot(root);
+		setWebAppMount(webAppMount);
+		setInternalPath(internalPath);
+		setStaticOnly(true);
+		if (getRoot().getState().isAvailable()) {
+			try {
+				start();
+			}
+			catch (LifecycleException ex) {
+				throw new IllegalStateException(ex);
+			}
+		}
+	}
+
+	@Override
+	protected WebResource createArchiveResource(JarEntry jarEntry, String webAppPath, Manifest manifest) {
+		return new JarResource(this, webAppPath, getBaseUrlString(), jarEntry);
+	}
+
+	@Override
+	protected void initInternal() throws LifecycleException {
+		try {
+			JarURLConnection connection = connect();
+			try {
+				setManifest(connection.getManifest());
+				setBaseUrl(connection.getJarFileURL());
+			}
+			finally {
+				if (!connection.getUseCaches()) {
+					connection.getJarFile().close();
+				}
+			}
+		}
+		catch (IOException ex) {
+			throw new IllegalStateException(ex);
+		}
+	}
+
+	@Override
+	protected JarFile openJarFile() throws IOException {
+		synchronized (this.archiveLock) {
+			if (this.archive == null) {
+				JarURLConnection connection = connect();
+				this.useCaches = connection.getUseCaches();
+				this.archive = connection.getJarFile();
+			}
+			this.archiveUseCount++;
+			return this.archive;
+		}
+	}
+
+	@Override
+	protected void closeJarFile() {
+		synchronized (this.archiveLock) {
+			this.archiveUseCount--;
+		}
+	}
+
+	@Override
+	protected boolean isMultiRelease() {
+		if (this.multiRelease == null) {
+			synchronized (this.archiveLock) {
+				if (this.multiRelease == null) {
+					// JarFile.isMultiRelease() is final so we must go to the manifest
+					Manifest manifest = getManifest();
+					Attributes attributes = (manifest != null) ? manifest.getMainAttributes() : null;
+					this.multiRelease = (attributes != null) ? attributes.containsKey(MULTI_RELEASE) : false;
+				}
+			}
+		}
+		return this.multiRelease.booleanValue();
+	}
+
+	@Override
+	public void gc() {
+		synchronized (this.archiveLock) {
+			if (this.archive != null && this.archiveUseCount == 0) {
+				try {
+					if (!this.useCaches) {
+						this.archive.close();
+					}
+				}
+				catch (IOException ex) {
+					// Ignore
+				}
+				this.archive = null;
+				this.archiveEntries = null;
+			}
+		}
+	}
+
+	private JarURLConnection connect() throws IOException {
+		URLConnection connection = this.url.openConnection();
+		ResourceUtils.useCachesIfNecessary(connection);
+		Assert.state(connection instanceof JarURLConnection,
+				() -> "URL '%s' did not return a JAR connection".formatted(this.url));
+		connection.connect();
+		return (JarURLConnection) connection;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizer.java
index 813edb3fbe05..75601111c1df 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizer.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizer.java
@@ -17,6 +17,7 @@
 package org.springframework.boot.web.embedded.tomcat;
 
 import org.apache.catalina.connector.Connector;
+import org.apache.commons.logging.Log;
 import org.apache.coyote.ProtocolHandler;
 import org.apache.coyote.http11.AbstractHttp11JsseProtocol;
 import org.apache.coyote.http11.Http11NioProtocol;
@@ -33,48 +34,62 @@
 import org.springframework.util.StringUtils;
 
 /**
- * {@link TomcatConnectorCustomizer} that configures SSL support on the given connector.
+ * Utility that configures SSL support on the given connector.
  *
  * @author Brian Clozel
  * @author Andy Wilkinson
  * @author Scott Frederick
  * @author Cyril Dangerville
+ * @author Moritz Halbritter
  */
-class SslConnectorCustomizer implements TomcatConnectorCustomizer {
+class SslConnectorCustomizer {
+
+	private final Log logger;
 
 	private final ClientAuth clientAuth;
 
-	private final SslBundle sslBundle;
+	private final Connector connector;
 
-	SslConnectorCustomizer(ClientAuth clientAuth, SslBundle sslBundle) {
+	SslConnectorCustomizer(Log logger, Connector connector, ClientAuth clientAuth) {
+		this.logger = logger;
 		this.clientAuth = clientAuth;
-		this.sslBundle = sslBundle;
+		this.connector = connector;
+	}
+
+	void update(SslBundle updatedSslBundle) {
+		this.logger.debug("SSL Bundle has been updated, reloading SSL configuration");
+		customize(updatedSslBundle);
 	}
 
-	@Override
-	public void customize(Connector connector) {
-		ProtocolHandler handler = connector.getProtocolHandler();
+	void customize(SslBundle sslBundle) {
+		ProtocolHandler handler = this.connector.getProtocolHandler();
 		Assert.state(handler instanceof AbstractHttp11JsseProtocol,
 				"To use SSL, the connector's protocol handler must be an AbstractHttp11JsseProtocol subclass");
-		configureSsl((AbstractHttp11JsseProtocol<?>) handler);
-		connector.setScheme("https");
-		connector.setSecure(true);
+		configureSsl(sslBundle, (AbstractHttp11JsseProtocol<?>) handler);
+		this.connector.setScheme("https");
+		this.connector.setSecure(true);
 	}
 
 	/**
 	 * Configure Tomcat's {@link AbstractHttp11JsseProtocol} for SSL.
+	 * @param sslBundle the SSL bundle
 	 * @param protocol the protocol
 	 */
-	void configureSsl(AbstractHttp11JsseProtocol<?> protocol) {
-		SslBundleKey key = this.sslBundle.getKey();
-		SslStoreBundle stores = this.sslBundle.getStores();
-		SslOptions options = this.sslBundle.getOptions();
+	private void configureSsl(SslBundle sslBundle, AbstractHttp11JsseProtocol<?> protocol) {
 		protocol.setSSLEnabled(true);
 		SSLHostConfig sslHostConfig = new SSLHostConfig();
 		sslHostConfig.setHostName(protocol.getDefaultSSLHostConfigName());
-		sslHostConfig.setSslProtocol(this.sslBundle.getProtocol());
-		protocol.addSslHostConfig(sslHostConfig);
 		configureSslClientAuth(sslHostConfig);
+		applySslBundle(sslBundle, protocol, sslHostConfig);
+		protocol.addSslHostConfig(sslHostConfig, true);
+	}
+
+	private void applySslBundle(SslBundle sslBundle, AbstractHttp11JsseProtocol<?> protocol,
+			SSLHostConfig sslHostConfig) {
+		SslBundleKey key = sslBundle.getKey();
+		SslStoreBundle stores = sslBundle.getStores();
+		SslOptions options = sslBundle.getOptions();
+		sslHostConfig.setSslProtocol(sslBundle.getProtocol());
 		SSLHostConfigCertificate certificate = new SSLHostConfigCertificate(sslHostConfig, Type.UNDEFINED);
 		String keystorePassword = (stores.getKeyStorePassword() != null) ? stores.getKeyStorePassword() : "";
 		certificate.setCertificateKeystorePassword(keystorePassword);
@@ -89,17 +104,14 @@ void configureSsl(AbstractHttp11JsseProtocol<?> protocol) {
 			String ciphers = StringUtils.arrayToCommaDelimitedString(options.getCiphers());
 			sslHostConfig.setCiphers(ciphers);
 		}
-		configureEnabledProtocols(protocol);
-		configureSslStoreProvider(protocol, sslHostConfig, certificate);
+		configureSslStoreProvider(protocol, sslHostConfig, certificate, stores);
+		configureEnabledProtocols(sslHostConfig, options);
 	}
 
-	private void configureEnabledProtocols(AbstractHttp11JsseProtocol<?> protocol) {
-		SslOptions options = this.sslBundle.getOptions();
+	private void configureEnabledProtocols(SSLHostConfig sslHostConfig, SslOptions options) {
 		if (options.getEnabledProtocols() != null) {
-			String enabledProtocols = StringUtils.arrayToCommaDelimitedString(options.getEnabledProtocols());
-			for (SSLHostConfig sslHostConfig : protocol.findSslHostConfigs()) {
-				sslHostConfig.setProtocols(enabledProtocols);
-			}
+			String enabledProtocols = StringUtils.arrayToDelimitedString(options.getEnabledProtocols(), "+");
+			sslHostConfig.setProtocols(enabledProtocols);
 		}
 	}
 
@@ -107,12 +119,11 @@ private void configureSslClientAuth(SSLHostConfig config) {
 		config.setCertificateVerification(ClientAuth.map(this.clientAuth, "none", "optional", "required"));
 	}
 
-	protected void configureSslStoreProvider(AbstractHttp11JsseProtocol<?> protocol, SSLHostConfig sslHostConfig,
-			SSLHostConfigCertificate certificate) {
+	private void configureSslStoreProvider(AbstractHttp11JsseProtocol<?> protocol, SSLHostConfig sslHostConfig,
+			SSLHostConfigCertificate certificate, SslStoreBundle stores) {
 		Assert.isInstanceOf(Http11NioProtocol.class, protocol,
 				"SslStoreProvider can only be used with Http11NioProtocol");
 		try {
-			SslStoreBundle stores = this.sslBundle.getStores();
 			if (stores.getKeyStore() != null) {
 				certificate.setCertificateKeystore(stores.getKeyStore());
 			}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactory.java
index ab31ecce7398..ff6d8e729cc2 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactory.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactory.java
@@ -35,6 +35,8 @@
 import org.apache.catalina.core.AprLifecycleListener;
 import org.apache.catalina.loader.WebappLoader;
 import org.apache.catalina.startup.Tomcat;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
 import org.apache.coyote.AbstractProtocol;
 import org.apache.coyote.ProtocolHandler;
 import org.apache.coyote.http2.Http2Protocol;
@@ -57,11 +59,14 @@
  *
  * @author Brian Clozel
  * @author HaiTao Zhang
+ * @author Moritz Halbritter
  * @since 2.0.0
  */
 public class TomcatReactiveWebServerFactory extends AbstractReactiveWebServerFactory
 		implements ConfigurableTomcatWebServerFactory {
 
+	private static final Log logger = LogFactory.getLog(TomcatReactiveWebServerFactory.class);
+
 	private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
 
 	/**
@@ -224,7 +229,12 @@ private void customizeProtocol(AbstractProtocol<?> protocol) {
 	}
 
 	private void customizeSsl(Connector connector) {
-		new SslConnectorCustomizer(getSsl().getClientAuth(), getSslBundle()).customize(connector);
+		SslConnectorCustomizer customizer = new SslConnectorCustomizer(logger, connector, getSsl().getClientAuth());
+		customizer.customize(getSslBundle());
+		String sslBundleName = getSsl().getBundle();
+		if (StringUtils.hasText(sslBundleName)) {
+			getSslBundles().addBundleUpdateHandler(sslBundleName, customizer::update);
+		}
 	}
 
 	@Override
@@ -333,7 +343,10 @@ public Collection<TomcatProtocolHandlerCustomizer<?>> getTomcatProtocolHandlerCu
 	}
 
 	/**
-	 * Add {@link Connector}s in addition to the default connector, e.g. for SSL or AJP
+	 * Add {@link Connector}s in addition to the default connector, e.g. for SSL or AJP.
+	 * <p>
+	 * {@link #getTomcatConnectorCustomizers Connector customizers} are not applied to
+	 * connectors added this way.
 	 * @param connectors the connectors to add
 	 * @since 2.2.0
 	 */
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactory.java
index ca5e6fa1d413..5c60b0c7a909 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactory.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactory.java
@@ -19,6 +19,7 @@
 import java.io.File;
 import java.io.InputStream;
 import java.lang.reflect.Method;
+import java.net.MalformedURLException;
 import java.net.URL;
 import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
@@ -46,6 +47,7 @@
 import org.apache.catalina.Manager;
 import org.apache.catalina.Valve;
 import org.apache.catalina.WebResource;
+import org.apache.catalina.WebResourceRoot;
 import org.apache.catalina.WebResourceRoot.ResourceSetType;
 import org.apache.catalina.WebResourceSet;
 import org.apache.catalina.Wrapper;
@@ -60,6 +62,8 @@
 import org.apache.catalina.webresources.AbstractResourceSet;
 import org.apache.catalina.webresources.EmptyResource;
 import org.apache.catalina.webresources.StandardRoot;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
 import org.apache.coyote.AbstractProtocol;
 import org.apache.coyote.ProtocolHandler;
 import org.apache.coyote.http2.Http2Protocol;
@@ -101,6 +105,7 @@
  * @author EddĂș MelĂ©ndez
  * @author Christoffer Sawicki
  * @author Dawid Antecki
+ * @author Moritz Halbritter
  * @since 2.0.0
  * @see #setPort(int)
  * @see #setContextLifecycleListeners(Collection)
@@ -109,6 +114,8 @@
 public class TomcatServletWebServerFactory extends AbstractServletWebServerFactory
 		implements ConfigurableTomcatWebServerFactory, ResourceLoaderAware {
 
+	private static final Log logger = LogFactory.getLog(TomcatServletWebServerFactory.class);
+
 	private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
 
 	private static final Set<Class<?>> NO_CLASSES = Collections.emptySet();
@@ -364,7 +371,12 @@ private void invokeProtocolHandlerCustomizers(ProtocolHandler protocolHandler) {
 	}
 
 	private void customizeSsl(Connector connector) {
-		new SslConnectorCustomizer(getSsl().getClientAuth(), getSslBundle()).customize(connector);
+		SslConnectorCustomizer customizer = new SslConnectorCustomizer(logger, connector, getSsl().getClientAuth());
+		customizer.customize(getSslBundle());
+		String sslBundleName = getSsl().getBundle();
+		if (StringUtils.hasText(sslBundleName)) {
+			getSslBundles().addBundleUpdateHandler(sslBundleName, customizer::update);
+		}
 	}
 
 	/**
@@ -705,7 +717,10 @@ public Collection<TomcatProtocolHandlerCustomizer<?>> getTomcatProtocolHandlerCu
 	}
 
 	/**
-	 * Add {@link Connector}s in addition to the default connector, e.g. for SSL or AJP
+	 * Add {@link Connector}s in addition to the default connector, e.g. for SSL or AJP.
+	 * <p>
+	 * {@link #getTomcatConnectorCustomizers Connector customizers} are not applied to
+	 * connectors added this way.
 	 * @param connectors the connectors to add
 	 */
 	public void addAdditionalTomcatConnectors(Connector... connectors) {
@@ -772,6 +787,10 @@ public void lifecycleEvent(LifecycleEvent event) {
 
 	private final class StaticResourceConfigurer implements LifecycleListener {
 
+		private static final String WEB_APP_MOUNT = "/";
+
+		private static final String INTERNAL_PATH = "/META-INF/resources";
+
 		private final Context context;
 
 		private StaticResourceConfigurer(Context context) {
@@ -804,23 +823,39 @@ private void addResourceJars(List<URL> resourceJarUrls) {
 
 		private void addResourceSet(String resource) {
 			try {
-				if (isInsideNestedJar(resource)) {
-					// It's a nested jar but we now don't want the suffix because Tomcat
-					// is going to try and locate it as a root URL (not the resource
-					// inside it)
-					resource = resource.substring(0, resource.length() - 2);
+				if (isInsideClassicNestedJar(resource)) {
+					addClassicNestedResourceSet(resource);
+					return;
 				}
+				WebResourceRoot root = this.context.getResources();
 				URL url = new URL(resource);
-				String path = "/META-INF/resources";
-				this.context.getResources().createWebResourceSet(ResourceSetType.RESOURCE_JAR, "/", url, path);
+				if (isInsideNestedJar(resource)) {
+					root.addJarResources(new NestedJarResourceSet(url, root, WEB_APP_MOUNT, INTERNAL_PATH));
+				}
+				else {
+					root.createWebResourceSet(ResourceSetType.RESOURCE_JAR, WEB_APP_MOUNT, url, INTERNAL_PATH);
+				}
 			}
 			catch (Exception ex) {
 				// Ignore (probably not a directory)
 			}
 		}
 
-		private boolean isInsideNestedJar(String dir) {
-			return dir.indexOf("!/") < dir.lastIndexOf("!/");
+		private void addClassicNestedResourceSet(String resource) throws MalformedURLException {
+			// It's a nested jar but we now don't want the suffix because Tomcat
+			// is going to try and locate it as a root URL (not the resource
+			// inside it)
+			URL url = new URL(resource.substring(0, resource.length() - 2));
+			this.context.getResources()
+				.createWebResourceSet(ResourceSetType.RESOURCE_JAR, WEB_APP_MOUNT, url, INTERNAL_PATH);
+		}
+
+		private boolean isInsideClassicNestedJar(String resource) {
+			return !isInsideNestedJar(resource) && resource.indexOf("!/") < resource.lastIndexOf("!/");
+		}
+
+		private boolean isInsideNestedJar(String resource) {
+			return resource.startsWith("jar:nested:");
 		}
 
 	}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatWebServer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatWebServer.java
index 7c05aa77f3c2..7d7ce965468e 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatWebServer.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatWebServer.java
@@ -105,7 +105,7 @@ public TomcatWebServer(Tomcat tomcat, boolean autoStart, Shutdown shutdown) {
 	}
 
 	private void initialize() throws WebServerException {
-		logger.info("Tomcat initialized with port(s): " + getPortsDescription(false));
+		logger.info("Tomcat initialized with " + getPortsDescription(false));
 		synchronized (this.monitor) {
 			try {
 				addInstanceIdToEngineName();
@@ -134,7 +134,7 @@ private void initialize() throws WebServerException {
 
 				// Unlike Jetty, all Tomcat threads are daemon threads. We create a
 				// blocking non-daemon to stop immediate shutdown
-				startDaemonAwaitThread();
+				startNonDaemonAwaitThread();
 			}
 			catch (Exception ex) {
 				stopSilently();
@@ -189,7 +189,7 @@ private void rethrowDeferredStartupExceptions() throws Exception {
 		}
 	}
 
-	private void startDaemonAwaitThread() {
+	private void startNonDaemonAwaitThread() {
 		Thread awaitThread = new Thread("container-" + (containerCounter.get())) {
 
 			@Override
@@ -209,6 +209,7 @@ public void start() throws WebServerException {
 			if (this.started) {
 				return;
 			}
+
 			try {
 				addPreviouslyRemovedConnectors();
 				Connector connector = this.tomcat.getConnector();
@@ -217,8 +218,7 @@ public void start() throws WebServerException {
 				}
 				checkThatConnectorsHaveStarted();
 				this.started = true;
-				logger.info("Tomcat started on port(s): " + getPortsDescription(true) + " with context path '"
-						+ getContextPath() + "'");
+				logger.info(getStartedLogMessage());
 			}
 			catch (ConnectorStartFailedException ex) {
 				stopSilently();
@@ -235,6 +235,10 @@ public void start() throws WebServerException {
 		}
 	}
 
+	String getStartedLogMessage() {
+		return "Tomcat started on " + getPortsDescription(true) + " with context path '" + getContextPath() + "'";
+	}
+
 	private void checkThatConnectorsHaveStarted() {
 		checkConnectorHasStarted(this.tomcat.getConnector());
 		for (Connector connector : this.tomcat.getService().findConnectors()) {
@@ -324,16 +328,10 @@ public void stop() throws WebServerException {
 			boolean wasStarted = this.started;
 			try {
 				this.started = false;
-				try {
-					if (this.gracefulShutdown != null) {
-						this.gracefulShutdown.abort();
-					}
-					stopTomcat();
-					this.tomcat.destroy();
-				}
-				catch (LifecycleException ex) {
-					// swallow and continue
+				if (this.gracefulShutdown != null) {
+					this.gracefulShutdown.abort();
 				}
+				removeServiceConnectors();
 			}
 			catch (Exception ex) {
 				throw new WebServerException("Unable to stop embedded Tomcat", ex);
@@ -346,16 +344,37 @@ public void stop() throws WebServerException {
 		}
 	}
 
+	@Override
+	public void destroy() throws WebServerException {
+		try {
+			stopTomcat();
+			this.tomcat.destroy();
+		}
+		catch (LifecycleException ex) {
+			// Swallow and continue
+		}
+		catch (Exception ex) {
+			throw new WebServerException("Unable to destroy embedded Tomcat", ex);
+		}
+	}
+
 	private String getPortsDescription(boolean localPort) {
-		StringBuilder ports = new StringBuilder();
-		for (Connector connector : this.tomcat.getService().findConnectors()) {
-			if (ports.length() != 0) {
-				ports.append(' ');
+		StringBuilder description = new StringBuilder();
+		Connector[] connectors = this.tomcat.getService().findConnectors();
+		description.append("port");
+		if (connectors.length != 1) {
+			description.append("s");
+		}
+		description.append(" ");
+		for (int i = 0; i < connectors.length; i++) {
+			if (i != 0) {
+				description.append(", ");
 			}
+			Connector connector = connectors[i];
 			int port = localPort ? connector.getLocalPort() : connector.getPort();
-			ports.append(port).append(" (").append(connector.getScheme()).append(')');
+			description.append(port).append(" (").append(connector.getScheme()).append(')');
 		}
-		return ports.toString();
+		return description.toString();
 	}
 
 	@Override
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServer.java
index 11ce72755bae..3a0a644d2459 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServer.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServer.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -78,11 +78,14 @@ protected HttpHandler createHttpHandler() {
 
 	@Override
 	protected String getStartLogMessage() {
-		String message = super.getStartLogMessage();
-		if (StringUtils.hasText(this.contextPath)) {
-			message += " with context path '" + this.contextPath + "'";
+		if (!StringUtils.hasText(this.contextPath)) {
+			return super.getStartLogMessage();
 		}
-		return message;
+		StringBuilder message = new StringBuilder(super.getStartLogMessage());
+		message.append(" with context path '");
+		message.append(this.contextPath);
+		message.append("'");
+		return message.toString();
 	}
 
 	public DeploymentManager getDeploymentManager() {
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowWebServer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowWebServer.java
index dd7f887bfb69..bce6152a7e68 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowWebServer.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowWebServer.java
@@ -132,13 +132,13 @@ public void start() throws WebServerException {
 					throw new WebServerException("Unable to start embedded Undertow", ex);
 				}
 				finally {
-					stopSilently();
+					destroySilently();
 				}
 			}
 		}
 	}
 
-	private void stopSilently() {
+	private void destroySilently() {
 		try {
 			if (this.undertow != null) {
 				this.undertow.stop();
@@ -182,11 +182,20 @@ protected HttpHandler createHttpHandler() {
 	}
 
 	private String getPortsDescription() {
+		StringBuilder description = new StringBuilder();
 		List<UndertowWebServer.Port> ports = getActualPorts();
+		description.append("port");
+		if (ports.size() != 1) {
+			description.append("s");
+		}
+		description.append(" ");
 		if (!ports.isEmpty()) {
-			return StringUtils.collectionToDelimitedString(ports, " ");
+			description.append(StringUtils.collectionToDelimitedString(ports, ", "));
+		}
+		else {
+			description.append("unknown");
 		}
-		return "unknown";
+		return description.toString();
 	}
 
 	private List<Port> getActualPorts() {
@@ -274,7 +283,7 @@ public void stop() throws WebServerException {
 				}
 			}
 			catch (Exception ex) {
-				throw new WebServerException("Unable to stop undertow", ex);
+				throw new WebServerException("Unable to stop Undertow", ex);
 			}
 		}
 	}
@@ -315,7 +324,7 @@ private void notifyGracefulCallback(boolean success) {
 	}
 
 	protected String getStartLogMessage() {
-		return "Undertow started on port(s) " + getPortsDescription();
+		return "Undertow started on " + getPortsDescription();
 	}
 
 	/**
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/AbstractConfigurableWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/AbstractConfigurableWebServerFactory.java
index 4a23afd75332..21fa14527ae6 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/AbstractConfigurableWebServerFactory.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/AbstractConfigurableWebServerFactory.java
@@ -146,6 +146,15 @@ public void setSslStoreProvider(SslStoreProvider sslStoreProvider) {
 		this.sslStoreProvider = sslStoreProvider;
 	}
 
+	/**
+	 * Return the configured {@link SslBundles}.
+	 * @return the {@link SslBundles} or {@code null}
+	 * @since 3.2.0
+	 */
+	public SslBundles getSslBundles() {
+		return this.sslBundles;
+	}
+
 	@Override
 	public void setSslBundles(SslBundles sslBundles) {
 		this.sslBundles = sslBundles;
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServer.java
index 6b70c02ca7b0..e5c02a86f801 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServer.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServer.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -61,4 +61,12 @@ default void shutDownGracefully(GracefulShutdownCallback callback) {
 		callback.shutdownComplete(GracefulShutdownResult.IMMEDIATE);
 	}
 
+	/**
+	 * Destroys the web server such that it cannot be started again.
+	 * @since 3.2.0
+	 */
+	default void destroy() {
+		stop();
+	}
+
 }
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServerSslBundle.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServerSslBundle.java
index e722830aa498..adcff6722b0e 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServerSslBundle.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServerSslBundle.java
@@ -62,10 +62,11 @@ private WebServerSslBundle(SslStoreBundle stores, String keyPassword, Ssl ssl) {
 
 	private static SslStoreBundle createPemStoreBundle(Ssl ssl) {
 		PemSslStoreDetails keyStoreDetails = new PemSslStoreDetails(ssl.getKeyStoreType(), ssl.getCertificate(),
-				ssl.getCertificatePrivateKey());
+				ssl.getCertificatePrivateKey())
+			.withAlias(ssl.getKeyAlias());
 		PemSslStoreDetails trustStoreDetails = new PemSslStoreDetails(ssl.getTrustStoreType(),
 				ssl.getTrustCertificate(), ssl.getTrustCertificatePrivateKey());
-		return new PemSslStoreBundle(keyStoreDetails, trustStoreDetails, ssl.getKeyAlias());
+		return new PemSslStoreBundle(keyStoreDetails, trustStoreDetails);
 	}
 
 	private static SslStoreBundle createJksStoreBundle(Ssl ssl) {
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/AbstractFilterRegistrationBean.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/AbstractFilterRegistrationBean.java
index 30550c059902..fa64a89724b0 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/AbstractFilterRegistrationBean.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/AbstractFilterRegistrationBean.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -158,6 +158,28 @@ public void addUrlPatterns(String... urlPatterns) {
 		Collections.addAll(this.urlPatterns, urlPatterns);
 	}
 
+	/**
+	 * Determines the {@link DispatcherType dispatcher types} for which the filter should
+	 * be registered. Applies defaults based on the type of filter being registered if
+	 * none have been configured. Modifications to the returned {@link EnumSet} will have
+	 * no effect on the registration.
+	 * @return the dispatcher types, never {@code null}
+	 * @since 3.2.0
+	 */
+	public EnumSet<DispatcherType> determineDispatcherTypes() {
+		if (this.dispatcherTypes == null) {
+			T filter = getFilter();
+			if (ClassUtils.isPresent("org.springframework.web.filter.OncePerRequestFilter",
+					filter.getClass().getClassLoader()) && filter instanceof OncePerRequestFilter) {
+				return EnumSet.allOf(DispatcherType.class);
+			}
+			else {
+				return EnumSet.of(DispatcherType.REQUEST);
+			}
+		}
+		return EnumSet.copyOf(this.dispatcherTypes);
+	}
+
 	/**
 	 * Convenience method to {@link #setDispatcherTypes(EnumSet) set dispatcher types}
 	 * using the specified elements.
@@ -169,9 +191,7 @@ public void setDispatcherTypes(DispatcherType first, DispatcherType... rest) {
 	}
 
 	/**
-	 * Sets the dispatcher types that should be used with the registration. If not
-	 * specified the types will be deduced based on the value of
-	 * {@link #isAsyncSupported()}.
+	 * Sets the dispatcher types that should be used with the registration.
 	 * @param dispatcherTypes the dispatcher types
 	 */
 	public void setDispatcherTypes(EnumSet<DispatcherType> dispatcherTypes) {
@@ -218,17 +238,7 @@ protected Dynamic addRegistration(String description, ServletContext servletCont
 	@Override
 	protected void configure(FilterRegistration.Dynamic registration) {
 		super.configure(registration);
-		EnumSet<DispatcherType> dispatcherTypes = this.dispatcherTypes;
-		if (dispatcherTypes == null) {
-			T filter = getFilter();
-			if (ClassUtils.isPresent("org.springframework.web.filter.OncePerRequestFilter",
-					filter.getClass().getClassLoader()) && filter instanceof OncePerRequestFilter) {
-				dispatcherTypes = EnumSet.allOf(DispatcherType.class);
-			}
-			else {
-				dispatcherTypes = EnumSet.of(DispatcherType.REQUEST);
-			}
-		}
+		EnumSet<DispatcherType> dispatcherTypes = determineDispatcherTypes();
 		Set<String> servletNames = new LinkedHashSet<>();
 		for (ServletRegistrationBean<?> servletRegistrationBean : this.servletRegistrationBeans) {
 			servletNames.add(servletRegistrationBean.getServletName());
@@ -255,6 +265,15 @@ protected void configure(FilterRegistration.Dynamic registration) {
 	 */
 	public abstract T getFilter();
 
+	/**
+	 * Returns the filter name that will be registered.
+	 * @return the filter name
+	 * @since 3.2.0
+	 */
+	public String getFilterName() {
+		return getOrDeduceName(getFilter());
+	}
+
 	@Override
 	public String toString() {
 		StringBuilder builder = new StringBuilder(getOrDeduceName(this));
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/ServletComponentRegisteringPostProcessor.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/ServletComponentRegisteringPostProcessor.java
index dcd825836e08..01b1e08c18da 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/ServletComponentRegisteringPostProcessor.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/ServletComponentRegisteringPostProcessor.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -19,10 +19,17 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 import java.util.Set;
 
+import org.springframework.aot.generate.GenerationContext;
+import org.springframework.aot.hint.MemberCategory;
+import org.springframework.aot.hint.TypeReference;
 import org.springframework.beans.BeansException;
 import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition;
+import org.springframework.beans.factory.aot.BeanFactoryInitializationAotContribution;
+import org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor;
+import org.springframework.beans.factory.aot.BeanFactoryInitializationCode;
 import org.springframework.beans.factory.config.BeanDefinition;
 import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
 import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
@@ -40,7 +47,8 @@
  * @see ServletComponentScan
  * @see ServletComponentScanRegistrar
  */
-class ServletComponentRegisteringPostProcessor implements BeanFactoryPostProcessor, ApplicationContextAware {
+class ServletComponentRegisteringPostProcessor
+		implements BeanFactoryPostProcessor, ApplicationContextAware, BeanFactoryInitializationAotProcessor {
 
 	private static final List<ServletComponentHandler> HANDLERS;
 
@@ -105,4 +113,29 @@ public void setApplicationContext(ApplicationContext applicationContext) throws
 		this.applicationContext = applicationContext;
 	}
 
+	@Override
+	public BeanFactoryInitializationAotContribution processAheadOfTime(ConfigurableListableBeanFactory beanFactory) {
+		return new BeanFactoryInitializationAotContribution() {
+
+			@Override
+			public void applyTo(GenerationContext generationContext,
+					BeanFactoryInitializationCode beanFactoryInitializationCode) {
+				for (String beanName : beanFactory.getBeanDefinitionNames()) {
+					BeanDefinition definition = beanFactory.getBeanDefinition(beanName);
+					if (Objects.equals(definition.getBeanClassName(),
+							WebListenerHandler.ServletComponentWebListenerRegistrar.class.getName())) {
+						String listenerClassName = (String) definition.getConstructorArgumentValues()
+							.getArgumentValue(0, String.class)
+							.getValue();
+						generationContext.getRuntimeHints()
+							.reflection()
+							.registerType(TypeReference.of(listenerClassName),
+									MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);
+					}
+				}
+			}
+
+		};
+	}
+
 }
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/ServletComponentScanRegistrar.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/ServletComponentScanRegistrar.java
index 188e8a9b6564..49d548f16b58 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/ServletComponentScanRegistrar.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/ServletComponentScanRegistrar.java
@@ -22,11 +22,9 @@
 import java.util.Set;
 import java.util.function.Supplier;
 
-import org.springframework.beans.factory.aot.BeanRegistrationExcludeFilter;
 import org.springframework.beans.factory.config.BeanDefinition;
 import org.springframework.beans.factory.support.BeanDefinitionRegistry;
 import org.springframework.beans.factory.support.GenericBeanDefinition;
-import org.springframework.beans.factory.support.RegisteredBean;
 import org.springframework.context.annotation.ImportBeanDefinitionRegistrar;
 import org.springframework.core.annotation.AnnotationAttributes;
 import org.springframework.core.type.AnnotationMetadata;
@@ -102,13 +100,4 @@ private void addPackageNames(Collection<String> additionalPackageNames) {
 
 	}
 
-	static class ServletComponentScanBeanRegistrationExcludeFilter implements BeanRegistrationExcludeFilter {
-
-		@Override
-		public boolean isExcludedFromAotProcessing(RegisteredBean registeredBean) {
-			return BEAN_NAME.equals(registeredBean.getBeanName());
-		}
-
-	}
-
 }
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/WebServletHandler.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/WebServletHandler.java
index 2a27c7961a3c..40f2c06f7404 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/WebServletHandler.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/WebServletHandler.java
@@ -59,14 +59,18 @@ private String determineName(Map<String, Object> attributes, BeanDefinition bean
 				: beanDefinition.getBeanClassName());
 	}
 
-	private MultipartConfigElement determineMultipartConfig(AnnotatedBeanDefinition beanDefinition) {
+	private BeanDefinition determineMultipartConfig(AnnotatedBeanDefinition beanDefinition) {
 		Map<String, Object> attributes = beanDefinition.getMetadata()
 			.getAnnotationAttributes(MultipartConfig.class.getName());
 		if (attributes == null) {
 			return null;
 		}
-		return new MultipartConfigElement((String) attributes.get("location"), (Long) attributes.get("maxFileSize"),
-				(Long) attributes.get("maxRequestSize"), (Integer) attributes.get("fileSizeThreshold"));
+		BeanDefinitionBuilder builder = BeanDefinitionBuilder.rootBeanDefinition(MultipartConfigElement.class);
+		builder.addConstructorArgValue(attributes.get("location"));
+		builder.addConstructorArgValue(attributes.get("maxFileSize"));
+		builder.addConstructorArgValue(attributes.get("maxRequestSize"));
+		builder.addConstructorArgValue(attributes.get("fileSizeThreshold"));
+		return builder.getBeanDefinition();
 	}
 
 }
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/context/ServletWebServerApplicationContext.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/context/ServletWebServerApplicationContext.java
index 534237cc7728..d6513792d21b 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/context/ServletWebServerApplicationContext.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/context/ServletWebServerApplicationContext.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -149,6 +149,7 @@ public final void refresh() throws BeansException, IllegalStateException {
 			WebServer webServer = this.webServer;
 			if (webServer != null) {
 				webServer.stop();
+				webServer.destroy();
 			}
 			throw ex;
 		}
@@ -171,6 +172,10 @@ protected void doClose() {
 			AvailabilityChangeEvent.publish(this, ReadinessState.REFUSING_TRAFFIC);
 		}
 		super.doClose();
+		WebServer webServer = this.webServer;
+		if (webServer != null) {
+			webServer.destroy();
+		}
 	}
 
 	private void createWebServer() {
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactory.java
index 349212d7e6c0..d34dabce3326 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactory.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactory.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -39,6 +39,7 @@
 
 import org.springframework.boot.context.properties.PropertyMapper;
 import org.springframework.boot.web.server.AbstractConfigurableWebServerFactory;
+import org.springframework.boot.web.server.Cookie;
 import org.springframework.boot.web.server.MimeMappings;
 import org.springframework.boot.web.servlet.ServletContextInitializer;
 import org.springframework.util.Assert;
@@ -335,14 +336,12 @@ public void onStartup(ServletContext servletContext) throws ServletException {
 			configureSessionCookie(servletContext.getSessionCookieConfig());
 		}
 
-		@SuppressWarnings("removal")
 		private void configureSessionCookie(SessionCookieConfig config) {
-			Session.Cookie cookie = this.session.getCookie();
+			Cookie cookie = this.session.getCookie();
 			PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
 			map.from(cookie::getName).to(config::setName);
 			map.from(cookie::getDomain).to(config::setDomain);
 			map.from(cookie::getPath).to(config::setPath);
-			map.from(cookie::getComment).to(config::setComment);
 			map.from(cookie::getHttpOnly).to(config::setHttpOnly);
 			map.from(cookie::getSecure).to(config::setSecure);
 			map.from(cookie::getMaxAge).asInt(Duration::getSeconds).to(config::setMaxAge);
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/server/Session.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/server/Session.java
index 336c03ced541..815f84abda2c 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/server/Session.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/server/Session.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -21,8 +21,9 @@
 import java.time.temporal.ChronoUnit;
 import java.util.Set;
 
-import org.springframework.boot.context.properties.DeprecatedConfigurationProperty;
+import org.springframework.boot.context.properties.NestedConfigurationProperty;
 import org.springframework.boot.convert.DurationUnit;
+import org.springframework.boot.web.server.Cookie;
 
 /**
  * Session properties.
@@ -44,6 +45,7 @@ public class Session {
 	 */
 	private File storeDir;
 
+	@NestedConfigurationProperty
 	private final Cookie cookie = new Cookie();
 
 	private final SessionStoreDirectory sessionStoreDirectory = new SessionStoreDirectory();
@@ -101,34 +103,6 @@ SessionStoreDirectory getSessionStoreDirectory() {
 		return this.sessionStoreDirectory;
 	}
 
-	/**
-	 * Session cookie properties.
-	 */
-	public static class Cookie extends org.springframework.boot.web.server.Cookie {
-
-		/**
-		 * Comment for the session cookie.
-		 */
-		private String comment;
-
-		/**
-		 * Return the comment for the session cookie.
-		 * @return the session cookie comment
-		 * @deprecated since 3.0.0 without replacement
-		 */
-		@Deprecated(since = "3.0.0", forRemoval = true)
-		@DeprecatedConfigurationProperty
-		public String getComment() {
-			return this.comment;
-		}
-
-		@Deprecated(since = "3.0.0", forRemoval = true)
-		public void setComment(String comment) {
-			this.comment = comment;
-		}
-
-	}
-
 	/**
 	 * Available session tracking modes (mirrors
 	 * {@link jakarta.servlet.SessionTrackingMode}.
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilder.java
index 8f5dbb3d7c0e..5d25c93e601a 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilder.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilder.java
@@ -113,7 +113,7 @@ public WebServiceMessageSender build() {
 
 	private ClientHttpRequestFactory getRequestFactory() {
 		ClientHttpRequestFactorySettings settings = new ClientHttpRequestFactorySettings(this.connectTimeout,
-				this.readTimeout, null, this.sslBundle);
+				this.readTimeout, this.sslBundle);
 		return (this.requestFactory != null) ? this.requestFactory.apply(settings)
 				: ClientHttpRequestFactories.get(settings);
 	}
diff --git a/spring-boot-project/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-boot-project/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json
index 4835aa0c30a3..3484dd4ad100 100644
--- a/spring-boot-project/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json
+++ b/spring-boot-project/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json
@@ -103,6 +103,13 @@
       "description": "Log groups to quickly change multiple loggers at the same time. For instance, `logging.group.db=org.hibernate,org.springframework.jdbc`.",
       "sourceType": "org.springframework.boot.context.logging.LoggingApplicationListener"
     },
+    {
+      "name": "logging.include-application-name",
+      "type": "java.lang.Boolean",
+      "description": "Whether to include the application name in the logs.",
+      "sourceType": "org.springframework.boot.context.logging.LoggingApplicationListener",
+      "defaultValue": true
+    },
     {
       "name": "logging.level",
       "type": "java.util.Map<java.lang.String,java.lang.String>",
@@ -165,6 +172,12 @@
       "sourceType": "org.springframework.boot.context.logging.LoggingApplicationListener",
       "defaultValue": "%clr(%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd'T'HH:mm:ss.SSSXXX}}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}"
     },
+    {
+      "name": "logging.pattern.correlation",
+      "type": "java.lang.String",
+      "description": "Appender pattern for log correlation. Supported only with the default Logback setup.",
+      "sourceType": "org.springframework.boot.context.logging.LoggingApplicationListener"
+    },
     {
       "name": "logging.pattern.dateformat",
       "type": "java.lang.String",
@@ -348,485 +361,6 @@
       "description": "Whether to defer DataSource initialization until after any EntityManagerFactory beans have been created and initialized.",
       "defaultValue": false
     },
-    {
-      "name": "spring.jta.atomikos.connectionfactory.borrow-connection-timeout",
-      "type": "java.lang.Integer",
-      "description": "Timeout, in seconds, for borrowing connections from the pool.",
-      "defaultValue": 30
-    },
-    {
-      "name": "spring.jta.atomikos.connectionfactory.ignore-session-transacted-flag",
-      "type": "java.lang.Boolean",
-      "description": "Whether to ignore the transacted flag when creating session.",
-      "defaultValue": true
-    },
-    {
-      "name": "spring.jta.atomikos.connectionfactory.local-transaction-mode",
-      "type": "java.lang.Boolean",
-      "description": "Whether local transactions are desired.",
-      "defaultValue": false
-    },
-    {
-      "name": "spring.jta.atomikos.connectionfactory.maintenance-interval",
-      "type": "java.lang.Integer",
-      "description": "Time, in seconds, between runs of the pool's maintenance thread.",
-      "defaultValue": 60
-    },
-    {
-      "name": "spring.jta.atomikos.connectionfactory.max-idle-time",
-      "type": "java.lang.Integer",
-      "description": "Time, in seconds, after which connections are cleaned up from the pool.",
-      "defaultValue": 60
-    },
-    {
-      "name": "spring.jta.atomikos.connectionfactory.max-lifetime",
-      "type": "java.lang.Integer",
-      "description": "Time, in seconds, that a connection can be pooled for before being destroyed. 0 denotes no limit.",
-      "defaultValue": 0
-    },
-    {
-      "name": "spring.jta.atomikos.connectionfactory.max-pool-size",
-      "type": "java.lang.Integer",
-      "description": "Maximum size of the pool.",
-      "defaultValue": 1
-    },
-    {
-      "name": "spring.jta.atomikos.connectionfactory.min-pool-size",
-      "type": "java.lang.Integer",
-      "description": "Minimum size of the pool.",
-      "defaultValue": 1
-    },
-    {
-      "name": "spring.jta.atomikos.connectionfactory.reap-timeout",
-      "type": "java.lang.Integer",
-      "description": "Reap timeout, in seconds, for borrowed connections. 0 denotes no limit.",
-      "defaultValue": 0
-    },
-    {
-      "name": "spring.jta.atomikos.connectionfactory.unique-resource-name",
-      "type": "java.lang.String",
-      "description": "Unique name used to identify the resource during recovery.",
-      "defaultValue": "jmsConnectionFactory"
-    },
-    {
-      "name": "spring.jta.atomikos.connectionfactory.xa-connection-factory-class-name",
-      "type": "java.lang.String",
-      "description": "Vendor-specific implementation of XAConnectionFactory."
-    },
-    {
-      "name": "spring.jta.atomikos.connectionfactory.xa-properties",
-      "type": "java.util.Properties",
-      "description": "Vendor-specific XA properties."
-    },
-    {
-      "name": "spring.jta.atomikos.datasource.borrow-connection-timeout",
-      "type": "java.lang.Integer",
-      "description": "Timeout, in seconds, for borrowing connections from the pool.",
-      "defaultValue": 30
-    },
-    {
-      "name": "spring.jta.atomikos.datasource.concurrent-connection-validation",
-      "type": "java.lang.Boolean",
-      "description": "Whether to use concurrent connection validation.",
-      "defaultValue": true
-    },
-    {
-      "name": "spring.jta.atomikos.datasource.default-isolation-level",
-      "type": "java.lang.Integer",
-      "description": "Default isolation level of connections provided by the pool."
-    },
-    {
-      "name": "spring.jta.atomikos.datasource.login-timeout",
-      "type": "java.lang.Integer",
-      "description": "Timeout, in seconds, for establishing a database connection.",
-      "defaultValue": 0
-    },
-    {
-      "name": "spring.jta.atomikos.datasource.maintenance-interval",
-      "type": "java.lang.Integer",
-      "description": "Time, in seconds, between runs of the pool's maintenance thread.",
-      "defaultValue": 60
-    },
-    {
-      "name": "spring.jta.atomikos.datasource.max-idle-time",
-      "type": "java.lang.Integer",
-      "description": "Time, in seconds, after which connections are cleaned up from the pool.",
-      "defaultValue": 60
-    },
-    {
-      "name": "spring.jta.atomikos.datasource.max-lifetime",
-      "type": "java.lang.Integer",
-      "description": "Time, in seconds, that a connection can be pooled for before being destroyed. 0 denotes no limit.",
-      "defaultValue": 0
-    },
-    {
-      "name": "spring.jta.atomikos.datasource.max-pool-size",
-      "type": "java.lang.Integer",
-      "description": "Maximum size of the pool.",
-      "defaultValue": 1
-    },
-    {
-      "name": "spring.jta.atomikos.datasource.min-pool-size",
-      "type": "java.lang.Integer",
-      "description": "Minimum size of the pool.",
-      "defaultValue": 1
-    },
-    {
-      "name": "spring.jta.atomikos.datasource.reap-timeout",
-      "type": "java.lang.Integer",
-      "description": "Reap timeout, in seconds, for borrowed connections. 0 denotes no limit.",
-      "defaultValue": 0
-    },
-    {
-      "name": "spring.jta.atomikos.datasource.test-query",
-      "type": "java.lang.String",
-      "description": "SQL query or statement used to validate a connection before returning it."
-    },
-    {
-      "name": "spring.jta.atomikos.datasource.unique-resource-name",
-      "type": "java.lang.String",
-      "description": "Unique name used to identify the resource during recovery.",
-      "defaultValue": "dataSource"
-    },
-    {
-      "name": "spring.jta.atomikos.datasource.xa-data-source-class-name",
-      "type": "java.lang.String",
-      "description": "Vendor-specific implementation of XAConnectionFactory."
-    },
-    {
-      "name": "spring.jta.atomikos.datasource.xa-properties",
-      "type": "java.util.Properties",
-      "description": "Vendor-specific XA properties."
-    },
-    {
-      "name": "spring.jta.bitronix.connectionfactory.acquire-increment",
-      "type": "java.lang.Integer",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.connectionfactory.acquisition-interval",
-      "type": "java.lang.Integer",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.connectionfactory.acquisition-timeout",
-      "type": "java.lang.Integer",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.connectionfactory.allow-local-transactions",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.connectionfactory.apply-transaction-timeout",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.connectionfactory.automatic-enlisting-enabled",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.connectionfactory.cache-producers-consumers",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.connectionfactory.class-name",
-      "type": "java.lang.String",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.connectionfactory.defer-connection-release",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.connectionfactory.disabled",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.connectionfactory.driver-properties",
-      "type": "java.util.Properties",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.connectionfactory.ignore-recovery-failures",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.connectionfactory.max-idle-time",
-      "type": "java.lang.Integer",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.connectionfactory.max-pool-size",
-      "type": "java.lang.Integer",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.connectionfactory.min-pool-size",
-      "type": "java.lang.Integer",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.connectionfactory.password",
-      "type": "java.lang.String",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.connectionfactory.share-transaction-connections",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.connectionfactory.test-connections",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.connectionfactory.two-pc-ordering-position",
-      "type": "java.lang.Integer",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.connectionfactory.unique-name",
-      "type": "java.lang.String",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.connectionfactory.use-tm-join",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.connectionfactory.user",
-      "type": "java.lang.String",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.acquire-increment",
-      "type": "java.lang.Integer",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.acquisition-interval",
-      "type": "java.lang.Integer",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.acquisition-timeout",
-      "type": "java.lang.Integer",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.allow-local-transactions",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.apply-transaction-timeout",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.automatic-enlisting-enabled",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.class-name",
-      "type": "java.lang.String",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.cursor-holdability",
-      "type": "java.lang.String",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.defer-connection-release",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.disabled",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.driver-properties",
-      "type": "java.util.Properties",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.enable-jdbc4-connection-test",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.ignore-recovery-failures",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.isolation-level",
-      "type": "java.lang.String",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.local-auto-commit",
-      "type": "java.lang.String",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.login-timeout",
-      "type": "java.lang.Integer",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.max-idle-time",
-      "type": "java.lang.Integer",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.max-pool-size",
-      "type": "java.lang.Integer",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.min-pool-size",
-      "type": "java.lang.Integer",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.prepared-statement-cache-size",
-      "type": "java.lang.Integer",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.share-transaction-connections",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.test-query",
-      "type": "java.lang.String",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.two-pc-ordering-position",
-      "type": "java.lang.Integer",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.unique-name",
-      "type": "java.lang.String",
-      "deprecation": {
-        "level": "error"
-      }
-    },
-    {
-      "name": "spring.jta.bitronix.datasource.use-tm-join",
-      "type": "java.lang.Boolean",
-      "deprecation": {
-        "level": "error"
-      }
-    },
     {
       "name": "spring.main.allow-bean-definition-overriding",
       "type": "java.lang.Boolean",
@@ -853,6 +387,13 @@
       "type": "org.springframework.boot.cloud.CloudPlatform",
       "description": "Override the Cloud Platform auto-detection."
     },
+    {
+      "name": "spring.main.keep-alive",
+      "type": "java.lang.Boolean",
+      "sourceType": "org.springframework.boot.SpringApplication",
+      "description": "Whether to keep the application alive even if there are no more non-daemon threads.",
+      "defaultValue": false
+    },
     {
       "name": "spring.main.lazy-initialization",
       "type": "java.lang.Boolean",
@@ -957,7 +498,7 @@
     {
       "name": "spring.reactor.debug-agent.enabled",
       "type": "java.lang.Boolean",
-      "sourceType": "org.springframework.boot.reactor.DebugAgentEnvironmentPostProcessor",
+      "sourceType": "org.springframework.boot.reactor.ReactorEnvironmentPostProcessor",
       "description": "Whether the Reactor Debug Agent should be enabled when reactor-tools is present.",
       "defaultValue": true
     },
diff --git a/spring-boot-project/spring-boot/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot/src/main/resources/META-INF/spring.factories
index 4641c7b8dd61..e6775b7491f1 100644
--- a/spring-boot-project/spring-boot/src/main/resources/META-INF/spring.factories
+++ b/spring-boot-project/spring-boot/src/main/resources/META-INF/spring.factories
@@ -57,7 +57,7 @@ org.springframework.boot.context.config.ConfigDataEnvironmentPostProcessor,\
 org.springframework.boot.env.RandomValuePropertySourceEnvironmentPostProcessor,\
 org.springframework.boot.env.SpringApplicationJsonEnvironmentPostProcessor,\
 org.springframework.boot.env.SystemEnvironmentPropertySourceEnvironmentPostProcessor,\
-org.springframework.boot.reactor.DebugAgentEnvironmentPostProcessor
+org.springframework.boot.reactor.ReactorEnvironmentPostProcessor
 
 # Failure Analyzers
 org.springframework.boot.diagnostics.FailureAnalyzer=\
diff --git a/spring-boot-project/spring-boot/src/main/resources/META-INF/spring/aot.factories b/spring-boot-project/spring-boot/src/main/resources/META-INF/spring/aot.factories
index 7996bc7918e7..905cd7174065 100644
--- a/spring-boot-project/spring-boot/src/main/resources/META-INF/spring/aot.factories
+++ b/spring-boot-project/spring-boot/src/main/resources/META-INF/spring/aot.factories
@@ -20,6 +20,3 @@ org.springframework.boot.jackson.JsonComponentModule.JsonComponentBeanFactoryIni
 org.springframework.beans.factory.aot.BeanRegistrationAotProcessor=\
 org.springframework.boot.context.properties.ConfigurationPropertiesBeanRegistrationAotProcessor,\
 org.springframework.boot.jackson.JsonMixinModuleEntriesBeanRegistrationAotProcessor
-
-org.springframework.beans.factory.aot.BeanRegistrationExcludeFilter=\
-org.springframework.boot.web.servlet.ServletComponentScanRegistrar.ServletComponentScanBeanRegistrationExcludeFilter
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2-file.xml b/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2-file.xml
index d7c510bb7a98..fb3edde9dfe7 100644
--- a/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2-file.xml
+++ b/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2-file.xml
@@ -4,8 +4,8 @@
 		<Property name="LOG_EXCEPTION_CONVERSION_WORD">%xwEx</Property>
 		<Property name="LOG_LEVEL_PATTERN">%5p</Property>
 		<Property name="LOG_DATEFORMAT_PATTERN">yyyy-MM-dd'T'HH:mm:ss.SSSXXX</Property>
-		<Property name="CONSOLE_LOG_PATTERN">%clr{%d{${sys:LOG_DATEFORMAT_PATTERN}}}{faint} %clr{${sys:LOG_LEVEL_PATTERN}} %clr{%pid}{magenta} %clr{---}{faint} %clr{[%15.15t]}{faint} %clr{%-40.40c{1.}}{cyan} %clr{:}{faint} %m%n${sys:LOG_EXCEPTION_CONVERSION_WORD}</Property>
-		<Property name="FILE_LOG_PATTERN">%d{${sys:LOG_DATEFORMAT_PATTERN}} ${sys:LOG_LEVEL_PATTERN} %pid --- [%t] %-40.40c{1.} : %m%n${sys:LOG_EXCEPTION_CONVERSION_WORD}</Property>
+		<Property name="CONSOLE_LOG_PATTERN">%clr{%d{${sys:LOG_DATEFORMAT_PATTERN}}}{faint} %clr{${sys:LOG_LEVEL_PATTERN}} %clr{%pid}{magenta} %clr{---}{faint} %clr{${sys:LOGGED_APPLICATION_NAME:-}[%15.15t]}{faint} %clr{${sys:LOG_CORRELATION_PATTERN:-}}{faint}%clr{%-40.40c{1.}}{cyan} %clr{:}{faint} %m%n${sys:LOG_EXCEPTION_CONVERSION_WORD}</Property>
+		<Property name="FILE_LOG_PATTERN">%d{${sys:LOG_DATEFORMAT_PATTERN}} ${sys:LOG_LEVEL_PATTERN} %pid --- ${sys:LOGGED_APPLICATION_NAME}[%t] ${sys:LOG_CORRELATION_PATTERN:-}%-40.40c{1.} : %m%n${sys:LOG_EXCEPTION_CONVERSION_WORD}</Property>
 	</Properties>
 	<Appenders>
 		<Console name="Console" target="SYSTEM_OUT" follow="true">
diff --git a/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2.xml b/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2.xml
index 65f1a1b612d7..600f2fa207ed 100644
--- a/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2.xml
+++ b/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2.xml
@@ -4,8 +4,8 @@
 		<Property name="LOG_EXCEPTION_CONVERSION_WORD">%xwEx</Property>
 		<Property name="LOG_LEVEL_PATTERN">%5p</Property>
 		<Property name="LOG_DATEFORMAT_PATTERN">yyyy-MM-dd'T'HH:mm:ss.SSSXXX</Property>
-		<Property name="CONSOLE_LOG_PATTERN">%clr{%d{${sys:LOG_DATEFORMAT_PATTERN}}}{faint} %clr{${sys:LOG_LEVEL_PATTERN}} %clr{%pid}{magenta} %clr{---}{faint} %clr{[%15.15t]}{faint} %clr{%-40.40c{1.}}{cyan} %clr{:}{faint} %m%n${sys:LOG_EXCEPTION_CONVERSION_WORD}</Property>
-		<Property name="FILE_LOG_PATTERN">%d{${sys:LOG_DATEFORMAT_PATTERN}} ${sys:LOG_LEVEL_PATTERN} %pid --- [%t] %-40.40c{1.} : %m%n${sys:LOG_EXCEPTION_CONVERSION_WORD}</Property>
+		<Property name="CONSOLE_LOG_PATTERN">%clr{%d{${sys:LOG_DATEFORMAT_PATTERN}}}{faint} %clr{${sys:LOG_LEVEL_PATTERN}} %clr{%pid}{magenta} %clr{---}{faint} %clr{${sys:LOGGED_APPLICATION_NAME}[%15.15t]}{faint} %clr{${sys:LOG_CORRELATION_PATTERN:-}}{faint}%clr{%-40.40c{1.}}{cyan} %clr{:}{faint} %m%n${sys:LOG_EXCEPTION_CONVERSION_WORD}</Property>
+		<Property name="FILE_LOG_PATTERN">%d{${sys:LOG_DATEFORMAT_PATTERN}} ${sys:LOG_LEVEL_PATTERN} %pid --- ${sys:LOGGED_APPLICATION_NAME}[%t] ${sys:LOG_CORRELATION_PATTERN:-}%-40.40c{1.} : %m%n${sys:LOG_EXCEPTION_CONVERSION_WORD}</Property>
 	</Properties>
 	<Appenders>
 		<Console name="Console" target="SYSTEM_OUT" follow="true">
diff --git a/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/defaults.xml b/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/defaults.xml
index bc2ec1238193..9c02f84e4099 100644
--- a/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/defaults.xml
+++ b/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/defaults.xml
@@ -6,13 +6,14 @@ Default logback configuration provided for import
 
 <included>
 	<conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter" />
+	<conversionRule conversionWord="correlationId" converterClass="org.springframework.boot.logging.logback.CorrelationIdConverter" />
 	<conversionRule conversionWord="wex" converterClass="org.springframework.boot.logging.logback.WhitespaceThrowableProxyConverter" />
 	<conversionRule conversionWord="wEx" converterClass="org.springframework.boot.logging.logback.ExtendedWhitespaceThrowableProxyConverter" />
 
-	<property name="CONSOLE_LOG_PATTERN" value="${CONSOLE_LOG_PATTERN:-%clr(%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd'T'HH:mm:ss.SSSXXX}}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>
+	<property name="CONSOLE_LOG_PATTERN" value="${CONSOLE_LOG_PATTERN:-%clr(%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd'T'HH:mm:ss.SSSXXX}}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr(${LOGGED_APPLICATION_NAME:-}[%15.15t]){faint} %clr(${LOG_CORRELATION_PATTERN:-}){faint}%clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>
 	<property name="CONSOLE_LOG_CHARSET" value="${CONSOLE_LOG_CHARSET:-${file.encoding:-UTF-8}}"/>
 	<property name="CONSOLE_LOG_THRESHOLD" value="${CONSOLE_LOG_THRESHOLD:-TRACE}"/>
-	<property name="FILE_LOG_PATTERN" value="${FILE_LOG_PATTERN:-%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd'T'HH:mm:ss.SSSXXX}} ${LOG_LEVEL_PATTERN:-%5p} ${PID:- } --- [%t] %-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>
+	<property name="FILE_LOG_PATTERN" value="${FILE_LOG_PATTERN:-%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd'T'HH:mm:ss.SSSXXX}} ${LOG_LEVEL_PATTERN:-%5p} ${PID:- } --- ${LOGGED_APPLICATION_NAME:-}[%t] ${LOG_CORRELATION_PATTERN:-}%-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>
 	<property name="FILE_LOG_CHARSET" value="${FILE_LOG_CHARSET:-${file.encoding:-UTF-8}}"/>
 	<property name="FILE_LOG_THRESHOLD" value="${FILE_LOG_THRESHOLD:-TRACE}"/>
 
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/SpringApplicationShutdownHookTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/SpringApplicationShutdownHookTests.java
index c8cf5b1ffb57..5f2d5c1dfc06 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/SpringApplicationShutdownHookTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/SpringApplicationShutdownHookTests.java
@@ -35,9 +35,9 @@
 import org.springframework.context.support.GenericApplicationContext;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
 import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
 import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /**
  * Tests for {@link SpringApplicationShutdownHook}.
@@ -51,6 +51,7 @@ class SpringApplicationShutdownHookTests {
 	@Test
 	void shutdownHookIsNotAddedUntilContextIsRegistered() {
 		TestSpringApplicationShutdownHook shutdownHook = new TestSpringApplicationShutdownHook();
+		shutdownHook.enableShutdownHookAddition();
 		assertThat(shutdownHook.isRuntimeShutdownHookAdded()).isFalse();
 		ConfigurableApplicationContext context = new GenericApplicationContext();
 		shutdownHook.registerApplicationContext(context);
@@ -60,12 +61,25 @@ void shutdownHookIsNotAddedUntilContextIsRegistered() {
 	@Test
 	void shutdownHookIsNotAddedUntilHandlerIsRegistered() {
 		TestSpringApplicationShutdownHook shutdownHook = new TestSpringApplicationShutdownHook();
+		shutdownHook.enableShutdownHookAddition();
 		assertThat(shutdownHook.isRuntimeShutdownHookAdded()).isFalse();
 		shutdownHook.getHandlers().add(() -> {
 		});
 		assertThat(shutdownHook.isRuntimeShutdownHookAdded()).isTrue();
 	}
 
+	@Test
+	void shutdownHookIsNotAddedUntilAdditionIsEnabled() {
+		TestSpringApplicationShutdownHook shutdownHook = new TestSpringApplicationShutdownHook();
+		shutdownHook.getHandlers().add(() -> {
+		});
+		assertThat(shutdownHook.isRuntimeShutdownHookAdded()).isFalse();
+		shutdownHook.enableShutdownHookAddition();
+		shutdownHook.getHandlers().add(() -> {
+		});
+		assertThat(shutdownHook.isRuntimeShutdownHookAdded()).isTrue();
+	}
+
 	@Test
 	void runClosesContextsBeforeRunningHandlerActions() {
 		TestSpringApplicationShutdownHook shutdownHook = new TestSpringApplicationShutdownHook();
@@ -172,8 +186,7 @@ void failsWhenDeregisterActiveContext() {
 		ConfigurableApplicationContext context = new GenericApplicationContext();
 		shutdownHook.registerApplicationContext(context);
 		context.refresh();
-		assertThatThrownBy(() -> shutdownHook.deregisterFailedApplicationContext(context))
-			.isInstanceOf(IllegalStateException.class);
+		assertThatIllegalStateException().isThrownBy(() -> shutdownHook.deregisterFailedApplicationContext(context));
 		assertThat(shutdownHook.isApplicationContextRegistered(context)).isTrue();
 	}
 
@@ -183,7 +196,7 @@ void deregistersFailedContext() {
 		GenericApplicationContext context = new GenericApplicationContext();
 		shutdownHook.registerApplicationContext(context);
 		context.registerBean(FailingBean.class);
-		assertThatThrownBy(context::refresh).isInstanceOf(BeanCreationException.class);
+		assertThatExceptionOfType(BeanCreationException.class).isThrownBy(context::refresh);
 		assertThat(shutdownHook.isApplicationContextRegistered(context)).isTrue();
 		shutdownHook.deregisterFailedApplicationContext(context);
 		assertThat(shutdownHook.isApplicationContextRegistered(context)).isFalse();
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/SpringApplicationTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/SpringApplicationTests.java
index 9523dc903c27..1fff2bf294e3 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/SpringApplicationTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/SpringApplicationTests.java
@@ -16,6 +16,7 @@
 
 package org.springframework.boot;
 
+import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
@@ -31,6 +32,7 @@
 
 import jakarta.annotation.PostConstruct;
 import org.assertj.core.api.Condition;
+import org.awaitility.Awaitility;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -97,6 +99,7 @@
 import org.springframework.context.support.AbstractApplicationContext;
 import org.springframework.context.support.StaticApplicationContext;
 import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
 import org.springframework.core.env.CommandLinePropertySource;
 import org.springframework.core.env.CompositePropertySource;
 import org.springframework.core.env.ConfigurableEnvironment;
@@ -158,6 +161,9 @@
  * @author Nguyen Bao Sach
  * @author Chris Bono
  * @author Sebastien Deleuze
+ * @author Moritz Halbritter
+ * @author Tadaya Tsuyukubo
+ * @author Yanming Zhou
  */
 @ExtendWith(OutputCaptureExtension.class)
 class SpringApplicationTests {
@@ -628,6 +634,15 @@ void runCommandLineRunnersAndApplicationRunners() {
 		assertThat(this.context).has(runTestRunnerBean("runnerC"));
 	}
 
+	@Test
+	void runCommandLineRunnersAndApplicationRunnersUsingOrderOnBeanDefinitions() {
+		SpringApplication application = new SpringApplication(BeanDefinitionOrderRunnerConfig.class);
+		application.setWebApplicationType(WebApplicationType.NONE);
+		this.context = application.run("arg");
+		BeanDefinitionOrderRunnerConfig config = this.context.getBean(BeanDefinitionOrderRunnerConfig.class);
+		assertThat(config.runners).containsExactly("runnerA", "runnerB", "runnerC");
+	}
+
 	@Test
 	@SuppressWarnings("unchecked")
 	void runnersAreCalledAfterStartedIsLoggedAndBeforeApplicationReadyEventIsPublished(CapturedOutput output)
@@ -1361,6 +1376,21 @@ void shouldUseAotInitializer() {
 		}
 	}
 
+	@Test
+	void shouldReportFriendlyErrorIfAotInitializerNotFound() {
+		SpringApplication application = new SpringApplication(TestSpringApplication.class);
+		application.setWebApplicationType(WebApplicationType.NONE);
+		application.setMainApplicationClass(TestSpringApplication.class);
+		System.setProperty(AotDetector.AOT_ENABLED, "true");
+		try {
+			assertThatIllegalStateException().isThrownBy(application::run)
+				.withMessageContaining("but AOT processing hasn't happened");
+		}
+		finally {
+			System.clearProperty(AotDetector.AOT_ENABLED);
+		}
+	}
+
 	@Test
 	void fromRunsWithAdditionalSources() {
 		assertThat(ExampleAdditionalConfig.local.get()).isNull();
@@ -1390,6 +1420,32 @@ void fromWithMultipleApplicationsOnlyAppliesAdditionalSourcesOnce() {
 		assertThatNoException().isThrownBy(() -> this.context.getBean(SingleUseAdditionalConfig.class));
 	}
 
+	@Test
+	void shouldStartDaemonThreadIfKeepAliveIsEnabled() {
+		SpringApplication application = new SpringApplication(ExampleConfig.class);
+		application.setWebApplicationType(WebApplicationType.NONE);
+		this.context = application.run("--spring.main.keep-alive=true");
+		Set<Thread> threads = getCurrentThreads();
+		assertThat(threads).filteredOn((thread) -> thread.getName().equals("keep-alive"))
+			.singleElement()
+			.satisfies((thread) -> assertThat(thread.isDaemon()).isFalse());
+	}
+
+	@Test
+	void shouldStopKeepAliveThreadIfContextIsClosed() {
+		SpringApplication application = new SpringApplication(ExampleConfig.class);
+		application.setWebApplicationType(WebApplicationType.NONE);
+		application.setKeepAlive(true);
+		this.context = application.run();
+		assertThat(getCurrentThreads()).filteredOn((thread) -> thread.getName().equals("keep-alive")).isNotEmpty();
+		this.context.close();
+		Awaitility.await()
+			.atMost(Duration.ofSeconds(30))
+			.untilAsserted(
+					() -> assertThat(getCurrentThreads()).filteredOn((thread) -> thread.getName().equals("keep-alive"))
+						.isEmpty());
+	}
+
 	private <S extends AvailabilityState> ArgumentMatcher<ApplicationEvent> isAvailabilityChangeEventWithState(
 			S state) {
 		return (argument) -> (argument instanceof AvailabilityChangeEvent<?>)
@@ -1432,6 +1488,10 @@ public boolean matches(ConfigurableApplicationContext value) {
 		};
 	}
 
+	private Set<Thread> getCurrentThreads() {
+		return Thread.getAllStackTraces().keySet();
+	}
+
 	static class TestEventListener<E extends ApplicationEvent> implements SmartApplicationListener {
 
 		private final Class<E> eventType;
@@ -1646,6 +1706,31 @@ TestCommandLineRunner runnerA() {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class BeanDefinitionOrderRunnerConfig {
+
+		private final List<String> runners = new ArrayList<>();
+
+		@Bean
+		@Order
+		CommandLineRunner runnerC() {
+			return (args) -> this.runners.add("runnerC");
+		}
+
+		@Bean
+		@Order(Ordered.LOWEST_PRECEDENCE - 1)
+		ApplicationRunner runnerB() {
+			return (args) -> this.runners.add("runnerB");
+		}
+
+		@Bean
+		@Order(Ordered.HIGHEST_PRECEDENCE)
+		CommandLineRunner runnerA() {
+			return (args) -> this.runners.add("runnerA");
+		}
+
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class ExitCodeCommandLineRunConfig {
 
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/StartupInfoLoggerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/StartupInfoLoggerTests.java
index 6bcd00527f9b..a6b1c90ba6de 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/StartupInfoLoggerTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/StartupInfoLoggerTests.java
@@ -16,11 +16,10 @@
 
 package org.springframework.boot;
 
-import java.time.Duration;
-
 import org.apache.commons.logging.Log;
 import org.junit.jupiter.api.Test;
 
+import org.springframework.boot.SpringApplication.Startup;
 import org.springframework.boot.system.ApplicationPid;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -72,11 +71,59 @@ void startingFormatInAotMode() {
 	@Test
 	void startedFormat() {
 		given(this.log.isInfoEnabled()).willReturn(true);
-		Duration timeTakenToStartup = Duration.ofMillis(10);
-		new StartupInfoLogger(getClass()).logStarted(this.log, timeTakenToStartup);
+		new StartupInfoLogger(getClass()).logStarted(this.log, new TestStartup(1345L, "Started"));
 		then(this.log).should()
 			.info(assertArg((message) -> assertThat(message.toString()).matches("Started " + getClass().getSimpleName()
-					+ " in \\d+\\.\\d{1,3} seconds \\(process running for \\d+\\.\\d{1,3}\\)")));
+					+ " in \\d+\\.\\d{1,3} seconds \\(process running for 1.345\\)")));
+	}
+
+	@Test
+	void startedWithoutUptimeFormat() {
+		given(this.log.isInfoEnabled()).willReturn(true);
+		new StartupInfoLogger(getClass()).logStarted(this.log, new TestStartup(null, "Started"));
+		then(this.log).should()
+			.info(assertArg((message) -> assertThat(message.toString())
+				.matches("Started " + getClass().getSimpleName() + " in \\d+\\.\\d{1,3} seconds")));
+	}
+
+	@Test
+	void restoredFormat() {
+		given(this.log.isInfoEnabled()).willReturn(true);
+		new StartupInfoLogger(getClass()).logStarted(this.log, new TestStartup(null, "Restored"));
+		then(this.log).should()
+			.info(assertArg((message) -> assertThat(message.toString())
+				.matches("Restored " + getClass().getSimpleName() + " in \\d+\\.\\d{1,3} seconds")));
+	}
+
+	static class TestStartup extends Startup {
+
+		private final long startTime = System.currentTimeMillis();
+
+		private final Long uptime;
+
+		private final String action;
+
+		TestStartup(Long uptime, String action) {
+			this.uptime = uptime;
+			this.action = action;
+			started();
+		}
+
+		@Override
+		long startTime() {
+			return this.startTime;
+		}
+
+		@Override
+		Long processUptime() {
+			return this.uptime;
+		}
+
+		@Override
+		String action() {
+			return this.action;
+		}
+
 	}
 
 }
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessorIntegrationTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessorIntegrationTests.java
index 7b2e834f4a38..b9c82a9b1275 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessorIntegrationTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessorIntegrationTests.java
@@ -220,7 +220,7 @@ void runWhenHasActiveProfilesFromMultipleLocationsActivatesProfileFromOneLocatio
 	}
 
 	@Test
-	void runWhenHasActiveProfilesFromMultipleAdditionaLocationsWithOneSwitchedOffLoadsExpectedProperties() {
+	void runWhenHasActiveProfilesFromMultipleAdditionalLocationsWithOneSwitchedOffLoadsExpectedProperties() {
 		ConfigurableApplicationContext context = this.application.run(
 				"--spring.config.additional-location=classpath:enabletwoprofiles.properties,classpath:enableprofile.properties");
 		ConfigurableEnvironment environment = context.getEnvironment();
@@ -230,7 +230,7 @@ void runWhenHasActiveProfilesFromMultipleAdditionaLocationsWithOneSwitchedOffLoa
 	}
 
 	@Test
-	void runWhenHaslocalFileLoadsWithLocalFileTakingPrecedenceOverClasspath() throws Exception {
+	void runWhenHasLocalFileLoadsWithLocalFileTakingPrecedenceOverClasspath() throws Exception {
 		File localFile = new File(new File("."), "application.properties");
 		assertThat(localFile).doesNotExist();
 		try {
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessorTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessorTests.java
index a63c056fbb44..f7139ad750ad 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessorTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessorTests.java
@@ -108,7 +108,7 @@ void applyToAppliesPostProcessing() {
 		TestConfigDataEnvironmentUpdateListener listener = new TestConfigDataEnvironmentUpdateListener();
 		ConfigDataEnvironmentPostProcessor.applyTo(this.environment, null, null, Collections.singleton("dev"),
 				listener);
-		assertThat(this.environment.getPropertySources().size()).isGreaterThan(before);
+		assertThat(this.environment.getPropertySources()).hasSizeGreaterThan(before);
 		assertThat(this.environment.getActiveProfiles()).containsExactly("dev");
 		assertThat(listener.getAddedPropertySources()).isNotEmpty();
 		assertThat(listener.getProfiles().getActive()).containsExactly("dev");
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/DelegatingApplicationContextInitializerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/DelegatingApplicationContextInitializerTests.java
index 748d1e65ced2..2436489db8bc 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/DelegatingApplicationContextInitializerTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/DelegatingApplicationContextInitializerTests.java
@@ -36,6 +36,8 @@
  *
  * @author Phillip Webb
  */
+@Deprecated(since = "3.2.0", forRemoval = true)
+@SuppressWarnings("removal")
 class DelegatingApplicationContextInitializerTests {
 
 	private final DelegatingApplicationContextInitializer initializer = new DelegatingApplicationContextInitializer();
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/DelegatingApplicationListenerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/DelegatingApplicationListenerTests.java
index 284c9377d47d..c56460014a8b 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/DelegatingApplicationListenerTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/DelegatingApplicationListenerTests.java
@@ -37,6 +37,8 @@
  *
  * @author Dave Syer
  */
+@Deprecated(since = "3.2.0", forRemoval = true)
+@SuppressWarnings("removal")
 class DelegatingApplicationListenerTests {
 
 	private final DelegatingApplicationListener listener = new DelegatingApplicationListener();
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/StandardConfigDataLocationResolverTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/StandardConfigDataLocationResolverTests.java
index d7430085b055..15709fb2c1e4 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/StandardConfigDataLocationResolverTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/StandardConfigDataLocationResolverTests.java
@@ -43,6 +43,7 @@
  *
  * @author Madhura Bhave
  * @author Phillip Webb
+ * @author Moritz Halbritter
  */
 class StandardConfigDataLocationResolverTests {
 
@@ -263,6 +264,26 @@ void resolveProfileSpecificWhenLocationIsFileReturnsEmptyList() {
 		assertThat(locations).isEmpty();
 	}
 
+	@Test
+	void resolveWhenOptionalAndLoaderIsUnknownShouldNotFail() {
+		ConfigDataLocation location = ConfigDataLocation.of("optional:some-unknown-loader:dummy.properties");
+		assertThatNoException().isThrownBy(() -> this.resolver.resolve(this.context, location));
+	}
+
+	@Test
+	void resolveWhenOptionalAndLoaderIsUnknownAndExtensionIsUnknownShouldNotFail() {
+		ConfigDataLocation location = ConfigDataLocation
+			.of("optional:some-unknown-loader:dummy.some-unknown-extension");
+		List<StandardConfigDataResource> locations = this.resolver.resolve(this.context, location);
+		assertThatNoException().isThrownBy(() -> this.resolver.resolve(this.context, location));
+	}
+
+	@Test
+	void resolveWhenOptionalAndExtensionIsUnknownShouldNotFail() {
+		ConfigDataLocation location = ConfigDataLocation.of("optional:file:dummy.some-unknown-extension");
+		assertThatNoException().isThrownBy(() -> this.resolver.resolve(this.context, location));
+	}
+
 	private String filePath(String... components) {
 		return "file [" + String.join(File.separator, components) + "]";
 	}
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/logging/LoggingApplicationListenerIntegrationTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/logging/LoggingApplicationListenerIntegrationTests.java
index 398bc8298296..d7c8b12a4901 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/logging/LoggingApplicationListenerIntegrationTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/logging/LoggingApplicationListenerIntegrationTests.java
@@ -30,7 +30,7 @@
 import org.springframework.boot.context.event.ApplicationStartingEvent;
 import org.springframework.boot.logging.LogFile;
 import org.springframework.boot.logging.LoggingSystem;
-import org.springframework.boot.logging.LoggingSystemProperties;
+import org.springframework.boot.logging.LoggingSystemProperty;
 import org.springframework.boot.testsupport.system.CapturedOutput;
 import org.springframework.boot.testsupport.system.OutputCaptureExtension;
 import org.springframework.context.ApplicationListener;
@@ -69,7 +69,7 @@ void logFileRegisteredInTheContextWhenApplicable(@TempDir File tempDir) {
 			assertThat(service.logFile).hasToString(logFile);
 		}
 		finally {
-			System.clearProperty(LoggingSystemProperties.LOG_FILE);
+			System.clearProperty(LoggingSystemProperty.LOG_FILE.getEnvironmentVariableName());
 		}
 	}
 
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/logging/LoggingApplicationListenerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/logging/LoggingApplicationListenerTests.java
index d08311428194..d422f10c49a5 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/logging/LoggingApplicationListenerTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/logging/LoggingApplicationListenerTests.java
@@ -58,7 +58,7 @@
 import org.springframework.boot.logging.LoggerGroups;
 import org.springframework.boot.logging.LoggingInitializationContext;
 import org.springframework.boot.logging.LoggingSystem;
-import org.springframework.boot.logging.LoggingSystemProperties;
+import org.springframework.boot.logging.LoggingSystemProperty;
 import org.springframework.boot.logging.java.JavaLoggingSystem;
 import org.springframework.boot.system.ApplicationPid;
 import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
@@ -92,6 +92,7 @@
  * @author Ben Hale
  * @author Fahim Farook
  * @author EddĂș MelĂ©ndez
+ * @author Jonatan Ivanov
  */
 @ExtendWith(OutputCaptureExtension.class)
 @ClassPathExclusions("log4j*.jar")
@@ -467,16 +468,16 @@ void closingChildContextDoesNotCleanUpLoggingSystem() {
 	void systemPropertiesAreSetForLoggingConfiguration() {
 		addPropertiesToEnvironment(this.context, "logging.exception-conversion-word=conversion",
 				"logging.file.name=" + this.logFile, "logging.file.path=path", "logging.pattern.console=console",
-				"logging.pattern.file=file", "logging.pattern.level=level",
+				"logging.pattern.file=file", "logging.pattern.level=level", "logging.pattern.correlation=correlation",
 				"logging.pattern.rolling-file-name=my.log.%d{yyyyMMdd}.%i.gz");
 		this.listener.initialize(this.context.getEnvironment(), this.context.getClassLoader());
-		assertThat(System.getProperty(LoggingSystemProperties.CONSOLE_LOG_PATTERN)).isEqualTo("console");
-		assertThat(System.getProperty(LoggingSystemProperties.FILE_LOG_PATTERN)).isEqualTo("file");
-		assertThat(System.getProperty(LoggingSystemProperties.EXCEPTION_CONVERSION_WORD)).isEqualTo("conversion");
-		assertThat(System.getProperty(LoggingSystemProperties.LOG_FILE)).isEqualTo(this.logFile.getAbsolutePath());
-		assertThat(System.getProperty(LoggingSystemProperties.LOG_LEVEL_PATTERN)).isEqualTo("level");
-		assertThat(System.getProperty(LoggingSystemProperties.LOG_PATH)).isEqualTo("path");
-		assertThat(System.getProperty(LoggingSystemProperties.PID_KEY)).isNotNull();
+		assertThat(getSystemProperty(LoggingSystemProperty.CONSOLE_PATTERN)).isEqualTo("console");
+		assertThat(getSystemProperty(LoggingSystemProperty.FILE_PATTERN)).isEqualTo("file");
+		assertThat(getSystemProperty(LoggingSystemProperty.EXCEPTION_CONVERSION_WORD)).isEqualTo("conversion");
+		assertThat(getSystemProperty(LoggingSystemProperty.LOG_FILE)).isEqualTo(this.logFile.getAbsolutePath());
+		assertThat(getSystemProperty(LoggingSystemProperty.LEVEL_PATTERN)).isEqualTo("level");
+		assertThat(getSystemProperty(LoggingSystemProperty.LOG_PATH)).isEqualTo("path");
+		assertThat(getSystemProperty(LoggingSystemProperty.PID)).isNotNull();
 	}
 
 	@Test
@@ -484,15 +485,14 @@ void environmentPropertiesIgnoreUnresolvablePlaceholders() {
 		// gh-7719
 		addPropertiesToEnvironment(this.context, "logging.pattern.console=console ${doesnotexist}");
 		this.listener.initialize(this.context.getEnvironment(), this.context.getClassLoader());
-		assertThat(System.getProperty(LoggingSystemProperties.CONSOLE_LOG_PATTERN))
-			.isEqualTo("console ${doesnotexist}");
+		assertThat(getSystemProperty(LoggingSystemProperty.CONSOLE_PATTERN)).isEqualTo("console ${doesnotexist}");
 	}
 
 	@Test
 	void environmentPropertiesResolvePlaceholders() {
 		addPropertiesToEnvironment(this.context, "logging.pattern.console=console ${pid}");
 		this.listener.initialize(this.context.getEnvironment(), this.context.getClassLoader());
-		assertThat(System.getProperty(LoggingSystemProperties.CONSOLE_LOG_PATTERN))
+		assertThat(getSystemProperty(LoggingSystemProperty.CONSOLE_PATTERN))
 			.isEqualTo(this.context.getEnvironment().getProperty("logging.pattern.console"));
 	}
 
@@ -500,7 +500,7 @@ void environmentPropertiesResolvePlaceholders() {
 	void logFilePropertiesCanReferenceSystemProperties() {
 		addPropertiesToEnvironment(this.context, "logging.file.name=" + this.tempDir + "${PID}.log");
 		this.listener.initialize(this.context.getEnvironment(), this.context.getClassLoader());
-		assertThat(System.getProperty(LoggingSystemProperties.LOG_FILE))
+		assertThat(getSystemProperty(LoggingSystemProperty.LOG_FILE))
 			.isEqualTo(this.tempDir + new ApplicationPid().toString() + ".log");
 	}
 
@@ -575,6 +575,10 @@ void loggingGroupsCanBeDefined() {
 		assertTraceEnabled("com.foo.baz", true);
 	}
 
+	private String getSystemProperty(LoggingSystemProperty property) {
+		return System.getProperty(property.getEnvironmentVariableName());
+	}
+
 	private void assertTraceEnabled(String name, boolean expected) {
 		assertThat(this.loggerContext.getLogger(name).isTraceEnabled()).isEqualTo(expected);
 	}
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/metrics/buffering/BufferingApplicationStartupTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/metrics/buffering/BufferingApplicationStartupTests.java
index ace6a59c857f..efc50373185c 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/metrics/buffering/BufferingApplicationStartupTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/metrics/buffering/BufferingApplicationStartupTests.java
@@ -27,7 +27,8 @@
 import org.springframework.core.metrics.StartupStep;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
 
 /**
  * Tests for {@link BufferingApplicationStartup}.
@@ -86,8 +87,8 @@ void bufferShouldBeEmptyWhenDraining() {
 	void startRecordingShouldFailIfEventsWereRecorded() {
 		BufferingApplicationStartup applicationStartup = new BufferingApplicationStartup(2);
 		applicationStartup.start("first").end();
-		assertThatThrownBy(applicationStartup::startRecording).isInstanceOf(IllegalStateException.class)
-			.hasMessage("Cannot restart recording once steps have been buffered.");
+		assertThatIllegalStateException().isThrownBy(applicationStartup::startRecording)
+			.withMessage("Cannot restart recording once steps have been buffered.");
 	}
 
 	@Test
@@ -95,8 +96,8 @@ void taggingShouldFailWhenEventAlreadyRecorded() {
 		BufferingApplicationStartup applicationStartup = new BufferingApplicationStartup(2);
 		StartupStep step = applicationStartup.start("first");
 		step.end();
-		assertThatThrownBy(() -> step.tag("name", "value")).isInstanceOf(IllegalStateException.class)
-			.hasMessage("StartupStep has already ended.");
+		assertThatIllegalStateException().isThrownBy(() -> step.tag("name", "value"))
+			.withMessage("StartupStep has already ended.");
 	}
 
 	@Test
@@ -104,7 +105,8 @@ void taggingShouldFailWhenRemovingEntry() {
 		BufferingApplicationStartup applicationStartup = new BufferingApplicationStartup(2);
 		StartupStep step = applicationStartup.start("first");
 		step.tag("name", "value");
-		assertThatThrownBy(() -> step.getTags().iterator().remove()).isInstanceOf(UnsupportedOperationException.class);
+		assertThatExceptionOfType(UnsupportedOperationException.class)
+			.isThrownBy(() -> step.getTags().iterator().remove());
 	}
 
 	@Test // gh-25792
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanRegistrationAotProcessorTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanRegistrationAotProcessorTests.java
index cb1d826c1fd8..3829bf9033dd 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanRegistrationAotProcessorTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanRegistrationAotProcessorTests.java
@@ -100,6 +100,20 @@ void aotContributedInitializerBindsValueObject() {
 		});
 	}
 
+	@Test
+	@CompileWithForkedClassLoader
+	void aotContributedInitializerBindsValueObjectWithSpecificConstructor() {
+		compile(createContext(ValueObjectSampleBeanWithSpecificConstructorConfiguration.class), (freshContext) -> {
+			TestPropertySourceUtils.addInlinedPropertiesToEnvironment(freshContext, "test.name=Hello",
+					"test.counter=30");
+			freshContext.refresh();
+			ValueObjectWithSpecificConstructorSampleBean bean = freshContext
+				.getBean(ValueObjectWithSpecificConstructorSampleBean.class);
+			assertThat(bean.name).isEqualTo("Hello");
+			assertThat(bean.counter).isEqualTo(30);
+		});
+	}
+
 	@Test
 	@CompileWithForkedClassLoader
 	void aotContributedInitializerBindsJavaBean() {
@@ -193,6 +207,32 @@ public static class ValueObjectSampleBean {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	@EnableConfigurationProperties(ValueObjectWithSpecificConstructorSampleBean.class)
+	static class ValueObjectSampleBeanWithSpecificConstructorConfiguration {
+
+	}
+
+	@ConfigurationProperties("test")
+	public static class ValueObjectWithSpecificConstructorSampleBean {
+
+		@SuppressWarnings("unused")
+		private final String name;
+
+		@SuppressWarnings("unused")
+		private final Integer counter;
+
+		ValueObjectWithSpecificConstructorSampleBean(String name, Integer counter) {
+			this.name = name;
+			this.counter = counter;
+		}
+
+		private ValueObjectWithSpecificConstructorSampleBean(String name) {
+			this(name, 42);
+		}
+
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	@ConfigurationPropertiesScan(basePackageClasses = BScanConfiguration.class)
 	static class ScanTestConfiguration {
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanTests.java
index f48d5be9527b..7e361c87471b 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanTests.java
@@ -233,24 +233,6 @@ void forValueObjectWithConstructorBindingAnnotatedClassReturnsBean() {
 			.isNotNull();
 	}
 
-	@Test
-	void forValueObjectWithDeprecatedConstructorBindingAnnotatedClassReturnsBean() {
-		ConfigurationPropertiesBean propertiesBean = ConfigurationPropertiesBean
-			.forValueObject(DeprecatedConstructorBindingOnConstructor.class, "valueObjectBean");
-		assertThat(propertiesBean.getName()).isEqualTo("valueObjectBean");
-		assertThat(propertiesBean.getInstance()).isNull();
-		assertThat(propertiesBean.getType()).isEqualTo(DeprecatedConstructorBindingOnConstructor.class);
-		assertThat(propertiesBean.asBindTarget().getBindMethod()).isEqualTo(BindMethod.VALUE_OBJECT);
-		assertThat(propertiesBean.getAnnotation()).isNotNull();
-		Bindable<?> target = propertiesBean.asBindTarget();
-		assertThat(target.getType())
-			.isEqualTo(ResolvableType.forClass(DeprecatedConstructorBindingOnConstructor.class));
-		assertThat(target.getValue()).isNull();
-		assertThat(BindConstructorProvider.DEFAULT.getBindConstructor(DeprecatedConstructorBindingOnConstructor.class,
-				false))
-			.isNotNull();
-	}
-
 	@Test
 	void forValueObjectWithRecordReturnsBean() {
 		Class<?> implicitConstructorBinding = new ByteBuddy(ClassFileVersion.JAVA_V16).makeRecord()
@@ -558,20 +540,6 @@ static class ConstructorBindingOnConstructor {
 
 	}
 
-	@ConfigurationProperties
-	@SuppressWarnings("removal")
-	static class DeprecatedConstructorBindingOnConstructor {
-
-		DeprecatedConstructorBindingOnConstructor(String name) {
-			this(name, -1);
-		}
-
-		@org.springframework.boot.context.properties.ConstructorBinding
-		DeprecatedConstructorBindingOnConstructor(String name, int age) {
-		}
-
-	}
-
 	@ConfigurationProperties
 	static class ConstructorBindingOnMultipleConstructors {
 
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesTests.java
index 98f78b06beca..2e2c22377472 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesTests.java
@@ -23,6 +23,7 @@
 import java.time.Period;
 import java.time.temporal.ChronoUnit;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
@@ -101,6 +102,7 @@
 import org.springframework.validation.annotation.Validated;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatException;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
 import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
 import static org.assertj.core.api.Assertions.entry;
@@ -645,24 +647,36 @@ void customProtocolResolver() {
 
 	@Test
 	void loadShouldUseConverterBean() {
-		prepareConverterContext(ConverterConfiguration.class, PersonProperties.class);
+		prepareConverterContext(PersonConverterConfiguration.class, PersonProperties.class);
 		Person person = this.context.getBean(PersonProperties.class).getPerson();
 		assertThat(person.firstName).isEqualTo("John");
 		assertThat(person.lastName).isEqualTo("Smith");
 	}
 
 	@Test
-	void loadWhenBeanFactoryConversionServiceAndConverterBean() {
+	void loadWhenBeanFactoryConversionServiceAndConverterBeanCanUseBeanFactoryConverter() {
 		DefaultConversionService conversionService = new DefaultConversionService();
 		conversionService.addConverter(new AlienConverter());
 		this.context.getBeanFactory().setConversionService(conversionService);
-		load(new Class<?>[] { ConverterConfiguration.class, PersonAndAlienProperties.class }, "test.person=John Smith",
-				"test.alien=Alf Tanner");
+		load(new Class<?>[] { PersonConverterConfiguration.class, PersonAndAlienProperties.class },
+				"test.person=John Smith", "test.alien=Alf Tanner");
 		PersonAndAlienProperties properties = this.context.getBean(PersonAndAlienProperties.class);
 		assertThat(properties.getPerson().firstName).isEqualTo("John");
 		assertThat(properties.getPerson().lastName).isEqualTo("Smith");
-		assertThat(properties.getAlien().firstName).isEqualTo("Alf");
-		assertThat(properties.getAlien().lastName).isEqualTo("Tanner");
+		assertThat(properties.getAlien().name).isEqualTo("rennaT flA");
+	}
+
+	@Test
+	void loadWhenBeanFactoryConversionServiceAndConverterBeanCanUseConverterBean() {
+		DefaultConversionService conversionService = new DefaultConversionService();
+		conversionService.addConverter(new PersonConverter());
+		this.context.getBeanFactory().setConversionService(conversionService);
+		load(new Class<?>[] { AlienConverterConfiguration.class, PersonAndAlienProperties.class },
+				"test.person=John Smith", "test.alien=Alf Tanner");
+		PersonAndAlienProperties properties = this.context.getBean(PersonAndAlienProperties.class);
+		assertThat(properties.getPerson().firstName).isEqualTo("John");
+		assertThat(properties.getPerson().lastName).isEqualTo("Smith");
+		assertThat(properties.getAlien().name).isEqualTo("rennaT flA");
 	}
 
 	@Test
@@ -709,11 +723,10 @@ private void prepareConverterContext(Class<?>... config) {
 
 	@Test
 	void loadWhenHasConfigurationPropertiesValidatorShouldApplyValidator() {
-		assertThatExceptionOfType(Exception.class).isThrownBy(() -> load(WithCustomValidatorConfiguration.class))
-			.satisfies((ex) -> {
-				assertThat(ex).hasCauseInstanceOf(BindException.class);
-				assertThat(ex.getCause()).hasCauseExactlyInstanceOf(BindValidationException.class);
-			});
+		assertThatException().isThrownBy(() -> load(WithCustomValidatorConfiguration.class)).satisfies((ex) -> {
+			assertThat(ex).hasCauseInstanceOf(BindException.class);
+			assertThat(ex.getCause()).hasCauseExactlyInstanceOf(BindValidationException.class);
+		});
 	}
 
 	@Test
@@ -726,7 +739,7 @@ void loadWhenHasUnsupportedConfigurationPropertiesValidatorShouldBind() {
 
 	@Test
 	void loadWhenConfigurationPropertiesIsAlsoValidatorShouldApplyValidator() {
-		assertThatExceptionOfType(Exception.class).isThrownBy(() -> load(ValidatorProperties.class)).satisfies((ex) -> {
+		assertThatException().isThrownBy(() -> load(ValidatorProperties.class)).satisfies((ex) -> {
 			assertThat(ex).hasCauseInstanceOf(BindException.class);
 			assertThat(ex.getCause()).hasCauseExactlyInstanceOf(BindValidationException.class);
 		});
@@ -734,8 +747,7 @@ void loadWhenConfigurationPropertiesIsAlsoValidatorShouldApplyValidator() {
 
 	@Test
 	void loadWhenConstructorBoundConfigurationPropertiesIsAlsoValidatorShouldApplyValidator() {
-		assertThatExceptionOfType(Exception.class)
-			.isThrownBy(() -> load(ValidatorConstructorBoundPropertiesConfiguration.class))
+		assertThatException().isThrownBy(() -> load(ValidatorConstructorBoundPropertiesConfiguration.class))
 			.satisfies((ex) -> {
 				assertThat(ex).hasCauseInstanceOf(BindException.class);
 				assertThat(ex.getCause()).hasCauseExactlyInstanceOf(BindValidationException.class);
@@ -909,8 +921,7 @@ void loadWhenBindingToConstructorParametersWithNotMatchingCustomDurationFormatSh
 		Map<String, Object> source = new HashMap<>();
 		source.put("test.duration", "P12D");
 		sources.addLast(new MapPropertySource("test", source));
-		assertThatExceptionOfType(Exception.class)
-			.isThrownBy(() -> load(ConstructorParameterWithFormatConfiguration.class))
+		assertThatException().isThrownBy(() -> load(ConstructorParameterWithFormatConfiguration.class))
 			.havingCause()
 			.isInstanceOf(BindException.class);
 	}
@@ -921,8 +932,7 @@ void loadWhenBindingToConstructorParametersWithNotMatchingCustomPeriodFormatShou
 		Map<String, Object> source = new HashMap<>();
 		source.put("test.period", "P12D");
 		sources.addLast(new MapPropertySource("test", source));
-		assertThatExceptionOfType(Exception.class)
-			.isThrownBy(() -> load(ConstructorParameterWithFormatConfiguration.class))
+		assertThatException().isThrownBy(() -> load(ConstructorParameterWithFormatConfiguration.class))
 			.havingCause()
 			.isInstanceOf(BindException.class);
 	}
@@ -938,8 +948,7 @@ void loadWhenBindingToConstructorParametersWithDefaultDataFormatShouldBind() {
 
 	@Test
 	void loadWhenBindingToConstructorParametersShouldValidate() {
-		assertThatExceptionOfType(Exception.class)
-			.isThrownBy(() -> load(ConstructorParameterValidationConfiguration.class))
+		assertThatException().isThrownBy(() -> load(ConstructorParameterValidationConfiguration.class))
 			.satisfies((ex) -> {
 				assertThat(ex).hasCauseInstanceOf(BindException.class);
 				assertThat(ex.getCause()).hasCauseExactlyInstanceOf(BindValidationException.class);
@@ -1161,6 +1170,35 @@ void loadWhenPotentiallyConstructorBoundPropertiesAreImportedUsesJavaBeanBinding
 		assertThat(properties.getProp()).isEqualTo("alpha");
 	}
 
+	@Test
+	void loadWhenBindingClasspathPatternToResourceArrayShouldBindMultipleValues() {
+		load(ResourceArrayPropertiesConfiguration.class,
+				"test.resources=classpath*:org/springframework/boot/context/properties/*.class");
+		ResourceArrayProperties properties = this.context.getBean(ResourceArrayProperties.class);
+		assertThat(properties.getResources()).hasSizeGreaterThan(1);
+	}
+
+	@Test
+	void loadWhenBindingClasspathPatternToResourceCollectionShouldBindMultipleValues() {
+		load(ResourceCollectionPropertiesConfiguration.class,
+				"test.resources=classpath*:org/springframework/boot/context/properties/*.class");
+		ResourceCollectionProperties properties = this.context.getBean(ResourceCollectionProperties.class);
+		assertThat(properties.getResources()).hasSizeGreaterThan(1);
+	}
+
+	@Test
+	void loadWhenBindingToConstructorParametersWithConversionToCustomListImplementation() {
+		load(ConstructorBoundCustomListPropertiesConfiguration.class, "test.values=a,b");
+		assertThat(this.context.getBean(ConstructorBoundCustomListProperties.class).getValues()).containsExactly("a",
+				"b");
+	}
+
+	@Test
+	void loadWhenBindingToJavaBeanWithConversionToCustomListImplementation() {
+		load(SetterBoundCustomListPropertiesConfiguration.class, "test.values=a,b");
+		assertThat(this.context.getBean(SetterBoundCustomListProperties.class).getValues()).containsExactly("a", "b");
+	}
+
 	private AnnotationConfigApplicationContext load(Class<?> configuration, String... inlinedProperties) {
 		return load(new Class<?>[] { configuration }, inlinedProperties);
 	}
@@ -1449,7 +1487,7 @@ public Resource resolve(String location, ResourceLoader resourceLoader) {
 	}
 
 	@Configuration(proxyBeanMethods = false)
-	static class ConverterConfiguration {
+	static class PersonConverterConfiguration {
 
 		@Bean
 		@ConfigurationPropertiesBinding
@@ -1459,6 +1497,17 @@ Converter<String, Person> personConverter() {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	static class AlienConverterConfiguration {
+
+		@Bean
+		@ConfigurationPropertiesBinding
+		Converter<String, Alien> alienConverter() {
+			return new AlienConverter();
+		}
+
+	}
+
 	@Configuration(proxyBeanMethods = false)
 	static class NonQualifiedConverterConfiguration {
 
@@ -2407,8 +2456,7 @@ static class AlienConverter implements Converter<String, Alien> {
 
 		@Override
 		public Alien convert(String source) {
-			String[] content = StringUtils.split(source, " ");
-			return new Alien(content[0], content[1]);
+			return new Alien(new StringBuilder(source).reverse().toString());
 		}
 
 	}
@@ -2476,21 +2524,14 @@ String getLastName() {
 
 	static class Alien {
 
-		private final String firstName;
-
-		private final String lastName;
-
-		Alien(String firstName, String lastName) {
-			this.firstName = firstName;
-			this.lastName = lastName;
-		}
+		private final String name;
 
-		String getFirstName() {
-			return this.firstName;
+		Alien(String name) {
+			this.name = name;
 		}
 
-		String getLastName() {
-			return this.lastName;
+		String getName() {
+			return this.name;
 		}
 
 	}
@@ -3043,4 +3084,120 @@ void setProp(String prop) {
 
 	}
 
+	@EnableConfigurationProperties(ResourceArrayProperties.class)
+	static class ResourceArrayPropertiesConfiguration {
+
+	}
+
+	@ConfigurationProperties("test")
+	static class ResourceArrayProperties {
+
+		private Resource[] resources;
+
+		Resource[] getResources() {
+			return this.resources;
+		}
+
+		void setResources(Resource[] resources) {
+			this.resources = resources;
+		}
+
+	}
+
+	@EnableConfigurationProperties(ResourceCollectionProperties.class)
+	static class ResourceCollectionPropertiesConfiguration {
+
+	}
+
+	@ConfigurationProperties("test")
+	static class ResourceCollectionProperties {
+
+		private Collection<Resource> resources;
+
+		Collection<Resource> getResources() {
+			return this.resources;
+		}
+
+		void setResources(Collection<Resource> resources) {
+			this.resources = resources;
+		}
+
+	}
+
+	@EnableConfigurationProperties(ConstructorBoundCustomListProperties.class)
+	static class ConstructorBoundCustomListPropertiesConfiguration {
+
+		@Bean
+		@ConfigurationPropertiesBinding
+		static Converter<ArrayList<?>, CustomList<?>> arrayListToCustomList() {
+			return new Converter<>() {
+
+				@Override
+				public CustomList<?> convert(ArrayList<?> source) {
+					return new CustomList<>(source);
+				}
+
+			};
+
+		}
+
+	}
+
+	@ConfigurationProperties("test")
+	static class ConstructorBoundCustomListProperties {
+
+		private final CustomList<String> values;
+
+		ConstructorBoundCustomListProperties(CustomList<String> values) {
+			this.values = values;
+		}
+
+		CustomList<String> getValues() {
+			return this.values;
+		}
+
+	}
+
+	@EnableConfigurationProperties(SetterBoundCustomListProperties.class)
+	static class SetterBoundCustomListPropertiesConfiguration {
+
+		@Bean
+		@ConfigurationPropertiesBinding
+		static Converter<ArrayList<?>, CustomList<?>> arrayListToCustomList() {
+			return new Converter<>() {
+
+				@Override
+				public CustomList<?> convert(ArrayList<?> source) {
+					return new CustomList<>(source);
+				}
+
+			};
+
+		}
+
+	}
+
+	@ConfigurationProperties("test")
+	static class SetterBoundCustomListProperties {
+
+		private CustomList<String> values;
+
+		CustomList<String> getValues() {
+			return this.values;
+		}
+
+		void setValues(CustomList<String> values) {
+			this.values = values;
+		}
+
+	}
+
+	static final class CustomList<E> extends ArrayList<E> {
+
+		CustomList(List<E> delegate) {
+			super(delegate);
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConversionServiceDeducerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConversionServiceDeducerTests.java
index 30286fe950b5..06b27871c9ed 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConversionServiceDeducerTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConversionServiceDeducerTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -30,6 +30,7 @@
 import org.springframework.context.annotation.Configuration;
 import org.springframework.core.convert.ConversionService;
 import org.springframework.core.convert.converter.Converter;
+import org.springframework.format.support.FormattingConversionService;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -69,14 +70,15 @@ void getConversionServiceWhenHasNoConversionServiceBeanAndNoQualifiedBeansAndBea
 	}
 
 	@Test
-	void getConversionServiceWhenHasQualifiedConverterBeansContainsCustomizedApplicationService() {
+	void getConversionServiceWhenHasQualifiedConverterBeansContainsCustomizedFormattingService() {
 		ApplicationContext applicationContext = new AnnotationConfigApplicationContext(
 				CustomConverterConfiguration.class);
 		ConversionServiceDeducer deducer = new ConversionServiceDeducer(applicationContext);
 		List<ConversionService> conversionServices = deducer.getConversionServices();
-		assertThat(conversionServices).hasSize(1);
-		assertThat(conversionServices.get(0)).isNotSameAs(ApplicationConversionService.getSharedInstance());
+		assertThat(conversionServices).hasSize(2);
+		assertThat(conversionServices.get(0)).isExactlyInstanceOf(FormattingConversionService.class);
 		assertThat(conversionServices.get(0).canConvert(InputStream.class, OutputStream.class)).isTrue();
+		assertThat(conversionServices.get(1)).isSameAs(ApplicationConversionService.getSharedInstance());
 	}
 
 	@Configuration(proxyBeanMethods = false)
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/PropertyMapperTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/PropertyMapperTests.java
index a67dd344b7a9..a6a5f4547f6c 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/PropertyMapperTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/PropertyMapperTests.java
@@ -18,11 +18,11 @@
 
 import java.util.function.Supplier;
 
-import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.fail;
 
 /**
  * Tests for {@link PropertyMapper}.
@@ -57,7 +57,7 @@ void fromValueAsIntShouldAdaptValue() {
 
 	@Test
 	void fromValueAlwaysApplyingWhenNonNullShouldAlwaysApplyNonNullToSource() {
-		this.map.alwaysApplyingWhenNonNull().from((String) null).toCall(Assertions::fail);
+		this.map.alwaysApplyingWhenNonNull().from((String) null).toCall(() -> fail(null));
 	}
 
 	@Test
@@ -101,14 +101,14 @@ void asShouldAdaptSupplier() {
 
 	@Test
 	void whenNonNullWhenSuppliedNullShouldNotMap() {
-		this.map.from(() -> null).whenNonNull().as(String::valueOf).toCall(Assertions::fail);
+		this.map.from(() -> null).whenNonNull().as(String::valueOf).toCall(() -> fail(null));
 	}
 
 	@Test
 	void whenNonNullWhenSuppliedThrowsNullPointerExceptionShouldNotMap() {
 		this.map.from(() -> {
 			throw new NullPointerException();
-		}).whenNonNull().as(String::valueOf).toCall(Assertions::fail);
+		}).whenNonNull().as(String::valueOf).toCall(() -> fail(null));
 	}
 
 	@Test
@@ -119,7 +119,7 @@ void whenTrueWhenValueIsTrueShouldMap() {
 
 	@Test
 	void whenTrueWhenValueIsFalseShouldNotMap() {
-		this.map.from(false).whenTrue().toCall(Assertions::fail);
+		this.map.from(false).whenTrue().toCall(() -> fail(null));
 	}
 
 	@Test
@@ -130,17 +130,17 @@ void whenFalseWhenValueIsFalseShouldMap() {
 
 	@Test
 	void whenFalseWhenValueIsTrueShouldNotMap() {
-		this.map.from(true).whenFalse().toCall(Assertions::fail);
+		this.map.from(true).whenFalse().toCall(() -> fail(null));
 	}
 
 	@Test
 	void whenHasTextWhenValueIsNullShouldNotMap() {
-		this.map.from(() -> null).whenHasText().toCall(Assertions::fail);
+		this.map.from(() -> null).whenHasText().toCall(() -> fail(null));
 	}
 
 	@Test
 	void whenHasTextWhenValueIsEmptyShouldNotMap() {
-		this.map.from("").whenHasText().toCall(Assertions::fail);
+		this.map.from("").whenHasText().toCall(() -> fail(null));
 	}
 
 	@Test
@@ -157,7 +157,7 @@ void whenEqualToWhenValueIsEqualShouldMatch() {
 
 	@Test
 	void whenEqualToWhenValueIsNotEqualShouldNotMatch() {
-		this.map.from("123").whenEqualTo("321").toCall(Assertions::fail);
+		this.map.from("123").whenEqualTo("321").toCall(() -> fail(null));
 	}
 
 	@Test
@@ -169,7 +169,7 @@ void whenInstanceOfWhenValueIsTargetTypeShouldMatch() {
 	@Test
 	void whenInstanceOfWhenValueIsNotTargetTypeShouldNotMatch() {
 		Supplier<Number> supplier = () -> 123L;
-		this.map.from(supplier).whenInstanceOf(Double.class).toCall(Assertions::fail);
+		this.map.from(supplier).whenInstanceOf(Double.class).toCall(() -> fail(null));
 	}
 
 	@Test
@@ -180,7 +180,7 @@ void whenWhenValueMatchesShouldMap() {
 
 	@Test
 	void whenWhenValueDoesNotMatchShouldNotMap() {
-		this.map.from("123").when("321"::equals).toCall(Assertions::fail);
+		this.map.from("123").when("321"::equals).toCall(() -> fail(null));
 	}
 
 	@Test
@@ -198,12 +198,12 @@ void whenWhenCombinedWithAsUsesSourceValue() {
 
 	@Test
 	void alwaysApplyingWhenNonNullShouldAlwaysApplyNonNullToSource() {
-		this.map.alwaysApplyingWhenNonNull().from(() -> null).toCall(Assertions::fail);
+		this.map.alwaysApplyingWhenNonNull().from(() -> null).toCall(() -> fail(null));
 	}
 
 	@Test
 	void whenWhenValueNotMatchesShouldSupportChainedCalls() {
-		this.map.from("123").when("456"::equals).when("123"::equals).toCall(Assertions::fail);
+		this.map.from("123").when("456"::equals).when("123"::equals).toCall(() -> fail(null));
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/BindableRuntimeHintsRegistrarTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/BindableRuntimeHintsRegistrarTests.java
index b1b4f8bbfbff..5cc460d485a9 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/BindableRuntimeHintsRegistrarTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/BindableRuntimeHintsRegistrarTests.java
@@ -35,6 +35,9 @@
 import org.springframework.boot.context.properties.ConfigurationPropertiesBean;
 import org.springframework.boot.context.properties.NestedConfigurationProperty;
 import org.springframework.boot.context.properties.bind.BindableRuntimeHintsRegistrarTests.BaseProperties.InheritedNested;
+import org.springframework.boot.context.properties.bind.BindableRuntimeHintsRegistrarTests.ComplexNestedProperties.ListenerRetry;
+import org.springframework.boot.context.properties.bind.BindableRuntimeHintsRegistrarTests.ComplexNestedProperties.Retry;
+import org.springframework.boot.context.properties.bind.BindableRuntimeHintsRegistrarTests.ComplexNestedProperties.Simple;
 import org.springframework.context.ApplicationContext;
 import org.springframework.context.ApplicationContextAware;
 import org.springframework.context.EnvironmentAware;
@@ -211,7 +214,7 @@ void registerHintsWhenHasCrossReference() {
 	}
 
 	@Test
-	void pregisterHintsWhenHasUnresolvedGeneric() {
+	void registerHintsWhenHasUnresolvedGeneric() {
 		RuntimeHints runtimeHints = registerHints(WithGeneric.class);
 		assertThat(runtimeHints.reflection().typeHints()).hasSize(2)
 			.anySatisfy(javaBeanBinding(WithGeneric.class, "getGeneric"))
@@ -259,6 +262,23 @@ void registerHintsWhenHasInheritedNestedProperties() {
 			.satisfies(javaBeanBinding(InheritedNested.class, "getAlpha", "setAlpha"));
 	}
 
+	@Test
+	void registerHintsWhenHasComplexNestedProperties() {
+		RuntimeHints runtimeHints = registerHints(ComplexNestedProperties.class);
+		assertThat(runtimeHints.reflection().typeHints()).hasSize(4);
+		assertThat(runtimeHints.reflection().getTypeHint(Retry.class)).satisfies((entry) -> {
+			assertThat(entry.getMemberCategories()).isEmpty();
+			assertThat(entry.methods()).extracting(ExecutableHint::getName)
+				.containsExactlyInAnyOrder("getCount", "setCount");
+		});
+		assertThat(runtimeHints.reflection().getTypeHint(ListenerRetry.class))
+			.satisfies(javaBeanBinding(ListenerRetry.class, "isStateless", "setStateless"));
+		assertThat(runtimeHints.reflection().getTypeHint(Simple.class))
+			.satisfies(javaBeanBinding(Simple.class, "getRetry"));
+		assertThat(runtimeHints.reflection().getTypeHint(ComplexNestedProperties.class))
+			.satisfies(javaBeanBinding(ComplexNestedProperties.class, "getSimple"));
+	}
+
 	private Consumer<TypeHint> javaBeanBinding(Class<?> type, String... expectedMethods) {
 		return javaBeanBinding(type, type.getDeclaredConstructors()[0], expectedMethods);
 	}
@@ -723,4 +743,52 @@ public void setBravo(String bravo) {
 
 	}
 
+	public static class ComplexNestedProperties {
+
+		private final Simple simple = new Simple();
+
+		public Simple getSimple() {
+			return this.simple;
+		}
+
+		public static class Simple {
+
+			private final ListenerRetry retry = new ListenerRetry();
+
+			public ListenerRetry getRetry() {
+				return this.retry;
+			}
+
+		}
+
+		public abstract static class Retry {
+
+			private int count = 5;
+
+			public int getCount() {
+				return this.count;
+			}
+
+			public void setCount(int count) {
+				this.count = count;
+			}
+
+		}
+
+		public static class ListenerRetry extends Retry {
+
+			private boolean stateless;
+
+			public boolean isStateless() {
+				return this.stateless;
+			}
+
+			public void setStateless(boolean stateless) {
+				this.stateless = stateless;
+			}
+
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/ValueObjectBinderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/ValueObjectBinderTests.java
index e991d0b74090..a47f30aa0fcb 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/ValueObjectBinderTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/ValueObjectBinderTests.java
@@ -26,11 +26,13 @@
 import java.util.Objects;
 import java.util.Optional;
 
+import com.jayway.jsonpath.JsonPath;
 import org.junit.jupiter.api.Test;
 
 import org.springframework.boot.context.properties.source.ConfigurationPropertyName;
 import org.springframework.boot.context.properties.source.ConfigurationPropertySource;
 import org.springframework.boot.context.properties.source.MockConfigurationPropertySource;
+import org.springframework.core.DefaultParameterNameDiscoverer;
 import org.springframework.core.ResolvableType;
 import org.springframework.core.convert.ConversionService;
 import org.springframework.core.test.tools.SourceFile;
@@ -40,7 +42,7 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
-import static org.junit.jupiter.api.Assertions.fail;
+import static org.assertj.core.api.Assertions.fail;
 
 /**
  * Tests for {@link ValueObjectBinder}.
@@ -391,6 +393,25 @@ public record RecordProperties(
 		});
 	}
 
+	@Test // gh-38201
+	void bindWithNonExtractableParameterNamesAndNonIterablePropertySource() throws Exception {
+		verifyJsonPathParametersCannotBeResolved();
+		MockConfigurationPropertySource source = new MockConfigurationPropertySource();
+		source.put("test.value", "test");
+		this.sources.add(source.nonIterable());
+		Bindable<NonExtractableParameterName> target = Bindable.of(NonExtractableParameterName.class);
+		NonExtractableParameterName bound = this.binder.bindOrCreate("test", target);
+		assertThat(bound.getValue()).isEqualTo("test");
+	}
+
+	private void verifyJsonPathParametersCannotBeResolved() throws NoSuchFieldException {
+		Class<?> jsonPathClass = NonExtractableParameterName.class.getDeclaredField("jsonPath").getType();
+		Constructor<?>[] constructors = jsonPathClass.getDeclaredConstructors();
+		assertThat(constructors).hasSize(1);
+		constructors[0].setAccessible(true);
+		assertThat(new DefaultParameterNameDiscoverer().getParameterNames(constructors[0])).isNull();
+	}
+
 	private void noConfigurationProperty(BindException ex) {
 		assertThat(ex.getProperty()).isNull();
 	}
@@ -845,4 +866,28 @@ String getImportName() {
 
 	}
 
+	static class NonExtractableParameterName {
+
+		private String value;
+
+		private JsonPath jsonPath;
+
+		String getValue() {
+			return this.value;
+		}
+
+		void setValue(String value) {
+			this.value = value;
+		}
+
+		JsonPath getJsonPath() {
+			return this.jsonPath;
+		}
+
+		void setJsonPath(JsonPath jsonPath) {
+			this.jsonPath = jsonPath;
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/source/ConfigurationPropertySourcesTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/source/ConfigurationPropertySourcesTests.java
index 552bbbc2effa..5907688999b9 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/source/ConfigurationPropertySourcesTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/source/ConfigurationPropertySourcesTests.java
@@ -54,7 +54,7 @@ void attachShouldAddAdapterAtBeginning() {
 		sources.addLast(new MapPropertySource("config", Collections.singletonMap("server.port", "4568")));
 		int size = sources.size();
 		ConfigurationPropertySources.attach(environment);
-		assertThat(sources.size()).isEqualTo(size + 1);
+		assertThat(sources).hasSize(size + 1);
 		PropertyResolver resolver = new PropertySourcesPropertyResolver(sources);
 		assertThat(resolver.getProperty("server.port")).isEqualTo("1234");
 	}
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/FailureAnalyzersIntegrationTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/FailureAnalyzersIntegrationTests.java
index b4ea65292ced..014fc6660704 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/FailureAnalyzersIntegrationTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/FailureAnalyzersIntegrationTests.java
@@ -28,7 +28,7 @@
 import org.springframework.context.annotation.Configuration;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatException;
 
 /**
  * Integration tests for {@link FailureAnalyzers}.
@@ -40,7 +40,7 @@ class FailureAnalyzersIntegrationTests {
 
 	@Test
 	void analysisIsPerformed(CapturedOutput output) {
-		assertThatExceptionOfType(Exception.class)
+		assertThatException()
 			.isThrownBy(() -> new SpringApplicationBuilder(TestConfiguration.class).web(WebApplicationType.NONE).run());
 		assertThat(output).contains("APPLICATION FAILED TO START");
 	}
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/BeanCurrentlyInCreationFailureAnalyzerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/BeanCurrentlyInCreationFailureAnalyzerTests.java
index 78bb9b62ec5a..1a11453bdd23 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/BeanCurrentlyInCreationFailureAnalyzerTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/BeanCurrentlyInCreationFailureAnalyzerTests.java
@@ -36,7 +36,7 @@
 import org.springframework.context.annotation.Configuration;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.fail;
+import static org.assertj.core.api.Assertions.fail;
 
 /**
  * Tests for {@link BeanCurrentlyInCreationFailureAnalyzer}.
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/BeanNotOfRequiredTypeFailureAnalyzerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/BeanNotOfRequiredTypeFailureAnalyzerTests.java
index f93bde145162..ac15c97b794d 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/BeanNotOfRequiredTypeFailureAnalyzerTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/BeanNotOfRequiredTypeFailureAnalyzerTests.java
@@ -29,7 +29,7 @@
 import org.springframework.scheduling.annotation.EnableAsync;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.fail;
+import static org.assertj.core.api.Assertions.fail;
 
 /**
  * Tests for {@link BeanNotOfRequiredTypeFailureAnalyzer}.
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/JakartaApiValidationExceptionFailureAnalyzerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/JakartaApiValidationExceptionFailureAnalyzerTests.java
index e09cf1c51072..75cadd887813 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/JakartaApiValidationExceptionFailureAnalyzerTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/JakartaApiValidationExceptionFailureAnalyzerTests.java
@@ -25,7 +25,7 @@
 import org.springframework.validation.annotation.Validated;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatException;
 
 /**
  * Tests for {@link ValidationExceptionFailureAnalyzer}
@@ -37,8 +37,7 @@ class JakartaApiValidationExceptionFailureAnalyzerTests {
 
 	@Test
 	void validatedPropertiesTest() {
-		assertThatExceptionOfType(Exception.class)
-			.isThrownBy(() -> new AnnotationConfigApplicationContext(TestConfiguration.class).close())
+		assertThatException().isThrownBy(() -> new AnnotationConfigApplicationContext(TestConfiguration.class).close())
 			.satisfies((ex) -> assertThat(new ValidationExceptionFailureAnalyzer().analyze(ex)).isNotNull());
 	}
 
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/env/OriginTrackedYamlLoaderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/env/OriginTrackedYamlLoaderTests.java
index b51d41a9cdfe..ce5fa86434b1 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/env/OriginTrackedYamlLoaderTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/env/OriginTrackedYamlLoaderTests.java
@@ -23,7 +23,7 @@
 
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
-import org.yaml.snakeyaml.constructor.ConstructorException;
+import org.yaml.snakeyaml.composer.ComposerException;
 
 import org.springframework.boot.origin.OriginTrackedValue;
 import org.springframework.boot.origin.TextResourceOrigin;
@@ -134,7 +134,7 @@ void unsupportedType() {
 		String yaml = "value: !!java.net.URL [!!java.lang.String [!!java.lang.StringBuilder [\"http://localhost:9000/\"]]]";
 		Resource resource = new ByteArrayResource(yaml.getBytes(StandardCharsets.UTF_8));
 		this.loader = new OriginTrackedYamlLoader(resource);
-		assertThatExceptionOfType(ConstructorException.class).isThrownBy(this.loader::load);
+		assertThatExceptionOfType(ComposerException.class).isThrownBy(this.loader::load);
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/env/RandomValuePropertySourceTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/env/RandomValuePropertySourceTests.java
index 77732be5dbd9..1fb9ad176d81 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/env/RandomValuePropertySourceTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/env/RandomValuePropertySourceTests.java
@@ -37,6 +37,7 @@
  *
  * @author Dave Syer
  * @author Matt Benson
+ * @author Moritz Halbritter
  */
 class RandomValuePropertySourceTests {
 
@@ -192,4 +193,9 @@ void addToEnvironmentAddsAfterSystemEnvironment() {
 				RandomValuePropertySource.RANDOM_PROPERTY_SOURCE_NAME, "mockProperties");
 	}
 
+	@Test
+	void randomStringIs32CharsLong() {
+		assertThat(this.source.getProperty("random.string")).asString().hasSize(32);
+	}
+
 }
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/env/YamlPropertySourceLoaderSnakeYaml20Tests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/env/YamlPropertySourceLoaderSnakeYaml20Tests.java
deleted file mode 100644
index 684440176523..000000000000
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/env/YamlPropertySourceLoaderSnakeYaml20Tests.java
+++ /dev/null
@@ -1,29 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.env;
-
-import org.springframework.boot.testsupport.classpath.ClassPathOverrides;
-
-/**
- * Tests for {@link YamlPropertySourceLoader} with SnakeYAML 2.0.
- *
- * @author Andy Wilkinson
- */
-@ClassPathOverrides("org.yaml:snakeyaml:2.0")
-class YamlPropertySourceLoaderSnakeYaml20Tests extends YamlPropertySourceLoaderTests {
-
-}
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/JsonMixinModuleTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/JsonMixinModuleTests.java
index 42bf7a8f8181..768a1c0b8da4 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/JsonMixinModuleTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/JsonMixinModuleTests.java
@@ -17,7 +17,6 @@
 package org.springframework.boot.jackson;
 
 import java.util.Arrays;
-import java.util.Collections;
 import java.util.List;
 
 import com.fasterxml.jackson.databind.Module;
@@ -35,7 +34,6 @@
 import org.springframework.util.ClassUtils;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
 
 /**
  * Tests for {@link JsonMixinModule}.
@@ -53,14 +51,6 @@ void closeContext() {
 		}
 	}
 
-	@Test
-	@Deprecated(since = "3.0.0", forRemoval = true)
-	@SuppressWarnings("removal")
-	void createWhenContextIsNullShouldThrowException() {
-		assertThatIllegalArgumentException().isThrownBy(() -> new JsonMixinModule(null, Collections.emptyList()))
-			.withMessageContaining("Context must not be null");
-	}
-
 	@Test
 	void jsonWithModuleWithRenameMixInClassShouldBeMixedIn() throws Exception {
 		load(RenameMixInClass.class);
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jdbc/HikariCheckpointRestoreLifecycleTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jdbc/HikariCheckpointRestoreLifecycleTests.java
new file mode 100644
index 000000000000..119af02e6021
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jdbc/HikariCheckpointRestoreLifecycleTests.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.jdbc;
+
+import java.util.UUID;
+
+import javax.sql.DataSource;
+
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatNoException;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link HikariCheckpointRestoreLifecycle}.
+ *
+ * @author Christoph Strobl
+ * @author Andy Wilkinson
+ */
+class HikariCheckpointRestoreLifecycleTests {
+
+	private final HikariCheckpointRestoreLifecycle lifecycle;
+
+	private final HikariDataSource dataSource;
+
+	HikariCheckpointRestoreLifecycleTests() {
+		HikariConfig config = new HikariConfig();
+		config.setAllowPoolSuspension(true);
+		config.setJdbcUrl("jdbc:hsqldb:mem:test-" + UUID.randomUUID());
+		config.setPoolName("lifecycle-tests");
+		this.dataSource = new HikariDataSource(config);
+		this.lifecycle = new HikariCheckpointRestoreLifecycle(this.dataSource);
+	}
+
+	@Test
+	void startedWhenStartedShouldSucceed() {
+		assertThat(this.lifecycle.isRunning()).isTrue();
+		this.lifecycle.start();
+		assertThat(this.lifecycle.isRunning()).isTrue();
+	}
+
+	@Test
+	void stopWhenStoppedShouldSucceed() {
+		assertThat(this.lifecycle.isRunning()).isTrue();
+		this.lifecycle.stop();
+		assertThat(this.dataSource.isRunning()).isFalse();
+		assertThatNoException().isThrownBy(this.lifecycle::stop);
+	}
+
+	@Test
+	void whenStoppedAndStartedDataSourceShouldPauseAndResume() {
+		assertThat(this.lifecycle.isRunning()).isTrue();
+		this.lifecycle.stop();
+		assertThat(this.dataSource.isRunning()).isFalse();
+		assertThat(this.dataSource.isClosed()).isFalse();
+		assertThat(this.lifecycle.isRunning()).isFalse();
+		assertThat(this.dataSource.getHikariPoolMXBean().getTotalConnections()).isZero();
+		this.lifecycle.start();
+		assertThat(this.dataSource.isRunning()).isTrue();
+		assertThat(this.dataSource.isClosed()).isFalse();
+		assertThat(this.lifecycle.isRunning()).isTrue();
+	}
+
+	@Test
+	void whenDataSourceIsClosedThenStartShouldThrow() {
+		this.dataSource.close();
+		assertThatExceptionOfType(RuntimeException.class).isThrownBy(this.lifecycle::start);
+	}
+
+	@Test
+	void startHasNoEffectWhenDataSourceIsNotAHikariDataSource() {
+		HikariCheckpointRestoreLifecycle nonHikariLifecycle = new HikariCheckpointRestoreLifecycle(
+				mock(DataSource.class));
+		assertThat(nonHikariLifecycle.isRunning()).isFalse();
+		nonHikariLifecycle.start();
+		assertThat(nonHikariLifecycle.isRunning()).isFalse();
+	}
+
+	@Test
+	void stopHasNoEffectWhenDataSourceIsNotAHikariDataSource() {
+		HikariCheckpointRestoreLifecycle nonHikariLifecycle = new HikariCheckpointRestoreLifecycle(
+				mock(DataSource.class));
+		assertThat(nonHikariLifecycle.isRunning()).isFalse();
+		nonHikariLifecycle.stop();
+		assertThat(nonHikariLifecycle.isRunning()).isFalse();
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jdbc/init/DataSourceScriptDatabaseInitializerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jdbc/init/DataSourceScriptDatabaseInitializerTests.java
index 19511e7a7058..a723a3de5186 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jdbc/init/DataSourceScriptDatabaseInitializerTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jdbc/init/DataSourceScriptDatabaseInitializerTests.java
@@ -34,7 +34,7 @@
 import org.springframework.jdbc.datasource.init.ScriptStatementFailedException;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
 
 /**
  * Tests for {@link DataSourceScriptDatabaseInitializer}.
@@ -82,7 +82,7 @@ protected void customize(ResourceDatabasePopulator populator) {
 				populator.setContinueOnError(false);
 			}
 		};
-		assertThatThrownBy(initializer::initializeDatabase).isInstanceOf(ScriptStatementFailedException.class);
+		assertThatExceptionOfType(ScriptStatementFailedException.class).isThrownBy(initializer::initializeDatabase);
 	}
 
 	@Override
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jdbc/init/PlatformPlaceholderDatabaseDriverResolverTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jdbc/init/PlatformPlaceholderDatabaseDriverResolverTests.java
index 0cbea0e61a15..7d7e47db40c5 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jdbc/init/PlatformPlaceholderDatabaseDriverResolverTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jdbc/init/PlatformPlaceholderDatabaseDriverResolverTests.java
@@ -29,6 +29,7 @@
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
 import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
 import static org.mockito.Mockito.mock;
 
 /**
@@ -68,6 +69,24 @@ void resolveAllWithDataSourceWhenValueDoesNotContainPlaceholderShouldReturnValue
 			.containsExactly("schema.sql");
 	}
 
+	@Test
+	void resolveAllWithDataSourceWhenValueDoesNotContainPlaceholderShouldNotInteractWithDataSource() {
+		DataSource dataSource = mock(DataSource.class);
+		new PlatformPlaceholderDatabaseDriverResolver().resolveAll(dataSource, "schema.sql");
+		then(dataSource).shouldHaveNoInteractions();
+	}
+
+	@Test
+	void resolveAllWithFailingDataSourceWhenValuesContainPlaceholdersShouldThrowNestedCause() throws SQLException {
+		DataSource dataSource = mock(DataSource.class);
+		given(dataSource.getConnection()).willThrow(new IllegalStateException("Test: invalid password"));
+		assertThatIllegalStateException()
+			.isThrownBy(() -> new PlatformPlaceholderDatabaseDriverResolver().resolveAll(dataSource, "schema.sql",
+					"schema-@@platform@@.sql", "data-@@platform@@.sql"))
+			.withMessage("Failed to determine DatabaseDriver")
+			.withStackTraceContaining("Test: invalid password");
+	}
+
 	@Test
 	void resolveAllWithDataSourceWhenValuesContainPlaceholdersShouldReturnValuesWithPlaceholdersReplaced()
 			throws SQLException {
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jdbc/metadata/CommonsDbcp2DataSourcePoolMetadataTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jdbc/metadata/CommonsDbcp2DataSourcePoolMetadataTests.java
index 75d4ac1a55e9..72c9420a98ef 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jdbc/metadata/CommonsDbcp2DataSourcePoolMetadataTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jdbc/metadata/CommonsDbcp2DataSourcePoolMetadataTests.java
@@ -16,6 +16,8 @@
 
 package org.springframework.boot.jdbc.metadata;
 
+import java.time.Duration;
+
 import org.apache.commons.dbcp2.BasicDataSource;
 import org.junit.jupiter.api.Test;
 
@@ -83,7 +85,7 @@ private CommonsDbcp2DataSourcePoolMetadata createDataSourceMetadata(int minSize,
 		BasicDataSource dataSource = createDataSource();
 		dataSource.setMinIdle(minSize);
 		dataSource.setMaxTotal(maxSize);
-		dataSource.setMinEvictableIdleTimeMillis(5000);
+		dataSource.setMinEvictableIdle(Duration.ofSeconds(5));
 		return new CommonsDbcp2DataSourcePoolMetadata(dataSource);
 	}
 
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/GsonJsonParserTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/GsonJsonParserTests.java
index 757a2d3b0da6..3a7b7f04aa84 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/GsonJsonParserTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/GsonJsonParserTests.java
@@ -18,8 +18,6 @@
 
 import java.io.IOException;
 
-import org.junit.jupiter.api.Disabled;
-
 /**
  * Tests for {@link GsonJsonParser}.
  *
@@ -33,9 +31,8 @@ protected JsonParser getParser() {
 	}
 
 	@Override
-	@Disabled("Gson does not protect against deeply nested JSON")
 	void listWithRepeatedOpenArray() throws IOException {
-		super.listWithRepeatedOpenArray();
+		// Gson does not protect against deeply nested JSON
 	}
 
 }
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/AbstractLoggingSystemTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/AbstractLoggingSystemTests.java
index 3ccc16255886..cba035869fff 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/AbstractLoggingSystemTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/AbstractLoggingSystemTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2020 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,14 +16,19 @@
 
 package org.springframework.boot.logging;
 
+import java.io.File;
 import java.nio.file.Path;
+import java.util.Arrays;
 
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.io.TempDir;
+import org.slf4j.MDC;
 
 import org.springframework.util.StringUtils;
 
+import static org.assertj.core.api.Assertions.contentOf;
+
 /**
  * Base for {@link LoggingSystem} tests.
  *
@@ -41,6 +46,7 @@ public abstract class AbstractLoggingSystemTests {
 	void configureTempDir(@TempDir Path temp) {
 		this.originalTempDirectory = System.getProperty(JAVA_IO_TMPDIR);
 		System.setProperty(JAVA_IO_TMPDIR, temp.toAbsolutePath().toString());
+		MDC.clear();
 	}
 
 	@AfterEach
@@ -50,8 +56,10 @@ void reinstateTempDir() {
 
 	@AfterEach
 	void clear() {
-		System.clearProperty(LoggingSystemProperties.LOG_FILE);
-		System.clearProperty(LoggingSystemProperties.PID_KEY);
+		for (LoggingSystemProperty property : LoggingSystemProperty.values()) {
+			System.getProperties().remove(property.getEnvironmentVariableName());
+		}
+		MDC.clear();
 	}
 
 	protected final String[] getSpringConfigLocations(AbstractLoggingSystem system) {
@@ -78,4 +86,15 @@ protected final String tmpDir() {
 		return path;
 	}
 
+	protected final String getLineWithText(File file, CharSequence outputSearch) {
+		return getLineWithText(contentOf(file), outputSearch);
+	}
+
+	protected final String getLineWithText(CharSequence output, CharSequence outputSearch) {
+		return Arrays.stream(output.toString().split("\\r?\\n"))
+			.filter((line) -> line.contains(outputSearch))
+			.findFirst()
+			.orElse(null);
+	}
+
 }
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/CorrelationIdFormatterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/CorrelationIdFormatterTests.java
new file mode 100644
index 000000000000..2e36d0f00fc0
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/CorrelationIdFormatterTests.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.logging;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+
+/**
+ * Tests for {@link CorrelationIdFormatter}.
+ *
+ * @author Phillip Webb
+ */
+class CorrelationIdFormatterTests {
+
+	@Test
+	void formatWithDefaultSpecWhenHasBothParts() {
+		Map<String, String> context = new HashMap<>();
+		context.put("traceId", "01234567890123456789012345678901");
+		context.put("spanId", "0123456789012345");
+		String formatted = CorrelationIdFormatter.DEFAULT.format(context::get);
+		assertThat(formatted).isEqualTo("[01234567890123456789012345678901-0123456789012345] ");
+	}
+
+	@Test
+	void formatWithDefaultSpecWhenHasNoParts() {
+		Map<String, String> context = new HashMap<>();
+		String formatted = CorrelationIdFormatter.DEFAULT.format(context::get);
+		assertThat(formatted).isEqualTo("[                                                 ] ");
+	}
+
+	@Test
+	void formatWithDefaultSpecWhenHasOnlyFirstPart() {
+		Map<String, String> context = new HashMap<>();
+		context.put("traceId", "01234567890123456789012345678901");
+		String formatted = CorrelationIdFormatter.DEFAULT.format(context::get);
+		assertThat(formatted).isEqualTo("[01234567890123456789012345678901-                ] ");
+	}
+
+	@Test
+	void formatWithDefaultSpecWhenHasOnlySecondPart() {
+		Map<String, String> context = new HashMap<>();
+		context.put("spanId", "0123456789012345");
+		String formatted = CorrelationIdFormatter.DEFAULT.format(context::get);
+		assertThat(formatted).isEqualTo("[                                -0123456789012345] ");
+	}
+
+	@Test
+	void formatWhenPartsAreShort() {
+		Map<String, String> context = new HashMap<>();
+		context.put("traceId", "0123456789012345678901234567");
+		context.put("spanId", "012345678901");
+		String formatted = CorrelationIdFormatter.DEFAULT.format(context::get);
+		assertThat(formatted).isEqualTo("[0123456789012345678901234567    -012345678901    ] ");
+	}
+
+	@Test
+	void formatWhenPartsAreLong() {
+		Map<String, String> context = new HashMap<>();
+		context.put("traceId", "01234567890123456789012345678901FFFF");
+		context.put("spanId", "0123456789012345FFFF");
+		String formatted = CorrelationIdFormatter.DEFAULT.format(context::get);
+		assertThat(formatted).isEqualTo("[01234567890123456789012345678901FFFF-0123456789012345FFFF] ");
+	}
+
+	@Test
+	void formatWithCustomSpec() {
+		Map<String, String> context = new HashMap<>();
+		context.put("a", "01234567890123456789012345678901");
+		context.put("b", "0123456789012345");
+		String formatted = CorrelationIdFormatter.of("a(32),b(16)").format(context::get);
+		assertThat(formatted).isEqualTo("[01234567890123456789012345678901-0123456789012345] ");
+	}
+
+	@Test
+	void formatToWithDefaultSpec() {
+		Map<String, String> context = new HashMap<>();
+		context.put("traceId", "01234567890123456789012345678901");
+		context.put("spanId", "0123456789012345");
+		StringBuilder formatted = new StringBuilder();
+		CorrelationIdFormatter.DEFAULT.formatTo(context::get, formatted);
+		assertThat(formatted).hasToString("[01234567890123456789012345678901-0123456789012345] ");
+	}
+
+	@Test
+	void ofWhenSpecIsMalformed() {
+		assertThatIllegalStateException().isThrownBy(() -> CorrelationIdFormatter.of("good(12),bad"))
+			.withMessage("Unable to parse correlation formatter spec 'good(12),bad'")
+			.havingCause()
+			.withMessage("Invalid specification part 'bad'");
+	}
+
+	@Test
+	void ofWhenSpecIsEmpty() {
+		assertThat(CorrelationIdFormatter.of("")).isSameAs(CorrelationIdFormatter.DEFAULT);
+	}
+
+	@Test
+	void toStringReturnsSpec() {
+		assertThat(CorrelationIdFormatter.DEFAULT).hasToString("traceId(32),spanId(16)");
+		assertThat(CorrelationIdFormatter.of("a(32),b(16)")).hasToString("a(32),b(16)");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LogFileTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LogFileTests.java
index 46ab410e0ee2..6639436655dd 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LogFileTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LogFileTests.java
@@ -57,8 +57,9 @@ private void testLoggingFile(PropertyResolver resolver) {
 		Properties properties = new Properties();
 		logFile.applyTo(properties);
 		assertThat(logFile).hasToString("log.file");
-		assertThat(properties.getProperty(LoggingSystemProperties.LOG_FILE)).isEqualTo("log.file");
-		assertThat(properties.getProperty(LoggingSystemProperties.LOG_PATH)).isNull();
+		assertThat(properties.getProperty(LoggingSystemProperty.LOG_FILE.getEnvironmentVariableName()))
+			.isEqualTo("log.file");
+		assertThat(properties.getProperty(LoggingSystemProperty.LOG_PATH.getEnvironmentVariableName())).isNull();
 	}
 
 	@Test
@@ -72,9 +73,10 @@ private void testLoggingPath(PropertyResolver resolver) {
 		Properties properties = new Properties();
 		logFile.applyTo(properties);
 		assertThat(logFile).hasToString("logpath" + File.separatorChar + "spring.log");
-		assertThat(properties.getProperty(LoggingSystemProperties.LOG_FILE))
+		assertThat(properties.getProperty(LoggingSystemProperty.LOG_FILE.getEnvironmentVariableName()))
 			.isEqualTo("logpath" + File.separatorChar + "spring.log");
-		assertThat(properties.getProperty(LoggingSystemProperties.LOG_PATH)).isEqualTo("logpath");
+		assertThat(properties.getProperty(LoggingSystemProperty.LOG_PATH.getEnvironmentVariableName()))
+			.isEqualTo("logpath");
 	}
 
 	@Test
@@ -91,8 +93,10 @@ private void testLoggingFileAndPath(PropertyResolver resolver) {
 		Properties properties = new Properties();
 		logFile.applyTo(properties);
 		assertThat(logFile).hasToString("log.file");
-		assertThat(properties.getProperty(LoggingSystemProperties.LOG_FILE)).isEqualTo("log.file");
-		assertThat(properties.getProperty(LoggingSystemProperties.LOG_PATH)).isEqualTo("logpath");
+		assertThat(properties.getProperty(LoggingSystemProperty.LOG_FILE.getEnvironmentVariableName()))
+			.isEqualTo("log.file");
+		assertThat(properties.getProperty(LoggingSystemProperty.LOG_PATH.getEnvironmentVariableName()))
+			.isEqualTo("logpath");
 	}
 
 	private PropertyResolver getPropertyResolver(Map<String, Object> properties) {
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LoggerConfigurationTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LoggerConfigurationTests.java
index 425e3bc0a676..d6bc631d96e4 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LoggerConfigurationTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LoggerConfigurationTests.java
@@ -53,10 +53,10 @@ void createWithLevelConfigurationWhenNameIsNullThrowsException() {
 	}
 
 	@Test
-	void createWithLevelConfigurationWhenEffectiveLevelIsNullThrowsException() {
+	void createWithLevelConfigurationWhenInheritedLevelConfigurationIsNullThrowsException() {
 		assertThatIllegalArgumentException()
 			.isThrownBy(() -> new LoggerConfiguration("test", null, (LevelConfiguration) null))
-			.withMessage("EffectiveLevelConfiguration must not be null");
+			.withMessage("InheritedLevelConfiguration must not be null");
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LoggingSystemPropertiesTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LoggingSystemPropertiesTests.java
index 3fb0106d718a..2c5a1aaf2855 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LoggingSystemPropertiesTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LoggingSystemPropertiesTests.java
@@ -18,6 +18,7 @@
 
 import java.util.Collections;
 import java.util.HashSet;
+import java.util.Map;
 import java.util.Set;
 
 import org.junit.jupiter.api.AfterEach;
@@ -36,6 +37,7 @@
  *
  * @author Andy Wilkinson
  * @author EddĂș MelĂ©ndez
+ * @author Jonatan Ivanov
  */
 class LoggingSystemPropertiesTests {
 
@@ -43,8 +45,9 @@ class LoggingSystemPropertiesTests {
 
 	@BeforeEach
 	void captureSystemPropertyNames() {
-		System.getProperties().remove(LoggingSystemProperties.CONSOLE_LOG_CHARSET);
-		System.getProperties().remove(LoggingSystemProperties.FILE_LOG_CHARSET);
+		for (LoggingSystemProperty property : LoggingSystemProperty.values()) {
+			System.getProperties().remove(property.getEnvironmentVariableName());
+		}
 		this.systemPropertyNames = new HashSet<>(System.getProperties().keySet());
 	}
 
@@ -56,58 +59,100 @@ void restoreSystemProperties() {
 	@Test
 	void pidIsSet() {
 		new LoggingSystemProperties(new MockEnvironment()).apply(null);
-		assertThat(System.getProperty(LoggingSystemProperties.PID_KEY)).isNotNull();
+		assertThat(getSystemProperty(LoggingSystemProperty.PID)).isNotNull();
 	}
 
 	@Test
 	void consoleLogPatternIsSet() {
 		new LoggingSystemProperties(new MockEnvironment().withProperty("logging.pattern.console", "console pattern"))
 			.apply(null);
-		assertThat(System.getProperty(LoggingSystemProperties.CONSOLE_LOG_PATTERN)).isEqualTo("console pattern");
+		assertThat(getSystemProperty(LoggingSystemProperty.CONSOLE_PATTERN)).isEqualTo("console pattern");
 	}
 
 	@Test
 	void consoleCharsetWhenNoPropertyUsesUtf8() {
 		new LoggingSystemProperties(new MockEnvironment()).apply(null);
-		assertThat(System.getProperty(LoggingSystemProperties.CONSOLE_LOG_CHARSET)).isEqualTo("UTF-8");
+		assertThat(getSystemProperty(LoggingSystemProperty.CONSOLE_CHARSET)).isEqualTo("UTF-8");
 	}
 
 	@Test
 	void consoleCharsetIsSet() {
 		new LoggingSystemProperties(new MockEnvironment().withProperty("logging.charset.console", "UTF-16"))
 			.apply(null);
-		assertThat(System.getProperty(LoggingSystemProperties.CONSOLE_LOG_CHARSET)).isEqualTo("UTF-16");
+		assertThat(getSystemProperty(LoggingSystemProperty.CONSOLE_CHARSET)).isEqualTo("UTF-16");
 	}
 
 	@Test
 	void fileLogPatternIsSet() {
 		new LoggingSystemProperties(new MockEnvironment().withProperty("logging.pattern.file", "file pattern"))
 			.apply(null);
-		assertThat(System.getProperty(LoggingSystemProperties.FILE_LOG_PATTERN)).isEqualTo("file pattern");
+		assertThat(getSystemProperty(LoggingSystemProperty.FILE_PATTERN)).isEqualTo("file pattern");
 	}
 
 	@Test
 	void fileCharsetWhenNoPropertyUsesUtf8() {
 		new LoggingSystemProperties(new MockEnvironment()).apply(null);
-		assertThat(System.getProperty(LoggingSystemProperties.FILE_LOG_CHARSET)).isEqualTo("UTF-8");
+		assertThat(getSystemProperty(LoggingSystemProperty.FILE_CHARSET)).isEqualTo("UTF-8");
 	}
 
 	@Test
 	void fileCharsetIsSet() {
 		new LoggingSystemProperties(new MockEnvironment().withProperty("logging.charset.file", "UTF-16")).apply(null);
-		assertThat(System.getProperty(LoggingSystemProperties.FILE_LOG_CHARSET)).isEqualTo("UTF-16");
+		assertThat(getSystemProperty(LoggingSystemProperty.FILE_CHARSET)).isEqualTo("UTF-16");
 	}
 
 	@Test
 	void consoleLogPatternCanReferencePid() {
 		new LoggingSystemProperties(environment("logging.pattern.console", "${PID:unknown}")).apply(null);
-		assertThat(System.getProperty(LoggingSystemProperties.CONSOLE_LOG_PATTERN)).matches("[0-9]+");
+		assertThat(getSystemProperty(LoggingSystemProperty.CONSOLE_PATTERN)).matches("[0-9]+");
 	}
 
 	@Test
 	void fileLogPatternCanReferencePid() {
 		new LoggingSystemProperties(environment("logging.pattern.file", "${PID:unknown}")).apply(null);
-		assertThat(System.getProperty(LoggingSystemProperties.FILE_LOG_PATTERN)).matches("[0-9]+");
+		assertThat(getSystemProperty(LoggingSystemProperty.FILE_PATTERN)).matches("[0-9]+");
+	}
+
+	private String getSystemProperty(LoggingSystemProperty property) {
+		return System.getProperty(property.getEnvironmentVariableName());
+	}
+
+	@Test
+	void correlationPatternIsSet() {
+		new LoggingSystemProperties(
+				new MockEnvironment().withProperty("logging.pattern.correlation", "correlation pattern"))
+			.apply(null);
+		assertThat(System.getProperty(LoggingSystemProperty.CORRELATION_PATTERN.getEnvironmentVariableName()))
+			.isEqualTo("correlation pattern");
+	}
+
+	@Test
+	void defaultValueResolverIsUsed() {
+		MockEnvironment environment = new MockEnvironment();
+		Map<String, String> defaultValues = Map
+			.of(LoggingSystemProperty.CORRELATION_PATTERN.getApplicationPropertyName(), "default correlation pattern");
+		new LoggingSystemProperties(environment, defaultValues::get, null).apply(null);
+		assertThat(System.getProperty(LoggingSystemProperty.CORRELATION_PATTERN.getEnvironmentVariableName()))
+			.isEqualTo("default correlation pattern");
+	}
+
+	@Test
+	void loggedApplicationNameWhenHasApplicationName() {
+		new LoggingSystemProperties(new MockEnvironment().withProperty("spring.application.name", "test")).apply(null);
+		assertThat(getSystemProperty(LoggingSystemProperty.APPLICATION_NAME)).isEqualTo("[test] ");
+	}
+
+	@Test
+	void loggedApplicationNameWhenHasNoApplicationName() {
+		new LoggingSystemProperties(new MockEnvironment()).apply(null);
+		assertThat(getSystemProperty(LoggingSystemProperty.APPLICATION_NAME)).isNull();
+	}
+
+	@Test
+	void loggedApplicationNameWhenApplicationNameLoggingDisabled() {
+		new LoggingSystemProperties(new MockEnvironment().withProperty("spring.application.name", "test")
+			.withProperty("logging.include-application-name", "false")).apply(null);
+		assertThat(getSystemProperty(LoggingSystemProperty.APPLICATION_NAME)).isNull();
 	}
 
 	private Environment environment(String key, Object value) {
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/java/JavaLoggingSystemTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/java/JavaLoggingSystemTests.java
index cb73992349a9..31802e0eab6d 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/java/JavaLoggingSystemTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/java/JavaLoggingSystemTests.java
@@ -33,7 +33,7 @@
 import org.springframework.boot.logging.LogLevel;
 import org.springframework.boot.logging.LoggerConfiguration;
 import org.springframework.boot.logging.LoggingSystem;
-import org.springframework.boot.logging.LoggingSystemProperties;
+import org.springframework.boot.logging.LoggingSystemProperty;
 import org.springframework.boot.testsupport.system.CapturedOutput;
 import org.springframework.boot.testsupport.system.OutputCaptureExtension;
 import org.springframework.util.ClassUtils;
@@ -113,7 +113,7 @@ void testCustomFormatter(CapturedOutput output) {
 
 	@Test
 	void testSystemPropertyInitializesFormat(CapturedOutput output) {
-		System.setProperty(LoggingSystemProperties.PID_KEY, "1234");
+		System.setProperty(LoggingSystemProperty.PID.getEnvironmentVariableName(), "1234");
 		this.loggingSystem.beforeInitialize();
 		this.loggingSystem.initialize(null,
 				"classpath:" + ClassUtils.addResourcePathToPackagePath(getClass(), "logging.properties"), null);
@@ -166,7 +166,7 @@ void setLevelToNull(CapturedOutput output) {
 	}
 
 	@Test
-	void getLoggingConfigurations() {
+	void getLoggerConfigurations() {
 		this.loggingSystem.beforeInitialize();
 		this.loggingSystem.initialize(null, null, null);
 		this.loggingSystem.setLogLevel(getClass().getName(), LogLevel.DEBUG);
@@ -176,7 +176,7 @@ void getLoggingConfigurations() {
 	}
 
 	@Test
-	void getLoggingConfiguration() {
+	void getLoggerConfiguration() {
 		this.loggingSystem.beforeInitialize();
 		this.loggingSystem.initialize(null, null, null);
 		this.loggingSystem.setLogLevel(getClass().getName(), LogLevel.DEBUG);
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/CorrelationIdConverterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/CorrelationIdConverterTests.java
new file mode 100644
index 000000000000..87d9b22cf3f5
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/CorrelationIdConverterTests.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.logging.log4j2;
+
+import java.util.Map;
+
+import org.apache.logging.log4j.core.AbstractLogEvent;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.impl.JdkMapAdapterStringMap;
+import org.apache.logging.log4j.util.ReadOnlyStringMap;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link CorrelationIdConverter}.
+ *
+ * @author Phillip Webb
+ */
+class CorrelationIdConverterTests {
+
+	private CorrelationIdConverter converter = CorrelationIdConverter.newInstance(null);
+
+	private final LogEvent event = new TestLogEvent();
+
+	@Test
+	void defaultPattern() {
+		StringBuilder result = new StringBuilder();
+		this.converter.format(this.event, result);
+		assertThat(result).hasToString("[01234567890123456789012345678901-0123456789012345] ");
+	}
+
+	@Test
+	void customPattern() {
+		this.converter = CorrelationIdConverter.newInstance(new String[] { "traceId(0),spanId(0)" });
+		StringBuilder result = new StringBuilder();
+		this.converter.format(this.event, result);
+		assertThat(result).hasToString("[01234567890123456789012345678901-0123456789012345] ");
+	}
+
+	static class TestLogEvent extends AbstractLogEvent {
+
+		@Override
+		public ReadOnlyStringMap getContextData() {
+			return new JdkMapAdapterStringMap(
+					Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345"));
+		}
+
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4J2LoggingSystemTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4J2LoggingSystemTests.java
index 7aabe82966fb..308d29d99e7f 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4J2LoggingSystemTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4J2LoggingSystemTests.java
@@ -39,6 +39,7 @@
 import org.apache.logging.log4j.core.config.LoggerConfig;
 import org.apache.logging.log4j.core.config.Reconfigurable;
 import org.apache.logging.log4j.core.config.composite.CompositeConfiguration;
+import org.apache.logging.log4j.core.config.plugins.util.PluginRegistry;
 import org.apache.logging.log4j.core.util.ShutdownCallbackRegistry;
 import org.apache.logging.log4j.jul.Log4jBridgeHandler;
 import org.apache.logging.log4j.util.PropertiesUtil;
@@ -47,13 +48,16 @@
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.slf4j.MDC;
 
 import org.springframework.boot.logging.AbstractLoggingSystemTests;
+import org.springframework.boot.logging.LogFile;
 import org.springframework.boot.logging.LogLevel;
 import org.springframework.boot.logging.LoggerConfiguration;
 import org.springframework.boot.logging.LoggingInitializationContext;
 import org.springframework.boot.logging.LoggingSystem;
 import org.springframework.boot.logging.LoggingSystemProperties;
+import org.springframework.boot.logging.LoggingSystemProperty;
 import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
 import org.springframework.boot.testsupport.logging.ConfigureClasspathToPreferLog4j2;
 import org.springframework.boot.testsupport.system.CapturedOutput;
@@ -86,12 +90,11 @@
 @ConfigureClasspathToPreferLog4j2
 class Log4J2LoggingSystemTests extends AbstractLoggingSystemTests {
 
-	private final TestLog4J2LoggingSystem loggingSystem = new TestLog4J2LoggingSystem();
+	private TestLog4J2LoggingSystem loggingSystem;
 
-	private final MockEnvironment environment = new MockEnvironment();
+	private MockEnvironment environment;
 
-	private final LoggingInitializationContext initializationContext = new LoggingInitializationContext(
-			this.environment);
+	private LoggingInitializationContext initializationContext;
 
 	private Logger logger;
 
@@ -99,6 +102,10 @@ class Log4J2LoggingSystemTests extends AbstractLoggingSystemTests {
 
 	@BeforeEach
 	void setup() {
+		PluginRegistry.getInstance().clear();
+		this.loggingSystem = new TestLog4J2LoggingSystem();
+		this.environment = new MockEnvironment();
+		this.initializationContext = new LoggingInitializationContext(this.environment);
 		LoggerContext loggerContext = (LoggerContext) LogManager.getContext(false);
 		this.configuration = loggerContext.getConfiguration();
 		this.loggingSystem.cleanUp();
@@ -113,6 +120,7 @@ void cleanUp() {
 		loggerContext.stop();
 		loggerContext.start(((Reconfigurable) this.configuration).reconfigure());
 		cleanUpPropertySources();
+		PluginRegistry.getInstance().clear();
 	}
 
 	@SuppressWarnings("unchecked")
@@ -198,7 +206,7 @@ void setLevelToNull(CapturedOutput output) {
 	}
 
 	@Test
-	void getLoggingConfigurations() {
+	void getLoggerConfigurations() {
 		this.loggingSystem.beforeInitialize();
 		this.loggingSystem.initialize(this.initializationContext, null, null);
 		this.loggingSystem.setLogLevel(getClass().getName(), LogLevel.DEBUG);
@@ -208,7 +216,7 @@ void getLoggingConfigurations() {
 	}
 
 	@Test
-	void getLoggingConfigurationsShouldReturnAllLoggers() {
+	void getLoggerConfigurationsShouldReturnAllLoggers() {
 		LogManager.getLogger("org.springframework.boot.logging.log4j2.Log4J2LoggingSystemTests$Nested");
 		this.loggingSystem.beforeInitialize();
 		this.loggingSystem.initialize(this.initializationContext, null, null);
@@ -225,7 +233,7 @@ void getLoggingConfigurationsShouldReturnAllLoggers() {
 	}
 
 	@Test // gh-35227
-	void getLoggingConfigurationsWhenHasCustomLevel() {
+	void getLoggerConfigurationWhenHasCustomLevel() {
 		this.loggingSystem.beforeInitialize();
 		this.loggingSystem.initialize(this.initializationContext, null, null);
 		LoggerContext loggerContext = (LoggerContext) LogManager.getContext(false);
@@ -242,7 +250,7 @@ private void assertIsPresent(String loggerName, Map<String, LogLevel> loggers, L
 	}
 
 	@Test
-	void getLoggingConfiguration() {
+	void getLoggerConfiguration() {
 		this.loggingSystem.beforeInitialize();
 		this.loggingSystem.initialize(this.initializationContext, null, null);
 		this.loggingSystem.setLogLevel(getClass().getName(), LogLevel.DEBUG);
@@ -252,7 +260,7 @@ void getLoggingConfiguration() {
 	}
 
 	@Test
-	void getLoggingConfigurationShouldReturnLoggerWithNullConfiguredLevel() {
+	void getLoggerConfigurationShouldReturnLoggerWithNullConfiguredLevel() {
 		this.loggingSystem.beforeInitialize();
 		this.loggingSystem.initialize(this.initializationContext, null, null);
 		this.loggingSystem.setLogLevel(getClass().getName(), LogLevel.DEBUG);
@@ -261,7 +269,7 @@ void getLoggingConfigurationShouldReturnLoggerWithNullConfiguredLevel() {
 	}
 
 	@Test
-	void getLoggingConfigurationForNonExistentLoggerShouldReturnNull() {
+	void getLoggerConfigurationForNonExistentLoggerShouldReturnNull() {
 		this.loggingSystem.beforeInitialize();
 		this.loggingSystem.initialize(this.initializationContext, null, null);
 		this.loggingSystem.setLogLevel(getClass().getName(), LogLevel.DEBUG);
@@ -361,7 +369,7 @@ void beforeInitializeFilterDisablesErrorLogging() {
 
 	@Test
 	void customExceptionConversionWord(CapturedOutput output) {
-		System.setProperty(LoggingSystemProperties.EXCEPTION_CONVERSION_WORD, "%ex");
+		System.setProperty(LoggingSystemProperty.EXCEPTION_CONVERSION_WORD.getEnvironmentVariableName(), "%ex");
 		try {
 			this.loggingSystem.beforeInitialize();
 			this.logger.info("Hidden");
@@ -373,7 +381,7 @@ void customExceptionConversionWord(CapturedOutput output) {
 			assertThat(output).contains("java.lang.RuntimeException: Expected").doesNotContain("Wrapped by:");
 		}
 		finally {
-			System.clearProperty(LoggingSystemProperties.EXCEPTION_CONVERSION_WORD);
+			System.clearProperty(LoggingSystemProperty.EXCEPTION_CONVERSION_WORD.getEnvironmentVariableName());
 		}
 	}
 
@@ -396,7 +404,7 @@ void initializationIsOnlyPerformedOnceUntilCleanedUp() {
 	}
 
 	@Test
-	void getLoggingConfigurationWithResetLevelReturnsNull() {
+	void getLoggerConfigurationWithResetLevelReturnsNull() {
 		this.loggingSystem.beforeInitialize();
 		this.loggingSystem.initialize(this.initializationContext, null, null);
 		this.loggingSystem.setLogLevel("com.example", LogLevel.WARN);
@@ -410,7 +418,7 @@ void getLoggingConfigurationWithResetLevelReturnsNull() {
 	}
 
 	@Test
-	void getLoggingConfigurationWithResetLevelWhenAlreadyConfiguredReturnsParentConfiguredLevel() {
+	void getLoggerConfigurationWithResetLevelWhenAlreadyConfiguredReturnsParentConfiguredLevel() {
 		LoggerContext loggerContext = (LoggerContext) LogManager.getContext(false);
 		this.loggingSystem.beforeInitialize();
 		this.loggingSystem.initialize(this.initializationContext, null, null);
@@ -495,6 +503,88 @@ void nonFileUrlsAreResolvedUsingLog4J2UrlConnectionFactory() {
 			.withMessageContaining("http has not been enabled");
 	}
 
+	@Test
+	void correlationLoggingToFileWhenExpectCorrelationIdTrueAndMdcContent() {
+		this.environment.setProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, "true");
+		new LoggingSystemProperties(this.environment).apply();
+		File file = new File(tmpDir(), "log4j2-test.log");
+		LogFile logFile = getLogFile(file.getPath(), null);
+		this.loggingSystem.setStandardConfigLocations(false);
+		this.loggingSystem.beforeInitialize();
+		this.loggingSystem.initialize(this.initializationContext, null, logFile);
+		MDC.setContextMap(Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345"));
+		this.logger.info("Hello world");
+		assertThat(getLineWithText(file, "Hello world"))
+			.contains(" [01234567890123456789012345678901-0123456789012345] ");
+	}
+
+	@Test
+	void correlationLoggingToConsoleWhenExpectCorrelationIdTrueAndMdcContent(CapturedOutput output) {
+		this.environment.setProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, "true");
+		this.loggingSystem.setStandardConfigLocations(false);
+		this.loggingSystem.beforeInitialize();
+		this.loggingSystem.initialize(this.initializationContext, null, null);
+		MDC.setContextMap(Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345"));
+		this.logger.info("Hello world");
+		assertThat(getLineWithText(output, "Hello world"))
+			.contains(" [01234567890123456789012345678901-0123456789012345] ");
+	}
+
+	@Test
+	void correlationLoggingToConsoleWhenExpectCorrelationIdFalseAndMdcContent(CapturedOutput output) {
+		this.environment.setProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, "false");
+		this.loggingSystem.setStandardConfigLocations(false);
+		this.loggingSystem.beforeInitialize();
+		this.loggingSystem.initialize(this.initializationContext, null, null);
+		MDC.setContextMap(Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345"));
+		this.logger.info("Hello world");
+		assertThat(getLineWithText(output, "Hello world")).doesNotContain("0123456789012345");
+	}
+
+	@Test
+	void correlationLoggingToConsoleWhenExpectCorrelationIdTrueAndNoMdcContent(CapturedOutput output) {
+		this.environment.setProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, "true");
+		this.loggingSystem.setStandardConfigLocations(false);
+		this.loggingSystem.beforeInitialize();
+		this.loggingSystem.initialize(this.initializationContext, null, null);
+		this.logger.info("Hello world");
+		assertThat(getLineWithText(output, "Hello world"))
+			.contains(" [                                                 ] ");
+	}
+
+	@Test
+	void correlationLoggingToConsoleWhenHasCorrelationPattern(CapturedOutput output) {
+		this.environment.setProperty("logging.pattern.correlation", "%correlationId{spanId(0),traceId(0)}");
+		this.loggingSystem.setStandardConfigLocations(false);
+		this.loggingSystem.beforeInitialize();
+		this.loggingSystem.initialize(this.initializationContext, null, null);
+		MDC.setContextMap(Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345"));
+		this.logger.info("Hello world");
+		assertThat(getLineWithText(output, "Hello world"))
+			.contains(" [0123456789012345-01234567890123456789012345678901] ");
+	}
+
+	@Test
+	void applicationNameLoggingWhenHasApplicationName(CapturedOutput output) {
+		this.environment.setProperty("spring.application.name", "myapp");
+		this.loggingSystem.setStandardConfigLocations(false);
+		this.loggingSystem.beforeInitialize();
+		this.loggingSystem.initialize(this.initializationContext, null, null);
+		this.logger.info("Hello world");
+		assertThat(getLineWithText(output, "Hello world")).contains("[myapp] ");
+	}
+
+	@Test
+	void applicationNameLoggingWhenDisabled(CapturedOutput output) {
+		this.environment.setProperty("spring.application.name", "myapp");
+		this.environment.setProperty("logging.include-application-name", "false");
+		this.loggingSystem.setStandardConfigLocations(false);
+		this.loggingSystem.beforeInitialize();
+		this.loggingSystem.initialize(this.initializationContext, null, null);
+		this.logger.info("Hello world");
+		assertThat(getLineWithText(output, "Hello world")).doesNotContain("myapp");
+	}
+
 	private String getRelativeClasspathLocation(String fileName) {
 		String defaultPath = ClassUtils.getPackageName(getClass());
 		defaultPath = defaultPath.replace('.', '/');
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2FileXmlTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2FileXmlTests.java
index e5ea6eb56bdd..53517e76dc29 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2FileXmlTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2FileXmlTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -23,7 +23,7 @@
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
-import org.springframework.boot.logging.LoggingSystemProperties;
+import org.springframework.boot.logging.LoggingSystemProperty;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -42,7 +42,9 @@ class Log4j2FileXmlTests extends Log4j2XmlTests {
 	@AfterEach
 	void stopConfiguration() {
 		super.stopConfiguration();
-		System.clearProperty(LoggingSystemProperties.LOG_FILE);
+		for (LoggingSystemProperty property : LoggingSystemProperty.values()) {
+			System.getProperties().remove(property.getEnvironmentVariableName());
+		}
 	}
 
 	@Test
@@ -52,7 +54,7 @@ void whenLogExceptionConversionWordIsNotConfiguredThenFileAppenderUsesDefault()
 
 	@Test
 	void whenLogExceptionConversionWordIsSetThenFileAppenderUsesIt() {
-		withSystemProperty(LoggingSystemProperties.EXCEPTION_CONVERSION_WORD, "custom",
+		withSystemProperty(LoggingSystemProperty.EXCEPTION_CONVERSION_WORD.getEnvironmentVariableName(), "custom",
 				() -> assertThat(fileAppenderPattern()).contains("custom"));
 	}
 
@@ -63,7 +65,7 @@ void whenLogLevelPatternIsNotConfiguredThenFileAppenderUsesDefault() {
 
 	@Test
 	void whenLogLevelPatternIsSetThenFileAppenderUsesIt() {
-		withSystemProperty(LoggingSystemProperties.LOG_LEVEL_PATTERN, "custom",
+		withSystemProperty(LoggingSystemProperty.LEVEL_PATTERN.getEnvironmentVariableName(), "custom",
 				() -> assertThat(fileAppenderPattern()).contains("custom"));
 	}
 
@@ -74,7 +76,7 @@ void whenLogLDateformatPatternIsNotConfiguredThenFileAppenderUsesDefault() {
 
 	@Test
 	void whenLogDateformatPatternIsSetThenFileAppenderUsesIt() {
-		withSystemProperty(LoggingSystemProperties.LOG_DATEFORMAT_PATTERN, "dd-MM-yyyy",
+		withSystemProperty(LoggingSystemProperty.DATEFORMAT_PATTERN.getEnvironmentVariableName(), "dd-MM-yyyy",
 				() -> assertThat(fileAppenderPattern()).contains("dd-MM-yyyy"));
 	}
 
@@ -85,7 +87,8 @@ protected String getConfigFileName() {
 
 	@Override
 	protected void prepareConfiguration() {
-		System.setProperty(LoggingSystemProperties.LOG_FILE, new File(this.temp, "test.log").getAbsolutePath());
+		System.setProperty(LoggingSystemProperty.LOG_FILE.getEnvironmentVariableName(),
+				new File(this.temp, "test.log").getAbsolutePath());
 		super.prepareConfiguration();
 	}
 
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2XmlTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2XmlTests.java
index 850d7a4d6b45..86ecd1e293c7 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2XmlTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2XmlTests.java
@@ -27,7 +27,7 @@
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
 
-import org.springframework.boot.logging.LoggingSystemProperties;
+import org.springframework.boot.logging.LoggingSystemProperty;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -53,7 +53,7 @@ void whenLogExceptionConversionWordIsNotConfiguredThenConsoleUsesDefault() {
 
 	@Test
 	void whenLogExceptionConversionWordIsSetThenConsoleUsesIt() {
-		withSystemProperty(LoggingSystemProperties.EXCEPTION_CONVERSION_WORD, "custom",
+		withSystemProperty(LoggingSystemProperty.EXCEPTION_CONVERSION_WORD.getEnvironmentVariableName(), "custom",
 				() -> assertThat(consolePattern()).contains("custom"));
 	}
 
@@ -64,7 +64,7 @@ void whenLogLevelPatternIsNotConfiguredThenConsoleUsesDefault() {
 
 	@Test
 	void whenLogLevelPatternIsSetThenConsoleUsesIt() {
-		withSystemProperty(LoggingSystemProperties.LOG_LEVEL_PATTERN, "custom",
+		withSystemProperty(LoggingSystemProperty.LEVEL_PATTERN.getEnvironmentVariableName(), "custom",
 				() -> assertThat(consolePattern()).contains("custom"));
 	}
 
@@ -75,7 +75,7 @@ void whenLogLDateformatPatternIsNotConfiguredThenConsoleUsesDefault() {
 
 	@Test
 	void whenLogDateformatPatternIsSetThenConsoleUsesIt() {
-		withSystemProperty(LoggingSystemProperties.LOG_DATEFORMAT_PATTERN, "dd-MM-yyyy",
+		withSystemProperty(LoggingSystemProperty.DATEFORMAT_PATTERN.getEnvironmentVariableName(), "dd-MM-yyyy",
 				() -> assertThat(consolePattern()).contains("dd-MM-yyyy"));
 	}
 
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/TestLog4J2LoggingSystem.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/TestLog4J2LoggingSystem.java
index cc945ae690a5..47f746592c03 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/TestLog4J2LoggingSystem.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/TestLog4J2LoggingSystem.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -27,6 +27,8 @@ class TestLog4J2LoggingSystem extends Log4J2LoggingSystem {
 
 	private final List<String> availableClasses = new ArrayList<>();
 
+	private String[] standardConfigLocations;
+
 	TestLog4J2LoggingSystem() {
 		super(TestLog4J2LoggingSystem.class.getClassLoader());
 	}
@@ -44,4 +46,18 @@ void availableClasses(String... classNames) {
 		Collections.addAll(this.availableClasses, classNames);
 	}
 
+	@Override
+	protected String[] getStandardConfigLocations() {
+		return (this.standardConfigLocations != null) ? this.standardConfigLocations
+				: super.getStandardConfigLocations();
+	}
+
+	void setStandardConfigLocations(boolean standardConfigLocations) {
+		this.standardConfigLocations = (!standardConfigLocations) ? new String[0] : null;
+	}
+
+	void setStandardConfigLocations(String[] standardConfigLocations) {
+		this.standardConfigLocations = standardConfigLocations;
+	}
+
 }
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/CorrelationIdConverterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/CorrelationIdConverterTests.java
new file mode 100644
index 000000000000..061251739dc7
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/CorrelationIdConverterTests.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.logging.logback;
+
+import java.util.List;
+import java.util.Map;
+
+import ch.qos.logback.classic.LoggerContext;
+import ch.qos.logback.classic.spi.LoggingEvent;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link CorrelationIdConverter}.
+ *
+ * @author Phillip Webb
+ */
+class CorrelationIdConverterTests {
+
+	private final CorrelationIdConverter converter;
+
+	private final LoggingEvent event = new LoggingEvent();
+
+	CorrelationIdConverterTests() {
+		this.converter = new CorrelationIdConverter();
+		this.converter.setContext(new LoggerContext());
+	}
+
+	@Test
+	void defaultPattern() {
+		addMdcProperties(this.event);
+		this.converter.start();
+		String converted = this.converter.convert(this.event);
+		this.converter.stop();
+		assertThat(converted).isEqualTo("[01234567890123456789012345678901-0123456789012345] ");
+	}
+
+	@Test
+	void customPattern() {
+		this.converter.setOptionList(List.of("traceId(0)", "spanId(0)"));
+		addMdcProperties(this.event);
+		this.converter.start();
+		String converted = this.converter.convert(this.event);
+		this.converter.stop();
+		assertThat(converted).isEqualTo("[01234567890123456789012345678901-0123456789012345] ");
+	}
+
+	private void addMdcProperties(LoggingEvent event) {
+		event.setMDCPropertyMap(Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345"));
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackConfigurationAotContributionTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackConfigurationAotContributionTests.java
index 4714728fd48f..d215c35d23e6 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackConfigurationAotContributionTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackConfigurationAotContributionTests.java
@@ -185,7 +185,7 @@ void typeFromParentsDefaultClassAnnotatedSetterIsRegisteredForReflection() {
 	@Test
 	void componentTypesOfArraysAreRegisteredForReflection() {
 		ComponentModel component = new ComponentModel();
-		component.setClassName(ArrayParmeters.class.getName());
+		component.setClassName(ArrayParameters.class.getName());
 		TestGenerationContext generationContext = applyContribution(component);
 		assertThat(invokePublicConstructorsAndInspectAndInvokePublicMethodsOf(InetSocketAddress.class))
 			.accepts(generationContext.getRuntimeHints());
@@ -287,7 +287,7 @@ public interface Contract {
 
 	}
 
-	public static class ArrayParmeters {
+	public static class ArrayParameters {
 
 		public void addDestinations(InetSocketAddress... addresses) {
 
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemParallelInitializationTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemParallelInitializationTests.java
new file mode 100644
index 000000000000..ea9d69cdf8fe
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemParallelInitializationTests.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.logging.logback;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import ch.qos.logback.classic.LoggerContext;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
+
+import org.springframework.boot.logging.LoggingSystem;
+import org.springframework.boot.testsupport.classpath.ForkedClassPath;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for parallel initialization of {@link LogbackLoggingSystem} that are separate
+ * from {@link LogbackLoggingSystemTests}. This isolation allows them to have complete
+ * control over how and when the logging system is initialized.
+ *
+ * @author Andy Wilkinson
+ */
+class LogbackLoggingSystemParallelInitializationTests {
+
+	private final LoggingSystem loggingSystem = LoggingSystem.get(getClass().getClassLoader());
+
+	@AfterEach
+	void cleanUp() {
+		this.loggingSystem.cleanUp();
+		((LoggerContext) LoggerFactory.getILoggerFactory()).stop();
+	}
+
+	@Test
+	@ForkedClassPath
+	void noExceptionsAreThrownWhenBeforeInitializeIsCalledInParallel() {
+		List<Thread> threads = new ArrayList<>();
+		List<Throwable> exceptions = new CopyOnWriteArrayList<>();
+		for (int i = 0; i < 10; i++) {
+			Thread thread = new Thread(() -> this.loggingSystem.beforeInitialize());
+			thread.setUncaughtExceptionHandler((t, ex) -> exceptions.add(ex));
+			threads.add(thread);
+		}
+		threads.forEach(Thread::start);
+		threads.forEach(this::join);
+		assertThat(exceptions).isEmpty();
+	}
+
+	private void join(Thread thread) {
+		try {
+			thread.join();
+		}
+		catch (InterruptedException ex) {
+			Thread.currentThread().interrupt();
+			throw new RuntimeException(ex);
+		}
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemPropertiesTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemPropertiesTests.java
index 2ab61b672f98..45c82c533de1 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemPropertiesTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemPropertiesTests.java
@@ -26,6 +26,7 @@
 
 import org.springframework.boot.convert.ApplicationConversionService;
 import org.springframework.boot.logging.LoggingSystemProperties;
+import org.springframework.boot.logging.LoggingSystemProperty;
 import org.springframework.core.convert.support.ConfigurableConversionService;
 import org.springframework.mock.env.MockEnvironment;
 
@@ -44,8 +45,9 @@ class LogbackLoggingSystemPropertiesTests {
 
 	@BeforeEach
 	void captureSystemPropertyNames() {
-		System.getProperties().remove(LoggingSystemProperties.CONSOLE_LOG_CHARSET);
-		System.getProperties().remove(LoggingSystemProperties.FILE_LOG_CHARSET);
+		for (LoggingSystemProperty property : LoggingSystemProperty.values()) {
+			System.getProperties().remove(property.getEnvironmentVariableName());
+		}
 		this.systemPropertyNames = new HashSet<>(System.getProperties().keySet());
 		this.environment = new MockEnvironment();
 		this.environment
@@ -62,7 +64,8 @@ void restoreSystemProperties() {
 	void applySetsStandardSystemProperties() {
 		this.environment.setProperty("logging.pattern.console", "boot");
 		new LogbackLoggingSystemProperties(this.environment).apply();
-		assertThat(System.getProperties()).containsEntry(LoggingSystemProperties.CONSOLE_LOG_PATTERN, "boot");
+		assertThat(System.getProperties())
+			.containsEntry(LoggingSystemProperty.CONSOLE_PATTERN.getEnvironmentVariableName(), "boot");
 	}
 
 	@Test
@@ -74,11 +77,11 @@ void applySetsLogbackSystemProperties() {
 		this.environment.setProperty("logging.logback.rollingpolicy.max-history", "mh");
 		new LogbackLoggingSystemProperties(this.environment).apply();
 		assertThat(System.getProperties())
-			.containsEntry(LogbackLoggingSystemProperties.ROLLINGPOLICY_FILE_NAME_PATTERN, "fnp")
-			.containsEntry(LogbackLoggingSystemProperties.ROLLINGPOLICY_CLEAN_HISTORY_ON_START, "chos")
-			.containsEntry(LogbackLoggingSystemProperties.ROLLINGPOLICY_MAX_FILE_SIZE, "1024")
-			.containsEntry(LogbackLoggingSystemProperties.ROLLINGPOLICY_TOTAL_SIZE_CAP, "2048")
-			.containsEntry(LogbackLoggingSystemProperties.ROLLINGPOLICY_MAX_HISTORY, "mh");
+			.containsEntry(RollingPolicySystemProperty.FILE_NAME_PATTERN.getEnvironmentVariableName(), "fnp")
+			.containsEntry(RollingPolicySystemProperty.CLEAN_HISTORY_ON_START.getEnvironmentVariableName(), "chos")
+			.containsEntry(RollingPolicySystemProperty.MAX_FILE_SIZE.getEnvironmentVariableName(), "1024")
+			.containsEntry(RollingPolicySystemProperty.TOTAL_SIZE_CAP.getEnvironmentVariableName(), "2048")
+			.containsEntry(RollingPolicySystemProperty.MAX_HISTORY.getEnvironmentVariableName(), "mh");
 	}
 
 	@Test
@@ -90,24 +93,24 @@ void applySetsLogbackSystemPropertiesFromDeprecated() {
 		this.environment.setProperty("logging.file.max-history", "mh");
 		new LogbackLoggingSystemProperties(this.environment).apply();
 		assertThat(System.getProperties())
-			.containsEntry(LogbackLoggingSystemProperties.ROLLINGPOLICY_FILE_NAME_PATTERN, "fnp")
-			.containsEntry(LogbackLoggingSystemProperties.ROLLINGPOLICY_CLEAN_HISTORY_ON_START, "chos")
-			.containsEntry(LogbackLoggingSystemProperties.ROLLINGPOLICY_MAX_FILE_SIZE, "1024")
-			.containsEntry(LogbackLoggingSystemProperties.ROLLINGPOLICY_TOTAL_SIZE_CAP, "2048")
-			.containsEntry(LogbackLoggingSystemProperties.ROLLINGPOLICY_MAX_HISTORY, "mh");
+			.containsEntry(RollingPolicySystemProperty.FILE_NAME_PATTERN.getEnvironmentVariableName(), "fnp")
+			.containsEntry(RollingPolicySystemProperty.CLEAN_HISTORY_ON_START.getEnvironmentVariableName(), "chos")
+			.containsEntry(RollingPolicySystemProperty.MAX_FILE_SIZE.getEnvironmentVariableName(), "1024")
+			.containsEntry(RollingPolicySystemProperty.TOTAL_SIZE_CAP.getEnvironmentVariableName(), "2048")
+			.containsEntry(RollingPolicySystemProperty.MAX_HISTORY.getEnvironmentVariableName(), "mh");
 	}
 
 	@Test
 	void consoleCharsetWhenNoPropertyUsesDefault() {
 		new LoggingSystemProperties(new MockEnvironment()).apply(null);
-		assertThat(System.getProperty(LoggingSystemProperties.CONSOLE_LOG_CHARSET))
+		assertThat(System.getProperty(LoggingSystemProperty.CONSOLE_CHARSET.getEnvironmentVariableName()))
 			.isEqualTo(Charset.defaultCharset().name());
 	}
 
 	@Test
 	void fileCharsetWhenNoPropertyUsesDefault() {
 		new LoggingSystemProperties(new MockEnvironment()).apply(null);
-		assertThat(System.getProperty(LoggingSystemProperties.FILE_LOG_CHARSET))
+		assertThat(System.getProperty(LoggingSystemProperty.FILE_CHARSET.getEnvironmentVariableName()))
 			.isEqualTo(Charset.defaultCharset().name());
 	}
 
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemTests.java
index a0a27b077e7e..845048e3473f 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemTests.java
@@ -21,6 +21,7 @@
 import java.lang.reflect.Modifier;
 import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
 import java.util.Arrays;
 import java.util.EnumSet;
 import java.util.HashSet;
@@ -38,6 +39,7 @@
 import ch.qos.logback.core.encoder.LayoutWrappingEncoder;
 import ch.qos.logback.core.rolling.RollingFileAppender;
 import ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy;
+import ch.qos.logback.core.util.DynamicClassLoadingException;
 import ch.qos.logback.core.util.StatusPrinter;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
@@ -45,6 +47,7 @@
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.slf4j.ILoggerFactory;
 import org.slf4j.LoggerFactory;
+import org.slf4j.MDC;
 import org.slf4j.bridge.SLF4JBridgeHandler;
 
 import org.springframework.beans.factory.aot.BeanFactoryInitializationAotContribution;
@@ -56,12 +59,15 @@
 import org.springframework.boot.logging.LoggingInitializationContext;
 import org.springframework.boot.logging.LoggingSystem;
 import org.springframework.boot.logging.LoggingSystemProperties;
+import org.springframework.boot.logging.LoggingSystemProperty;
+import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
 import org.springframework.boot.testsupport.classpath.ClassPathOverrides;
 import org.springframework.boot.testsupport.system.CapturedOutput;
 import org.springframework.boot.testsupport.system.OutputCaptureExtension;
 import org.springframework.core.convert.ConversionService;
 import org.springframework.core.convert.support.ConfigurableConversionService;
 import org.springframework.core.env.ConfigurableEnvironment;
+import org.springframework.core.env.MapPropertySource;
 import org.springframework.mock.env.MockEnvironment;
 import org.springframework.test.util.ReflectionTestUtils;
 import org.springframework.util.ReflectionUtils;
@@ -86,8 +92,11 @@
  * @author Robert Thornton
  * @author EddĂș MelĂ©ndez
  * @author Scott Frederick
+ * @author Jonatan Ivanov
+ * @author Moritz Halbritter
  */
 @ExtendWith(OutputCaptureExtension.class)
+@ClassPathExclusions({ "log4j-core-*.jar", "log4j-api-*.jar" })
 class LogbackLoggingSystemTests extends AbstractLoggingSystemTests {
 
 	private final LogbackLoggingSystem loggingSystem = new LogbackLoggingSystem(getClass().getClassLoader());
@@ -102,8 +111,9 @@ class LogbackLoggingSystemTests extends AbstractLoggingSystemTests {
 
 	@BeforeEach
 	void setup() {
-		System.getProperties().remove(LoggingSystemProperties.CONSOLE_LOG_CHARSET);
-		System.getProperties().remove(LoggingSystemProperties.FILE_LOG_CHARSET);
+		for (LoggingSystemProperty property : LoggingSystemProperty.values()) {
+			System.getProperties().remove(property.getEnvironmentVariableName());
+		}
 		this.systemPropertyNames = new HashSet<>(System.getProperties().keySet());
 		this.loggingSystem.cleanUp();
 		this.logger = ((LoggerContext) LoggerFactory.getILoggerFactory()).getLogger(getClass());
@@ -122,7 +132,7 @@ void cleanUp() {
 	}
 
 	@Test
-	@ClassPathOverrides("org.jboss.logging:jboss-logging:3.5.0.Final")
+	@ClassPathOverrides({ "org.jboss.logging:jboss-logging:3.5.0.Final", "org.apache.logging.log4j:log4j-core:2.19.0" })
 	void jbossLoggingRoutesThroughLog4j2ByDefault() {
 		System.getProperties().remove("org.jboss.logging.provider");
 		org.jboss.logging.Logger jbossLogger = org.jboss.logging.Logger.getLogger(getClass());
@@ -235,7 +245,7 @@ void setLevelToNull(CapturedOutput output) {
 	}
 
 	@Test
-	void getLoggingConfigurations() {
+	void getLoggerConfigurations() {
 		this.loggingSystem.beforeInitialize();
 		initialize(this.initializationContext, null, null);
 		this.loggingSystem.setLogLevel(getClass().getName(), LogLevel.DEBUG);
@@ -245,7 +255,7 @@ void getLoggingConfigurations() {
 	}
 
 	@Test
-	void getLoggingConfiguration() {
+	void getLoggerConfiguration() {
 		this.loggingSystem.beforeInitialize();
 		initialize(this.initializationContext, null, null);
 		this.loggingSystem.setLogLevel(getClass().getName(), LogLevel.DEBUG);
@@ -255,7 +265,7 @@ void getLoggingConfiguration() {
 	}
 
 	@Test
-	void getLoggingConfigurationForLoggerThatDoesNotExistShouldReturnNull() {
+	void getLoggerConfigurationForLoggerThatDoesNotExistShouldReturnNull() {
 		this.loggingSystem.beforeInitialize();
 		initialize(this.initializationContext, null, null);
 		LoggerConfiguration configuration = this.loggingSystem.getLoggerConfiguration("doesnotexist");
@@ -263,7 +273,7 @@ void getLoggingConfigurationForLoggerThatDoesNotExistShouldReturnNull() {
 	}
 
 	@Test
-	void getLoggingConfigurationForALL() {
+	void getLoggerConfigurationForALL() {
 		this.loggingSystem.beforeInitialize();
 		initialize(this.initializationContext, null, null);
 		Logger logger = (Logger) LoggerFactory.getILoggerFactory().getLogger(getClass().getName());
@@ -503,7 +513,7 @@ void exceptionsIncludeClassPackaging(CapturedOutput output) {
 
 	@Test
 	void customExceptionConversionWord(CapturedOutput output) {
-		System.setProperty(LoggingSystemProperties.EXCEPTION_CONVERSION_WORD, "%ex");
+		System.setProperty(LoggingSystemProperty.EXCEPTION_CONVERSION_WORD.getEnvironmentVariableName(), "%ex");
 		try {
 			this.loggingSystem.beforeInitialize();
 			this.logger.info("Hidden");
@@ -514,7 +524,7 @@ void customExceptionConversionWord(CapturedOutput output) {
 			assertThat(output).contains("java.lang.RuntimeException: Expected").doesNotContain("Wrapped by:");
 		}
 		finally {
-			System.clearProperty(LoggingSystemProperties.EXCEPTION_CONVERSION_WORD);
+			System.clearProperty(LoggingSystemProperty.EXCEPTION_CONVERSION_WORD.getEnvironmentVariableName());
 		}
 	}
 
@@ -525,7 +535,8 @@ void initializeShouldSetSystemProperty() {
 		this.logger.info("Hidden");
 		LogFile logFile = getLogFile(tmpDir() + "/example.log", null, false);
 		initialize(this.initializationContext, "classpath:logback-nondefault.xml", logFile);
-		assertThat(System.getProperty(LoggingSystemProperties.LOG_FILE)).endsWith("example.log");
+		assertThat(System.getProperty(LoggingSystemProperty.LOG_FILE.getEnvironmentVariableName()))
+			.endsWith("example.log");
 	}
 
 	@Test
@@ -544,6 +555,7 @@ void initializeShouldApplyLogbackSystemPropertiesToTheContext() {
 				(field) -> expectedProperties.add((String) field.get(null)), this::isPublicStaticFinal);
 		expectedProperties.removeAll(Arrays.asList("LOG_FILE", "LOG_PATH"));
 		expectedProperties.add("org.jboss.logging.provider");
+		expectedProperties.add("LOG_CORRELATION_PATTERN");
 		assertThat(properties).containsOnlyKeys(expectedProperties);
 		assertThat(properties).containsEntry("CONSOLE_LOG_CHARSET", Charset.defaultCharset().name());
 	}
@@ -676,8 +688,159 @@ void springProfileIfNestedWithinSecondPhaseElementSanityChecker(CapturedOutput o
 		assertThat(output).contains("<springProfile> elements cannot be nested within an");
 	}
 
+	@Test
+	void correlationLoggingToFileWhenExpectCorrelationIdTrueAndMdcContent() {
+		this.environment.setProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, "true");
+		File file = new File(tmpDir(), "logback-test.log");
+		LogFile logFile = getLogFile(file.getPath(), null);
+		initialize(this.initializationContext, null, logFile);
+		MDC.setContextMap(Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345"));
+		this.logger.info("Hello world");
+		assertThat(getLineWithText(file, "Hello world"))
+			.contains(" [01234567890123456789012345678901-0123456789012345] ");
+	}
+
+	@Test
+	void correlationLoggingToConsoleWhenExpectCorrelationIdTrueAndMdcContent(CapturedOutput output) {
+		this.environment.setProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, "true");
+		initialize(this.initializationContext, null, null);
+		MDC.setContextMap(Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345"));
+		this.logger.info("Hello world");
+		assertThat(getLineWithText(output, "Hello world"))
+			.contains(" [01234567890123456789012345678901-0123456789012345] ");
+	}
+
+	@Test
+	void correlationLoggingToConsoleWhenExpectCorrelationIdFalseAndMdcContent(CapturedOutput output) {
+		this.environment.setProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, "false");
+		initialize(this.initializationContext, null, null);
+		MDC.setContextMap(Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345"));
+		this.logger.info("Hello world");
+		assertThat(getLineWithText(output, "Hello world")).doesNotContain("0123456789012345");
+	}
+
+	@Test
+	void correlationLoggingToConsoleWhenExpectCorrelationIdTrueAndNoMdcContent(CapturedOutput output) {
+		this.environment.setProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, "true");
+		initialize(this.initializationContext, null, null);
+		this.logger.info("Hello world");
+		assertThat(getLineWithText(output, "Hello world"))
+			.contains(" [                                                 ] ");
+	}
+
+	@Test
+	void correlationLoggingToConsoleWhenHasCorrelationPattern(CapturedOutput output) {
+		this.environment.setProperty("logging.pattern.correlation", "%correlationId{spanId(0),traceId(0)}");
+		initialize(this.initializationContext, null, null);
+		MDC.setContextMap(Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345"));
+		this.logger.info("Hello world");
+		assertThat(getLineWithText(output, "Hello world"))
+			.contains(" [0123456789012345-01234567890123456789012345678901] ");
+	}
+
+	@Test
+	void correlationLoggingToConsoleWhenUsingXmlConfiguration(CapturedOutput output) {
+		this.environment.setProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, "true");
+		initialize(this.initializationContext, "classpath:logback-include-base.xml", null);
+		MDC.setContextMap(Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345"));
+		this.logger.info("Hello world");
+		assertThat(getLineWithText(output, "Hello world"))
+			.contains(" [01234567890123456789012345678901-0123456789012345] ");
+	}
+
+	@Test
+	void correlationLoggingToFileWhenUsingFileConfiguration() {
+		this.environment.setProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, "true");
+		File file = new File(tmpDir(), "logback-test.log");
+		LogFile logFile = getLogFile(file.getPath(), null);
+		initialize(this.initializationContext, "classpath:logback-include-base.xml", logFile);
+		MDC.setContextMap(Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345"));
+		this.logger.info("Hello world");
+		assertThat(getLineWithText(file, "Hello world"))
+			.contains(" [01234567890123456789012345678901-0123456789012345] ");
+	}
+
+	@Test
+	void applicationNameLoggingWhenHasApplicationName(CapturedOutput output) {
+		this.environment.setProperty("spring.application.name", "myapp");
+		initialize(this.initializationContext, null, null);
+		this.logger.info("Hello world");
+		assertThat(getLineWithText(output, "Hello world")).contains("[myapp] ");
+	}
+
+	@Test
+	void applicationNameLoggingWhenDisabled(CapturedOutput output) {
+		this.environment.setProperty("spring.application.name", "myapp");
+		this.environment.setProperty("logging.include-application-name", "false");
+		initialize(this.initializationContext, null, null);
+		this.logger.info("Hello world");
+		assertThat(getLineWithText(output, "Hello world")).doesNotContain("myapp");
+	}
+
+	@Test
+	void whenConfigurationErrorIsDetectedUnderlyingCausesAreIncludedAsSuppressedExceptions() {
+		this.loggingSystem.beforeInitialize();
+		assertThatIllegalStateException()
+			.isThrownBy(() -> initialize(this.initializationContext, "classpath:logback-broken.xml",
+					getLogFile(tmpDir() + "/tmp.log", null)))
+			.satisfies((ex) -> assertThat(ex.getSuppressed())
+				.hasAtLeastOneElementOfType(DynamicClassLoadingException.class));
+	}
+
+	@Test
+	void whenConfigLocationIsNotXmlThenIllegalArgumentExceptionShouldBeThrown() {
+		this.loggingSystem.beforeInitialize();
+		assertThatIllegalStateException()
+			.isThrownBy(() -> initialize(this.initializationContext, "classpath:logback-invalid-format.txt",
+					getLogFile(tmpDir() + "/tmp.log", null)))
+			.satisfies((ex) -> assertThat(ex.getCause()).isInstanceOf(IllegalArgumentException.class)
+				.hasMessageStartingWith("Unsupported file extension"));
+	}
+
+	@Test
+	void whenConfigLocationIsXmlAndHasQueryParametersThenIllegalArgumentExceptionShouldNotBeThrown() {
+		this.loggingSystem.beforeInitialize();
+		assertThatIllegalStateException()
+			.isThrownBy(() -> initialize(this.initializationContext, "file:///logback-nonexistent.xml?raw=true",
+					getLogFile(tmpDir() + "/tmp.log", null)))
+			.satisfies((ex) -> assertThat(ex.getCause()).isNotInstanceOf(IllegalArgumentException.class));
+	}
+
+	@Test
+	void shouldRespectConsoleThreshold(CapturedOutput output) {
+		this.environment.setProperty("logging.threshold.console", "warn");
+		this.loggingSystem.beforeInitialize();
+		initialize(this.initializationContext, null, null);
+		this.logger.info("Some info message");
+		this.logger.warn("Some warn message");
+		assertThat(output).doesNotContain("Some info message").contains("Some warn message");
+	}
+
+	@Test
+	void shouldRespectFileThreshold() {
+		this.environment.setProperty("logging.threshold.file", "warn");
+		this.loggingSystem.beforeInitialize();
+		initialize(this.initializationContext, null, getLogFile(null, tmpDir()));
+		this.logger.info("Some info message");
+		this.logger.warn("Some warn message");
+		Path file = Path.of(tmpDir(), "spring.log");
+		assertThat(file).content(StandardCharsets.UTF_8)
+			.doesNotContain("Some info message")
+			.contains("Some warn message");
+	}
+
+	@Test
+	void applyingSystemPropertiesDoesNotCauseUnwantedStatusWarnings(CapturedOutput output) {
+		this.loggingSystem.beforeInitialize();
+		this.environment.getPropertySources()
+			.addFirst(new MapPropertySource("test", Map.of("logging.pattern.console", "[CONSOLE]%m")));
+		this.loggingSystem.initialize(this.initializationContext, "classpath:logback-nondefault.xml", null);
+		assertThat(output).doesNotContain("WARN");
+	}
+
 	private void initialize(LoggingInitializationContext context, String configLocation, LogFile logFile) {
 		this.loggingSystem.getSystemProperties((ConfigurableEnvironment) context.getEnvironment()).apply(logFile);
+		this.loggingSystem.beforeInitialize();
 		this.loggingSystem.initialize(context, configLocation, logFile);
 	}
 
@@ -699,15 +862,4 @@ private static SizeAndTimeBasedRollingPolicy<?> getRollingPolicy() {
 		return (SizeAndTimeBasedRollingPolicy<?>) getFileAppender().getRollingPolicy();
 	}
 
-	private String getLineWithText(File file, CharSequence outputSearch) {
-		return getLineWithText(contentOf(file), outputSearch);
-	}
-
-	private String getLineWithText(CharSequence output, CharSequence outputSearch) {
-		return Arrays.stream(output.toString().split("\\r?\\n"))
-			.filter((line) -> line.contains(outputSearch))
-			.findFirst()
-			.orElse(null);
-	}
-
 }
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/r2dbc/ConnectionFactoryBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/r2dbc/ConnectionFactoryBuilderTests.java
index e9e5e3988da0..43eedcdaf6e7 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/r2dbc/ConnectionFactoryBuilderTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/r2dbc/ConnectionFactoryBuilderTests.java
@@ -26,7 +26,9 @@
 import io.r2dbc.pool.ConnectionPool;
 import io.r2dbc.pool.ConnectionPoolConfiguration;
 import io.r2dbc.pool.PoolingConnectionFactoryProvider;
+import io.r2dbc.spi.Connection;
 import io.r2dbc.spi.ConnectionFactory;
+import io.r2dbc.spi.ConnectionFactoryMetadata;
 import io.r2dbc.spi.ConnectionFactoryOptions;
 import io.r2dbc.spi.Option;
 import io.r2dbc.spi.ValidationDepth;
@@ -34,6 +36,7 @@
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
+import org.reactivestreams.Publisher;
 
 import org.springframework.boot.r2dbc.ConnectionFactoryBuilder.PoolingAwareOptionsCapableWrapper;
 import org.springframework.core.ResolvableType;
@@ -50,6 +53,7 @@
  * @author Mark Paluch
  * @author Tadaya Tsuyukubo
  * @author Stephane Nicoll
+ * @author Moritz Halbritter
  */
 class ConnectionFactoryBuilderTests {
 
@@ -235,6 +239,15 @@ void stringlyTypedOptionIsMappedWhenCreatingPoolConfiguration(Option option) {
 		assertThat(configuration).extracting(expectedOption.property).isEqualTo(expectedOption.value);
 	}
 
+	@Test
+	void shouldApplyDecorators() {
+		String url = "r2dbc:pool:h2:mem:///" + UUID.randomUUID();
+		ConnectionFactory connectionFactory = ConnectionFactoryBuilder.withUrl(url)
+			.decorator((ignored) -> new MyConnectionFactory())
+			.build();
+		assertThat(connectionFactory).isInstanceOf(MyConnectionFactory.class);
+	}
+
 	private static Iterable<Arguments> primitivePoolingConnectionProviderOptions() {
 		return extractPoolingConnectionProviderOptions((field) -> {
 			ResolvableType type = ResolvableType.forField(field);
@@ -320,4 +333,18 @@ static ExpectedOption get(Option<?> option) {
 
 	}
 
+	private static class MyConnectionFactory implements ConnectionFactory {
+
+		@Override
+		public Publisher<? extends Connection> create() {
+			return null;
+		}
+
+		@Override
+		public ConnectionFactoryMetadata getMetadata() {
+			return null;
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/reactor/DebugAgentEnvironmentPostProcessorTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/reactor/DebugAgentEnvironmentPostProcessorTests.java
deleted file mode 100644
index 87efbcc782f4..000000000000
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/reactor/DebugAgentEnvironmentPostProcessorTests.java
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.reactor;
-
-import org.junit.jupiter.api.Disabled;
-import org.junit.jupiter.api.Test;
-import reactor.core.Scannable;
-import reactor.core.publisher.Flux;
-
-import org.springframework.boot.testsupport.classpath.ClassPathOverrides;
-import org.springframework.mock.env.MockEnvironment;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Tests for {@link DebugAgentEnvironmentPostProcessor}.
- *
- * @author Brian Clozel
- */
-@Disabled("We need the not-yet-released reactor-tools 3.4.11 for JDK 17 compatibility")
-@ClassPathOverrides("io.projectreactor:reactor-tools:3.4.11")
-class DebugAgentEnvironmentPostProcessorTests {
-
-	static {
-		MockEnvironment environment = new MockEnvironment();
-		DebugAgentEnvironmentPostProcessor postProcessor = new DebugAgentEnvironmentPostProcessor();
-		postProcessor.postProcessEnvironment(environment, null);
-	}
-
-	@Test
-	void enablesReactorDebugAgent() {
-		InstrumentedFluxProvider fluxProvider = new InstrumentedFluxProvider();
-		Flux<Integer> flux = fluxProvider.newFluxJust();
-		assertThat(Scannable.from(flux).stepName())
-			.startsWith("Flux.just ⇱ at org.springframework.boot.reactor.InstrumentedFluxProvider.newFluxJust");
-	}
-
-}
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/reactor/InstrumentedFluxProvider.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/reactor/InstrumentedFluxProvider.java
index 2ec6e0c03b02..eaf3abfa3401 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/reactor/InstrumentedFluxProvider.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/reactor/InstrumentedFluxProvider.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -22,7 +22,7 @@
  * Utility class that should be instrumented by the reactor debug agent.
  *
  * @author Brian Clozel
- * @see DebugAgentEnvironmentPostProcessorTests
+ * @see ReactorEnvironmentPostProcessorTests
  */
 class InstrumentedFluxProvider {
 
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/reactor/ReactorEnvironmentPostProcessorTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/reactor/ReactorEnvironmentPostProcessorTests.java
new file mode 100644
index 000000000000..29efc1f0b27a
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/reactor/ReactorEnvironmentPostProcessorTests.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.reactor;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.api.condition.JRE;
+import reactor.core.Scannable;
+import reactor.core.publisher.Flux;
+
+import org.springframework.mock.env.MockEnvironment;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ReactorEnvironmentPostProcessor}.
+ *
+ * @author Brian Clozel
+ */
+
+@Disabled("Tests rely on static initialization and are flaky on CI")
+class ReactorEnvironmentPostProcessorTests {
+
+	static {
+		MockEnvironment environment = new MockEnvironment();
+		environment.setProperty("spring.threads.virtual.enabled", "true");
+		ReactorEnvironmentPostProcessor postProcessor = new ReactorEnvironmentPostProcessor();
+		postProcessor.postProcessEnvironment(environment, null);
+	}
+
+	@Test
+	void enablesReactorDebugAgent() {
+		InstrumentedFluxProvider fluxProvider = new InstrumentedFluxProvider();
+		Flux<Integer> flux = fluxProvider.newFluxJust();
+		assertThat(Scannable.from(flux).stepName())
+			.startsWith("Flux.just ⇱ at org.springframework.boot.reactor.InstrumentedFluxProvider.newFluxJust");
+	}
+
+	@Test
+	@EnabledForJreRange(max = JRE.JAVA_20)
+	void shouldNotEnableVirtualThreads() {
+		assertThat(System.getProperty("reactor.schedulers.defaultBoundedElasticOnVirtualThreads")).isNotEqualTo("true");
+	}
+
+	@Test
+	@EnabledForJreRange(min = JRE.JAVA_21)
+	void shouldEnableVirtualThreads() {
+		assertThat(System.getProperty("reactor.schedulers.defaultBoundedElasticOnVirtualThreads")).isEqualTo("true");
+	}
+
+	@AfterEach
+	void cleanup() {
+		System.setProperty("reactor.schedulers.defaultBoundedElasticOnVirtualThreads", "false");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactoryTests.java
index 4d206e26fee1..0c1f8196b200 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactoryTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactoryTests.java
@@ -56,7 +56,7 @@
 import org.springframework.core.codec.CharSequenceEncoder;
 import org.springframework.core.codec.StringDecoder;
 import org.springframework.core.io.buffer.NettyDataBufferFactory;
-import org.springframework.http.client.reactive.ReactorResourceFactory;
+import org.springframework.http.client.ReactorResourceFactory;
 import org.springframework.messaging.rsocket.RSocketRequester;
 import org.springframework.messaging.rsocket.RSocketStrategies;
 
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/sql/init/AbstractScriptDatabaseInitializerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/sql/init/AbstractScriptDatabaseInitializerTests.java
index 81702612e789..d46d3221e85f 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/sql/init/AbstractScriptDatabaseInitializerTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/sql/init/AbstractScriptDatabaseInitializerTests.java
@@ -44,6 +44,16 @@ void whenDatabaseIsInitializedThenSchemaAndDataScriptsAreApplied() {
 		assertThat(numberOfEmbeddedRows("SELECT COUNT(*) FROM EXAMPLE")).isOne();
 	}
 
+	@Test
+	void whenDatabaseIsInitializedWithDirectoryLocationsThenFailureIsHelpful() {
+		DatabaseInitializationSettings settings = new DatabaseInitializationSettings();
+		settings.setSchemaLocations(Arrays.asList("/org/springframework/boot/sql/init"));
+		settings.setDataLocations(Arrays.asList("/org/springframework/boot/sql/init"));
+		T initializer = createEmbeddedDatabaseInitializer(settings);
+		assertThatIllegalStateException().isThrownBy(initializer::initializeDatabase)
+			.withMessage("No schema scripts found at location '/org/springframework/boot/sql/init'");
+	}
+
 	@Test
 	void whenContinueOnErrorIsFalseThenInitializationFailsOnError() {
 		DatabaseInitializationSettings settings = new DatabaseInitializationSettings();
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslBundleRegistryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslBundleRegistryTests.java
index d8cf034eef5f..110e4c79fbf3 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslBundleRegistryTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslBundleRegistryTests.java
@@ -16,7 +16,16 @@
 
 package org.springframework.boot.ssl;
 
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.awaitility.Awaitility;
+import org.hamcrest.Matchers;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.boot.testsupport.system.CapturedOutput;
+import org.springframework.boot.testsupport.system.OutputCaptureExtension;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -28,14 +37,21 @@
  * Tests for {@link DefaultSslBundleRegistry}.
  *
  * @author Phillip Webb
+ * @author Moritz Halbritter
  */
+@ExtendWith(OutputCaptureExtension.class)
 class DefaultSslBundleRegistryTests {
 
-	private SslBundle bundle1 = mock(SslBundle.class);
+	private final SslBundle bundle1 = mock(SslBundle.class);
 
-	private SslBundle bundle2 = mock(SslBundle.class);
+	private final SslBundle bundle2 = mock(SslBundle.class);
 
-	private DefaultSslBundleRegistry registry = new DefaultSslBundleRegistry();
+	private DefaultSslBundleRegistry registry;
+
+	@BeforeEach
+	void setUp() {
+		this.registry = new DefaultSslBundleRegistry();
+	}
 
 	@Test
 	void createWithNameAndBundleRegistersBundle() {
@@ -89,4 +105,29 @@ void getBundleReturnsBundle() {
 		assertThat(this.registry.getBundle("test2")).isSameAs(this.bundle2);
 	}
 
+	@Test
+	void updateBundleShouldNotifyUpdateHandlers() {
+		AtomicReference<SslBundle> updatedBundle = new AtomicReference<>();
+		this.registry.registerBundle("test1", this.bundle1);
+		this.registry.addBundleUpdateHandler("test1", updatedBundle::set);
+		this.registry.updateBundle("test1", this.bundle2);
+		Awaitility.await().untilAtomic(updatedBundle, Matchers.equalTo(this.bundle2));
+	}
+
+	@Test
+	void shouldFailIfUpdatingNonRegisteredBundle() {
+		assertThatExceptionOfType(NoSuchSslBundleException.class)
+			.isThrownBy(() -> this.registry.updateBundle("dummy", this.bundle1))
+			.withMessageContaining("'dummy'");
+	}
+
+	@Test
+	void shouldLogIfUpdatingBundleWithoutListeners(CapturedOutput output) {
+		this.registry.registerBundle("test1", this.bundle1);
+		this.registry.getBundle("test1");
+		this.registry.updateBundle("test1", this.bundle2);
+		assertThat(output).contains(
+				"SSL bundle 'test1' has been updated but may be in use by a technology that doesn't support SSL reloading");
+	}
+
 }
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/jks/JksSslStoreBundleTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/jks/JksSslStoreBundleTests.java
index 87b0f972e233..159e98c45654 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/jks/JksSslStoreBundleTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/jks/JksSslStoreBundleTests.java
@@ -32,6 +32,7 @@
  *
  * @author Scott Frederick
  * @author Phillip Webb
+ * @author Moritz Halbritter
  */
 @MockPkcs11Security
 class JksSslStoreBundleTests {
@@ -58,10 +59,11 @@ void whenStoresHaveNoValues() {
 
 	@Test
 	void whenTypePKCS11AndLocationThrowsException() {
-		JksSslStoreDetails keyStoreDetails = new JksSslStoreDetails("PKCS11", null, "test.jks", null);
-		JksSslStoreDetails trustStoreDetails = null;
-		JksSslStoreBundle bundle = new JksSslStoreBundle(keyStoreDetails, trustStoreDetails);
-		assertThatIllegalStateException().isThrownBy(bundle::getKeyStore)
+		assertThatIllegalStateException().isThrownBy(() -> {
+			JksSslStoreDetails keyStoreDetails = new JksSslStoreDetails("PKCS11", null, "test.jks", null);
+			JksSslStoreDetails trustStoreDetails = null;
+			new JksSslStoreBundle(keyStoreDetails, trustStoreDetails);
+		})
 			.withMessageContaining(
 					"Unable to create key store: Location is 'test.jks', but must be empty or null for PKCS11 hardware key stores");
 	}
@@ -102,22 +104,22 @@ void whenHasTrustStoreType() {
 
 	@Test
 	void whenHasKeyStoreProvider() {
-		JksSslStoreDetails keyStoreDetails = new JksSslStoreDetails(null, "com.example.KeyStoreProvider",
-				"classpath:test.jks", "secret");
-		JksSslStoreDetails trustStoreDetails = null;
-		JksSslStoreBundle bundle = new JksSslStoreBundle(keyStoreDetails, trustStoreDetails);
-		assertThatIllegalStateException().isThrownBy(bundle::getKeyStore)
-			.withMessageContaining("com.example.KeyStoreProvider");
+		assertThatIllegalStateException().isThrownBy(() -> {
+			JksSslStoreDetails keyStoreDetails = new JksSslStoreDetails(null, "com.example.KeyStoreProvider",
+					"classpath:test.jks", "secret");
+			JksSslStoreDetails trustStoreDetails = null;
+			new JksSslStoreBundle(keyStoreDetails, trustStoreDetails);
+		}).withMessageContaining("com.example.KeyStoreProvider");
 	}
 
 	@Test
 	void whenHasTrustStoreProvider() {
-		JksSslStoreDetails keyStoreDetails = null;
-		JksSslStoreDetails trustStoreDetails = new JksSslStoreDetails(null, "com.example.KeyStoreProvider",
-				"classpath:test.jks", "secret");
-		JksSslStoreBundle bundle = new JksSslStoreBundle(keyStoreDetails, trustStoreDetails);
-		assertThatIllegalStateException().isThrownBy(bundle::getTrustStore)
-			.withMessageContaining("com.example.KeyStoreProvider");
+		assertThatIllegalStateException().isThrownBy(() -> {
+			JksSslStoreDetails keyStoreDetails = null;
+			JksSslStoreDetails trustStoreDetails = new JksSslStoreDetails(null, "com.example.KeyStoreProvider",
+					"classpath:test.jks", "secret");
+			new JksSslStoreBundle(keyStoreDetails, trustStoreDetails);
+		}).withMessageContaining("com.example.KeyStoreProvider");
 	}
 
 	private Consumer<KeyStore> storeContainingCertAndKey(String keyAlias, String keyPassword) {
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemCertificateParserTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemCertificateParserTests.java
index 20ceee1b9a4d..db8f71f6b744 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemCertificateParserTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemCertificateParserTests.java
@@ -19,6 +19,7 @@
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.security.cert.X509Certificate;
+import java.util.List;
 
 import org.junit.jupiter.api.Test;
 
@@ -35,19 +36,19 @@ class PemCertificateParserTests {
 
 	@Test
 	void parseCertificate() throws Exception {
-		X509Certificate[] certificates = PemCertificateParser.parse(read("test-cert.pem"));
+		List<X509Certificate> certificates = PemCertificateParser.parse(read("test-cert.pem"));
 		assertThat(certificates).isNotNull();
 		assertThat(certificates).hasSize(1);
-		assertThat(certificates[0].getType()).isEqualTo("X.509");
+		assertThat(certificates.get(0).getType()).isEqualTo("X.509");
 	}
 
 	@Test
 	void parseCertificateChain() throws Exception {
-		X509Certificate[] certificates = PemCertificateParser.parse(read("test-cert-chain.pem"));
+		List<X509Certificate> certificates = PemCertificateParser.parse(read("test-cert-chain.pem"));
 		assertThat(certificates).isNotNull();
 		assertThat(certificates).hasSize(2);
-		assertThat(certificates[0].getType()).isEqualTo("X.509");
-		assertThat(certificates[1].getType()).isEqualTo("X.509");
+		assertThat(certificates.get(0).getType()).isEqualTo("X.509");
+		assertThat(certificates.get(1).getType()).isEqualTo("X.509");
 	}
 
 	private String read(String path) throws IOException {
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemContentTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemContentTests.java
index 649d66f699b2..fac38bc5fd50 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemContentTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemContentTests.java
@@ -18,12 +18,17 @@
 
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.util.List;
 
 import org.junit.jupiter.api.Test;
 
 import org.springframework.core.io.ClassPathResource;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
 
 /**
  * Tests for {@link PemContent}.
@@ -33,12 +38,61 @@
 class PemContentTests {
 
 	@Test
-	void loadWhenContentIsNullReturnsNull() {
-		assertThat(PemContent.load(null)).isNull();
+	void getCertificateWhenNoCertificatesThrowsException() {
+		PemContent content = PemContent.of("");
+		assertThatIllegalStateException().isThrownBy(content::getCertificates)
+			.withMessage("Missing certificates or unrecognized format");
 	}
 
 	@Test
-	void loadWhenContentIsPemContentReturnsContent() {
+	void getCertificateReturnsCertificates() throws Exception {
+		PemContent content = PemContent.load(contentFromClasspath("/test-cert-chain.pem"));
+		List<X509Certificate> certificates = content.getCertificates();
+		assertThat(certificates).isNotNull();
+		assertThat(certificates).hasSize(2);
+		assertThat(certificates.get(0).getType()).isEqualTo("X.509");
+		assertThat(certificates.get(1).getType()).isEqualTo("X.509");
+	}
+
+	@Test
+	void getPrivateKeyWhenNoKeyThrowsException() {
+		PemContent content = PemContent.of("");
+		assertThatIllegalStateException().isThrownBy(content::getPrivateKey)
+			.withMessage("Missing private key or unrecognized format");
+	}
+
+	@Test
+	void getPrivateKeyReturnsPrivateKey() throws Exception {
+		PemContent content = PemContent
+			.load(contentFromClasspath("/org/springframework/boot/web/server/pkcs8/dsa.key"));
+		PrivateKey privateKey = content.getPrivateKey();
+		assertThat(privateKey).isNotNull();
+		assertThat(privateKey.getFormat()).isEqualTo("PKCS#8");
+		assertThat(privateKey.getAlgorithm()).isEqualTo("DSA");
+	}
+
+	@Test
+	void equalsAndHashCode() {
+		PemContent c1 = PemContent.of("aaa");
+		PemContent c2 = PemContent.of("aaa");
+		PemContent c3 = PemContent.of("bbb");
+		assertThat(c1.hashCode()).isEqualTo(c2.hashCode());
+		assertThat(c1).isEqualTo(c1).isEqualTo(c2).isNotEqualTo(c3);
+	}
+
+	@Test
+	void toStringReturnsString() {
+		PemContent content = PemContent.of("test");
+		assertThat(content).hasToString("test");
+	}
+
+	@Test
+	void loadWithStringWhenContentIsNullReturnsNull() throws Exception {
+		assertThat(PemContent.load((String) null)).isNull();
+	}
+
+	@Test
+	void loadWithStringWhenContentIsPemContentReturnsContent() throws Exception {
 		String content = """
 				-----BEGIN CERTIFICATE-----
 				MIICpDCCAYwCCQCDOqHKPjAhCTANBgkqhkiG9w0BAQUFADAUMRIwEAYDVQQDDAls
@@ -57,21 +111,43 @@ void loadWhenContentIsPemContentReturnsContent() {
 				+lGuHKdhNOVW9CmqPD1y76o6c8PQKuF7KZEoY2jvy3GeIfddBvqXgZ4PbWvFz1jO
 				32C9XWHwRA4=
 				-----END CERTIFICATE-----""";
-		assertThat(PemContent.load(content)).isEqualTo(content);
+		assertThat(PemContent.load(content)).hasToString(content);
+	}
+
+	@Test
+	void loadWithStringWhenClasspathLocationReturnsContent() throws IOException {
+		String actual = PemContent.load("classpath:test-cert.pem").toString();
+		String expected = contentFromClasspath("test-cert.pem");
+		assertThat(actual).isEqualTo(expected);
 	}
 
 	@Test
-	void loadWhenClasspathLocationReturnsContent() throws IOException {
-		String actual = PemContent.load("classpath:test-cert.pem");
-		String expected = new ClassPathResource("test-cert.pem").getContentAsString(StandardCharsets.UTF_8);
+	void loadWithStringWhenFileLocationReturnsContent() throws IOException {
+		String actual = PemContent.load("src/test/resources/test-cert.pem").toString();
+		String expected = contentFromClasspath("test-cert.pem");
 		assertThat(actual).isEqualTo(expected);
 	}
 
 	@Test
-	void loadWhenFileLocationReturnsContent() throws IOException {
-		String actual = PemContent.load("src/test/resources/test-cert.pem");
-		String expected = new ClassPathResource("test-cert.pem").getContentAsString(StandardCharsets.UTF_8);
+	void loadWithPathReturnsContent() throws IOException {
+		Path path = Path.of("src/test/resources/test-cert.pem");
+		String actual = PemContent.load(path).toString();
+		String expected = contentFromClasspath("test-cert.pem");
 		assertThat(actual).isEqualTo(expected);
 	}
 
+	@Test
+	void ofWhenNullReturnsNull() {
+		assertThat(PemContent.of(null)).isNull();
+	}
+
+	@Test
+	void ofReturnsContent() {
+		assertThat(PemContent.of("test")).hasToString("test");
+	}
+
+	private static String contentFromClasspath(String path) throws IOException {
+		return new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
+	}
+
 }
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemPrivateKeyParserTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemPrivateKeyParserTests.java
index 82203b75dcf8..22ceb5455b43 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemPrivateKeyParserTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemPrivateKeyParserTests.java
@@ -19,9 +19,11 @@
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.security.PrivateKey;
+import java.security.interfaces.ECPrivateKey;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.params.provider.ValueSource;
 
 import org.springframework.core.io.ClassPathResource;
@@ -34,49 +36,156 @@
  *
  * @author Scott Frederick
  * @author Moritz Halbritter
+ * @author Phillip Webb
  */
 class PemPrivateKeyParserTests {
 
-	@Test
-	void parsePkcs8RsaKeyFile() throws Exception {
-		PrivateKey privateKey = PemPrivateKeyParser.parse(read("ssl/pkcs8/key-rsa.pem"));
+	@ParameterizedTest
+	// @formatter:off
+	@CsvSource({
+			"dsa.key,		DSA",
+			"rsa.key,		RSA",
+			"rsa-pss.key,	RSASSA-PSS"
+	})
+		// @formatter:on
+	void shouldParseTraditionalPkcs8(String file, String algorithm) throws IOException {
+		PrivateKey privateKey = PemPrivateKeyParser.parse(read("org/springframework/boot/web/server/pkcs8/" + file));
 		assertThat(privateKey).isNotNull();
 		assertThat(privateKey.getFormat()).isEqualTo("PKCS#8");
-		assertThat(privateKey.getAlgorithm()).isEqualTo("RSA");
+		assertThat(privateKey.getAlgorithm()).isEqualTo(algorithm);
 	}
 
 	@ParameterizedTest
-	@ValueSource(strings = { "key-ec-nist-p256.pem", "key-ec-nist-p384.pem", "key-ec-prime256v1.pem",
-			"key-ec-secp256r1.pem" })
-	void parsePkcs8EcKeyFile(String fileName) throws Exception {
-		PrivateKey privateKey = PemPrivateKeyParser.parse(read("ssl/pkcs8/" + fileName));
+	// @formatter:off
+	@CsvSource({
+			"rsa.key,	RSA"
+	})
+		// @formatter:on
+	void shouldParseTraditionalPkcs1(String file, String algorithm) throws IOException {
+		PrivateKey privateKey = PemPrivateKeyParser.parse(read("org/springframework/boot/web/server/pkcs1/" + file));
 		assertThat(privateKey).isNotNull();
 		assertThat(privateKey.getFormat()).isEqualTo("PKCS#8");
-		assertThat(privateKey.getAlgorithm()).isEqualTo("EC");
+		assertThat(privateKey.getAlgorithm()).isEqualTo(algorithm);
 	}
 
-	@Test
-	void parsePkcs8DsaKeyFile() throws Exception {
-		PrivateKey privateKey = PemPrivateKeyParser.parse(read("ssl/pkcs8/key-dsa.pem"));
+	@ParameterizedTest
+	// @formatter:off
+	@ValueSource(strings = {
+			"dsa.key"
+	})
+		// @formatter:on
+	void shouldNotParseUnsupportedTraditionalPkcs1(String file) {
+		assertThatIllegalStateException()
+			.isThrownBy(() -> PemPrivateKeyParser.parse(read("org/springframework/boot/web/server/pkcs1/" + file)))
+			.withMessageContaining("Missing private key or unrecognized format");
+	}
+
+	@ParameterizedTest
+	// @formatter:off
+	@CsvSource({
+			"brainpoolP256r1.key,	brainpoolP256r1,	1.3.36.3.3.2.8.1.1.7",
+			"brainpoolP320r1.key,	brainpoolP320r1,	1.3.36.3.3.2.8.1.1.9",
+			"brainpoolP384r1.key,	brainpoolP384r1,	1.3.36.3.3.2.8.1.1.11",
+			"brainpoolP512r1.key,	brainpoolP512r1,	1.3.36.3.3.2.8.1.1.13",
+			"prime256v1.key,		secp256r1,			1.2.840.10045.3.1.7",
+			"secp224r1.key,			secp224r1,			1.3.132.0.33",
+			"secp256k1.key,			secp256k1,			1.3.132.0.10",
+			"secp256r1.key,			secp256r1,			1.2.840.10045.3.1.7",
+			"secp384r1.key,			secp384r1,			1.3.132.0.34",
+			"secp521r1.key,			secp521r1,			1.3.132.0.35"
+	})
+		// @formatter:on
+	void shouldParseEcPkcs8(String file, String curveName, String oid) throws IOException {
+		PrivateKey privateKey = PemPrivateKeyParser.parse(read("org/springframework/boot/web/server/pkcs8/" + file));
 		assertThat(privateKey).isNotNull();
 		assertThat(privateKey.getFormat()).isEqualTo("PKCS#8");
-		assertThat(privateKey.getAlgorithm()).isEqualTo("DSA");
+		assertThat(privateKey.getAlgorithm()).isEqualTo("EC");
+		assertThat(privateKey).isInstanceOf(ECPrivateKey.class);
+		ECPrivateKey ecPrivateKey = (ECPrivateKey) privateKey;
+		assertThat(ecPrivateKey.getParams().toString()).contains(curveName).contains(oid);
 	}
 
-	@Test
-	void parsePkcs8Ed25519KeyFile() throws Exception {
-		PrivateKey privateKey = PemPrivateKeyParser.parse(read("ssl/pkcs8/key-ec-ed25519.pem"));
+	@ParameterizedTest
+	// @formatter:off
+	@ValueSource(strings = {
+			"brainpoolP256t1.key",
+			"brainpoolP320t1.key",
+			"brainpoolP384t1.key",
+			"brainpoolP512t1.key"
+	})
+		// @formatter:on
+	void shouldNotParseUnsupportedEcPkcs8(String file) {
+		assertThatIllegalStateException()
+			.isThrownBy(() -> PemPrivateKeyParser.parse(read("org/springframework/boot/web/server/pkcs8/" + file)))
+			.withMessageContaining("Missing private key or unrecognized format");
+	}
+
+	@ParameterizedTest
+	// @formatter:off
+	@ValueSource(strings = {
+			"ed448.key",
+			"ed25519.key"
+	})
+		// @formatter:on
+	void shouldParseEdDsaPkcs8(String file) throws IOException {
+		PrivateKey privateKey = PemPrivateKeyParser.parse(read("org/springframework/boot/web/server/pkcs8/" + file));
 		assertThat(privateKey).isNotNull();
 		assertThat(privateKey.getFormat()).isEqualTo("PKCS#8");
 		assertThat(privateKey.getAlgorithm()).isEqualTo("EdDSA");
 	}
 
-	@Test
-	void parsePkcs8KeyFileWithEcdsa() throws Exception {
-		PrivateKey privateKey = PemPrivateKeyParser.parse(read("test-ec-key.pem"));
+	@ParameterizedTest
+	// @formatter:off
+	@ValueSource(strings = {
+			"x448.key",
+			"x25519.key"
+	})
+		// @formatter:on
+	void shouldParseXdhPkcs8(String file) throws IOException {
+		PrivateKey privateKey = PemPrivateKeyParser.parse(read("org/springframework/boot/web/server/pkcs8/" + file));
+		assertThat(privateKey).isNotNull();
+		assertThat(privateKey.getFormat()).isEqualTo("PKCS#8");
+		assertThat(privateKey.getAlgorithm()).isEqualTo("XDH");
+	}
+
+	@ParameterizedTest
+	// @formatter:off
+	@CsvSource({
+			"brainpoolP256r1.key,	brainpoolP256r1,	1.3.36.3.3.2.8.1.1.7",
+			"brainpoolP320r1.key,	brainpoolP320r1,	1.3.36.3.3.2.8.1.1.9",
+			"brainpoolP384r1.key,	brainpoolP384r1,	1.3.36.3.3.2.8.1.1.11",
+			"brainpoolP512r1.key,	brainpoolP512r1,	1.3.36.3.3.2.8.1.1.13",
+			"prime256v1.key,		secp256r1,			1.2.840.10045.3.1.7",
+			"secp224r1.key,			secp224r1,			1.3.132.0.33",
+			"secp256k1.key,			secp256k1,			1.3.132.0.10",
+			"secp256r1.key,			secp256r1,			1.2.840.10045.3.1.7",
+			"secp384r1.key,			secp384r1,			1.3.132.0.34",
+			"secp521r1.key,			secp521r1,			1.3.132.0.35"
+	})
+		// @formatter:on
+	void shouldParseEcSec1(String file, String curveName, String oid) throws IOException {
+		PrivateKey privateKey = PemPrivateKeyParser.parse(read("org/springframework/boot/web/server/sec1/" + file));
 		assertThat(privateKey).isNotNull();
 		assertThat(privateKey.getFormat()).isEqualTo("PKCS#8");
 		assertThat(privateKey.getAlgorithm()).isEqualTo("EC");
+		assertThat(privateKey).isInstanceOf(ECPrivateKey.class);
+		ECPrivateKey ecPrivateKey = (ECPrivateKey) privateKey;
+		assertThat(ecPrivateKey.getParams().toString()).contains(curveName).contains(oid);
+	}
+
+	@ParameterizedTest
+	// @formatter:off
+	@ValueSource(strings = {
+			"brainpoolP256t1.key",
+			"brainpoolP320t1.key",
+			"brainpoolP384t1.key",
+			"brainpoolP512t1.key"
+	})
+		// @formatter:on
+	void shouldNotParseUnsupportedEcSec1(String file) {
+		assertThatIllegalStateException()
+			.isThrownBy(() -> PemPrivateKeyParser.parse(read("org/springframework/boot/web/server/sec1/" + file)))
+			.withMessageContaining("Missing private key or unrecognized format");
 	}
 
 	@Test
@@ -84,45 +193,70 @@ void parseWithNonKeyTextWillThrowException() {
 		assertThatIllegalStateException().isThrownBy(() -> PemPrivateKeyParser.parse(read("test-banner.txt")));
 	}
 
-	@Test
-	void parsePkcs8EncryptedRsaKeyFile() throws Exception {
-		// created with:
-		// openssl genpkey -aes-256-cbc -algorithm RSA \
-		// -pkeyopt rsa_keygen_bits:4096 -out key-rsa-encrypted.key
-		PrivateKey privateKey = PemPrivateKeyParser.parse(read("ssl/pkcs8/key-rsa-encrypted.pem"), "test");
+	@ParameterizedTest
+	// @formatter:off
+	@CsvSource({
+			"dsa-aes-128-cbc.key,				DSA",
+			"rsa-aes-256-cbc.key,				RSA",
+			"prime256v1-aes-256-cbc.key,		EC",
+			"ed25519-aes-256-cbc.key,			EdDSA",
+			"x448-aes-256-cbc.key,				XDH"
+	})
+		// @formatter:on
+	void shouldParseEncryptedPkcs8(String file, String algorithm) throws IOException {
+		// Created with:
+		// openssl pkcs8 -topk8 -in <input file> -out <output file> -v2 <algorithm>
+		// -passout pass:test
+		// where <algorithm> is aes128 or aes256
+		PrivateKey privateKey = PemPrivateKeyParser.parse(read("org/springframework/boot/web/server/pkcs8/" + file),
+				"test");
 		assertThat(privateKey).isNotNull();
 		assertThat(privateKey.getFormat()).isEqualTo("PKCS#8");
-		assertThat(privateKey.getAlgorithm()).isEqualTo("RSA");
+		assertThat(privateKey.getAlgorithm()).isEqualTo(algorithm);
 	}
 
 	@Test
-	void parsePkcs8EncryptedEcKeyFile() throws Exception {
-		// created with:
-		// openssl genpkey -aes-256-cbc -algorithm EC \
-		// -pkeyopt ec_paramgen_curve:prime256v1 -out key-ec-encrypted.key
-		PrivateKey privateKey = PemPrivateKeyParser.parse(read("ssl/pkcs8/key-ec-encrypted.pem"), "test");
-		assertThat(privateKey).isNotNull();
-		assertThat(privateKey.getFormat()).isEqualTo("PKCS#8");
-		assertThat(privateKey.getAlgorithm()).isEqualTo("EC");
+	void shouldNotParseEncryptedPkcs8NotUsingAes() {
+		// Created with:
+		// openssl pkcs8 -topk8 -in rsa.key -out rsa-des-ede3-cbc.key -v2 des3 -passout
+		// pass:test
+		assertThatIllegalStateException()
+			.isThrownBy(() -> PemPrivateKeyParser
+				.parse(read("org/springframework/boot/web/server/pkcs8/rsa-des-ede3-cbc.key"), "test"))
+			.isInstanceOf(IllegalStateException.class)
+			.withMessageContaining("Error decrypting private key");
 	}
 
 	@Test
-	void failParsingPkcs1EncryptedKeyFile() throws Exception {
-		// created with:
-		// openssl genrsa -aes-256-cbc -out key-rsa-encrypted.pem
+	void shouldNotParseEncryptedPkcs8NotUsingPbkdf2() {
+		// Created with:
+		// openssl pkcs8 -topk8 -in rsa.key -out rsa-des-ede3-cbc.key -scrypt -passout
+		// pass:test
 		assertThatIllegalStateException()
-			.isThrownBy(() -> PemPrivateKeyParser.parse(read("ssl/pkcs1/key-rsa-encrypted.pem"), "test"))
-			.withMessageContaining("Unrecognized private key format");
+			.isThrownBy(() -> PemPrivateKeyParser
+				.parse(read("org/springframework/boot/web/server/pkcs8/rsa-scrypt.key"), "test"))
+			.withMessageContaining("Error decrypting private key");
 	}
 
 	@Test
-	void failParsingEcEncryptedKeyFile() throws Exception {
+	void shouldNotParseEncryptedSec1() {
 		// created with:
 		// openssl ecparam -genkey -name prime256v1 | openssl ec -aes-128-cbc -out
-		// key-ec-prime256v1-encrypted.pem
+		// prime256v1-aes-128-cbc.key
+		assertThatIllegalStateException()
+			.isThrownBy(() -> PemPrivateKeyParser
+				.parse(read("org/springframework/boot/web/server/sec1/prime256v1-aes-128-cbc.key"), "test"))
+			.withMessageContaining("Missing private key or unrecognized format");
+	}
+
+	@Test
+	void shouldNotParseEncryptedPkcs1() {
+		// created with:
+		// openssl genrsa -aes-256-cbc -out rsa-aes-256-cbc.key
 		assertThatIllegalStateException()
-			.isThrownBy(() -> PemPrivateKeyParser.parse(read("ssl/ec/key-ec-prime256v1-encrypted.pem"), "test"))
-			.withMessageContaining("Unrecognized private key format");
+			.isThrownBy(() -> PemPrivateKeyParser
+				.parse(read("org/springframework/boot/web/server/pkcs1/rsa-aes-256-cbc.key"), "test"))
+			.withMessageContaining("Missing private key or unrecognized format");
 	}
 
 	private String read(String path) throws IOException {
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreBundleTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreBundleTests.java
index 61f5c4983bd0..b34901931062 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreBundleTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreBundleTests.java
@@ -17,6 +17,9 @@
 package org.springframework.boot.ssl.pem;
 
 import java.security.KeyStore;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.util.List;
 import java.util.function.Consumer;
 
 import org.junit.jupiter.api.Test;
@@ -30,11 +33,70 @@
  *
  * @author Scott Frederick
  * @author Phillip Webb
+ * @author Moritz Halbritter
  */
 class PemSslStoreBundleTests {
 
+	private static final String CERTIFICATE = """
+			-----BEGIN CERTIFICATE-----
+			MIIDqzCCApOgAwIBAgIIFMqbpqvipw0wDQYJKoZIhvcNAQELBQAwbDELMAkGA1UE
+			BhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExEjAQBgNVBAcTCVBhbG8gQWx0bzEP
+			MA0GA1UEChMGVk13YXJlMQ8wDQYDVQQLEwZTcHJpbmcxEjAQBgNVBAMTCWxvY2Fs
+			aG9zdDAgFw0yMzA1MDUxMTI2NThaGA8yMTIzMDQxMTExMjY1OFowbDELMAkGA1UE
+			BhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExEjAQBgNVBAcTCVBhbG8gQWx0bzEP
+			MA0GA1UEChMGVk13YXJlMQ8wDQYDVQQLEwZTcHJpbmcxEjAQBgNVBAMTCWxvY2Fs
+			aG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAPwHWxoE3xjRmNdD
+			+m+e/aFlr5wEGQUdWSDD613OB1w7kqO/audEp3c6HxDB3GPcEL0amJwXgY6CQMYu
+			sythuZX/EZSc2HdilTBu/5T+mbdWe5JkKThpiA0RYeucQfKuB7zv4ypioa4wiR4D
+			nPsZXjg95OF8pCzYEssv8wT49v+M3ohWUgfF0FPlMFCSo0YVTuzB1mhDlWKq/jhQ
+			11WpTmk/dQX+l6ts6bYIcJt4uItG+a68a4FutuSjZdTAE0f5SOYRBpGH96mjLwEP
+			fW8ZjzvKb9g4R2kiuoPxvCDs1Y/8V2yvKqLyn5Tx9x/DjFmOi0DRK/TgELvNceCb
+			UDJmhXMCAwEAAaNPME0wHQYDVR0OBBYEFMBIGU1nwix5RS3O5hGLLoMdR1+NMCwG
+			A1UdEQQlMCOCCWxvY2FsaG9zdIcQAAAAAAAAAAAAAAAAAAAAAYcEfwAAATANBgkq
+			hkiG9w0BAQsFAAOCAQEAhepfJgTFvqSccsT97XdAZfvB0noQx5NSynRV8NWmeOld
+			hHP6Fzj6xCxHSYvlUfmX8fVP9EOAuChgcbbuTIVJBu60rnDT21oOOnp8FvNonCV6
+			gJ89sCL7wZ77dw2RKIeUFjXXEV3QJhx2wCOVmLxnJspDoKFIEVjfLyiPXKxqe/6b
+			dG8zzWDZ6z+M2JNCtVoOGpljpHqMPCmbDktncv6H3dDTZ83bmLj1nbpOU587gAJ8
+			fl1PiUDyPRIl2cnOJd+wCHKsyym/FL7yzk0OSEZ81I92LpGd/0b2Ld3m/bpe+C4Z
+			ILzLXTnC6AhrLcDc9QN/EO+BiCL52n7EplNLtSn1LQ==
+			-----END CERTIFICATE-----
+			""".strip();
+
+	private static final String PRIVATE_KEY = """
+			-----BEGIN PRIVATE KEY-----
+			MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQD8B1saBN8Y0ZjX
+			Q/pvnv2hZa+cBBkFHVkgw+tdzgdcO5Kjv2rnRKd3Oh8Qwdxj3BC9GpicF4GOgkDG
+			LrMrYbmV/xGUnNh3YpUwbv+U/pm3VnuSZCk4aYgNEWHrnEHyrge87+MqYqGuMIke
+			A5z7GV44PeThfKQs2BLLL/ME+Pb/jN6IVlIHxdBT5TBQkqNGFU7swdZoQ5Viqv44
+			UNdVqU5pP3UF/perbOm2CHCbeLiLRvmuvGuBbrbko2XUwBNH+UjmEQaRh/epoy8B
+			D31vGY87ym/YOEdpIrqD8bwg7NWP/Fdsryqi8p+U8fcfw4xZjotA0Sv04BC7zXHg
+			m1AyZoVzAgMBAAECggEAfEqiZqANaF+BqXQIb4Dw42ZTJzWsIyYYnPySOGZRoe5t
+			QJ03uwtULYv34xtANe1DQgd6SMyc46ugBzzjtprQ3ET5Jhn99U6kdcjf+dpf85dO
+			hOEppP0CkDNI39nleinSfh6uIOqYgt/D143/nqQhn8oCdSOzkbwT9KnWh1bC9T7I
+			vFjGfElvt1/xl88qYgrWgYLgXaencNGgiv/4/M0FNhiHEGsVC7SCu6kapC/WIQpE
+			5IdV+HR+tiLoGZhXlhqorY7QC4xKC4wwafVSiFxqDOQAuK+SMD4TCEv0Aop+c+SE
+			YBigVTmgVeJkjK7IkTEhKkAEFmRF5/5w+bZD9FhTNQKBgQD+4fNG1ChSU8RdizZT
+			5dPlDyAxpETSCEXFFVGtPPh2j93HDWn7XugNyjn5FylTH507QlabC+5wZqltdIjK
+			GRB5MIinQ9/nR2fuwGc9s+0BiSEwNOUB1MWm7wWL/JUIiKq6sTi6sJIfsYg79zco
+			qxl5WE94aoINx9Utq1cdWhwJTQKBgQD9IjPksd4Jprz8zMrGLzR8k1gqHyhv24qY
+			EJ7jiHKKAP6xllTUYwh1IBSL6w2j5lfZPpIkb4Jlk2KUoX6fN81pWkBC/fTBUSIB
+			EHM9bL51+yKEYUbGIy/gANuRbHXsWg3sjUsFTNPN4hGTFk3w2xChCyl/f5us8Lo8
+			Z633SNdpvwKBgQCGyDU9XzNzVZihXtx7wS0sE7OSjKtX5cf/UCbA1V0OVUWR3SYO
+			J0HPCQFfF0BjFHSwwYPKuaR9C8zMdLNhK5/qdh/NU7czNi9fsZ7moh7SkRFbzJzN
+			OxbKD9t/CzJEMQEXeF/nWTfsSpUgILqqZtAxuuFLbAcaAnJYlCKdAumQgQKBgQCK
+			mqjJh68pn7gJwGUjoYNe1xtGbSsqHI9F9ovZ0MPO1v6e5M7sQJHH+Fnnxzv/y8e8
+			d6tz8e73iX1IHymDKv35uuZHCGF1XOR+qrA/KQUc+vcKf21OXsP/JtkTRs1HLoRD
+			S5aRf2DWcfvniyYARSNU2xTM8GWgi2ueWbMDHUp+ZwKBgA/swC+K+Jg5DEWm6Sau
+			e6y+eC6S+SoXEKkI3wf7m9aKoZo0y+jh8Gas6gratlc181pSM8O3vZG0n19b493I
+			apCFomMLE56zEzvyzfpsNhFhk5MBMCn0LPyzX6MiynRlGyWIj0c99fbHI3pOMufP
+			WgmVLTZ8uDcSW1MbdUCwFSk5
+			-----END PRIVATE KEY-----
+			""".strip();
+
+	private static final char[] EMPTY_KEY_PASSWORD = new char[] {};
+
 	@Test
-	void whenNullStores() {
+	void createWithDetailsWhenNullStores() {
 		PemSslStoreDetails keyStoreDetails = null;
 		PemSslStoreDetails trustStoreDetails = null;
 		PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails);
@@ -44,7 +106,7 @@ void whenNullStores() {
 	}
 
 	@Test
-	void whenStoresHaveNoValues() {
+	void createWithDetailsWhenStoresHaveNoValues() {
 		PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate(null);
 		PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate(null);
 		PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails);
@@ -54,7 +116,7 @@ void whenStoresHaveNoValues() {
 	}
 
 	@Test
-	void whenHasKeyStoreDetailsCertAndKey() {
+	void createWithDetailsWhenHasKeyStoreDetailsCertAndKey() {
 		PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
 			.withPrivateKey("classpath:test-key.pem");
 		PemSslStoreDetails trustStoreDetails = null;
@@ -64,7 +126,7 @@ void whenHasKeyStoreDetailsCertAndKey() {
 	}
 
 	@Test
-	void whenHasKeyStoreDetailsCertAndEncryptedKey() {
+	void createWithDetailsWhenHasKeyStoreDetailsCertAndEncryptedKey() {
 		PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
 			.withPrivateKey("classpath:ssl/pkcs8/key-rsa-encrypted.pem")
 			.withPrivateKeyPassword("test");
@@ -75,17 +137,17 @@ void whenHasKeyStoreDetailsCertAndEncryptedKey() {
 	}
 
 	@Test
-	void whenHasKeyStoreDetailsAndTrustStoreDetailsWithoutKey() {
+	void createWithDetailsWhenHasKeyStoreDetailsAndTrustStoreDetailsWithoutKey() {
 		PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
 			.withPrivateKey("classpath:test-key.pem");
 		PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem");
 		PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails);
 		assertThat(bundle.getKeyStore()).satisfies(storeContainingCertAndKey("ssl"));
-		assertThat(bundle.getTrustStore()).satisfies(storeContainingCert("ssl-0"));
+		assertThat(bundle.getTrustStore()).satisfies(storeContainingCert("ssl"));
 	}
 
 	@Test
-	void whenHasKeyStoreDetailsAndTrustStoreDetails() {
+	void createWithDetailsWhenHasKeyStoreDetailsAndTrustStoreDetails() {
 		PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
 			.withPrivateKey("classpath:test-key.pem");
 		PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
@@ -96,7 +158,18 @@ void whenHasKeyStoreDetailsAndTrustStoreDetails() {
 	}
 
 	@Test
-	void whenHasKeyStoreDetailsAndTrustStoreDetailsAndAlias() {
+	void createWithDetailsWhenHasEmbeddedKeyStoreDetailsAndTrustStoreDetails() {
+		PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate(CERTIFICATE).withPrivateKey(PRIVATE_KEY);
+		PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate(CERTIFICATE)
+			.withPrivateKey(PRIVATE_KEY);
+		PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails);
+		assertThat(bundle.getKeyStore()).satisfies(storeContainingCertAndKey("ssl"));
+		assertThat(bundle.getTrustStore()).satisfies(storeContainingCertAndKey("ssl"));
+	}
+
+	@Test
+	@SuppressWarnings("removal")
+	void createWithDetailsWhenHasKeyStoreDetailsAndTrustStoreDetailsAndAlias() {
 		PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
 			.withPrivateKey("classpath:test-key.pem");
 		PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
@@ -107,7 +180,7 @@ void whenHasKeyStoreDetailsAndTrustStoreDetailsAndAlias() {
 	}
 
 	@Test
-	void whenHasStoreType() {
+	void createWithDetailsWhenHasStoreType() {
 		PemSslStoreDetails keyStoreDetails = new PemSslStoreDetails("PKCS12", "classpath:test-cert.pem",
 				"classpath:test-key.pem");
 		PemSslStoreDetails trustStoreDetails = new PemSslStoreDetails("PKCS12", "classpath:test-cert.pem",
@@ -117,6 +190,31 @@ void whenHasStoreType() {
 		assertThat(bundle.getTrustStore()).satisfies(storeContainingCertAndKey("PKCS12", "ssl"));
 	}
 
+	@Test
+	void createWithDetailsWhenHasKeyStoreDetailsAndTrustStoreDetailsAndKeyPassword() {
+		PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
+			.withPrivateKey("classpath:test-key.pem")
+			.withAlias("ksa")
+			.withPassword("kss");
+		PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
+			.withPrivateKey("classpath:test-key.pem")
+			.withAlias("tsa")
+			.withPassword("tss");
+		PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails);
+		assertThat(bundle.getKeyStore()).satisfies(storeContainingCertAndKey("ksa", "kss".toCharArray()));
+		assertThat(bundle.getTrustStore()).satisfies(storeContainingCertAndKey("tsa", "tss".toCharArray()));
+	}
+
+	@Test
+	void createWithPemSslStoreCreatesInstance() {
+		List<X509Certificate> certificates = PemContent.of(CERTIFICATE).getCertificates();
+		PrivateKey privateKey = PemContent.of(PRIVATE_KEY).getPrivateKey();
+		PemSslStore pemSslStore = PemSslStore.of(certificates, privateKey);
+		PemSslStoreBundle bundle = new PemSslStoreBundle(pemSslStore, pemSslStore);
+		assertThat(bundle.getKeyStore()).satisfies(storeContainingCertAndKey("ssl"));
+		assertThat(bundle.getTrustStore()).satisfies(storeContainingCertAndKey("ssl"));
+	}
+
 	private Consumer<KeyStore> storeContainingCert(String keyAlias) {
 		return storeContainingCert(KeyStore.getDefaultType(), keyAlias);
 	}
@@ -127,7 +225,7 @@ private Consumer<KeyStore> storeContainingCert(String keyStoreType, String keyAl
 			assertThat(keyStore.getType()).isEqualTo(keyStoreType);
 			assertThat(keyStore.containsAlias(keyAlias)).isTrue();
 			assertThat(keyStore.getCertificate(keyAlias)).isNotNull();
-			assertThat(keyStore.getKey(keyAlias, new char[] {})).isNull();
+			assertThat(keyStore.getKey(keyAlias, EMPTY_KEY_PASSWORD)).isNull();
 		});
 	}
 
@@ -136,12 +234,20 @@ private Consumer<KeyStore> storeContainingCertAndKey(String keyAlias) {
 	}
 
 	private Consumer<KeyStore> storeContainingCertAndKey(String keyStoreType, String keyAlias) {
+		return storeContainingCertAndKey(keyStoreType, keyAlias, EMPTY_KEY_PASSWORD);
+	}
+
+	private Consumer<KeyStore> storeContainingCertAndKey(String keyAlias, char[] keyPassword) {
+		return storeContainingCertAndKey(KeyStore.getDefaultType(), keyAlias, keyPassword);
+	}
+
+	private Consumer<KeyStore> storeContainingCertAndKey(String keyStoreType, String keyAlias, char[] keyPassword) {
 		return ThrowingConsumer.of((keyStore) -> {
 			assertThat(keyStore).isNotNull();
 			assertThat(keyStore.getType()).isEqualTo(keyStoreType);
 			assertThat(keyStore.containsAlias(keyAlias)).isTrue();
 			assertThat(keyStore.getCertificate(keyAlias)).isNotNull();
-			assertThat(keyStore.getKey(keyAlias, new char[] {})).isNotNull();
+			assertThat(keyStore.getKey(keyAlias, keyPassword)).isNotNull();
 		});
 	}
 
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreTests.java
new file mode 100644
index 000000000000..9e4ca402b14d
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreTests.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.ssl.pem;
+
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link PemSslStore}.
+ *
+ * @author Phillip Webb
+ */
+class PemSslStoreTests {
+
+	@Test
+	void withAliasReturnsStoreWithNewAlias() {
+		List<X509Certificate> certificates = List.of(mock(X509Certificate.class));
+		PrivateKey privateKey = mock(PrivateKey.class);
+		PemSslStore store = PemSslStore.of("type", "alias", "secret", certificates, privateKey);
+		assertThat(store.withAlias("newalias").alias()).isEqualTo("newalias");
+	}
+
+	@Test
+	void withPasswordReturnsStoreWithNewPassword() {
+		List<X509Certificate> certificates = List.of(mock(X509Certificate.class));
+		PrivateKey privateKey = mock(PrivateKey.class);
+		PemSslStore store = PemSslStore.of("type", "alias", "secret", certificates, privateKey);
+		assertThat(store.withPassword("newsecret").password()).isEqualTo("newsecret");
+	}
+
+	@Test
+	void ofWhenNullCertificatesThrowsException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> PemSslStore.of(null, null, null, null, null))
+			.withMessage("Certificates must not be empty");
+	}
+
+	@Test
+	void ofWhenEmptyCertificatesThrowsException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> PemSslStore.of(null, null, null, Collections.emptyList(), null))
+			.withMessage("Certificates must not be empty");
+	}
+
+	@Test
+	void ofReturnsPemSslStore() {
+		List<X509Certificate> certificates = List.of(mock(X509Certificate.class));
+		PrivateKey privateKey = mock(PrivateKey.class);
+		PemSslStore store = PemSslStore.of("type", "alias", "password", certificates, privateKey);
+		assertThat(store.type()).isEqualTo("type");
+		assertThat(store.alias()).isEqualTo("alias");
+		assertThat(store.password()).isEqualTo("password");
+		assertThat(store.certificates()).isEqualTo(certificates);
+		assertThat(store.privateKey()).isEqualTo(privateKey);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/system/JavaVersionTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/system/JavaVersionTests.java
index 0b0787b9feab..d87c1230f700 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/system/JavaVersionTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/system/JavaVersionTests.java
@@ -103,4 +103,10 @@ void currentJavaVersionTwenty() {
 		assertThat(JavaVersion.getJavaVersion()).isEqualTo(JavaVersion.TWENTY);
 	}
 
+	@Test
+	@EnabledOnJre(JRE.JAVA_21)
+	void currentJavaVersionTwentyOne() {
+		assertThat(JavaVersion.getJavaVersion()).isEqualTo(JavaVersion.TWENTY_ONE);
+	}
+
 }
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/SimpleAsyncTaskExecutorBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/SimpleAsyncTaskExecutorBuilderTests.java
new file mode 100644
index 000000000000..bd6f3607eb36
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/SimpleAsyncTaskExecutorBuilderTests.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.task;
+
+import java.util.Collections;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.api.condition.JRE;
+
+import org.springframework.boot.testsupport.assertj.SimpleAsyncTaskExecutorAssert;
+import org.springframework.core.task.SimpleAsyncTaskExecutor;
+import org.springframework.core.task.TaskDecorator;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+
+/**
+ * Tests for {@link SimpleAsyncTaskExecutorBuilder}.
+ *
+ * @author Stephane Nicoll
+ * @author Filip Hrisafov
+ * @author Moritz Halbritter
+ */
+class SimpleAsyncTaskExecutorBuilderTests {
+
+	private final SimpleAsyncTaskExecutorBuilder builder = new SimpleAsyncTaskExecutorBuilder();
+
+	@Test
+	void threadNamePrefixShouldApply() {
+		SimpleAsyncTaskExecutor executor = this.builder.threadNamePrefix("test-").build();
+		assertThat(executor.getThreadNamePrefix()).isEqualTo("test-");
+	}
+
+	@Test
+	@EnabledForJreRange(min = JRE.JAVA_21)
+	void virtualThreadsShouldApply() {
+		SimpleAsyncTaskExecutor executor = this.builder.virtualThreads(true).build();
+		SimpleAsyncTaskExecutorAssert.assertThat(executor).usesVirtualThreads();
+	}
+
+	@Test
+	void concurrencyLimitShouldApply() {
+		SimpleAsyncTaskExecutor executor = this.builder.concurrencyLimit(1).build();
+		assertThat(executor.getConcurrencyLimit()).isEqualTo(1);
+	}
+
+	@Test
+	void taskDecoratorShouldApply() {
+		TaskDecorator taskDecorator = mock(TaskDecorator.class);
+		SimpleAsyncTaskExecutor executor = this.builder.taskDecorator(taskDecorator).build();
+		assertThat(executor).extracting("taskDecorator").isSameAs(taskDecorator);
+	}
+
+	@Test
+	void customizersWhenCustomizersAreNullShouldThrowException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.builder.customizers((SimpleAsyncTaskExecutorCustomizer[]) null))
+			.withMessageContaining("Customizers must not be null");
+	}
+
+	@Test
+	void customizersCollectionWhenCustomizersAreNullShouldThrowException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.builder.customizers((Set<SimpleAsyncTaskExecutorCustomizer>) null))
+			.withMessageContaining("Customizers must not be null");
+	}
+
+	@Test
+	void customizersShouldApply() {
+		SimpleAsyncTaskExecutorCustomizer customizer = mock(SimpleAsyncTaskExecutorCustomizer.class);
+		SimpleAsyncTaskExecutor executor = this.builder.customizers(customizer).build();
+		then(customizer).should().customize(executor);
+	}
+
+	@Test
+	void customizersShouldBeAppliedLast() {
+		TaskDecorator taskDecorator = mock(TaskDecorator.class);
+		SimpleAsyncTaskExecutor executor = spy(new SimpleAsyncTaskExecutor());
+		this.builder.threadNamePrefix("test-")
+			.virtualThreads(true)
+			.concurrencyLimit(1)
+			.taskDecorator(taskDecorator)
+			.additionalCustomizers((taskExecutor) -> {
+				then(taskExecutor).should().setConcurrencyLimit(1);
+				then(taskExecutor).should().setVirtualThreads(true);
+				then(taskExecutor).should().setThreadNamePrefix("test-");
+				then(taskExecutor).should().setTaskDecorator(taskDecorator);
+			});
+		this.builder.configure(executor);
+	}
+
+	@Test
+	void customizersShouldReplaceExisting() {
+		SimpleAsyncTaskExecutorCustomizer customizer1 = mock(SimpleAsyncTaskExecutorCustomizer.class);
+		SimpleAsyncTaskExecutorCustomizer customizer2 = mock(SimpleAsyncTaskExecutorCustomizer.class);
+		SimpleAsyncTaskExecutor executor = this.builder.customizers(customizer1)
+			.customizers(Collections.singleton(customizer2))
+			.build();
+		then(customizer1).shouldHaveNoInteractions();
+		then(customizer2).should().customize(executor);
+	}
+
+	@Test
+	void additionalCustomizersWhenCustomizersAreNullShouldThrowException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.builder.additionalCustomizers((SimpleAsyncTaskExecutorCustomizer[]) null))
+			.withMessageContaining("Customizers must not be null");
+	}
+
+	@Test
+	void additionalCustomizersCollectionWhenCustomizersAreNullShouldThrowException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.builder.additionalCustomizers((Set<SimpleAsyncTaskExecutorCustomizer>) null))
+			.withMessageContaining("Customizers must not be null");
+	}
+
+	@Test
+	void additionalCustomizersShouldAddToExisting() {
+		SimpleAsyncTaskExecutorCustomizer customizer1 = mock(SimpleAsyncTaskExecutorCustomizer.class);
+		SimpleAsyncTaskExecutorCustomizer customizer2 = mock(SimpleAsyncTaskExecutorCustomizer.class);
+		SimpleAsyncTaskExecutor executor = this.builder.customizers(customizer1)
+			.additionalCustomizers(customizer2)
+			.build();
+		then(customizer1).should().customize(executor);
+		then(customizer2).should().customize(executor);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/SimpleAsyncTaskSchedulerBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/SimpleAsyncTaskSchedulerBuilderTests.java
new file mode 100644
index 000000000000..9b4e7da12c88
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/SimpleAsyncTaskSchedulerBuilderTests.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.task;
+
+import java.util.Collections;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.api.condition.JRE;
+
+import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+
+/**
+ * Tests for {@link SimpleAsyncTaskSchedulerBuilder}.
+ *
+ * @author Stephane Nicoll
+ * @author Moritz Halbritter
+ */
+class SimpleAsyncTaskSchedulerBuilderTests {
+
+	private final SimpleAsyncTaskSchedulerBuilder builder = new SimpleAsyncTaskSchedulerBuilder();
+
+	@Test
+	void threadNamePrefixShouldApply() {
+		SimpleAsyncTaskScheduler scheduler = this.builder.threadNamePrefix("test-").build();
+		assertThat(scheduler.getThreadNamePrefix()).isEqualTo("test-");
+	}
+
+	@Test
+	void concurrencyLimitShouldApply() {
+		SimpleAsyncTaskScheduler scheduler = this.builder.concurrencyLimit(1).build();
+		assertThat(scheduler.getConcurrencyLimit()).isEqualTo(1);
+	}
+
+	@Test
+	@EnabledForJreRange(min = JRE.JAVA_21)
+	void virtualThreadsShouldApply() {
+		SimpleAsyncTaskScheduler scheduler = this.builder.virtualThreads(true).build();
+		assertThat(scheduler).extracting("virtualThreadDelegate").isNotNull();
+	}
+
+	@Test
+	void customizersWhenCustomizersAreNullShouldThrowException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.builder.customizers((SimpleAsyncTaskSchedulerCustomizer[]) null))
+			.withMessageContaining("Customizers must not be null");
+	}
+
+	@Test
+	void customizersCollectionWhenCustomizersAreNullShouldThrowException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.builder.customizers((Set<SimpleAsyncTaskSchedulerCustomizer>) null))
+			.withMessageContaining("Customizers must not be null");
+	}
+
+	@Test
+	void customizersShouldApply() {
+		SimpleAsyncTaskSchedulerCustomizer customizer = mock(SimpleAsyncTaskSchedulerCustomizer.class);
+		SimpleAsyncTaskScheduler scheduler = this.builder.customizers(customizer).build();
+		then(customizer).should().customize(scheduler);
+	}
+
+	@Test
+	void customizersShouldBeAppliedLast() {
+		SimpleAsyncTaskScheduler scheduler = spy(new SimpleAsyncTaskScheduler());
+		this.builder.concurrencyLimit(1).threadNamePrefix("test-").additionalCustomizers((taskScheduler) -> {
+			then(taskScheduler).should().setConcurrencyLimit(1);
+			then(taskScheduler).should().setThreadNamePrefix("test-");
+		});
+		this.builder.configure(scheduler);
+	}
+
+	@Test
+	void customizersShouldReplaceExisting() {
+		SimpleAsyncTaskSchedulerCustomizer customizer1 = mock(SimpleAsyncTaskSchedulerCustomizer.class);
+		SimpleAsyncTaskSchedulerCustomizer customizer2 = mock(SimpleAsyncTaskSchedulerCustomizer.class);
+		SimpleAsyncTaskScheduler scheduler = this.builder.customizers(customizer1)
+			.customizers(Collections.singleton(customizer2))
+			.build();
+		then(customizer1).shouldHaveNoInteractions();
+		then(customizer2).should().customize(scheduler);
+	}
+
+	@Test
+	void additionalCustomizersWhenCustomizersAreNullShouldThrowException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.builder.additionalCustomizers((SimpleAsyncTaskSchedulerCustomizer[]) null))
+			.withMessageContaining("Customizers must not be null");
+	}
+
+	@Test
+	void additionalCustomizersCollectionWhenCustomizersAreNullShouldThrowException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.builder.additionalCustomizers((Set<SimpleAsyncTaskSchedulerCustomizer>) null))
+			.withMessageContaining("Customizers must not be null");
+	}
+
+	@Test
+	void additionalCustomizersShouldAddToExisting() {
+		SimpleAsyncTaskSchedulerCustomizer customizer1 = mock(SimpleAsyncTaskSchedulerCustomizer.class);
+		SimpleAsyncTaskSchedulerCustomizer customizer2 = mock(SimpleAsyncTaskSchedulerCustomizer.class);
+		SimpleAsyncTaskScheduler scheduler = this.builder.customizers(customizer1)
+			.additionalCustomizers(customizer2)
+			.build();
+		then(customizer1).should().customize(scheduler);
+		then(customizer2).should().customize(scheduler);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/TaskExecutorBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/TaskExecutorBuilderTests.java
index 52c205e8ed60..7df760b63f82 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/TaskExecutorBuilderTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/TaskExecutorBuilderTests.java
@@ -37,6 +37,7 @@
  * @author Stephane Nicoll
  * @author Filip Hrisafov
  */
+@SuppressWarnings("removal")
 class TaskExecutorBuilderTests {
 
 	private final TaskExecutorBuilder builder = new TaskExecutorBuilder();
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/TaskSchedulerBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/TaskSchedulerBuilderTests.java
index e07573910c16..095e8fda4edb 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/TaskSchedulerBuilderTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/TaskSchedulerBuilderTests.java
@@ -35,6 +35,7 @@
  *
  * @author Stephane Nicoll
  */
+@SuppressWarnings("removal")
 class TaskSchedulerBuilderTests {
 
 	private final TaskSchedulerBuilder builder = new TaskSchedulerBuilder();
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/ThreadPoolTaskExecutorBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/ThreadPoolTaskExecutorBuilderTests.java
new file mode 100644
index 000000000000..b57ffc6905a9
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/ThreadPoolTaskExecutorBuilderTests.java
@@ -0,0 +1,169 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.task;
+
+import java.time.Duration;
+import java.util.Collections;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.core.task.TaskDecorator;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+
+/**
+ * Tests for {@link ThreadPoolTaskExecutorBuilder}.
+ *
+ * @author Stephane Nicoll
+ * @author Filip Hrisafov
+ */
+class ThreadPoolTaskExecutorBuilderTests {
+
+	private final ThreadPoolTaskExecutorBuilder builder = new ThreadPoolTaskExecutorBuilder();
+
+	@Test
+	void poolSettingsShouldApply() {
+		ThreadPoolTaskExecutor executor = this.builder.queueCapacity(10)
+			.corePoolSize(4)
+			.maxPoolSize(8)
+			.allowCoreThreadTimeOut(true)
+			.keepAlive(Duration.ofMinutes(1))
+			.build();
+		assertThat(executor).hasFieldOrPropertyWithValue("queueCapacity", 10);
+		assertThat(executor.getCorePoolSize()).isEqualTo(4);
+		assertThat(executor.getMaxPoolSize()).isEqualTo(8);
+		assertThat(executor).hasFieldOrPropertyWithValue("allowCoreThreadTimeOut", true);
+		assertThat(executor.getKeepAliveSeconds()).isEqualTo(60);
+	}
+
+	@Test
+	void awaitTerminationShouldApply() {
+		ThreadPoolTaskExecutor executor = this.builder.awaitTermination(true).build();
+		assertThat(executor).hasFieldOrPropertyWithValue("waitForTasksToCompleteOnShutdown", true);
+	}
+
+	@Test
+	void awaitTerminationPeriodShouldApplyWithMillisecondPrecision() {
+		Duration period = Duration.ofMillis(50);
+		ThreadPoolTaskExecutor executor = this.builder.awaitTerminationPeriod(period).build();
+		assertThat(executor).hasFieldOrPropertyWithValue("awaitTerminationMillis", period.toMillis());
+	}
+
+	@Test
+	void threadNamePrefixShouldApply() {
+		ThreadPoolTaskExecutor executor = this.builder.threadNamePrefix("test-").build();
+		assertThat(executor.getThreadNamePrefix()).isEqualTo("test-");
+	}
+
+	@Test
+	void taskDecoratorShouldApply() {
+		TaskDecorator taskDecorator = mock(TaskDecorator.class);
+		ThreadPoolTaskExecutor executor = this.builder.taskDecorator(taskDecorator).build();
+		assertThat(executor).extracting("taskDecorator").isSameAs(taskDecorator);
+	}
+
+	@Test
+	void customizersWhenCustomizersAreNullShouldThrowException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.builder.customizers((ThreadPoolTaskExecutorCustomizer[]) null))
+			.withMessageContaining("Customizers must not be null");
+	}
+
+	@Test
+	void customizersCollectionWhenCustomizersAreNullShouldThrowException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.builder.customizers((Set<ThreadPoolTaskExecutorCustomizer>) null))
+			.withMessageContaining("Customizers must not be null");
+	}
+
+	@Test
+	void customizersShouldApply() {
+		ThreadPoolTaskExecutorCustomizer customizer = mock(ThreadPoolTaskExecutorCustomizer.class);
+		ThreadPoolTaskExecutor executor = this.builder.customizers(customizer).build();
+		then(customizer).should().customize(executor);
+	}
+
+	@Test
+	void customizersShouldBeAppliedLast() {
+		TaskDecorator taskDecorator = mock(TaskDecorator.class);
+		ThreadPoolTaskExecutor executor = spy(new ThreadPoolTaskExecutor());
+		this.builder.queueCapacity(10)
+			.corePoolSize(4)
+			.maxPoolSize(8)
+			.allowCoreThreadTimeOut(true)
+			.keepAlive(Duration.ofMinutes(1))
+			.awaitTermination(true)
+			.awaitTerminationPeriod(Duration.ofSeconds(30))
+			.threadNamePrefix("test-")
+			.taskDecorator(taskDecorator)
+			.additionalCustomizers((taskExecutor) -> {
+				then(taskExecutor).should().setQueueCapacity(10);
+				then(taskExecutor).should().setCorePoolSize(4);
+				then(taskExecutor).should().setMaxPoolSize(8);
+				then(taskExecutor).should().setAllowCoreThreadTimeOut(true);
+				then(taskExecutor).should().setKeepAliveSeconds(60);
+				then(taskExecutor).should().setWaitForTasksToCompleteOnShutdown(true);
+				then(taskExecutor).should().setAwaitTerminationSeconds(30);
+				then(taskExecutor).should().setThreadNamePrefix("test-");
+				then(taskExecutor).should().setTaskDecorator(taskDecorator);
+			});
+		this.builder.configure(executor);
+	}
+
+	@Test
+	void customizersShouldReplaceExisting() {
+		ThreadPoolTaskExecutorCustomizer customizer1 = mock(ThreadPoolTaskExecutorCustomizer.class);
+		ThreadPoolTaskExecutorCustomizer customizer2 = mock(ThreadPoolTaskExecutorCustomizer.class);
+		ThreadPoolTaskExecutor executor = this.builder.customizers(customizer1)
+			.customizers(Collections.singleton(customizer2))
+			.build();
+		then(customizer1).shouldHaveNoInteractions();
+		then(customizer2).should().customize(executor);
+	}
+
+	@Test
+	void additionalCustomizersWhenCustomizersAreNullShouldThrowException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.builder.additionalCustomizers((ThreadPoolTaskExecutorCustomizer[]) null))
+			.withMessageContaining("Customizers must not be null");
+	}
+
+	@Test
+	void additionalCustomizersCollectionWhenCustomizersAreNullShouldThrowException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.builder.additionalCustomizers((Set<ThreadPoolTaskExecutorCustomizer>) null))
+			.withMessageContaining("Customizers must not be null");
+	}
+
+	@Test
+	void additionalCustomizersShouldAddToExisting() {
+		ThreadPoolTaskExecutorCustomizer customizer1 = mock(ThreadPoolTaskExecutorCustomizer.class);
+		ThreadPoolTaskExecutorCustomizer customizer2 = mock(ThreadPoolTaskExecutorCustomizer.class);
+		ThreadPoolTaskExecutor executor = this.builder.customizers(customizer1)
+			.additionalCustomizers(customizer2)
+			.build();
+		then(customizer1).should().customize(executor);
+		then(customizer2).should().customize(executor);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/ThreadPoolTaskSchedulerBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/ThreadPoolTaskSchedulerBuilderTests.java
new file mode 100644
index 000000000000..11b4f15f49af
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/ThreadPoolTaskSchedulerBuilderTests.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.task;
+
+import java.time.Duration;
+import java.util.Collections;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+
+/**
+ * Tests for {@link ThreadPoolTaskSchedulerBuilder}.
+ *
+ * @author Stephane Nicoll
+ */
+class ThreadPoolTaskSchedulerBuilderTests {
+
+	private final ThreadPoolTaskSchedulerBuilder builder = new ThreadPoolTaskSchedulerBuilder();
+
+	@Test
+	void poolSettingsShouldApply() {
+		ThreadPoolTaskScheduler scheduler = this.builder.poolSize(4).build();
+		assertThat(scheduler.getPoolSize()).isEqualTo(4);
+	}
+
+	@Test
+	void awaitTerminationShouldApply() {
+		ThreadPoolTaskScheduler executor = this.builder.awaitTermination(true).build();
+		assertThat(executor).hasFieldOrPropertyWithValue("waitForTasksToCompleteOnShutdown", true);
+	}
+
+	@Test
+	void awaitTerminationPeriodShouldApply() {
+		Duration period = Duration.ofMinutes(1);
+		ThreadPoolTaskScheduler executor = this.builder.awaitTerminationPeriod(period).build();
+		assertThat(executor).hasFieldOrPropertyWithValue("awaitTerminationMillis", period.toMillis());
+	}
+
+	@Test
+	void threadNamePrefixShouldApply() {
+		ThreadPoolTaskScheduler scheduler = this.builder.threadNamePrefix("test-").build();
+		assertThat(scheduler.getThreadNamePrefix()).isEqualTo("test-");
+	}
+
+	@Test
+	void customizersWhenCustomizersAreNullShouldThrowException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.builder.customizers((ThreadPoolTaskSchedulerCustomizer[]) null))
+			.withMessageContaining("Customizers must not be null");
+	}
+
+	@Test
+	void customizersCollectionWhenCustomizersAreNullShouldThrowException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.builder.customizers((Set<ThreadPoolTaskSchedulerCustomizer>) null))
+			.withMessageContaining("Customizers must not be null");
+	}
+
+	@Test
+	void customizersShouldApply() {
+		ThreadPoolTaskSchedulerCustomizer customizer = mock(ThreadPoolTaskSchedulerCustomizer.class);
+		ThreadPoolTaskScheduler scheduler = this.builder.customizers(customizer).build();
+		then(customizer).should().customize(scheduler);
+	}
+
+	@Test
+	void customizersShouldBeAppliedLast() {
+		ThreadPoolTaskScheduler scheduler = spy(new ThreadPoolTaskScheduler());
+		this.builder.poolSize(4).threadNamePrefix("test-").additionalCustomizers((taskScheduler) -> {
+			then(taskScheduler).should().setPoolSize(4);
+			then(taskScheduler).should().setThreadNamePrefix("test-");
+		});
+		this.builder.configure(scheduler);
+	}
+
+	@Test
+	void customizersShouldReplaceExisting() {
+		ThreadPoolTaskSchedulerCustomizer customizer1 = mock(ThreadPoolTaskSchedulerCustomizer.class);
+		ThreadPoolTaskSchedulerCustomizer customizer2 = mock(ThreadPoolTaskSchedulerCustomizer.class);
+		ThreadPoolTaskScheduler scheduler = this.builder.customizers(customizer1)
+			.customizers(Collections.singleton(customizer2))
+			.build();
+		then(customizer1).shouldHaveNoInteractions();
+		then(customizer2).should().customize(scheduler);
+	}
+
+	@Test
+	void additionalCustomizersWhenCustomizersAreNullShouldThrowException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.builder.additionalCustomizers((ThreadPoolTaskSchedulerCustomizer[]) null))
+			.withMessageContaining("Customizers must not be null");
+	}
+
+	@Test
+	void additionalCustomizersCollectionWhenCustomizersAreNullShouldThrowException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.builder.additionalCustomizers((Set<ThreadPoolTaskSchedulerCustomizer>) null))
+			.withMessageContaining("Customizers must not be null");
+	}
+
+	@Test
+	void additionalCustomizersShouldAddToExisting() {
+		ThreadPoolTaskSchedulerCustomizer customizer1 = mock(ThreadPoolTaskSchedulerCustomizer.class);
+		ThreadPoolTaskSchedulerCustomizer customizer2 = mock(ThreadPoolTaskSchedulerCustomizer.class);
+		ThreadPoolTaskScheduler scheduler = this.builder.customizers(customizer1)
+			.additionalCustomizers(customizer2)
+			.build();
+		then(customizer1).should().customize(scheduler);
+		then(customizer2).should().customize(scheduler);
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/util/LambdaSafeTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/util/LambdaSafeTests.java
index 0c3c50f5319a..0e890533fa46 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/util/LambdaSafeTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/util/LambdaSafeTests.java
@@ -29,7 +29,7 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
-import static org.junit.jupiter.api.Assertions.fail;
+import static org.assertj.core.api.Assertions.fail;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.contains;
 import static org.mockito.BDDMockito.given;
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/validation/MessageSourceMessageInterpolatorIntegrationTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/validation/MessageSourceMessageInterpolatorIntegrationTests.java
index bf1f4b475936..b900be613d14 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/validation/MessageSourceMessageInterpolatorIntegrationTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/validation/MessageSourceMessageInterpolatorIntegrationTests.java
@@ -32,7 +32,7 @@
 import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThatException;
 
 /**
  * Integration tests for {@link MessageSourceMessageInterpolator}.
@@ -85,8 +85,8 @@ void blank() {
 
 	@Test
 	void recursion() {
-		assertThatThrownBy(() -> validate("recursion"))
-			.hasStackTraceContaining("Circular reference '{recursion -> middle -> recursion}'");
+		assertThatException().isThrownBy(() -> validate("recursion"))
+			.withStackTraceContaining("Circular reference '{recursion -> middle -> recursion}'");
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesHttpComponentsTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesHttpComponentsTests.java
index 98b9095afec7..b8df330e7790 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesHttpComponentsTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesHttpComponentsTests.java
@@ -39,7 +39,7 @@ class ClientHttpRequestFactoriesHttpComponentsTests
 
 	@Override
 	protected long connectTimeout(HttpComponentsClientHttpRequestFactory requestFactory) {
-		return (int) ReflectionTestUtils.getField(requestFactory, "connectTimeout");
+		return (long) ReflectionTestUtils.getField(requestFactory, "connectTimeout");
 	}
 
 	@Override
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesJettyTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesJettyTests.java
new file mode 100644
index 000000000000..27bc1800477c
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesJettyTests.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.web.client;
+
+import org.eclipse.jetty.client.HttpClient;
+
+import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
+import org.springframework.http.client.JettyClientHttpRequestFactory;
+import org.springframework.test.util.ReflectionTestUtils;
+
+/**
+ * Tests for {@link ClientHttpRequestFactories} when Jetty is the predominant HTTP client.
+ *
+ * @author Arjen Poutsma
+ */
+@ClassPathExclusions({ "httpclient5-*.jar", "okhttp-*.jar" })
+class ClientHttpRequestFactoriesJettyTests
+		extends AbstractClientHttpRequestFactoriesTests<JettyClientHttpRequestFactory> {
+
+	ClientHttpRequestFactoriesJettyTests() {
+		super(JettyClientHttpRequestFactory.class);
+	}
+
+	@Override
+	protected long connectTimeout(JettyClientHttpRequestFactory requestFactory) {
+		return ((HttpClient) ReflectionTestUtils.getField(requestFactory, "httpClient")).getConnectTimeout();
+	}
+
+	@Override
+	protected long readTimeout(JettyClientHttpRequestFactory requestFactory) {
+		return (long) ReflectionTestUtils.getField(requestFactory, "readTimeout");
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesOkHttp3Tests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesOkHttp3Tests.java
index 59e4c3144474..ad414d38f298 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesOkHttp3Tests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesOkHttp3Tests.java
@@ -27,7 +27,6 @@
 import org.springframework.test.util.ReflectionTestUtils;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
 
 /**
  * Tests for {@link ClientHttpRequestFactories} when OkHttp 3 is the predominant HTTP
@@ -36,7 +35,9 @@
  * @author Andy Wilkinson
  */
 @ClassPathOverrides("com.squareup.okhttp3:okhttp:3.14.9")
-@ClassPathExclusions("httpclient5-*.jar")
+@ClassPathExclusions({ "httpclient5-*.jar", "jetty-client-*.jar" })
+@Deprecated(since = "3.2.0")
+@SuppressWarnings("removal")
 class ClientHttpRequestFactoriesOkHttp3Tests
 		extends AbstractClientHttpRequestFactoriesTests<OkHttp3ClientHttpRequestFactory> {
 
@@ -50,12 +51,6 @@ void okHttp3IsBeingUsed() {
 			.startsWith("okhttp-3.");
 	}
 
-	@Test
-	void getFailsWhenBufferRequestBodyIsEnabled() {
-		assertThatIllegalStateException().isThrownBy(() -> ClientHttpRequestFactories
-			.get(ClientHttpRequestFactorySettings.DEFAULTS.withBufferRequestBody(true)));
-	}
-
 	@Override
 	protected long connectTimeout(OkHttp3ClientHttpRequestFactory requestFactory) {
 		return ((OkHttpClient) ReflectionTestUtils.getField(requestFactory, "client")).connectTimeoutMillis();
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesOkHttp4Tests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesOkHttp4Tests.java
index 13158708f54d..f6241c7a413e 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesOkHttp4Tests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesOkHttp4Tests.java
@@ -26,7 +26,6 @@
 import org.springframework.test.util.ReflectionTestUtils;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
 
 /**
  * Tests for {@link ClientHttpRequestFactories} when OkHttp 4 is the predominant HTTP
@@ -34,7 +33,9 @@
  *
  * @author Andy Wilkinson
  */
-@ClassPathExclusions("httpclient5-*.jar")
+@ClassPathExclusions({ "httpclient5-*.jar", "jetty-client-*.jar" })
+@Deprecated(since = "3.2.0")
+@SuppressWarnings("removal")
 class ClientHttpRequestFactoriesOkHttp4Tests
 		extends AbstractClientHttpRequestFactoriesTests<OkHttp3ClientHttpRequestFactory> {
 
@@ -48,12 +49,6 @@ void okHttp4IsBeingUsed() {
 			.startsWith("okhttp-4.");
 	}
 
-	@Test
-	void getFailsWhenBufferRequestBodyIsEnabled() {
-		assertThatIllegalStateException().isThrownBy(() -> ClientHttpRequestFactories
-			.get(ClientHttpRequestFactorySettings.DEFAULTS.withBufferRequestBody(true)));
-	}
-
 	@Override
 	protected long connectTimeout(OkHttp3ClientHttpRequestFactory requestFactory) {
 		return ((OkHttpClient) ReflectionTestUtils.getField(requestFactory, "client")).connectTimeoutMillis();
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesRuntimeHintsTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesRuntimeHintsTests.java
index bb143d3297bb..1fc41a8e29e3 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesRuntimeHintsTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesRuntimeHintsTests.java
@@ -26,6 +26,7 @@
 import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
 import org.springframework.http.client.AbstractClientHttpRequestFactoryWrapper;
 import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
+import org.springframework.http.client.JettyClientHttpRequestFactory;
 import org.springframework.http.client.OkHttp3ClientHttpRequestFactory;
 import org.springframework.http.client.SimpleClientHttpRequestFactory;
 import org.springframework.util.ReflectionUtils;
@@ -59,15 +60,11 @@ void shouldRegisterHttpComponentHints() {
 		assertThat(reflection
 			.onMethod(method(HttpComponentsClientHttpRequestFactory.class, "setConnectTimeout", int.class)))
 			.accepts(hints);
-		assertThat(
-				reflection.onMethod(method(HttpComponentsClientHttpRequestFactory.class, "setReadTimeout", int.class)))
-			.accepts(hints);
-		assertThat(reflection
-			.onMethod(method(HttpComponentsClientHttpRequestFactory.class, "setBufferRequestBody", boolean.class)))
-			.accepts(hints);
 	}
 
 	@Test
+	@Deprecated(since = "3.2.0")
+	@SuppressWarnings("removal")
 	void shouldRegisterOkHttpHints() {
 		RuntimeHints hints = new RuntimeHints();
 		new ClientHttpRequestFactoriesRuntimeHints().registerHints(hints, getClass().getClassLoader());
@@ -79,6 +76,17 @@ void shouldRegisterOkHttpHints() {
 		assertThat(hints.reflection().getTypeHint(OkHttp3ClientHttpRequestFactory.class).methods()).hasSize(2);
 	}
 
+	@Test
+	void shouldRegisterJettyClientHints() {
+		RuntimeHints hints = new RuntimeHints();
+		new ClientHttpRequestFactoriesRuntimeHints().registerHints(hints, getClass().getClassLoader());
+		ReflectionHintsPredicates reflection = RuntimeHintsPredicates.reflection();
+		assertThat(reflection.onMethod(method(JettyClientHttpRequestFactory.class, "setConnectTimeout", int.class)))
+			.accepts(hints);
+		assertThat(reflection.onMethod(method(JettyClientHttpRequestFactory.class, "setReadTimeout", long.class)))
+			.accepts(hints);
+	}
+
 	@Test
 	void shouldRegisterSimpleHttpHints() {
 		RuntimeHints hints = new RuntimeHints();
@@ -88,9 +96,6 @@ void shouldRegisterSimpleHttpHints() {
 			.accepts(hints);
 		assertThat(reflection.onMethod(method(SimpleClientHttpRequestFactory.class, "setReadTimeout", int.class)))
 			.accepts(hints);
-		assertThat(reflection
-			.onMethod(method(SimpleClientHttpRequestFactory.class, "setBufferRequestBody", boolean.class)))
-			.accepts(hints);
 	}
 
 	private static Method method(Class<?> target, String name, Class<?>... parameterTypes) {
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesSimpleTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesSimpleTests.java
index a9e75aa6496b..bb4425484511 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesSimpleTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesSimpleTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -26,7 +26,7 @@
  *
  * @author Andy Wilkinson
  */
-@ClassPathExclusions({ "httpclient5-*.jar", "okhttp-*.jar" })
+@ClassPathExclusions({ "httpclient5-*.jar", "jetty-client-*.jar", "okhttp-*.jar" })
 class ClientHttpRequestFactoriesSimpleTests
 		extends AbstractClientHttpRequestFactoriesTests<SimpleClientHttpRequestFactory> {
 
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesTests.java
index 34d591efb33d..ff27a20d03a5 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesTests.java
@@ -27,6 +27,7 @@
 import org.springframework.http.client.ClientHttpRequest;
 import org.springframework.http.client.ClientHttpRequestFactory;
 import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
+import org.springframework.http.client.JdkClientHttpRequestFactory;
 import org.springframework.http.client.OkHttp3ClientHttpRequestFactory;
 import org.springframework.http.client.SimpleClientHttpRequestFactory;
 
@@ -69,12 +70,21 @@ void getOfHttpComponentsFactoryReturnsHttpComponentsFactory() {
 	}
 
 	@Test
+	@Deprecated(since = "3.2.0")
+	@SuppressWarnings("removal")
 	void getOfOkHttpFactoryReturnsOkHttpFactory() {
 		ClientHttpRequestFactory requestFactory = ClientHttpRequestFactories.get(OkHttp3ClientHttpRequestFactory.class,
 				ClientHttpRequestFactorySettings.DEFAULTS);
 		assertThat(requestFactory).isInstanceOf(OkHttp3ClientHttpRequestFactory.class);
 	}
 
+	@Test
+	void getOfJdkFactoryReturnsJdkFactory() {
+		ClientHttpRequestFactory requestFactory = ClientHttpRequestFactories.get(JdkClientHttpRequestFactory.class,
+				ClientHttpRequestFactorySettings.DEFAULTS);
+		assertThat(requestFactory).isInstanceOf(JdkClientHttpRequestFactory.class);
+	}
+
 	@Test
 	void getOfUnknownTypeCreatesFactory() {
 		ClientHttpRequestFactory requestFactory = ClientHttpRequestFactories.get(TestClientHttpRequestFactory.class,
@@ -100,14 +110,6 @@ void getOfUnknownTypeWithReadTimeoutCreatesFactoryAndConfiguresReadTimeout() {
 			.isEqualTo(Duration.ofSeconds(90).toMillis());
 	}
 
-	@Test
-	void getOfUnknownTypeWithBodyBufferingCreatesFactoryAndConfiguresBodyBuffering() {
-		ClientHttpRequestFactory requestFactory = ClientHttpRequestFactories.get(TestClientHttpRequestFactory.class,
-				ClientHttpRequestFactorySettings.DEFAULTS.withBufferRequestBody(true));
-		assertThat(requestFactory).isInstanceOf(TestClientHttpRequestFactory.class);
-		assertThat(((TestClientHttpRequestFactory) requestFactory).bufferRequestBody).isTrue();
-	}
-
 	@Test
 	void getOfUnconfigurableTypeWithConnectTimeoutThrows() {
 		assertThatIllegalStateException()
@@ -124,14 +126,6 @@ void getOfUnconfigurableTypeWithReadTimeoutThrows() {
 			.withMessageContaining("suitable setReadTimeout method");
 	}
 
-	@Test
-	void getOfUnconfigurableTypeWithBodyBufferingThrows() {
-		assertThatIllegalStateException()
-			.isThrownBy(() -> ClientHttpRequestFactories.get(UnconfigurableClientHttpRequestFactory.class,
-					ClientHttpRequestFactorySettings.DEFAULTS.withBufferRequestBody(true)))
-			.withMessageContaining("suitable setBufferRequestBody method");
-	}
-
 	@Test
 	void getOfTypeWithDeprecatedConnectTimeoutThrowsWithConnectTimeout() {
 		assertThatIllegalStateException()
@@ -148,14 +142,6 @@ void getOfTypeWithDeprecatedReadTimeoutThrowsWithReadTimeout() {
 			.withMessageContaining("setReadTimeout method marked as deprecated");
 	}
 
-	@Test
-	void getOfTypeWithDeprecatedBufferRequestBodyThrowsWithBufferRequestBody() {
-		assertThatIllegalStateException()
-			.isThrownBy(() -> ClientHttpRequestFactories.get(DeprecatedMethodsClientHttpRequestFactory.class,
-					ClientHttpRequestFactorySettings.DEFAULTS.withBufferRequestBody(false)))
-			.withMessageContaining("setBufferRequestBody method marked as deprecated");
-	}
-
 	@Test
 	void connectTimeoutCanBeConfiguredOnAWrappedRequestFactory() {
 		SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
@@ -176,25 +162,12 @@ void readTimeoutCanBeConfiguredOnAWrappedRequestFactory() {
 		assertThat(requestFactory).hasFieldOrPropertyWithValue("readTimeout", 1234);
 	}
 
-	@Test
-	void bufferRequestBodyCanBeConfiguredOnAWrappedRequestFactory() {
-		SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
-		assertThat(requestFactory).hasFieldOrPropertyWithValue("bufferRequestBody", true);
-		BufferingClientHttpRequestFactory result = ClientHttpRequestFactories.get(
-				() -> new BufferingClientHttpRequestFactory(requestFactory),
-				ClientHttpRequestFactorySettings.DEFAULTS.withBufferRequestBody(false));
-		assertThat(result).extracting("requestFactory").isSameAs(requestFactory);
-		assertThat(requestFactory).hasFieldOrPropertyWithValue("bufferRequestBody", false);
-	}
-
 	public static class TestClientHttpRequestFactory implements ClientHttpRequestFactory {
 
 		private int connectTimeout;
 
 		private int readTimeout;
 
-		private boolean bufferRequestBody;
-
 		@Override
 		public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) throws IOException {
 			throw new UnsupportedOperationException();
@@ -208,10 +181,6 @@ public void setReadTimeout(int timeout) {
 			this.readTimeout = timeout;
 		}
 
-		public void setBufferRequestBody(boolean bufferRequestBody) {
-			this.bufferRequestBody = bufferRequestBody;
-		}
-
 	}
 
 	public static class UnconfigurableClientHttpRequestFactory implements ClientHttpRequestFactory {
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactorySettingsTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactorySettingsTests.java
index 8103a3ae6973..c9145b7dd266 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactorySettingsTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactorySettingsTests.java
@@ -39,7 +39,6 @@ void defaultsHasNullValues() {
 		ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS;
 		assertThat(settings.connectTimeout()).isNull();
 		assertThat(settings.readTimeout()).isNull();
-		assertThat(settings.bufferRequestBody()).isNull();
 		assertThat(settings.sslBundle()).isNull();
 	}
 
@@ -49,7 +48,6 @@ void withConnectTimeoutReturnsInstanceWithUpdatedConnectionTimeout() {
 			.withConnectTimeout(ONE_SECOND);
 		assertThat(settings.connectTimeout()).isEqualTo(ONE_SECOND);
 		assertThat(settings.readTimeout()).isNull();
-		assertThat(settings.bufferRequestBody()).isNull();
 		assertThat(settings.sslBundle()).isNull();
 	}
 
@@ -59,17 +57,6 @@ void withReadTimeoutReturnsInstanceWithUpdatedReadTimeout() {
 			.withReadTimeout(ONE_SECOND);
 		assertThat(settings.connectTimeout()).isNull();
 		assertThat(settings.readTimeout()).isEqualTo(ONE_SECOND);
-		assertThat(settings.bufferRequestBody()).isNull();
-		assertThat(settings.sslBundle()).isNull();
-	}
-
-	@Test
-	void withBufferRequestBodyReturnsInstanceWithUpdatedBufferRequestBody() {
-		ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS
-			.withBufferRequestBody(true);
-		assertThat(settings.connectTimeout()).isNull();
-		assertThat(settings.readTimeout()).isNull();
-		assertThat(settings.bufferRequestBody()).isTrue();
 		assertThat(settings.sslBundle()).isNull();
 	}
 
@@ -79,7 +66,6 @@ void withSslBundleReturnsInstanceWithUpdatedSslBundle() {
 		ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS.withSslBundle(sslBundle);
 		assertThat(settings.connectTimeout()).isNull();
 		assertThat(settings.readTimeout()).isNull();
-		assertThat(settings.bufferRequestBody()).isNull();
 		assertThat(settings.sslBundle()).isSameAs(sslBundle);
 	}
 
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactoryTests.java
index 09ecc2c3cef7..336ed38ab011 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactoryTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactoryTests.java
@@ -22,6 +22,7 @@
 import java.util.Arrays;
 
 import org.awaitility.Awaitility;
+import org.eclipse.jetty.server.ConnectionLimit;
 import org.eclipse.jetty.server.Connector;
 import org.eclipse.jetty.server.Server;
 import org.eclipse.jetty.server.ServerConnector;
@@ -29,10 +30,9 @@
 import org.junit.jupiter.api.Test;
 import org.mockito.InOrder;
 
-import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides;
+import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactory;
 import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactoryTests;
 import org.springframework.boot.web.server.Shutdown;
-import org.springframework.http.client.reactive.JettyResourceFactory;
 import org.springframework.http.server.reactive.HttpHandler;
 import org.springframework.web.reactive.function.client.WebClient;
 
@@ -47,8 +47,8 @@
  *
  * @author Brian Clozel
  * @author Madhura Bhave
+ * @author Moritz Halbritter
  */
-@Servlet5ClassPathOverrides
 class JettyReactiveWebServerFactoryTests extends AbstractReactiveWebServerFactoryTests {
 
 	@Override
@@ -58,7 +58,8 @@ protected JettyReactiveWebServerFactory getFactory() {
 
 	@Test
 	@Override
-	@Disabled("Jetty 11 does not support User-Agent-based compression")
+	@Disabled("Jetty 12 does not support User-Agent-based compression")
+	// TODO Is this true with Jetty 12?
 	protected void noCompressionForUserAgent() {
 
 	}
@@ -111,20 +112,6 @@ void useForwardedHeaders() {
 		assertForwardHeaderIsUsed(factory);
 	}
 
-	@Test
-	void useServerResources() throws Exception {
-		JettyResourceFactory resourceFactory = new JettyResourceFactory();
-		resourceFactory.afterPropertiesSet();
-		JettyReactiveWebServerFactory factory = getFactory();
-		factory.setResourceFactory(resourceFactory);
-		JettyWebServer webServer = (JettyWebServer) factory.getWebServer(new EchoHandler());
-		webServer.start();
-		Connector connector = webServer.getServer().getConnectors()[0];
-		assertThat(connector.getByteBufferPool()).isEqualTo(resourceFactory.getByteBufferPool());
-		assertThat(connector.getExecutor()).isEqualTo(resourceFactory.getExecutor());
-		assertThat(connector.getScheduler()).isEqualTo(resourceFactory.getScheduler());
-	}
-
 	@Test
 	void whenServerIsShuttingDownGracefullyThenNewConnectionsCannotBeMade() {
 		JettyReactiveWebServerFactory factory = getFactory();
@@ -148,4 +135,29 @@ void whenServerIsShuttingDownGracefullyThenNewConnectionsCannotBeMade() {
 		this.webServer.stop();
 	}
 
+	@Test
+	void shouldApplyMaxConnections() {
+		JettyReactiveWebServerFactory factory = getFactory();
+		factory.setMaxConnections(1);
+		this.webServer = factory.getWebServer(new EchoHandler());
+		Server server = ((JettyWebServer) this.webServer).getServer();
+		ConnectionLimit connectionLimit = server.getBean(ConnectionLimit.class);
+		assertThat(connectionLimit).isNotNull();
+		assertThat(connectionLimit.getMaxConnections()).isOne();
+	}
+
+	@Override
+	protected String startedLogMessage() {
+		return ((JettyWebServer) this.webServer).getStartedLogMessage();
+	}
+
+	@Override
+	protected void addConnector(int port, AbstractReactiveWebServerFactory factory) {
+		((JettyReactiveWebServerFactory) factory).addServerCustomizers((server) -> {
+			ServerConnector connector = new ServerConnector(server);
+			connector.setPort(port);
+			server.addConnector(connector);
+		});
+	}
+
 }
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactoryTests.java
index 63fe24022315..dbb73701e06c 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactoryTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactoryTests.java
@@ -40,28 +40,26 @@
 import org.apache.hc.core5.http.HttpResponse;
 import org.apache.jasper.servlet.JspServlet;
 import org.awaitility.Awaitility;
+import org.eclipse.jetty.ee10.servlet.ErrorPageErrorHandler;
+import org.eclipse.jetty.ee10.servlet.ServletHolder;
+import org.eclipse.jetty.ee10.webapp.AbstractConfiguration;
+import org.eclipse.jetty.ee10.webapp.ClassMatcher;
+import org.eclipse.jetty.ee10.webapp.Configuration;
+import org.eclipse.jetty.ee10.webapp.WebAppContext;
+import org.eclipse.jetty.server.ConnectionLimit;
 import org.eclipse.jetty.server.Connector;
 import org.eclipse.jetty.server.Handler;
 import org.eclipse.jetty.server.Server;
 import org.eclipse.jetty.server.ServerConnector;
 import org.eclipse.jetty.server.SslConnectionFactory;
-import org.eclipse.jetty.server.handler.HandlerCollection;
-import org.eclipse.jetty.server.handler.HandlerWrapper;
-import org.eclipse.jetty.servlet.ErrorPageErrorHandler;
-import org.eclipse.jetty.servlet.ServletHolder;
 import org.eclipse.jetty.util.ssl.SslContextFactory;
 import org.eclipse.jetty.util.thread.QueuedThreadPool;
 import org.eclipse.jetty.util.thread.ThreadPool;
-import org.eclipse.jetty.webapp.AbstractConfiguration;
-import org.eclipse.jetty.webapp.ClassMatcher;
-import org.eclipse.jetty.webapp.Configuration;
-import org.eclipse.jetty.webapp.WebAppContext;
 import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 import org.mockito.InOrder;
 
 import org.springframework.boot.testsupport.system.CapturedOutput;
-import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides;
 import org.springframework.boot.web.server.Compression;
 import org.springframework.boot.web.server.GracefulShutdownResult;
 import org.springframework.boot.web.server.PortInUseException;
@@ -86,13 +84,22 @@
  * @author Dave Syer
  * @author Andy Wilkinson
  * @author Henri Kerola
+ * @author Moritz Halbritter
  */
-@Servlet5ClassPathOverrides
 class JettyServletWebServerFactoryTests extends AbstractServletWebServerFactoryTests {
 
 	@Override
 	protected JettyServletWebServerFactory getFactory() {
-		return new JettyServletWebServerFactory(0);
+		JettyServletWebServerFactory factory = new JettyServletWebServerFactory(0);
+		factory.addServerCustomizers((server) -> {
+			for (Connector connector : server.getConnectors()) {
+				if (connector instanceof ServerConnector serverConnector) {
+					// TODO Set the shutdown idle timeout in main code?
+					serverConnector.setShutdownIdleTimeout(10000);
+				}
+			}
+		});
+		return factory;
 	}
 
 	@Override
@@ -142,10 +149,17 @@ protected void handleExceptionCausedByBlockedPortOnSecondaryConnector(RuntimeExc
 
 	@Test
 	@Override
-	@Disabled("Jetty 11 does not support User-Agent-based compression")
+	@Disabled("Jetty 12 does not support User-Agent-based compression")
 	protected void noCompressionForUserAgent() {
 	}
 
+	@Test
+	@Override
+	@Disabled("Jetty 12 does not support SSL session tracking")
+	protected void sslSessionTracking() {
+
+	}
+
 	@Test
 	void contextPathIsLoggedOnStartupWhenCompressionIsEnabled(CapturedOutput output) {
 		AbstractServletWebServerFactory factory = getFactory();
@@ -232,10 +246,10 @@ void sslCiphersConfiguration() {
 	}
 
 	@Test
-	void stopCalledWithoutStart() {
+	void destroyCalledWithoutStart() {
 		JettyServletWebServerFactory factory = getFactory();
 		this.webServer = factory.getWebServer(exampleServletRegistration());
-		this.webServer.stop();
+		this.webServer.destroy();
 		Server server = ((JettyWebServer) this.webServer).getServer();
 		assertThat(server.isStopped()).isTrue();
 	}
@@ -383,11 +397,9 @@ void wrappedHandlers() throws Exception {
 		JettyServletWebServerFactory factory = getFactory();
 		factory.setServerCustomizers(Collections.singletonList((server) -> {
 			Handler handler = server.getHandler();
-			HandlerWrapper wrapper = new HandlerWrapper();
+			Handler.Wrapper wrapper = new Handler.Wrapper();
 			wrapper.setHandler(handler);
-			HandlerCollection collection = new HandlerCollection();
-			collection.addHandler(wrapper);
-			server.setHandler(collection);
+			server.setHandler(wrapper);
 		}));
 		this.webServer = factory.getWebServer(exampleServletRegistration());
 		this.webServer.start();
@@ -505,7 +517,7 @@ public void contextDestroyed(ServletContextEvent event) {
 	@Test
 	void errorHandlerCanBeOverridden() {
 		JettyServletWebServerFactory factory = getFactory();
-		factory.addConfigurations(new AbstractConfiguration() {
+		factory.addConfigurations(new AbstractConfiguration(new AbstractConfiguration.Builder()) {
 
 			@Override
 			public void configure(WebAppContext context) throws Exception {
@@ -518,6 +530,22 @@ public void configure(WebAppContext context) throws Exception {
 		assertThat(context.getErrorHandler()).isInstanceOf(CustomErrorHandler.class);
 	}
 
+	@Test
+	void shouldApplyMaxConnections() {
+		JettyServletWebServerFactory factory = getFactory();
+		factory.setMaxConnections(1);
+		this.webServer = factory.getWebServer();
+		Server server = ((JettyWebServer) this.webServer).getServer();
+		ConnectionLimit connectionLimit = server.getBean(ConnectionLimit.class);
+		assertThat(connectionLimit).isNotNull();
+		assertThat(connectionLimit.getMaxConnections()).isOne();
+	}
+
+	@Override
+	protected String startedLogMessage() {
+		return ((JettyWebServer) this.webServer).getStartedLogMessage();
+	}
+
 	private WebAppContext findWebAppContext(JettyWebServer webServer) {
 		return findWebAppContext(webServer.getServer().getHandler());
 	}
@@ -526,7 +554,7 @@ private WebAppContext findWebAppContext(Handler handler) {
 		if (handler instanceof WebAppContext webAppContext) {
 			return webAppContext;
 		}
-		if (handler instanceof HandlerWrapper wrapper) {
+		if (handler instanceof Handler.Wrapper wrapper) {
 			return findWebAppContext(wrapper.getHandler());
 		}
 		throw new IllegalStateException("No WebAppContext found");
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactoryTests.java
index 61f123b74bc3..d67008187107 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactoryTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactoryTests.java
@@ -23,6 +23,7 @@
 
 import io.netty.channel.Channel;
 import org.awaitility.Awaitility;
+import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 import org.mockito.InOrder;
 import reactor.core.CoreSubscriber;
@@ -33,12 +34,18 @@
 import reactor.netty.http.server.HttpServer;
 import reactor.test.StepVerifier;
 
+import org.springframework.boot.ssl.DefaultSslBundleRegistry;
+import org.springframework.boot.ssl.SslBundle;
+import org.springframework.boot.ssl.SslBundles;
+import org.springframework.boot.ssl.pem.PemSslStoreBundle;
+import org.springframework.boot.ssl.pem.PemSslStoreDetails;
 import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactory;
 import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactoryTests;
 import org.springframework.boot.web.server.PortInUseException;
 import org.springframework.boot.web.server.Shutdown;
 import org.springframework.boot.web.server.Ssl;
 import org.springframework.http.MediaType;
+import org.springframework.http.client.ReactorResourceFactory;
 import org.springframework.http.client.reactive.ReactorClientHttpConnector;
 import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
 import org.springframework.web.reactive.function.BodyInserters;
@@ -46,6 +53,7 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatNoException;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.BDDMockito.given;
 import static org.mockito.Mockito.inOrder;
@@ -56,6 +64,7 @@
  *
  * @author Brian Clozel
  * @author Chris Bono
+ * @author Moritz Halbritter
  */
 class NettyReactiveWebServerFactoryTests extends AbstractReactiveWebServerFactoryTests {
 
@@ -79,6 +88,23 @@ void getPortWhenDisposableServerPortOperationIsUnsupportedReturnsMinusOne() {
 		assertThat(this.webServer.getPort()).isEqualTo(-1);
 	}
 
+	@Test
+	void resourceFactoryAndWebServerLifecycle() {
+		NettyReactiveWebServerFactory factory = getFactory();
+		factory.setPort(0);
+		ReactorResourceFactory resourceFactory = new ReactorResourceFactory();
+		factory.setResourceFactory(resourceFactory);
+		this.webServer = factory.getWebServer(new EchoHandler());
+		assertThatNoException().isThrownBy(() -> {
+			resourceFactory.start();
+			this.webServer.start();
+			this.webServer.stop();
+			resourceFactory.stop();
+			resourceFactory.start();
+			this.webServer.start();
+		});
+	}
+
 	private void portMatchesRequirement(PortInUseException exception) {
 		assertThat(exception.getPort()).isEqualTo(this.webServer.getPort());
 	}
@@ -112,6 +138,16 @@ void whenSslIsConfiguredWithAValidAliasARequestSucceeds() {
 		StepVerifier.create(result).expectNext("Hello World").expectComplete().verify(Duration.ofSeconds(30));
 	}
 
+	@Test
+	void whenSslBundleIsUpdatedThenSslIsReloaded() {
+		DefaultSslBundleRegistry bundles = new DefaultSslBundleRegistry("bundle1", createSslBundle("1.key", "1.crt"));
+		Mono<String> result = testSslWithBundle(bundles, "bundle1");
+		StepVerifier.create(result).expectNext("Hello World").expectComplete().verify(Duration.ofSeconds(30));
+		bundles.updateBundle("bundle1", createSslBundle("2.key", "2.crt"));
+		Mono<String> result2 = executeSslRequest();
+		StepVerifier.create(result2).expectNext("Hello World").expectComplete().verify(Duration.ofSeconds(30));
+	}
+
 	@Test
 	void whenServerIsShuttingDownGracefullyThenNewConnectionsCannotBeMade() {
 		NettyReactiveWebServerFactory factory = getFactory();
@@ -135,7 +171,13 @@ void whenServerIsShuttingDownGracefullyThenNewConnectionsCannotBeMade() {
 		this.webServer.stop();
 	}
 
-	protected Mono<String> testSslWithAlias(String alias) {
+	@Override
+	@Test
+	@Disabled("Reactor Netty does not support mutiple ports")
+	protected void startedLogMessageWithMultiplePorts() {
+	}
+
+	private Mono<String> testSslWithAlias(String alias) {
 		String keyStore = "classpath:test.jks";
 		String keyPassword = "password";
 		NettyReactiveWebServerFactory factory = getFactory();
@@ -146,6 +188,19 @@ protected Mono<String> testSslWithAlias(String alias) {
 		factory.setSsl(ssl);
 		this.webServer = factory.getWebServer(new EchoHandler());
 		this.webServer.start();
+		return executeSslRequest();
+	}
+
+	private Mono<String> testSslWithBundle(SslBundles sslBundles, String bundle) {
+		NettyReactiveWebServerFactory factory = getFactory();
+		factory.setSslBundles(sslBundles);
+		factory.setSsl(Ssl.forBundle(bundle));
+		this.webServer = factory.getWebServer(new EchoHandler());
+		this.webServer.start();
+		return executeSslRequest();
+	}
+
+	private Mono<String> executeSslRequest() {
 		ReactorClientHttpConnector connector = buildTrustAllSslConnector();
 		WebClient client = WebClient.builder()
 			.baseUrl("https://localhost:" + this.webServer.getPort())
@@ -164,6 +219,23 @@ protected NettyReactiveWebServerFactory getFactory() {
 		return new NettyReactiveWebServerFactory(0);
 	}
 
+	@Override
+	protected String startedLogMessage() {
+		return ((NettyWebServer) this.webServer).getStartedLogMessage();
+	}
+
+	@Override
+	protected void addConnector(int port, AbstractReactiveWebServerFactory factory) {
+		throw new UnsupportedOperationException("Reactor Netty does not support multiple ports");
+	}
+
+	private static SslBundle createSslBundle(String key, String certificate) {
+		return SslBundle.of(new PemSslStoreBundle(
+				new PemSslStoreDetails(null, "classpath:org/springframework/boot/web/embedded/netty/" + certificate,
+						"classpath:org/springframework/boot/web/embedded/netty/" + key),
+				null));
+	}
+
 	static class NoPortNettyReactiveWebServerFactory extends NettyReactiveWebServerFactory {
 
 		NoPortNettyReactiveWebServerFactory(int port) {
@@ -182,7 +254,7 @@ static class NoPortNettyWebServer extends NettyWebServer {
 
 		NoPortNettyWebServer(HttpServer httpServer, ReactorHttpHandlerAdapter handlerAdapter, Duration lifecycleTimeout,
 				Shutdown shutdown) {
-			super(httpServer, handlerAdapter, lifecycleTimeout, shutdown);
+			super(httpServer, handlerAdapter, lifecycleTimeout, shutdown, null);
 		}
 
 		@Override
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizerTests.java
index 058d2dd6e525..a845ffb1e7e4 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizerTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizerTests.java
@@ -27,6 +27,8 @@
 import org.apache.catalina.LifecycleState;
 import org.apache.catalina.connector.Connector;
 import org.apache.catalina.startup.Tomcat;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
 import org.apache.tomcat.util.net.SSLHostConfig;
 import org.apache.tomcat.util.net.SSLHostConfigCertificate;
 import org.junit.jupiter.api.AfterEach;
@@ -65,16 +67,16 @@
 @MockPkcs11Security
 class SslConnectorCustomizerTests {
 
-	private Tomcat tomcat;
+	private final Log logger = LogFactory.getLog(SslConnectorCustomizerTests.class);
 
-	private Connector connector;
+	private Tomcat tomcat;
 
 	@BeforeEach
 	void setup() {
 		this.tomcat = new Tomcat();
-		this.connector = new Connector("org.apache.coyote.http11.Http11NioProtocol");
-		this.connector.setPort(0);
-		this.tomcat.setConnector(this.connector);
+		Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol");
+		connector.setPort(0);
+		this.tomcat.setConnector(connector);
 	}
 
 	@AfterEach
@@ -89,10 +91,9 @@ void sslCiphersConfiguration() throws Exception {
 		ssl.setKeyStore("classpath:test.jks");
 		ssl.setKeyStorePassword("secret");
 		ssl.setCiphers(new String[] { "ALPHA", "BRAVO", "CHARLIE" });
-		SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(),
-				WebServerSslBundle.get(ssl));
 		Connector connector = this.tomcat.getConnector();
-		customizer.customize(connector);
+		SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, connector, ssl.getClientAuth());
+		customizer.customize(WebServerSslBundle.get(ssl));
 		this.tomcat.start();
 		SSLHostConfig[] sslHostConfigs = connector.getProtocolHandler().findSslHostConfigs();
 		assertThat(sslHostConfigs[0].getCiphers()).isEqualTo("ALPHA:BRAVO:CHARLIE");
@@ -105,10 +106,9 @@ void sslEnabledMultipleProtocolsConfiguration() throws Exception {
 		ssl.setKeyStore("src/test/resources/test.jks");
 		ssl.setEnabledProtocols(new String[] { "TLSv1.1", "TLSv1.2" });
 		ssl.setCiphers(new String[] { "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", "BRAVO" });
-		SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(),
-				WebServerSslBundle.get(ssl));
 		Connector connector = this.tomcat.getConnector();
-		customizer.customize(connector);
+		SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, connector, ssl.getClientAuth());
+		customizer.customize(WebServerSslBundle.get(ssl));
 		this.tomcat.start();
 		SSLHostConfig sslHostConfig = connector.getProtocolHandler().findSslHostConfigs()[0];
 		assertThat(sslHostConfig.getSslProtocol()).isEqualTo("TLS");
@@ -122,10 +122,9 @@ void sslEnabledProtocolsConfiguration() throws Exception {
 		ssl.setKeyStore("src/test/resources/test.jks");
 		ssl.setEnabledProtocols(new String[] { "TLSv1.2" });
 		ssl.setCiphers(new String[] { "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", "BRAVO" });
-		SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(),
-				WebServerSslBundle.get(ssl));
 		Connector connector = this.tomcat.getConnector();
-		customizer.customize(connector);
+		SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, connector, ssl.getClientAuth());
+		customizer.customize(WebServerSslBundle.get(ssl));
 		this.tomcat.start();
 		SSLHostConfig sslHostConfig = connector.getProtocolHandler().findSslHostConfigs()[0];
 		assertThat(sslHostConfig.getSslProtocol()).isEqualTo("TLS");
@@ -141,10 +140,9 @@ void customizeWhenSslStoreProviderProvidesOnlyKeyStoreShouldUseDefaultTruststore
 		SslStoreProvider sslStoreProvider = mock(SslStoreProvider.class);
 		KeyStore keyStore = loadStore();
 		given(sslStoreProvider.getKeyStore()).willReturn(keyStore);
-		SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(),
-				WebServerSslBundle.get(ssl, null, sslStoreProvider));
 		Connector connector = this.tomcat.getConnector();
-		customizer.customize(connector);
+		SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, connector, ssl.getClientAuth());
+		customizer.customize(WebServerSslBundle.get(ssl, null, sslStoreProvider));
 		this.tomcat.start();
 		SSLHostConfig sslHostConfig = connector.getProtocolHandler().findSslHostConfigs()[0];
 		SSLHostConfig sslHostConfigWithDefaults = new SSLHostConfig();
@@ -163,10 +161,9 @@ void customizeWhenSslStoreProviderProvidesOnlyTrustStoreShouldUseDefaultKeystore
 		SslStoreProvider sslStoreProvider = mock(SslStoreProvider.class);
 		KeyStore trustStore = loadStore();
 		given(sslStoreProvider.getTrustStore()).willReturn(trustStore);
-		SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(),
-				WebServerSslBundle.get(ssl, null, sslStoreProvider));
 		Connector connector = this.tomcat.getConnector();
-		customizer.customize(connector);
+		SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, connector, ssl.getClientAuth());
+		customizer.customize(WebServerSslBundle.get(ssl, null, sslStoreProvider));
 		this.tomcat.start();
 		SSLHostConfig sslHostConfig = connector.getProtocolHandler().findSslHostConfigs()[0];
 		assertThat(sslHostConfig.getTruststore()).isEqualTo(trustStore);
@@ -182,10 +179,9 @@ void customizeWhenSslStoreProviderPresentShouldIgnorePasswordFromSsl(CapturedOut
 		SslStoreProvider sslStoreProvider = mock(SslStoreProvider.class);
 		given(sslStoreProvider.getTrustStore()).willReturn(loadStore());
 		given(sslStoreProvider.getKeyStore()).willReturn(loadStore());
-		SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(),
-				WebServerSslBundle.get(ssl, null, sslStoreProvider));
 		Connector connector = this.tomcat.getConnector();
-		customizer.customize(connector);
+		SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, connector, ssl.getClientAuth());
+		customizer.customize(WebServerSslBundle.get(ssl, null, sslStoreProvider));
 		this.tomcat.start();
 		assertThat(connector.getState()).isEqualTo(LifecycleState.STARTED);
 		assertThat(output).doesNotContain("Password verification failed");
@@ -194,9 +190,9 @@ void customizeWhenSslStoreProviderPresentShouldIgnorePasswordFromSsl(CapturedOut
 	@Test
 	void customizeWhenSslIsEnabledWithNoKeyStoreAndNotPkcs11ThrowsException() {
 		assertThatIllegalStateException().isThrownBy(() -> {
-			SslConnectorCustomizer customizer = new SslConnectorCustomizer(Ssl.ClientAuth.NONE,
-					WebServerSslBundle.get(new Ssl()));
-			customizer.customize(this.tomcat.getConnector());
+			SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, this.tomcat.getConnector(),
+					Ssl.ClientAuth.NONE);
+			customizer.customize(WebServerSslBundle.get(new Ssl()));
 		}).withMessageContaining("SSL is enabled but no trust material is configured");
 	}
 
@@ -207,10 +203,11 @@ void customizeWhenSslIsEnabledWithPkcs11AndKeyStoreThrowsException() {
 		ssl.setKeyStoreProvider(MockPkcs11SecurityProvider.NAME);
 		ssl.setKeyStore("src/test/resources/test.jks");
 		ssl.setKeyPassword("password");
-		SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(),
-				WebServerSslBundle.get(ssl));
-		assertThatIllegalStateException().isThrownBy(() -> customizer.customize(this.tomcat.getConnector()))
-			.withMessageContaining("must be empty or null for PKCS11 hardware key stores");
+		assertThatIllegalStateException().isThrownBy(() -> {
+			SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, this.tomcat.getConnector(),
+					ssl.getClientAuth());
+			customizer.customize(WebServerSslBundle.get(ssl));
+		}).withMessageContaining("must be empty or null for PKCS11 hardware key stores");
 	}
 
 	@Test
@@ -219,9 +216,9 @@ void customizeWhenSslIsEnabledWithPkcs11AndKeyStoreProvider() {
 		ssl.setKeyStoreType("PKCS11");
 		ssl.setKeyStoreProvider(MockPkcs11SecurityProvider.NAME);
 		ssl.setKeyStorePassword("1234");
-		SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(),
-				WebServerSslBundle.get(ssl));
-		assertThatNoException().isThrownBy(() -> customizer.customize(this.tomcat.getConnector()));
+		SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, this.tomcat.getConnector(),
+				ssl.getClientAuth());
+		assertThatNoException().isThrownBy(() -> customizer.customize(WebServerSslBundle.get(ssl)));
 	}
 
 	private KeyStore loadStore() throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException {
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactoryTests.java
index 4bbea8b0d41b..aaa1170c62ff 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactoryTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactoryTests.java
@@ -275,4 +275,16 @@ private void handleExceptionCausedByBlockedPortOnPrimaryConnector(RuntimeExcepti
 		assertThat(((PortInUseException) ex).getPort()).isEqualTo(blockedPort);
 	}
 
+	@Override
+	protected String startedLogMessage() {
+		return ((TomcatWebServer) this.webServer).getStartedLogMessage();
+	}
+
+	@Override
+	protected void addConnector(int port, AbstractReactiveWebServerFactory factory) {
+		Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol");
+		connector.setPort(port);
+		((TomcatReactiveWebServerFactory) factory).addAdditionalTomcatConnectors(connector);
+	}
+
 }
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactoryTests.java
index 6e1851e72dc6..5dafd69bc45c 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactoryTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactoryTests.java
@@ -32,6 +32,9 @@
 
 import javax.naming.InitialContext;
 import javax.naming.NamingException;
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.net.ssl.SSLSession;
 
 import jakarta.servlet.MultipartConfigElement;
 import jakarta.servlet.ServletContext;
@@ -60,8 +63,11 @@
 import org.apache.hc.client5.http.HttpHostConnectException;
 import org.apache.hc.client5.http.classic.HttpClient;
 import org.apache.hc.client5.http.impl.classic.HttpClients;
+import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
+import org.apache.hc.client5.http.ssl.TrustSelfSignedStrategy;
 import org.apache.hc.core5.http.HttpResponse;
 import org.apache.hc.core5.http.NoHttpResponseException;
+import org.apache.hc.core5.ssl.SSLContextBuilder;
 import org.apache.jasper.servlet.JspServlet;
 import org.apache.tomcat.JarScanFilter;
 import org.apache.tomcat.JarScanType;
@@ -73,9 +79,11 @@
 import org.junit.jupiter.api.Test;
 import org.mockito.InOrder;
 
+import org.springframework.boot.ssl.DefaultSslBundleRegistry;
 import org.springframework.boot.testsupport.system.CapturedOutput;
 import org.springframework.boot.web.server.PortInUseException;
 import org.springframework.boot.web.server.Shutdown;
+import org.springframework.boot.web.server.Ssl;
 import org.springframework.boot.web.server.WebServerException;
 import org.springframework.boot.web.servlet.server.AbstractServletWebServerFactory;
 import org.springframework.boot.web.servlet.server.AbstractServletWebServerFactoryTests;
@@ -87,6 +95,7 @@
 import org.springframework.http.HttpStatus;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
+import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
 import org.springframework.util.FileSystemUtils;
 import org.springframework.util.LinkedMultiValueMap;
 import org.springframework.util.MultiValueMap;
@@ -107,6 +116,7 @@
  * @author Phillip Webb
  * @author Dave Syer
  * @author Stephane Nicoll
+ * @author Moritz Halbritter
  */
 class TomcatServletWebServerFactoryTests extends AbstractServletWebServerFactoryTests {
 
@@ -120,11 +130,6 @@ void restoreTccl() {
 		Thread.currentThread().setContextClassLoader(getClass().getClassLoader());
 	}
 
-	@Override
-	protected boolean isCookieCommentSupported() {
-		return false;
-	}
-
 	// JMX MBean names clash if you get more than one Engine with the same name...
 	@Test
 	void tomcatEngineNames() {
@@ -345,10 +350,10 @@ void startupFailureDoesNotResultInUnstoppedThreadsBeingReported(CapturedOutput o
 	}
 
 	@Test
-	void stopCalledWithoutStart() {
+	void destroyCalledWithoutStart() {
 		TomcatServletWebServerFactory factory = getFactory();
 		this.webServer = factory.getWebServer(exampleServletRegistration());
-		this.webServer.stop();
+		this.webServer.destroy();
 		Tomcat tomcat = ((TomcatWebServer) this.webServer).getTomcat();
 		assertThat(tomcat.getServer().getState()).isSameAs(LifecycleState.DESTROYED);
 	}
@@ -641,6 +646,30 @@ void whenServerIsShuttingDownARequestOnAnIdleConnectionResultsInConnectionReset(
 		this.webServer.stop();
 	}
 
+	@Test
+	void shouldUpdateSslWhenReloadingSslBundles() throws Exception {
+		TomcatServletWebServerFactory factory = getFactory();
+		addTestTxtFile(factory);
+		DefaultSslBundleRegistry bundles = new DefaultSslBundleRegistry("test",
+				createPemSslBundle("classpath:org/springframework/boot/web/embedded/tomcat/1.crt",
+						"classpath:org/springframework/boot/web/embedded/tomcat/1.key"));
+		factory.setSslBundles(bundles);
+		factory.setSsl(Ssl.forBundle("test"));
+		this.webServer = factory.getWebServer();
+		this.webServer.start();
+		RememberingHostnameVerifier verifier = new RememberingHostnameVerifier();
+		SSLConnectionSocketFactory socketFactory = new SSLConnectionSocketFactory(
+				new SSLContextBuilder().loadTrustMaterial(null, new TrustSelfSignedStrategy()).build(), verifier);
+		HttpComponentsClientHttpRequestFactory requestFactory = createHttpComponentsRequestFactory(socketFactory);
+		assertThat(getResponse(getLocalUrl("https", "/test.txt"), requestFactory)).isEqualTo("test");
+		assertThat(verifier.getLastPrincipal()).isEqualTo("CN=1");
+		requestFactory = createHttpComponentsRequestFactory(socketFactory);
+		bundles.updateBundle("test", createPemSslBundle("classpath:org/springframework/boot/web/embedded/tomcat/2.crt",
+				"classpath:org/springframework/boot/web/embedded/tomcat/2.key"));
+		assertThat(getResponse(getLocalUrl("https", "/test.txt"), requestFactory)).isEqualTo("test");
+		assertThat(verifier.getLastPrincipal()).isEqualTo("CN=2");
+	}
+
 	@Override
 	protected JspServlet getJspServlet() throws ServletException {
 		Tomcat tomcat = ((TomcatWebServer) this.webServer).getTomcat();
@@ -694,4 +723,30 @@ protected void handleExceptionCausedByBlockedPortOnSecondaryConnector(RuntimeExc
 		assertThat(((ConnectorStartFailedException) ex).getPort()).isEqualTo(blockedPort);
 	}
 
+	@Override
+	protected String startedLogMessage() {
+		return ((TomcatWebServer) this.webServer).getStartedLogMessage();
+	}
+
+	private static class RememberingHostnameVerifier implements HostnameVerifier {
+
+		private volatile String lastPrincipal;
+
+		@Override
+		public boolean verify(String hostname, SSLSession session) {
+			try {
+				this.lastPrincipal = session.getPeerPrincipal().getName();
+			}
+			catch (SSLPeerUnverifiedException ex) {
+				throw new RuntimeException(ex);
+			}
+			return true;
+		}
+
+		String getLastPrincipal() {
+			return this.lastPrincipal;
+		}
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowReactiveWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowReactiveWebServerFactoryTests.java
index 836532109fff..8b607e228a97 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowReactiveWebServerFactoryTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowReactiveWebServerFactoryTests.java
@@ -27,6 +27,7 @@
 import org.mockito.InOrder;
 import reactor.core.publisher.Mono;
 
+import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactory;
 import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactoryTests;
 import org.springframework.boot.web.server.Shutdown;
 import org.springframework.http.MediaType;
@@ -155,4 +156,15 @@ private void awaitFile(File file) {
 		Awaitility.waitAtMost(Duration.ofSeconds(10)).until(file::exists, is(true));
 	}
 
+	@Override
+	protected String startedLogMessage() {
+		return ((UndertowWebServer) this.webServer).getStartLogMessage();
+	}
+
+	@Override
+	protected void addConnector(int port, AbstractReactiveWebServerFactory factory) {
+		((UndertowReactiveWebServerFactory) factory)
+			.addBuilderCustomizers((builder) -> builder.addHttpListener(port, "0.0.0.0"));
+	}
+
 }
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServerFactoryTests.java
index dd42e2bf9966..48d9aafda094 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServerFactoryTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServerFactoryTests.java
@@ -40,6 +40,7 @@
 import org.apache.jasper.servlet.JspServlet;
 import org.awaitility.Awaitility;
 import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 import org.mockito.InOrder;
 
@@ -211,6 +212,20 @@ void whenServerIsShuttingDownGracefullyThenRequestsAreRejectedWithServiceUnavail
 		this.webServer.stop();
 	}
 
+	@Test
+	@Override
+	@Disabled("Restart after stop is not supported with Undertow")
+	protected void restartAfterStop() {
+
+	}
+
+	@Test
+	@Override
+	@Disabled("Undertow's architecture prevents separating stop and destroy")
+	protected void servletContextListenerContextDestroyedIsNotCalledWhenContainerIsStopped() {
+
+	}
+
 	private void testAccessLog(String prefix, String suffix, String expectedFile)
 			throws IOException, URISyntaxException {
 		UndertowServletWebServerFactory factory = getFactory();
@@ -318,4 +333,9 @@ protected void handleExceptionCausedByBlockedPortOnSecondaryConnector(RuntimeExc
 		handleExceptionCausedByBlockedPortOnPrimaryConnector(ex, blockedPort);
 	}
 
+	@Override
+	protected String startedLogMessage() {
+		return ((UndertowServletWebServer) this.webServer).getStartLogMessage();
+	}
+
 }
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/server/AbstractReactiveWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/server/AbstractReactiveWebServerFactoryTests.java
index 2985bf9d34d3..f0e29376d781 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/server/AbstractReactiveWebServerFactoryTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/server/AbstractReactiveWebServerFactoryTests.java
@@ -41,10 +41,10 @@
 import io.netty.handler.ssl.util.InsecureTrustManagerFactory;
 import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
 import org.awaitility.Awaitility;
-import org.eclipse.jetty.client.api.ContentResponse;
-import org.eclipse.jetty.client.util.StringRequestContent;
+import org.eclipse.jetty.client.ContentResponse;
+import org.eclipse.jetty.client.StringRequestContent;
 import org.eclipse.jetty.http2.client.HTTP2Client;
-import org.eclipse.jetty.http2.client.http.HttpClientTransportOverHTTP2;
+import org.eclipse.jetty.http2.client.transport.HttpClientTransportOverHTTP2;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
 import reactor.core.publisher.Mono;
@@ -77,8 +77,8 @@
 import org.springframework.web.reactive.function.client.WebClientRequestException;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatException;
 import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /**
  * Base for testing classes that extends {@link AbstractReactiveWebServerFactory}.
@@ -95,6 +95,12 @@ void tearDown() {
 		if (this.webServer != null) {
 			try {
 				this.webServer.stop();
+				try {
+					this.webServer.destroy();
+				}
+				catch (Exception ex) {
+					// Ignore
+				}
 			}
 			catch (Exception ex) {
 				// Ignore
@@ -124,13 +130,37 @@ void specificPort() throws Exception {
 		assertThat(this.webServer.getPort()).isEqualTo(specificPort);
 	}
 
+	@Test
+	protected void restartAfterStop() throws Exception {
+		AbstractReactiveWebServerFactory factory = getFactory();
+		this.webServer = factory.getWebServer(new EchoHandler());
+		this.webServer.start();
+		int port = this.webServer.getPort();
+		assertThat(getResponse(port, "/test")).isEqualTo("Hello World");
+		this.webServer.stop();
+		assertThatException().isThrownBy(() -> getResponse(port, "/test"));
+		this.webServer.start();
+		assertThat(getResponse(this.webServer.getPort(), "/test")).isEqualTo("Hello World");
+	}
+
+	private String getResponse(int port, String uri) {
+		WebClient webClient = getWebClient(port).build();
+		Mono<String> result = webClient.post()
+			.uri(uri)
+			.contentType(MediaType.TEXT_PLAIN)
+			.body(BodyInserters.fromValue("Hello World"))
+			.retrieve()
+			.bodyToMono(String.class);
+		return result.block(Duration.ofSeconds(30));
+	}
+
 	@Test
 	void portIsMinusOneWhenConnectionIsClosed() {
 		AbstractReactiveWebServerFactory factory = getFactory();
 		this.webServer = factory.getWebServer(new EchoHandler());
 		this.webServer.start();
 		assertThat(this.webServer.getPort()).isGreaterThan(0);
-		this.webServer.stop();
+		this.webServer.destroy();
 		assertThat(this.webServer.getPort()).isEqualTo(-1);
 	}
 
@@ -211,7 +241,8 @@ void sslWithInvalidAliasFailsDuringStartup() {
 	}
 
 	protected void assertThatSslWithInvalidAliasCallFails(ThrowingCallable call) {
-		assertThatThrownBy(call).hasStackTraceContaining("Keystore does not contain alias 'test-alias-404'");
+		assertThatException().isThrownBy(call)
+			.withStackTraceContaining("Keystore does not contain alias 'test-alias-404'");
 	}
 
 	protected ReactorClientHttpConnector buildTrustAllSslConnector() {
@@ -571,6 +602,25 @@ protected void whenHttp2IsEnabledAndSslIsDisabledThenHttp11CanStillBeUsed() {
 		assertThat(result.block(Duration.ofSeconds(30))).isEqualTo("Hello World");
 	}
 
+	@Test
+	void startedLogMessageWithSinglePort() {
+		AbstractReactiveWebServerFactory factory = getFactory();
+		this.webServer = factory.getWebServer(new EchoHandler());
+		this.webServer.start();
+		assertThat(startedLogMessage()).matches("(Jetty|Netty|Tomcat|Undertow) started on port "
+				+ this.webServer.getPort() + "( \\(http(/1.1)?\\))?( with context path '(/)?')?");
+	}
+
+	@Test
+	protected void startedLogMessageWithMultiplePorts() {
+		AbstractReactiveWebServerFactory factory = getFactory();
+		addConnector(0, factory);
+		this.webServer = factory.getWebServer(new EchoHandler());
+		this.webServer.start();
+		assertThat(startedLogMessage()).matches("(Jetty|Tomcat|Undertow) started on ports " + this.webServer.getPort()
+				+ "( \\(http(/1.1)?\\))?, [0-9]+( \\(http(/1.1)?\\))?( with context path '(/)?')?");
+	}
+
 	protected WebClient prepareCompressionTest() {
 		Compression compression = new Compression();
 		compression.setEnabled(true);
@@ -642,6 +692,10 @@ protected final void doWithBlockedPort(BlockedPortAction action) throws Exceptio
 		}
 	}
 
+	protected abstract String startedLogMessage();
+
+	protected abstract void addConnector(int port, AbstractReactiveWebServerFactory factory);
+
 	public interface BlockedPortAction {
 
 		void run(int port);
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/SslConfigurationValidatorTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/SslConfigurationValidatorTests.java
index b87a7cff8272..df9697747967 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/SslConfigurationValidatorTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/SslConfigurationValidatorTests.java
@@ -24,7 +24,7 @@
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
 
 /**
  * Tests for {@link SslConfigurationValidator}.
@@ -66,17 +66,17 @@ void validateKeyAliasWhenEmptyAliasShouldNotFail() {
 
 	@Test
 	void validateKeyAliasWhenAliasNotFoundShouldThrowException() {
-		assertThatThrownBy(() -> SslConfigurationValidator.validateKeyAlias(this.keyStore, INVALID_ALIAS))
-			.isInstanceOf(IllegalStateException.class)
-			.hasMessage("Keystore does not contain alias '" + INVALID_ALIAS + "'");
+		assertThatIllegalStateException()
+			.isThrownBy(() -> SslConfigurationValidator.validateKeyAlias(this.keyStore, INVALID_ALIAS))
+			.withMessage("Keystore does not contain alias '" + INVALID_ALIAS + "'");
 	}
 
 	@Test
 	void validateKeyAliasWhenKeyStoreThrowsExceptionOnContains() throws KeyStoreException {
 		KeyStore uninitializedKeyStore = KeyStore.getInstance(KeyStore.getDefaultType());
-		assertThatThrownBy(() -> SslConfigurationValidator.validateKeyAlias(uninitializedKeyStore, "alias"))
-			.isInstanceOf(IllegalStateException.class)
-			.hasMessage("Could not determine if keystore contains alias 'alias'");
+		assertThatIllegalStateException()
+			.isThrownBy(() -> SslConfigurationValidator.validateKeyAlias(uninitializedKeyStore, "alias"))
+			.withMessage("Could not determine if keystore contains alias 'alias'");
 	}
 
 }
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/WebServerSslBundleTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/WebServerSslBundleTests.java
index 9d6c0a1d6fb6..c6a22a22fa8e 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/WebServerSslBundleTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/WebServerSslBundleTests.java
@@ -25,6 +25,8 @@
 import org.springframework.boot.ssl.SslBundleKey;
 import org.springframework.boot.ssl.SslOptions;
 import org.springframework.boot.ssl.SslStoreBundle;
+import org.springframework.boot.web.embedded.test.MockPkcs11Security;
+import org.springframework.boot.web.embedded.test.MockPkcs11SecurityProvider;
 import org.springframework.core.io.ClassPathResource;
 import org.springframework.core.io.Resource;
 
@@ -38,7 +40,9 @@
  *
  * @author Scott Frederick
  * @author Phillip Webb
+ * @author Moritz Halbritter
  */
+@MockPkcs11Security
 class WebServerSslBundleTests {
 
 	@Test
@@ -82,14 +86,13 @@ void whenFromJksProperties() {
 	@Test
 	void whenFromJksPropertiesWithPkcs11StoreType() {
 		Ssl ssl = new Ssl();
-		ssl.setKeyStorePassword("secret");
 		ssl.setKeyStoreType("PKCS11");
+		ssl.setKeyStoreProvider(MockPkcs11SecurityProvider.NAME);
+		ssl.setKeyStore("src/test/resources/test.jks");
 		ssl.setKeyPassword("password");
 		ssl.setClientAuth(Ssl.ClientAuth.NONE);
-		SslBundle bundle = WebServerSslBundle.get(ssl);
-		assertThat(bundle).isNotNull();
-		assertThat(bundle.getStores().getKeyStorePassword()).isEqualTo("secret");
-		assertThat(bundle.getKey().getPassword()).isEqualTo("password");
+		assertThatIllegalStateException().isThrownBy(() -> WebServerSslBundle.get(ssl))
+			.withMessageContaining("must be empty or null for PKCS11 hardware key stores");
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/AbstractFilterRegistrationBeanTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/AbstractFilterRegistrationBeanTests.java
index 5a45891cc13a..762bcea7ca16 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/AbstractFilterRegistrationBeanTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/AbstractFilterRegistrationBeanTests.java
@@ -27,6 +27,7 @@
 import jakarta.servlet.Filter;
 import jakarta.servlet.FilterRegistration;
 import jakarta.servlet.ServletContext;
+import jakarta.servlet.ServletException;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.Mock;
@@ -34,7 +35,7 @@
 
 import static org.assertj.core.api.Assertions.assertThatCode;
 import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
@@ -204,14 +205,16 @@ void withSpecificDispatcherTypesEnumSet() throws Exception {
 
 	@Test
 	void failsWithDoubleRegistration() {
-		assertThatThrownBy(() -> {
-			AbstractFilterRegistrationBean<?> bean = createFilterRegistrationBean();
-			bean.setName("double-registration");
-			given(this.servletContext.addFilter(anyString(), any(Filter.class))).willReturn(null);
-			bean.onStartup(this.servletContext);
-		}).isInstanceOf(IllegalStateException.class)
-			.hasMessage(
-					"Failed to register 'filter double-registration' on the servlet context. Possibly already registered?");
+		assertThatIllegalStateException().isThrownBy(() -> doubleRegistration())
+			.withMessage("Failed to register 'filter double-registration' on the "
+					+ "servlet context. Possibly already registered?");
+	}
+
+	private void doubleRegistration() throws ServletException {
+		AbstractFilterRegistrationBean<?> bean = createFilterRegistrationBean();
+		bean.setName("double-registration");
+		given(this.servletContext.addFilter(anyString(), any(Filter.class))).willReturn(null);
+		bean.onStartup(this.servletContext);
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/ServletComponentScanIntegrationTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/ServletComponentScanIntegrationTests.java
index bbc5e0a06fd2..b955eed39566 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/ServletComponentScanIntegrationTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/ServletComponentScanIntegrationTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2022 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -38,7 +38,6 @@
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.boot.testsupport.classpath.ForkedClassPath;
 import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories;
-import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides;
 import org.springframework.boot.web.context.ServerPortInfoApplicationContextInitializer;
 import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory;
 import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
@@ -46,7 +45,10 @@
 import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext;
 import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory;
 import org.springframework.boot.web.servlet.server.ServletWebServerFactory;
-import org.springframework.boot.web.servlet.testcomponents.TestMultipartServlet;
+import org.springframework.boot.web.servlet.testcomponents.filter.TestFilter;
+import org.springframework.boot.web.servlet.testcomponents.listener.TestListener;
+import org.springframework.boot.web.servlet.testcomponents.servlet.TestMultipartServlet;
+import org.springframework.boot.web.servlet.testcomponents.servlet.TestServlet;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.web.client.RestTemplate;
@@ -128,11 +130,9 @@ private void writeIndex(File temp) throws IOException {
 		File metaInf = new File(temp, "META-INF");
 		metaInf.mkdirs();
 		Properties index = new Properties();
-		index.setProperty("org.springframework.boot.web.servlet.testcomponents.TestFilter", WebFilter.class.getName());
-		index.setProperty("org.springframework.boot.web.servlet.testcomponents.TestListener",
-				WebListener.class.getName());
-		index.setProperty("org.springframework.boot.web.servlet.testcomponents.TestServlet",
-				WebServlet.class.getName());
+		index.setProperty(TestFilter.class.getName(), WebFilter.class.getName());
+		index.setProperty(TestListener.class.getName(), WebListener.class.getName());
+		index.setProperty(TestServlet.class.getName(), WebServlet.class.getName());
 		try (FileWriter writer = new FileWriter(new File(metaInf, "spring.components"))) {
 			index.store(writer, null);
 		}
@@ -159,7 +159,6 @@ protected ServletWebServerFactory webServerFactory(ObjectProvider<WebListenerReg
 	}
 
 	@Configuration(proxyBeanMethods = false)
-	@Servlet5ClassPathOverrides
 	static class JettyTestConfiguration extends AbstractTestConfiguration {
 
 		@Override
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/ServletComponentScanRegistrarTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/ServletComponentScanRegistrarTests.java
index ff784ba9b854..c829ac8e339f 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/ServletComponentScanRegistrarTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/ServletComponentScanRegistrarTests.java
@@ -21,8 +21,12 @@
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
 
+import org.springframework.aot.hint.MemberCategory;
+import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
 import org.springframework.aot.test.generate.TestGenerationContext;
 import org.springframework.beans.factory.support.RootBeanDefinition;
+import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext;
+import org.springframework.boot.web.servlet.testcomponents.listener.TestListener;
 import org.springframework.context.ApplicationContextInitializer;
 import org.springframework.context.annotation.AnnotationConfigApplicationContext;
 import org.springframework.context.annotation.Configuration;
@@ -35,6 +39,7 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatNoException;
 
 /**
  * Tests for {@link ServletComponentScanRegistrar}
@@ -141,6 +146,29 @@ void processAheadOfTimeDoesNotRegisterServletComponentRegisteringPostProcessor()
 		});
 	}
 
+	@Test
+	void processAheadOfTimeRegistersReflectionHintsForWebListeners() {
+		AnnotationConfigServletWebServerApplicationContext context = new AnnotationConfigServletWebServerApplicationContext();
+		context.registerBean(ScanListenerPackage.class);
+		TestGenerationContext generationContext = new TestGenerationContext(
+				ClassName.get(getClass().getPackageName(), "TestTarget"));
+		new ApplicationContextAotGenerator().processAheadOfTime(context, generationContext);
+		assertThat(RuntimeHintsPredicates.reflection()
+			.onType(TestListener.class)
+			.withMemberCategory(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS))
+			.accepts(generationContext.getRuntimeHints());
+	}
+
+	@Test
+	void processAheadOfTimeSucceedsForWebServletWithMultipartConfig() {
+		AnnotationConfigServletWebServerApplicationContext context = new AnnotationConfigServletWebServerApplicationContext();
+		context.registerBean(ScanServletPackage.class);
+		TestGenerationContext generationContext = new TestGenerationContext(
+				ClassName.get(getClass().getPackageName(), "TestTarget"));
+		assertThatNoException()
+			.isThrownBy(() -> new ApplicationContextAotGenerator().processAheadOfTime(context, generationContext));
+	}
+
 	@SuppressWarnings("unchecked")
 	private void compile(GenericApplicationContext context, Consumer<GenericApplicationContext> freshContext) {
 		TestGenerationContext generationContext = new TestGenerationContext(
@@ -192,4 +220,16 @@ static class NoBasePackages {
 
 	}
 
+	@Configuration(proxyBeanMethods = false)
+	@ServletComponentScan("org.springframework.boot.web.servlet.testcomponents.listener")
+	static class ScanListenerPackage {
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	@ServletComponentScan("org.springframework.boot.web.servlet.testcomponents.servlet")
+	static class ScanServletPackage {
+
+	}
+
 }
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/ServletRegistrationBeanTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/ServletRegistrationBeanTests.java
index eb10d1d71b48..cc392f5ca003 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/ServletRegistrationBeanTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/ServletRegistrationBeanTests.java
@@ -24,6 +24,7 @@
 
 import jakarta.servlet.Servlet;
 import jakarta.servlet.ServletContext;
+import jakarta.servlet.ServletException;
 import jakarta.servlet.ServletRegistration;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -33,7 +34,7 @@
 import org.springframework.boot.web.servlet.mock.MockServlet;
 
 import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.BDDMockito.given;
@@ -68,14 +69,16 @@ void startupWithDefaults() throws Exception {
 
 	@Test
 	void failsWithDoubleRegistration() {
-		assertThatThrownBy(() -> {
-			ServletRegistrationBean<MockServlet> bean = new ServletRegistrationBean<>(this.servlet);
-			bean.setName("double-registration");
-			given(this.servletContext.addServlet(anyString(), any(Servlet.class))).willReturn(null);
-			bean.onStartup(this.servletContext);
-		}).isInstanceOf(IllegalStateException.class)
-			.hasMessage(
-					"Failed to register 'servlet double-registration' on the servlet context. Possibly already registered?");
+		assertThatIllegalStateException().isThrownBy(() -> doubleRegistration())
+			.withMessage("Failed to register 'servlet double-registration' on "
+					+ "the servlet context. Possibly already registered?");
+	}
+
+	private void doubleRegistration() throws ServletException {
+		ServletRegistrationBean<MockServlet> bean = new ServletRegistrationBean<>(this.servlet);
+		bean.setName("double-registration");
+		given(this.servletContext.addServlet(anyString(), any(Servlet.class))).willReturn(null);
+		bean.onStartup(this.servletContext);
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/context/ServletWebServerApplicationContextTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/context/ServletWebServerApplicationContextTests.java
index 4bde93562782..059b6224c294 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/context/ServletWebServerApplicationContextTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/context/ServletWebServerApplicationContextTests.java
@@ -84,6 +84,7 @@
 import static org.mockito.Mockito.atMost;
 import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.withSettings;
 
 /**
@@ -156,12 +157,35 @@ void localPortIsAvailable() {
 	}
 
 	@Test
-	void stopOnClose() {
+	void stopOnStop() {
 		addWebServerFactoryBean();
 		this.context.refresh();
 		MockServletWebServerFactory factory = getWebServerFactory();
-		this.context.close();
+		then(factory.getWebServer()).should().start();
+		this.context.stop();
+		then(factory.getWebServer()).should().stop();
+	}
+
+	@Test
+	void startOnStartAfterStop() {
+		addWebServerFactoryBean();
+		this.context.refresh();
+		MockServletWebServerFactory factory = getWebServerFactory();
+		then(factory.getWebServer()).should().start();
+		this.context.stop();
 		then(factory.getWebServer()).should().stop();
+		this.context.start();
+		then(factory.getWebServer()).should(times(2)).start();
+	}
+
+	@Test
+	void stopAndDestroyOnClose() {
+		addWebServerFactoryBean();
+		this.context.refresh();
+		MockServletWebServerFactory factory = getWebServerFactory();
+		this.context.close();
+		then(factory.getWebServer()).should(times(2)).stop();
+		then(factory.getWebServer()).should().destroy();
 	}
 
 	@Test
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/context/ServletWebServerMvcIntegrationTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/context/ServletWebServerMvcIntegrationTests.java
index 5680880dea13..24d04bdb3be1 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/context/ServletWebServerMvcIntegrationTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/context/ServletWebServerMvcIntegrationTests.java
@@ -22,7 +22,6 @@
 import org.junit.jupiter.api.Test;
 
 import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories;
-import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides;
 import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory;
 import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
 import org.springframework.boot.web.embedded.undertow.UndertowServletWebServerFactory;
@@ -75,7 +74,6 @@ void tomcat() throws Exception {
 	}
 
 	@Test
-	@Servlet5ClassPathOverrides
 	void jetty() throws Exception {
 		this.context = new AnnotationConfigServletWebServerApplicationContext(JettyConfig.class);
 		doTest(this.context, "/hello");
@@ -88,7 +86,6 @@ void undertow() throws Exception {
 	}
 
 	@Test
-	@Servlet5ClassPathOverrides
 	void advancedConfig() throws Exception {
 		this.context = new AnnotationConfigServletWebServerApplicationContext(AdvancedConfig.class);
 		doTest(this.context, "/example/spring/hello");
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactoryTests.java
index adadee3b8a0f..29862093ea17 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactoryTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactoryTests.java
@@ -99,9 +99,9 @@
 import org.apache.jasper.servlet.JspServlet;
 import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
 import org.awaitility.Awaitility;
-import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.client.ContentResponse;
 import org.eclipse.jetty.http2.client.HTTP2Client;
-import org.eclipse.jetty.http2.client.http.HttpClientTransportOverHTTP2;
+import org.eclipse.jetty.http2.client.transport.HttpClientTransportOverHTTP2;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assumptions;
 import org.junit.jupiter.api.Test;
@@ -155,18 +155,19 @@
 import org.springframework.util.StreamUtils;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatException;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
 import static org.assertj.core.api.Assertions.assertThatIOException;
 import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
 import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.junit.jupiter.api.Assertions.fail;
+import static org.assertj.core.api.Assertions.fail;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.BDDMockito.given;
 import static org.mockito.BDDMockito.then;
 import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
 
 /**
  * Base for testing classes that extends {@link AbstractServletWebServerFactory}.
@@ -197,6 +198,12 @@ void tearDown() {
 		if (this.webServer != null) {
 			try {
 				this.webServer.stop();
+				try {
+					this.webServer.destroy();
+				}
+				catch (Exception ex) {
+					// Ignore
+				}
 			}
 			catch (Exception ex) {
 				// Ignore
@@ -204,10 +211,6 @@ void tearDown() {
 		}
 	}
 
-	protected boolean isCookieCommentSupported() {
-		return true;
-	}
-
 	@Test
 	void startServlet() throws Exception {
 		AbstractServletWebServerFactory factory = getFactory();
@@ -237,6 +240,19 @@ void stopCalledTwice() {
 		this.webServer.stop();
 	}
 
+	@Test
+	protected void restartAfterStop() throws IOException, URISyntaxException {
+		AbstractServletWebServerFactory factory = getFactory();
+		this.webServer = factory.getWebServer(exampleServletRegistration());
+		this.webServer.start();
+		assertThat(getResponse(getLocalUrl("/hello"))).isEqualTo("Hello World");
+		int port = this.webServer.getPort();
+		this.webServer.stop();
+		assertThatIOException().isThrownBy(() -> getResponse(getLocalUrl(port, "/hello")));
+		this.webServer.start();
+		assertThat(getResponse(getLocalUrl("/hello"))).isEqualTo("Hello World");
+	}
+
 	@Test
 	void emptyServerWhenPortIsMinusOne() {
 		AbstractServletWebServerFactory factory = getFactory();
@@ -299,7 +315,7 @@ void portIsMinusOneWhenConnectionIsClosed() {
 		this.webServer = factory.getWebServer();
 		this.webServer.start();
 		assertThat(this.webServer.getPort()).isGreaterThan(0);
-		this.webServer.stop();
+		this.webServer.destroy();
 		assertThat(this.webServer.getPort()).isEqualTo(-1);
 	}
 
@@ -481,7 +497,8 @@ void sslWithInvalidAliasFailsDuringStartup() {
 	}
 
 	protected void assertThatSslWithInvalidAliasCallFails(ThrowingCallable call) {
-		assertThatThrownBy(call).hasStackTraceContaining("Keystore does not contain alias 'test-alias-404'");
+		assertThatException().isThrownBy(call)
+			.withStackTraceContaining("Keystore does not contain alias 'test-alias-404'");
 	}
 
 	@Test
@@ -773,7 +790,7 @@ private JksSslStoreDetails getJksStoreDetails(String location) {
 		return new JksSslStoreDetails(getStoreType(location), null, location, "secret");
 	}
 
-	private SslBundle createPemSslBundle(String cert, String privateKey) {
+	protected SslBundle createPemSslBundle(String cert, String privateKey) {
 		PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate(cert).withPrivateKey(privateKey);
 		PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate(cert);
 		SslStoreBundle stores = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails);
@@ -791,14 +808,13 @@ protected void testRestrictedSSLProtocolsAndCipherSuites(String[] protocols, Str
 		assertThat(getResponse(getLocalUrl("https", "/hello"), requestFactory)).contains("scheme=https");
 	}
 
-	private HttpComponentsClientHttpRequestFactory createHttpComponentsRequestFactory(
+	protected HttpComponentsClientHttpRequestFactory createHttpComponentsRequestFactory(
 			SSLConnectionSocketFactory socketFactory) {
 		PoolingHttpClientConnectionManager connectionManager = PoolingHttpClientConnectionManagerBuilder.create()
 			.setSSLSocketFactory(socketFactory)
 			.build();
 		HttpClient httpClient = this.httpClientBuilder.get().setConnectionManager(connectionManager).build();
-		HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient);
-		return requestFactory;
+		return new HttpComponentsClientHttpRequestFactory(httpClient);
 	}
 
 	private String getStoreType(String keyStore) {
@@ -818,7 +834,7 @@ void persistSession() throws Exception {
 		this.webServer.start();
 		String s1 = getResponse(getLocalUrl("/session"));
 		String s2 = getResponse(getLocalUrl("/session"));
-		this.webServer.stop();
+		this.webServer.destroy();
 		this.webServer = factory.getWebServer(sessionServletRegistration());
 		this.webServer.start();
 		String s3 = getResponse(getLocalUrl("/session"));
@@ -837,7 +853,7 @@ void persistSessionInSpecificSessionStoreDir() throws Exception {
 		this.webServer = factory.getWebServer(sessionServletRegistration());
 		this.webServer.start();
 		getResponse(getLocalUrl("/session"));
-		this.webServer.stop();
+		this.webServer.destroy();
 		File[] dirContents = sessionStoreDir.listFiles((dir, name) -> !(".".equals(name) || "..".equals(name)));
 		assertThat(dirContents).isNotEmpty();
 	}
@@ -870,13 +886,11 @@ void getValidSessionStoreWhenSessionStoreReferencesFile() throws Exception {
 	}
 
 	@Test
-	@SuppressWarnings("removal")
 	void sessionCookieConfiguration() {
 		AbstractServletWebServerFactory factory = getFactory();
 		factory.getSession().getCookie().setName("testname");
 		factory.getSession().getCookie().setDomain("testdomain");
 		factory.getSession().getCookie().setPath("/testpath");
-		factory.getSession().getCookie().setComment("testcomment");
 		factory.getSession().getCookie().setHttpOnly(true);
 		factory.getSession().getCookie().setSecure(true);
 		factory.getSession().getCookie().setMaxAge(Duration.ofSeconds(60));
@@ -886,9 +900,6 @@ void sessionCookieConfiguration() {
 		assertThat(sessionCookieConfig.getName()).isEqualTo("testname");
 		assertThat(sessionCookieConfig.getDomain()).isEqualTo("testdomain");
 		assertThat(sessionCookieConfig.getPath()).isEqualTo("/testpath");
-		if (isCookieCommentSupported()) {
-			assertThat(sessionCookieConfig.getComment()).isEqualTo("testcomment");
-		}
 		assertThat(sessionCookieConfig.isHttpOnly()).isTrue();
 		assertThat(sessionCookieConfig.isSecure()).isTrue();
 		assertThat(sessionCookieConfig.getMaxAge()).isEqualTo(60);
@@ -936,6 +947,7 @@ void cookieSameSiteSuppliers() throws Exception {
 		this.webServer = factory.getWebServer();
 		this.webServer.start();
 		ClientHttpResponse clientResponse = getClientResponse(getLocalUrl("/"));
+		assertThat(clientResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
 		List<String> setCookieHeaders = clientResponse.getHeaders().get("Set-Cookie");
 		assertThat(setCookieHeaders).satisfiesExactlyInAnyOrder(
 				(header) -> assertThat(header).contains("JSESSIONID").doesNotContain("SameSite"),
@@ -946,7 +958,7 @@ void cookieSameSiteSuppliers() throws Exception {
 	}
 
 	@Test
-	void sslSessionTracking() {
+	protected void sslSessionTracking() {
 		AbstractServletWebServerFactory factory = getFactory();
 		Ssl ssl = new Ssl();
 		ssl.setEnabled(true);
@@ -1006,14 +1018,12 @@ void compressionWithoutContentSizeHeader() throws Exception {
 	void mimeMappingsAreCorrectlyConfigured() {
 		AbstractServletWebServerFactory factory = getFactory();
 		this.webServer = factory.getWebServer();
-		Map<String, String> configuredMimeMappings = getActualMimeMappings();
+		Collection<MimeMappings.Mapping> configuredMimeMappings = getActualMimeMappings().entrySet()
+			.stream()
+			.map((entry) -> new MimeMappings.Mapping(entry.getKey(), entry.getValue()))
+			.toList();
 		Collection<MimeMappings.Mapping> expectedMimeMappings = MimeMappings.DEFAULT.getAll();
-		configuredMimeMappings
-			.forEach((key, value) -> assertThat(expectedMimeMappings).contains(new MimeMappings.Mapping(key, value)));
-		for (MimeMappings.Mapping mapping : expectedMimeMappings) {
-			assertThat(configuredMimeMappings).containsEntry(mapping.getExtension(), mapping.getMimeType());
-		}
-		assertThat(configuredMimeMappings).hasSameSizeAs(expectedMimeMappings);
+		assertThat(configuredMimeMappings).containsExactlyInAnyOrderElementsOf(expectedMimeMappings);
 	}
 
 	@Test
@@ -1143,7 +1153,6 @@ public void destroy() {
 	}
 
 	@Test
-	@SuppressWarnings("removal")
 	void sessionConfiguration() {
 		AbstractServletWebServerFactory factory = getFactory();
 		factory.getSession().setTimeout(Duration.ofSeconds(123));
@@ -1151,7 +1160,6 @@ void sessionConfiguration() {
 		factory.getSession().getCookie().setName("testname");
 		factory.getSession().getCookie().setDomain("testdomain");
 		factory.getSession().getCookie().setPath("/testpath");
-		factory.getSession().getCookie().setComment("testcomment");
 		factory.getSession().getCookie().setHttpOnly(true);
 		factory.getSession().getCookie().setSecure(true);
 		factory.getSession().getCookie().setMaxAge(Duration.ofMinutes(1));
@@ -1163,20 +1171,26 @@ void sessionConfiguration() {
 		assertThat(servletContext.getSessionCookieConfig().getName()).isEqualTo("testname");
 		assertThat(servletContext.getSessionCookieConfig().getDomain()).isEqualTo("testdomain");
 		assertThat(servletContext.getSessionCookieConfig().getPath()).isEqualTo("/testpath");
-		if (isCookieCommentSupported()) {
-			assertThat(servletContext.getSessionCookieConfig().getComment()).isEqualTo("testcomment");
-		}
 		assertThat(servletContext.getSessionCookieConfig().isHttpOnly()).isTrue();
 		assertThat(servletContext.getSessionCookieConfig().isSecure()).isTrue();
 		assertThat(servletContext.getSessionCookieConfig().getMaxAge()).isEqualTo(60);
 	}
 
 	@Test
-	void servletContextListenerContextDestroyedIsCalledWhenContainerIsStopped() throws Exception {
+	protected void servletContextListenerContextDestroyedIsNotCalledWhenContainerIsStopped() throws Exception {
 		ServletContextListener listener = mock(ServletContextListener.class);
 		this.webServer = getFactory().getWebServer((servletContext) -> servletContext.addListener(listener));
 		this.webServer.start();
 		this.webServer.stop();
+		then(listener).should(times(0)).contextDestroyed(any(ServletContextEvent.class));
+	}
+
+	@Test
+	void servletContextListenerContextDestroyedIsCalledWhenContainerIsDestroyed() throws Exception {
+		ServletContextListener listener = mock(ServletContextListener.class);
+		this.webServer = getFactory().getWebServer((servletContext) -> servletContext.addListener(listener));
+		this.webServer.start();
+		this.webServer.destroy();
 		then(listener).should().contextDestroyed(any(ServletContextEvent.class));
 	}
 
@@ -1321,6 +1335,35 @@ void whenARequestIsActiveAfterGracefulShutdownEndsThenStopWillComplete() throws
 		}
 	}
 
+	@Test
+	void startedLogMessageWithSinglePort() {
+		AbstractServletWebServerFactory factory = getFactory();
+		this.webServer = factory.getWebServer();
+		this.webServer.start();
+		assertThat(startedLogMessage()).matches("(Jetty|Tomcat|Undertow) started on port " + this.webServer.getPort()
+				+ " \\(http(/1.1)?\\)( with context path '(/)?')?");
+	}
+
+	@Test
+	void startedLogMessageWithSinglePortAndContextPath() {
+		AbstractServletWebServerFactory factory = getFactory();
+		factory.setContextPath("/test");
+		this.webServer = factory.getWebServer();
+		this.webServer.start();
+		assertThat(startedLogMessage()).matches("(Jetty|Tomcat|Undertow) started on port " + this.webServer.getPort()
+				+ " \\(http(/1.1)?\\) with context path '/test'");
+	}
+
+	@Test
+	void startedLogMessageWithMultiplePorts() {
+		AbstractServletWebServerFactory factory = getFactory();
+		addConnector(0, factory);
+		this.webServer = factory.getWebServer();
+		this.webServer.start();
+		assertThat(startedLogMessage()).matches("(Jetty|Tomcat|Undertow) started on ports " + this.webServer.getPort()
+				+ " \\(http(/1.1)?\\), [0-9]+ \\(http(/1.1)?\\)( with context path '(/)?')?");
+	}
+
 	protected Future<Object> initiateGetRequest(int port, String path) {
 		return initiateGetRequest(HttpClients.createMinimal(), port, path);
 	}
@@ -1414,7 +1457,7 @@ protected void service(HttpServletRequest req, HttpServletResponse resp) throws
 
 	protected abstract Charset getCharset(Locale locale);
 
-	private void addTestTxtFile(AbstractServletWebServerFactory factory) throws IOException {
+	protected void addTestTxtFile(AbstractServletWebServerFactory factory) throws IOException {
 		FileCopyUtils.copy("test", new FileWriter(new File(this.tempDir, "test.txt")));
 		factory.setDocumentRoot(this.tempDir);
 		factory.setRegisterDefaultServlet(true);
@@ -1569,6 +1612,8 @@ private void loadStore(KeyStore keyStore, Resource resource)
 		}
 	}
 
+	protected abstract String startedLogMessage();
+
 	private class TestGzipInputStreamFactory implements InputStreamFactory {
 
 		private final AtomicBoolean requested = new AtomicBoolean();
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/TestFilter.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/filter/TestFilter.java
similarity index 88%
rename from spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/TestFilter.java
rename to spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/filter/TestFilter.java
index 8e734aef2d10..38e30ff931d4 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/TestFilter.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/filter/TestFilter.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package org.springframework.boot.web.servlet.testcomponents;
+package org.springframework.boot.web.servlet.testcomponents.filter;
 
 import java.io.IOException;
 
@@ -27,7 +27,7 @@
 import jakarta.servlet.annotation.WebFilter;
 
 @WebFilter("/*")
-class TestFilter implements Filter {
+public class TestFilter implements Filter {
 
 	@Override
 	public void init(FilterConfig filterConfig) throws ServletException {
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/TestListener.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/listener/TestListener.java
similarity index 96%
rename from spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/TestListener.java
rename to spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/listener/TestListener.java
index 0047904791f5..48e2d017161e 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/TestListener.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/listener/TestListener.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package org.springframework.boot.web.servlet.testcomponents;
+package org.springframework.boot.web.servlet.testcomponents.listener;
 
 import java.io.IOException;
 import java.util.EnumSet;
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/TestMultipartServlet.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/servlet/TestMultipartServlet.java
similarity index 87%
rename from spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/TestMultipartServlet.java
rename to spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/servlet/TestMultipartServlet.java
index 1985e452a9bf..3669b8438de6 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/TestMultipartServlet.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/servlet/TestMultipartServlet.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package org.springframework.boot.web.servlet.testcomponents;
+package org.springframework.boot.web.servlet.testcomponents.servlet;
 
 import jakarta.servlet.annotation.MultipartConfig;
 import jakarta.servlet.annotation.WebServlet;
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/TestServlet.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/servlet/TestServlet.java
similarity index 94%
rename from spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/TestServlet.java
rename to spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/servlet/TestServlet.java
index d9c0844b9938..24a230c6d2c8 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/TestServlet.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/testcomponents/servlet/TestServlet.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package org.springframework.boot.web.servlet.testcomponents;
+package org.springframework.boot.web.servlet.testcomponents.servlet;
 
 import java.io.IOException;
 
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilderJettyClientIntegrationTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilderJettyClientIntegrationTests.java
new file mode 100644
index 000000000000..ce64fddef180
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilderJettyClientIntegrationTests.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.webservices.client;
+
+import java.time.Duration;
+
+import org.eclipse.jetty.client.HttpClient;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
+import org.springframework.http.client.ClientHttpRequestFactory;
+import org.springframework.http.client.JettyClientHttpRequestFactory;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.ws.transport.WebServiceMessageSender;
+import org.springframework.ws.transport.http.ClientHttpRequestMessageSender;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link HttpWebServiceMessageSenderBuilder} when Http Components is not
+ * available and, therefore, Jetty's client is used instead.
+ *
+ * @author Stephane Nicoll
+ */
+@ClassPathExclusions("httpclient5-*.jar")
+class HttpWebServiceMessageSenderBuilderJettyClientIntegrationTests {
+
+	private final HttpWebServiceMessageSenderBuilder builder = new HttpWebServiceMessageSenderBuilder();
+
+	@Test
+	void buildUseJettyClientIfHttpComponentsIsNotAvailable() {
+		WebServiceMessageSender messageSender = this.builder.build();
+		assertJettyClientHttpRequestFactory(messageSender);
+	}
+
+	@Test
+	void buildWithCustomTimeouts() {
+		WebServiceMessageSender messageSender = this.builder.setConnectTimeout(Duration.ofSeconds(5))
+			.setReadTimeout(Duration.ofSeconds(2))
+			.build();
+		JettyClientHttpRequestFactory factory = assertJettyClientHttpRequestFactory(messageSender);
+		HttpClient client = (HttpClient) ReflectionTestUtils.getField(factory, "httpClient");
+		assertThat(client).isNotNull();
+		assertThat(client.getConnectTimeout()).isEqualTo(5000);
+		assertThat(factory).hasFieldOrPropertyWithValue("readTimeout", 2000L);
+	}
+
+	private JettyClientHttpRequestFactory assertJettyClientHttpRequestFactory(WebServiceMessageSender messageSender) {
+		assertThat(messageSender).isInstanceOf(ClientHttpRequestMessageSender.class);
+		ClientHttpRequestMessageSender sender = (ClientHttpRequestMessageSender) messageSender;
+		ClientHttpRequestFactory requestFactory = sender.getRequestFactory();
+		assertThat(requestFactory).isInstanceOf(JettyClientHttpRequestFactory.class);
+		return (JettyClientHttpRequestFactory) requestFactory;
+	}
+
+}
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilderOkHttp3IntegrationTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilderOkHttp3IntegrationTests.java
deleted file mode 100644
index e48feb9be032..000000000000
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilderOkHttp3IntegrationTests.java
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- * Copyright 2012-2023 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.boot.webservices.client;
-
-import java.time.Duration;
-
-import okhttp3.OkHttpClient;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.boot.testsupport.classpath.ClassPathExclusions;
-import org.springframework.http.client.ClientHttpRequestFactory;
-import org.springframework.http.client.OkHttp3ClientHttpRequestFactory;
-import org.springframework.test.util.ReflectionTestUtils;
-import org.springframework.ws.transport.WebServiceMessageSender;
-import org.springframework.ws.transport.http.ClientHttpRequestMessageSender;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Tests for {@link HttpWebServiceMessageSenderBuilder} when Http Components is not
- * available.
- *
- * @author Stephane Nicoll
- */
-@ClassPathExclusions("httpclient5-*.jar")
-class HttpWebServiceMessageSenderBuilderOkHttp3IntegrationTests {
-
-	private final HttpWebServiceMessageSenderBuilder builder = new HttpWebServiceMessageSenderBuilder();
-
-	@Test
-	void buildUseOkHttp3ByDefault() {
-		WebServiceMessageSender messageSender = this.builder.build();
-		assertOkHttp3RequestFactory(messageSender);
-	}
-
-	@Test
-	void buildWithCustomTimeouts() {
-		WebServiceMessageSender messageSender = this.builder.setConnectTimeout(Duration.ofSeconds(5))
-			.setReadTimeout(Duration.ofSeconds(2))
-			.build();
-		OkHttp3ClientHttpRequestFactory factory = assertOkHttp3RequestFactory(messageSender);
-		OkHttpClient client = (OkHttpClient) ReflectionTestUtils.getField(factory, "client");
-		assertThat(client).isNotNull();
-		assertThat(client.connectTimeoutMillis()).isEqualTo(5000);
-		assertThat(client.readTimeoutMillis()).isEqualTo(2000);
-	}
-
-	private OkHttp3ClientHttpRequestFactory assertOkHttp3RequestFactory(WebServiceMessageSender messageSender) {
-		assertThat(messageSender).isInstanceOf(ClientHttpRequestMessageSender.class);
-		ClientHttpRequestMessageSender sender = (ClientHttpRequestMessageSender) messageSender;
-		ClientHttpRequestFactory requestFactory = sender.getRequestFactory();
-		assertThat(requestFactory).isInstanceOf(OkHttp3ClientHttpRequestFactory.class);
-		return (OkHttp3ClientHttpRequestFactory) requestFactory;
-	}
-
-}
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilderSimpleIntegrationTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilderSimpleIntegrationTests.java
index 2c3cb5374861..6c3a0b2ef18e 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilderSimpleIntegrationTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilderSimpleIntegrationTests.java
@@ -34,7 +34,7 @@
  *
  * @author Stephane Nicoll
  */
-@ClassPathExclusions({ "httpclient5-*.jar", "okhttp*.jar" })
+@ClassPathExclusions({ "httpclient5-*.jar", "jetty-client-*.jar", "okhttp*.jar" })
 class HttpWebServiceMessageSenderBuilderSimpleIntegrationTests {
 
 	private final HttpWebServiceMessageSenderBuilder builder = new HttpWebServiceMessageSenderBuilder();
diff --git a/spring-boot-project/spring-boot/src/test/resources/logback-invalid-format.txt b/spring-boot-project/spring-boot/src/test/resources/logback-invalid-format.txt
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/ssl/pem/ca.crt b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/ssl/pem/ca.crt
new file mode 100644
index 000000000000..b9343e01ef3f
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/ssl/pem/ca.crt
@@ -0,0 +1,19 @@
+-----BEGIN TRUSTED CERTIFICATE-----
+MIIDIDCCAgsCFH3lh1RXOEy2ESqUPyzb+9zxMYUnMA0GCSqGSIb3DQEBCwUAME8x
+CzAJBgNVBAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxHDAaBgNVBAoME0Rl
+ZmF1bHQgQ29tcGFueSBMdGQxCzAJBgNVBAMMAkNBMCAXDTIzMTAwNTA3MjU1M1oY
+DzIxMjMwOTExMDcyNTUzWjBPMQswCQYDVQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVs
+dCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMQswCQYDVQQDDAJD
+QTCCAR4wDQYJKoZIhvcNAQEBBQADggELADCCAQYCgf4NNpc+6B3qvwKcRYgoXmJ4
+3wyWktBK7BdShz/YnW1OlFZ+R845ZiDw0KdzElZWkYqn+BYJus6lPIS5dfLcrGSf
+a1e8IK02RpBiY/WJvupetnSk8gKA7emF94NlV4gXr4ICJAhXvXUFyBLpdEUE/lcg
+lgCbVJzs5jWUnffEF9mrClzzo0+iXw34zwmyYyBTFmlOEr+QUEdAb6Lr/klpTVit
+as2Ddg1QT4EaSIdTEpkVRZp2dyYVdqSxpaBq21xg0viDHsYQrP96IfacmUB7kFFn
+HsnptDHFvJj2WSQDX+PRS7tLl4mmfizZg80eGfLD22ShNspRSGnbJc0OzegPiwID
+AQABMA0GCSqGSIb3DQEBCwUAA4H/AAnC+FQqdeJaG5I7R+pNjgKplL2UsxW983kA
+CVVkv/Dt0+4rbPC67o9/8Tr+g4eo/wUntMNo2ghF3oBItGr7pJE16zPiLwIvha9c
+8BDhCEZWyhz3vkamZUi19lOnkm3zTmmDE/nX4WYH6CL4UWjxvniZYwW8AdVSnFXY
+ncriuvfliLa3dw1SJ7FtxdcBn4yfzrZWcY+psYNHpftLGYRmQF/VCDSB9EAIEggr
+yBcP749u2y8s44WvKAnnwfLcALIrylY25zN0pao/l2X8HI6qHUeA/QbbEBpDoQvR
+du/rgaHCVvFFxATefhBJ0CUA1Nn5nrGwyRTKnZWtR080qwUp
+-----END TRUSTED CERTIFICATE-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/ssl/pem/ca.pem b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/ssl/pem/ca.pem
new file mode 100644
index 000000000000..c5102f84da50
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/ssl/pem/ca.pem
@@ -0,0 +1,27 @@
+-----BEGIN PRIVATE KEY-----
+MIIEqQIBADANBgkqhkiG9w0BAQEFAASCBJMwggSPAgEAAoH+DTaXPugd6r8CnEWI
+KF5ieN8MlpLQSuwXUoc/2J1tTpRWfkfOOWYg8NCncxJWVpGKp/gWCbrOpTyEuXXy
+3Kxkn2tXvCCtNkaQYmP1ib7qXrZ0pPICgO3phfeDZVeIF6+CAiQIV711BcgS6XRF
+BP5XIJYAm1Sc7OY1lJ33xBfZqwpc86NPol8N+M8JsmMgUxZpThK/kFBHQG+i6/5J
+aU1YrWrNg3YNUE+BGkiHUxKZFUWadncmFXaksaWgattcYNL4gx7GEKz/eiH2nJlA
+e5BRZx7J6bQxxbyY9lkkA1/j0Uu7S5eJpn4s2YPNHhnyw9tkoTbKUUhp2yXNDs3o
+D4sCAwEAAQKB/goGHht1EC0kFyDihvbJE79Kx0v7uNT94nuTa1Yzp9bzJeLLKqHU
+3qySPlZH1QP7icr/pAhhlZ85GB9yYXoTtopSbs6jo4QHaEWcO4vyL+8GT9tKVafl
+1UDyktXw36fIV8Kz/zhA3GQ0clR1Bl9RbFumMHOmbx4xTvieFnbG+TQ2THfFccGS
+jCO6+dab6daXs8sBt0rGMh72utIISVsFJc7v3B8BpaNOI4iBMciRSyZeE4Vw/lRg
+e3iErAVUmUjBrUK/wBy/l9cbbpkp+rvhQpmTIPtKd5f29AQNL7p6V+2+yRb2woRk
+0i1HwOHGOhiCTxXZB9/nZykaT/T2+J9BAn8+DEWCRcfifyNEyuE54G6BvLvgGTgs
++kXWS7p0+wO9CFBDZARu/MXFEfcWt4ZTIj8HtMiKhxNbC1LiGtQnJoLV6AM75E5Q
+toh/xyYOnHbhnbhsSNcpJk5iIdqQE6hWh+rYXFr1aJFMRZaWRkcUG8iIxWQQjRvw
+qxLm9GQtEhF7An82hAlPCDs+6kT1otBEN8vGaW8qkxWYJf6kSd/I0/TEKRYpIwBa
+Ist2BN5GrJTitKhzQIq2ZyT2byHxS0VIvInZJ6sFC+V6fHYpzWbS3zkBy2zswfAZ
+UYrdjLVv16qZYsdjUnhkyUaBbBXnrTPlPzxXvgTeqJeJ5tbR6wgeqPUxAn8lcQxE
+t00N/UBQE8jjPu4QNc59RVqjsYaQ8POcAZjY6fpdIC6Ytsm0yMl8mNRiuCimws28
+4hOo/eVO8XeSBGgxIidJbdRgWjV2PbtWV85ZCO6v0Sic+TOVfe5AwMv1I2FwnBJ7
+QlVjXB6podDkbnuNJOfkIPJ6QRFP8qu8ksmfAn8mttuZeYIBawLv4eC/IVSgIc3l
+UTC7rPfKGgBHMWaYS4lGS2n7mMwektR7IiJVYPBjcIlRgaw5KbDUF50rS2Elissj
+uVANDQgpJYoI5KcqRBmlhRCKGmNgdIWA2Ip5hTGNskp3YIymamif71t0SNUEhpgU
+u2tqbjlON/e7NkdxAn8VdVYq+4sAWRdU4VJqqyf8dyBx68sysvY6HYlKS2bpfu3C
+J3gbPximDZhzMvKx2/CAzMbAT3anyr/DiUImk+QdWSmht+1SLH7A14MDjzQ0D5xt
+GgPqWn7PtcJojFMjc/o5/fKgFf4CYkJhv2KycX9UeldBxpqNpNigzFWBLdtu
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/1.crt b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/1.crt
new file mode 100644
index 000000000000..dd4be7410d6e
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/1.crt
@@ -0,0 +1,9 @@
+-----BEGIN CERTIFICATE-----
+MIIBLjCB4aADAgECAhQ25wrNnapZEkFc8kgf5NDHXKxnTzAFBgMrZXAwDDEKMAgG
+A1UEAwwBMTAgFw0yMzEwMTAwODU1MTJaGA8yMTIzMDkxNjA4NTUxMlowDDEKMAgG
+A1UEAwwBMTAqMAUGAytlcAMhAOyxNxHzcNj7xTkcjVLI09sYUGUGIvdV5s0YWXT8
+XAiwo1MwUTAdBgNVHQ4EFgQUmm23oLIu5MgdBb/snZSuE+MrRZ0wHwYDVR0jBBgw
+FoAUmm23oLIu5MgdBb/snZSuE+MrRZ0wDwYDVR0TAQH/BAUwAwEB/zAFBgMrZXAD
+QQA2KMpIyySC8u4onW2MVW1iK2dJJZbMRaNMLlQuE+ZIHQLwflYW4sH/Pp76pboc
+QhqKXcO7xH7f2tD5hE2izcUB
+-----END CERTIFICATE-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/1.key
new file mode 100644
index 000000000000..712fa35133c4
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/1.key
@@ -0,0 +1,3 @@
+-----BEGIN PRIVATE KEY-----
+MC4CAQAwBQYDK2VwBCIEIJb1A+i5bmilBD9mUbhk1oFVI6FAZQGnhduv7xV6WWEc
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/2.crt b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/2.crt
new file mode 100644
index 000000000000..7c13395e0a54
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/2.crt
@@ -0,0 +1,9 @@
+-----BEGIN CERTIFICATE-----
+MIIBLjCB4aADAgECAhR4TMDk3qg5sKREp16lEHR3bV3M9zAFBgMrZXAwDDEKMAgG
+A1UEAwwBMjAgFw0yMzEwMTAwODU1MjBaGA8yMTIzMDkxNjA4NTUyMFowDDEKMAgG
+A1UEAwwBMjAqMAUGAytlcAMhADPft6hzyCjHCe5wSprChuuO/CuPIJ2t+l4roS1D
+43/wo1MwUTAdBgNVHQ4EFgQUfrRibAWml4Ous4kpnBIggM2xnLcwHwYDVR0jBBgw
+FoAUfrRibAWml4Ous4kpnBIggM2xnLcwDwYDVR0TAQH/BAUwAwEB/zAFBgMrZXAD
+QQC/MOclal2Cp0B3kmaLbK0M8mapclIOJa78hzBkqPA3URClAF2GmF187wHqi7qV
++xZ+KWv26pLJR46vk8Kc6ZIO
+-----END CERTIFICATE-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/2.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/2.key
new file mode 100644
index 000000000000..9917897564bf
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/2.key
@@ -0,0 +1,3 @@
+-----BEGIN PRIVATE KEY-----
+MC4CAQAwBQYDK2VwBCIEICxhres2Z2lICm7/isnm+2iNR12GmgG7KK86BNDZDeIF
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/1.crt b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/1.crt
new file mode 100644
index 000000000000..dd4be7410d6e
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/1.crt
@@ -0,0 +1,9 @@
+-----BEGIN CERTIFICATE-----
+MIIBLjCB4aADAgECAhQ25wrNnapZEkFc8kgf5NDHXKxnTzAFBgMrZXAwDDEKMAgG
+A1UEAwwBMTAgFw0yMzEwMTAwODU1MTJaGA8yMTIzMDkxNjA4NTUxMlowDDEKMAgG
+A1UEAwwBMTAqMAUGAytlcAMhAOyxNxHzcNj7xTkcjVLI09sYUGUGIvdV5s0YWXT8
+XAiwo1MwUTAdBgNVHQ4EFgQUmm23oLIu5MgdBb/snZSuE+MrRZ0wHwYDVR0jBBgw
+FoAUmm23oLIu5MgdBb/snZSuE+MrRZ0wDwYDVR0TAQH/BAUwAwEB/zAFBgMrZXAD
+QQA2KMpIyySC8u4onW2MVW1iK2dJJZbMRaNMLlQuE+ZIHQLwflYW4sH/Pp76pboc
+QhqKXcO7xH7f2tD5hE2izcUB
+-----END CERTIFICATE-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/1.key
new file mode 100644
index 000000000000..712fa35133c4
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/1.key
@@ -0,0 +1,3 @@
+-----BEGIN PRIVATE KEY-----
+MC4CAQAwBQYDK2VwBCIEIJb1A+i5bmilBD9mUbhk1oFVI6FAZQGnhduv7xV6WWEc
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/2.crt b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/2.crt
new file mode 100644
index 000000000000..7c13395e0a54
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/2.crt
@@ -0,0 +1,9 @@
+-----BEGIN CERTIFICATE-----
+MIIBLjCB4aADAgECAhR4TMDk3qg5sKREp16lEHR3bV3M9zAFBgMrZXAwDDEKMAgG
+A1UEAwwBMjAgFw0yMzEwMTAwODU1MjBaGA8yMTIzMDkxNjA4NTUyMFowDDEKMAgG
+A1UEAwwBMjAqMAUGAytlcAMhADPft6hzyCjHCe5wSprChuuO/CuPIJ2t+l4roS1D
+43/wo1MwUTAdBgNVHQ4EFgQUfrRibAWml4Ous4kpnBIggM2xnLcwHwYDVR0jBBgw
+FoAUfrRibAWml4Ous4kpnBIggM2xnLcwDwYDVR0TAQH/BAUwAwEB/zAFBgMrZXAD
+QQC/MOclal2Cp0B3kmaLbK0M8mapclIOJa78hzBkqPA3URClAF2GmF187wHqi7qV
++xZ+KWv26pLJR46vk8Kc6ZIO
+-----END CERTIFICATE-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/2.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/2.key
new file mode 100644
index 000000000000..9917897564bf
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/2.key
@@ -0,0 +1,3 @@
+-----BEGIN PRIVATE KEY-----
+MC4CAQAwBQYDK2VwBCIEICxhres2Z2lICm7/isnm+2iNR12GmgG7KK86BNDZDeIF
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs1/dsa.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs1/dsa.key
new file mode 100644
index 000000000000..a30fec06f820
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs1/dsa.key
@@ -0,0 +1,12 @@
+-----BEGIN DSA PRIVATE KEY-----
+MIIBuwIBAAKBgQCDX+Ux7y7dkfCnQgRIXzAlFrG8uPxwFdC8J4FJNrAurnjL//PV
+8LEHBDVbyPjHaoNbH8Pfc2pJnOzndWZVf0nqBd4Q/Tz9w/pJ9g6E8HOh+rzU3eK5
+mF0rezcMbJsot2Vdx6XrTztDKi2GY0etKNV399DYtepIYA6v5ovuYAOjLwIVAPyb
+9zR+UjyCkBwESDm9dpzKsGp1AoGAS2vtTiO7/MT8cJwo4mxYtiJnV5R2mk1JJOTe
+4AFPgmnce/YbBzU2JwL9J9HGewDkmxudW0zoxZVeNw4dRua6oB5STV8XciW8vSo6
+mdDBJFoBW9/DUscRP4j2aRfkXGlYuiEF6ZT8g6pPHZG7pLviihMAWNRVLmBt38wa
+8FA9aZECgYADbfwh7OhSE1J0WRaEk/4Usos5Oi6fhUyqr2u34Ereug9Gt4tkhePa
+b3y31i2PQfsatpR+4VpBC6zpPgHQYpuqlqDRWJCd+Cxo9751nOiA3xYVxNoiwIn/
+WxoUkC8Jv8kYFAJRceXkF/auVh77MUoruAmoT2lGE6zP6ngP2q6jFwIVAJMF5kZ+
+AUZbUBUpZaPuZ15RL8GF
+-----END DSA PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs1/key-rsa-encrypted.pem b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs1/rsa-aes-256-cbc.key
similarity index 100%
rename from spring-boot-project/spring-boot/src/test/resources/ssl/pkcs1/key-rsa-encrypted.pem
rename to spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs1/rsa-aes-256-cbc.key
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs1/rsa.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs1/rsa.key
new file mode 100644
index 000000000000..cf847bcbac64
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs1/rsa.key
@@ -0,0 +1,51 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIJKAIBAAKCAgEA1zZsVLbl+bOl2QlFZ5R5hbJrR5u+G3Ddd/f25JWwno9tfRik
++39KrIHJKwxq/TH1d0WjfOuvFBz5Ucnh86NOSnffz7kxLgP8QcjQkwuVTjDj4lTp
+sJpi/U1npAZ6Eu1/KeC3R2u8qqvYG3U3G6cQYw5Y+0wHf5EYlIvKfx5oVEEf67pz
+7q2TffqoOV7PQHyHt+/BKFJI1ehj8W/2uQJJ9UTmGvixAYLZ6gHLmtEUj1cBZUb1
+5X/lVbbsiL8B2prbuPoJWYcppH8L2cqSq4JP43Y2VR3CtrQH9MBbLxBfPVnzuD1j
+X1J++xMMN7NCRpBNI/xoJNxvxO+3RgNbkhz4f8WhJbuCwaoNJStytq2fcqOfT3t7
+lW10Gj67joMJZtIsW7JPquFGW6RNq7YqMCJ9ML8Vv9B9UJcUEeE+a+IX6epJBQK6
+1KC6TIe4SB+xl1Vu1xosuAyvni2H7QDjbiZvE8WX2apKk7fv81Epi+9J5Dn4zMpk
+Z5GG1/itA+rZSCWCs8c3lbGq0olZMsXNNhbNZOqiih3yeaMw/B/3v/XMn98rliMl
+C8VOX92eKKBJTX/CjMuSU48mxNE8lQJaDcfld9JE6ndmiZWe7BpXEF8wYtL/NObI
+xZXfcLDsEHopPpU9gaRDZmtL2aUweliF3WIIUmoo/C3kR7ZOSJ5fC66a+yECAwEA
+AQKCAgAhLFxqen7cjJqF5+3w12wb9bKfqRwWssEQmwJNnd1Js6YW4FOeCLMEAEV4
+A0QCn07NAcj/mny0RvsPZmUT3xpUVEIFjPBNvYOGyGOOJvzuvo6B9sDG3iVgEixl
+ljH+9OjjFaZqteqxDCgVo23JL2lRO4bvxXpqaX02eI3QJmnCgv9eoLD6G3teseJ4
+ZWrg79EjwystAfIENvwg3TdUsUuhKOunQKpYJ0lbzscJqCzZI3otmFCS/bHmEnpH
+YdnxTmmMC86hJDqBBqxW9+i/0yhpUXFykVHQQ9PuIDBuAsILfPAaeCv3J4o3PWpm
+s5UFt3yMjX2oIOqBmsnPWvkkfp63Gr2rGfAMftXSA0l9VcyMPZx78jihZx43f8bK
+MVu4Rd/V1Yxc0n7fr/TTOl5m3Fb4rdOuPOoLEDUQeO4SplStxbjIAEwa1oFFSD8x
+xtsBhSP63+dwflkeuV7OgRP8Fsuu2MDnn5AHeHaM81J4smLjpZ9j6BdhWMVmimvH
+L0Y/MiScC9ngTXpop5ph1VzOXVM1R05jnt53P3UNNTubkndOnuBoa71Zbcjz1HkZ
+APWbETt/1CgJ7aCN9CV7FYNA8/z9t4R/VObquwHE3qfzIOAzSb+rehoBr3nrtzAZ
+A4uUOcvgnHzbm9FG0ysdqhri830KxnYfzeQ+bXeZDGU8PZcuIQKCAQEA/BS0oeP0
+5HAPSFVtNrSMNHTUvprHyfxp959Q8gAHD8MmQctoEUcABARUeDNNZbP/pNF2lCWB
+rJfDLod8VZsViBj3coF0w4VinuC1hphEiu+He7UhOdS/PlpEr6Ci0gTsIIhqboVa
+vKdvaEYaEHVp1//P8yC+M9CtF/fSCNlRElqgWzwkcFxMB4ErTWnIU0+ni+GtcKMs
+4DrpAuSf4LXUBxoP6MBuTbRMcurEFE7Vwbslup/AZphXCF++2E5viOvXn3/uBPkh
+L/wVTAgjb9Vdd/zTQKYp8Ol769OosrQcfb6Aa6OBuGhpkUVVSdNiSqZrclVfSWOR
+5WoHHjiDiZ9a/QKCAQEA2o763dX7lKVTSXTAB7jTBMONrOX7g1gY8Dt2RvCzZY6R
+zLQ/mQOcU8r1PdJc3WEdHP6YYzWy+7buMp/vsMsu17UnVW3ac22vISOLwStbZ0XJ
+Dvm7rPQvaBG7/EDJvSGv654MCPGyM/JEgispK1I4yTAKltBFQ2NebUtg8NBPqK0q
+KrRUiMB1H2QhoRIW47yFTTm3snosu1nnQ/qGDgWWUW2iGZtftN40HzXQPy2bMv2x
+/ATRjsWVJcdqytqlg0wYM+4Ekkz633cnR59qZ76o3DoNEJUoM5fBwTKNfxExKRSc
+WkqkXWoWGqkvXaW7jPec+8HQ/o15aLzCvhP3OIKz9QKCAQEA+n4I0SaY37eLODHL
+iST4fdfq4E0mY0z0cCBca14jpkIh7heWnjSTi2pSFe/E5V9slfefga+ToFJengn8
+P4UQbGGC4sJJqVEOoxpgyBLfacCEPSXMko8aS3ef8XYK1fAWRG3KdXEGrZkkV9Xx
+aJGEUCPgHJVY7Fxc5QhaKnjo2vg7iO3Gt/C/jGWLBi4r5r2snI/xrZA4s8lWao2N
+YdrNixEW5g7yjTyxCzDHD/cW6qBx6XV913ViZuvd1Ux8AO97IQAbIc3+cJRrBVbB
+AAxiCS2vLvrvino5ripx5MKd3UZEjrG34eu/m5/uFKJ9dfjRpJe5TFApVnN6B0nZ
+TBSScQKCAQAieroC8zYcTjSkewGsdjD8KGmaZDHYl7Zfd9ICAQkcNXC07Z624gXw
+hi1IUn6KAj8YiuW5iQgyg7pyTB8BMhyytQZ+iLUUzrH5NWVf1Ro3YaAFd8puz5sG
+/P0+H250IvNg5W8anh6x6T97lZmKFw+UVbrl7fdvWSbVcTXa59IZVzA2ynonlM0l
+ZaOUiIkJ5nzVIQzk4DdcWyOL6uLpJWKAeB5Bkex4WTG51sCCpww78B/7FTuGHY+Z
+BSvI0tOXshKDZsJb3j8Zr++HchPUSBTVoWbcPdu4v/E2LGZ8LFcoFvNPn0Ts48aW
+8CfjyziaVZnzcbEp52HG7zh9yiKPTLddAoIBAEpS/V+z3Vu1iMdQ9uoBIdcQbcLX
+GYBoyyLEgmBBAYfNHJ9YTt4HwvDr57vgAqnadXmQh9+IRdpF7rSizr3OBqnBJE0J
+nbGLKvJArMw4IcF29JOkpuR3GiuigfYgQ0JgYw7fZwc24eesKwopDWutUexo0Tlc
+ef2CmgR/+rymyEknpX8xT5ExYaNz8odguNjRqSohM63p2UXkdi5CLYpu0q28iY59
+0s+3LAwsLPeZirR8TZ0NgirMMAIrILsCYmP78OGV6stOEUj1oUPIu6Txa/Z20saA
+b6z079eXrl1voiiRyJ0h7tHL9VEQA62dICHY9BY1I7H9ZL+Fi1Af5jfXGSs=
+-----END RSA PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP256r1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP256r1.key
new file mode 100644
index 000000000000..d17b664d739e
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP256r1.key
@@ -0,0 +1,5 @@
+-----BEGIN PRIVATE KEY-----
+MIGIAgEAMBQGByqGSM49AgEGCSskAwMCCAEBBwRtMGsCAQEEIBfCkWEWyc2tHIvS
+Ao6hhcj09dnh8NOmtZeqGmcXHnIqoUQDQgAElux3elmSzb/WqEZXb1vdXx/tcIpC
+Yq2vewG8H1SikMoACeFVRcjuy31gJ4Q8M7UQmrR4+WXSptV/UQ6Gkt3XuQ==
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP256t1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP256t1.key
new file mode 100644
index 000000000000..25bca43c321f
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP256t1.key
@@ -0,0 +1,5 @@
+-----BEGIN PRIVATE KEY-----
+MIGIAgEAMBQGByqGSM49AgEGCSskAwMCCAEBCARtMGsCAQEEIILhgc3joEZDWMDm
+9TYgrENN7gbqtMpMw1e2MTLwlJhCoUQDQgAEiDN20JP8O9zSK46tP6MkXJPNAfyN
+IQ0hOgcQ//Fw5V0yiSU+BPGeDoIDsW8LnElS1hIZWq0JVQNZge5ei2bshQ==
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP320r1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP320r1.key
new file mode 100644
index 000000000000..e1bd06c2793b
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP320r1.key
@@ -0,0 +1,6 @@
+-----BEGIN PRIVATE KEY-----
+MIGiAgEAMBQGByqGSM49AgEGCSskAwMCCAEBCQSBhjCBgwIBAQQoNifTnb4a5dOR
+yr8QFVM7Zkw/f/AMm5T5PQ6iVTCzrnw/kZ8glwl+JKFUA1IABHSnWUC/tKXpNHGE
+P89QVKEgvetwCQWFoOENAgXORniLiaLdAdsR80ouTsZiFgHG9su0l5ESEnFWQr5x
+UMj/vPwwhSYm+YP5ucx5NezuBM4d
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP320t1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP320t1.key
new file mode 100644
index 000000000000..5d89229f12f2
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP320t1.key
@@ -0,0 +1,6 @@
+-----BEGIN PRIVATE KEY-----
+MIGiAgEAMBQGByqGSM49AgEGCSskAwMCCAEBCgSBhjCBgwIBAQQotGnirBX69ezE
+7a9yIBQcqeCMm7hc5YAG8D4396ytBa/2/O/lonDlOqFUA1IABDQHDepa3l/S8Gt9
+WrNCNpCPZNBXvmkGPnVXZchZI5BtUySwYxHX1tpatGs3jY7drVYm+NyxZE81pecY
+TvXR8bu7e3BIp2SwZmXEDxdYp1fw
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP384r1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP384r1.key
new file mode 100644
index 000000000000..c248a0f0d448
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP384r1.key
@@ -0,0 +1,6 @@
+-----BEGIN PRIVATE KEY-----
+MIG6AgEAMBQGByqGSM49AgEGCSskAwMCCAEBCwSBnjCBmwIBAQQwK6y6NydLMTNm
+LhDNPyTDKEemTWTUuMGfBQxEz+lQKAqz/So4uA+fQzor/t8to+uioWQDYgAEaK8X
+3KCyRDMpbACw2xG4UUe9OxyuGWFaGKPxhKJDyW5Z56gT5P1Q2y4CblL/X9VcDIMX
+dcQqRNBkPQfy1+fJXwKO0ClfD6MIE3bv6PTZ55J6H2H1dpg38a2soRchz0FN
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP384t1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP384t1.key
new file mode 100644
index 000000000000..13a4e0c7b032
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP384t1.key
@@ -0,0 +1,6 @@
+-----BEGIN PRIVATE KEY-----
+MIG6AgEAMBQGByqGSM49AgEGCSskAwMCCAEBDASBnjCBmwIBAQQwBGUaEvTtnxm8
+fOWj2c2cX4991rvGmfGviuWWQRblSii/v9FG4nQ4Q2IrgBy+hgK9oWQDYgAEQL0d
+QoOTArIx70V/XxipoxxBeKT7zmIe7id5pQiw4O4nA2S2BFxQF9eW9ipnm6DaN6ja
+X/+2k+cC4qIfqzeLcLUFXxz0qdec8lNNtr9QmwoQlv11beeHmQu9C1GwHmvG
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP512r1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP512r1.key
new file mode 100644
index 000000000000..790b619c365c
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP512r1.key
@@ -0,0 +1,7 @@
+-----BEGIN PRIVATE KEY-----
+MIHsAgEAMBQGByqGSM49AgEGCSskAwMCCAEBDQSB0DCBzQIBAQRAmEQNFMGIDLoj
+Ktdg8V71WdSs7FiBkE6Bft25+yY8ohugk/u8aKeIKNVtSirMgQhGFJy/BIBkvM6V
+1JfnrglkjqGBhQOBggAEYbjTnA0x42NdM7jVv7jAoZq0iOYopbwejlOEsx8/MqRa
+Yt4Ef83holIsgOHWSeW+kw1oMDmieoCrhnkM/3KgGzV+BxCeieAWGxABsj9YhAmb
+ATorRJ4q/pMxRq8gIUv05/dGuUttl1gdbKKnGQjxDBM4v5H+/4z00nzzj4Gbfx8=
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP512t1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP512t1.key
new file mode 100644
index 000000000000..e4343646b3fc
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/brainpoolP512t1.key
@@ -0,0 +1,7 @@
+-----BEGIN PRIVATE KEY-----
+MIHsAgEAMBQGByqGSM49AgEGCSskAwMCCAEBDgSB0DCBzQIBAQRATamyZ088BsIP
+Scslwa0I1xfC0/6udycncr+i/QIFOXNr4OQiVb4KC/CS/2FPotVMFSHZfCS2bgi6
+Yvg3Mta5SKGBhQOBggAEm/uIqsytMZypsqCuL0jwZh8xCRVEkUd02YPXcOBhMzS9
+bhAao4CLAuXhWzplr5qk/7ttvczl7qFDOvBzNAIieZHwbFrouZ6Pew8pQXRcMDB+
+FnXwNgpljTNmz/f1ePjVKU1ZgbQ+xVf8Qt8OI9S0Pla8siTgbVweGMLtq0A8Wuo=
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/dsa-aes-128-cbc.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/dsa-aes-128-cbc.key
new file mode 100644
index 000000000000..5ab8a9217ef0
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/dsa-aes-128-cbc.key
@@ -0,0 +1,23 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+MIIDzTBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQIJ2oQD0S0aMcCAggA
+MAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAECBBCNXluHkzV+EEdxlhmcIAWBBIID
+cAWJ20CCQlOCxxCddujc5gPPotUyKTbXA7xI3J9DdjrqBaHrNY/Yii4Zxk9lpzPL
+3x3M3O4C8nJjk4OEA0fiyBYOV1d+KZfa7UZojIe/e+7BuTH9WAvgVzORYYEFX6x7
+DTTmYH6VSej/RBdNTV1KeWA4+20Zn+vqCwcBG6R78cQDP3xGOmT6jOAQR/0kkqEv
+GN22UGpuMlHb7SDiYeDRqQPdRMkpu1RZS37MWEZ//yuwqEyaowK0JgTVGKyibf3E
+qXYV7TqEumdfrpZxPXkmJwswUSoFwE1wM0XU5WMg+lnNiroaVQjzqLEEqENpJ03c
+Y27l0m83GRVvISvwLGOKY0Sbcb2d6NnqBjbVjbJopjBrww3iiTUqGiQZTFjiJ3CD
+VNKdG3HPWEdjZG5xoF9BLF3ZVz/jrfLkK2RcZBs0U6SyENd3H1gOk3yv4Uw2VjXi
+h/PA27oFJ2I5DN9bcHhbKVMFykc9JZbisDO0TNx6gbVxZUdNEER0nsP4/sZCwXp+
+K7rGvrUk17vBesp0K/tTfdBM2xVkV6oCMKPeQpsdekwCSAdNamSpxZn5LqUaX+Gw
+bMf1FxTlIL0ujLiN6/U3VWxLuJuoJauFyM5wYlpqgBOQIszVREQqMOs/Zp1WT5uS
+VUA7tdcIpSE7aA3q2rg1BkaiLMSEBMY/XWAI6SCj7iQyRD6GDse/ayJrQ4rUPvD4
+iRLQN9B1BYwAplGX3Cmi++Yos7tOq8D2HfTDsI0bHAi+oSDTRUAsiezx5QRLgrp1
+pO2dyBlJ5HOlK4SA8h5CMlk2fVgCLGCZ61VcqF1DHu5NKuCnCR73QrwuZAPlo19L
+Jt1YAgcXBfFoSbjAvE+AXcxu2uw+t2kj6cjGRSRpBNSHCn3bJ2zu0qrPL3lOMhtt
+DYp0QNWJkW/6/fpmXXAYQQv6bFY0Bia8Ima9LATcCwpYcRox1pL2rvU1EWLDVy6N
+PdFdlzLM7UcrY9Vy40gEj1qz9epAXqzfPkmbyP3i41BjKlZfmmzpJEI6hIJMtT6T
+xWU1Kgg+mAED6STAJ6nem2bIEzMfsNM00VrHXoLU4IWPCe8NMYOaVox1xBWs1mer
+lu9dgGBAy4/gn6v55XzjQ4R16wyiiJn3ZQGrUD0AE6N2E/tfWiphVG1tYDjjWCLv
+bXG/U6P6DjClkBocb4DgKBc=
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/dsa.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/dsa.key
new file mode 100644
index 000000000000..93a9ee56e4f2
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/dsa.key
@@ -0,0 +1,21 @@
+-----BEGIN PRIVATE KEY-----
+MIIDXQIBADCCAzYGByqGSM44BAEwggMpAoIBgQC1d6MOqEDLxjfjz1v7Vg44bBBX
+VOoNdWhvJPl9NL2Js57UmYGrTqFit2VCLxbS5FVLyOZ25S0myQRsEtKyi4V9+ELI
+0q3oQplm+l3tZqKjtO69ZlbzYSl9IG1254/FCwBnBTuN7Vl6A2vryIhY7cL4E053
+Yy0xqfP7swPgMYNBqjc9c53hNHKiseQJ8Q2wFKZqm13xgqnBqmWbm7yNmzKJbjMW
+Zt3/WpJdjqfRnjSljFOkYPPBkGRUIKHcZ9fw4odVov2vblGzXwR+sFeE3lcF50WN
+uppjszMXlqR4y937CUSFbCabatRHEcPTq/FxioERCrCdx3AKOfwAquahtvWb9V7A
+47FlibDDeybh5jCH0j9HSjhjSiDZdadSgGKFynPNlVAlOETZKGqkeqAZZ+dsPkVO
+n8tx+VXZJF00YSe1HLKJUWXaT1tEGF6vw/dXhhQVir4j63fN2tZdhTOW1ao/J/iT
+VYEQQjYeMKKuZQveBKRBlpAjhmjOztbE8VL4O/cCHQCxPXlCpORbfOMEEGU0hbEE
+HsxFHMXHCnURCJxVAoIBgQCBqsk+z57UndC1Ut6u19wILXs7UBgLo0ivId2QHtm5
+kY77P9/lNOyCIQkBnULbJ36lHm6yxLZ8imyC5Lc7wlFJpJ6PpiTJ3nPi3fzhbftB
+2KCJVSwB3XfkjvyyS8bfwwqyrmce9el+AIFJuWPrFSkjNthq7U5vU5a+uNT9XZrs
+EaDbjkjVJXRX1oDS3IfWXWpb9i/LOE9HU+NfDKfydasWASvwNX1F5BKXD0AH9adj
+9Q7b0p4DVTh+UPWLBk9/e6gsA5HaRI1urAMNxs5Xnmd8UYF1I+AmjQ9Mi63Pa0YW
+QjpdH2hoOQGLemQ/72woFVzLaHWBcTuSwjREilaAA5M8CWq4rpuA79MrcHgzSp2C
+W1gtZa2/3SymcJ7Py2PHbncod8gR9dxHWVO07ccOXUG0iL9m4MzQ27uVvTh8Nrma
+M+JET778E2FaAkAIT34eNMC6Yk2IDrxU9L66FFx3+3n0cOeWaJxIWrIQ6uWk+uIH
+VzPsZAQU/V0/QBABlHuSj1cEHgIcYTbB5VrbIgust0jVvQCnlF4b1V0qz2iDJt6o
+sA==
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/ed25519-aes-256-cbc.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/ed25519-aes-256-cbc.key
new file mode 100644
index 000000000000..138b58e56f64
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/ed25519-aes-256-cbc.key
@@ -0,0 +1,6 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+MIGbMFcGCSqGSIb3DQEFDTBKMCkGCSqGSIb3DQEFDDAcBAi1t9NlcmE8TwICCAAw
+DAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEJacGnDl5HIWWbv604Vp0CUEQDEx
+jZKBJnhmLTPMJbE1TgVe+9N8ZG1CVpSFz0xo9xCk15G4E9jgxXw/a8Sqy7NiDqRb
+FLJrSStMU+ygP7wXIFo=
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/ed25519.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/ed25519.key
new file mode 100644
index 000000000000..a9bf1bfb8f98
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/ed25519.key
@@ -0,0 +1,3 @@
+-----BEGIN PRIVATE KEY-----
+MC4CAQAwBQYDK2VwBCIEIJ55hBE+FwS4M3k/c45ZJKPHtsklKrb6qJlER0cMJ2rn
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/ed448.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/ed448.key
new file mode 100644
index 000000000000..934617d4c1cd
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/ed448.key
@@ -0,0 +1,4 @@
+-----BEGIN PRIVATE KEY-----
+MEcCAQAwBQYDK2VxBDsEOSSF8O0uKk5pRrjUNV+QgonwO+WeDRb/i1U7vM+TLzh7
+jAV58E6oglA53konKxGv+GC38dCb72gSeQ==
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-ec-encrypted.pem b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/prime256v1-aes-256-cbc.key
similarity index 100%
rename from spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-ec-encrypted.pem
rename to spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/prime256v1-aes-256-cbc.key
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/prime256v1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/prime256v1.key
new file mode 100644
index 000000000000..775a7a0fcacf
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/prime256v1.key
@@ -0,0 +1,5 @@
+-----BEGIN PRIVATE KEY-----
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQguc9upTMQn8b+loAx
+6c8q20dHYBf3V9374I3kJIDmC1ShRANCAARnLuOxL7n7Gq12zd9vq2neAv6PYc1h
+W6M2gJKSbfFYGhte382jOJ2TgwaTQL/J5IPSfuJKkmAPBIl8CdJKWlwA
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/rsa-aes-256-cbc.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/rsa-aes-256-cbc.key
new file mode 100644
index 000000000000..638f6fd3fd43
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/rsa-aes-256-cbc.key
@@ -0,0 +1,54 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+MIIJrTBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQIX1pl8K5MBZoCAggA
+MAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAEqBBDAZbI9gwQvuQp1MWqsGkJeBIIJ
+UIsFUQY5CFVcTqF2wNDdyA/5X4YvQ1wKPwYdSIHhGAe4UjljSqUBG5StfZmdA4aT
+DdrzdApbZxP8BUlIjrjfR1tsbgnvevEkWUTftA2jzOmKBmYQ44WmQV2JGz6rWcH1
+ZUc0ArKq1Jz2OU+JnE3Olrbf4QupcYCQ+qgB5Afkp78hMUWiGPXaW12Ankjk0qVU
+6zKiLP7cpignOlDTth+pYe/ltFQr1cLSgTsas/9X565usS333s8P7RQQsPRa5+De
+hsgZIGeJTX6RGG0ipxZjd97jle54T6UPnYQWmHHZuX0LNqThTeUHZxPaMLJ7jnFY
+NtqNvlXydkWRqdVmP2L2uk4mECrQrKqcqLIdlEL+sB00t+hjNtyCh6dIaQ5rbYDS
+1k7fNDx1m2k1u3ydtUUeN6/7OJ7X1Is8k3WDTxHguFDz1mmeCw0lgH9tWZ4peG0/
+hIP2p02icaoSx24K7b6yHShJ/+B6lp17tYe/FgVBzO75tB7ljH5bZjYcZ9iIGy9h
+T9Jq53M/lAnsAADLt1fiYRTWq9G5w/wzl0vqNTVpnpE7nXTs7d6Di812k8uyf2G+
+RU8Bsv50SJEZzW4liXhGxJXaI2TKKa8o27vPm/hK6cL2uoS6d22+/dUL1yP0ZXl7
+LOgqnNS3e6wT5xfdbXXclGUER8jP1QRPTm3evI13BRWHswLTeHWmdivZFrCHHw6o
+7f3LARYLkefwO/FsC9IJzpdgN3B4V/K0BIcVYwXYgrqUIei91b+3EHgqvB3cXLdG
+r91IBTvV15V9Hz8FUmTo+0uRdP7nrQ9+4451p6RP8FUuaAV03/a47YWemkZtqmzd
+zuWB/Eo1fzmxrbHyXZoN9D04ubOB9S/6jUy9N2IwQykeKy+go/FHltQr8l0JhkKs
+ipbaRc719n2Fj/hBkaIzLl/nxK2KWR2kCAiVXo8WHJOzzRlgEMUCbgoNbvf7X+ek
+7O2VXOR2ZrqDSXs0WsZsq2LeAXlN2rIS3TKVru1T+0YKe/z/qZFvdygkTGB0qX7n
+G+v03iRVSGingsl3UiW/S0wLDxdxnBgERggD+YSwQ/pFQTPn4AOe7xStW/2/d95W
+S6rA23ijN+U3O1yN1jCJjMZUFK4DDwKbIUyqcF+m8jvYLrvYxNuVh3pwwDbGARGF
+q3rzm4K0UUeCZa3sBlV9EkVhIxdibO9fPFP/9o+pGHacZ9/B0QtCXLfb3RnRX3AW
+uM5L3gMd14TeIaeTMyHz4H9epUNwph022TKV98au8diLNGtB8eNZuu4wAYTfwYfi
+kUcS+Yp/EqwO8/evdCvWSe5xJ1QuLcl+Fr6XGEs+QmcGNDSq9VaqNu5QndZSBR26
+Zv5vGpukqwxGXdHmETvLavam4io4Q/2XUQgZLdCTKxs4Sf3BiAyrW0DWEFQ0vLXt
+FFNQ6AXuVe7jvaGDox86RZ3bHDwWJePBAkQtOza/lFkvLd2h9bcjppeHxznr36Ha
+AnVfIJ57sjBlQA0bpUmTGDkcC1FmRnM5ADQdCENu6ZCkgkVwpFeYfJX/Vk3wMH41
+DQwSF7gP75DDLBnwyb8WooMWEkULBzBEa6N6koZejmgEaULv0aWN0BhE8G1XxVq+
++hOwgNMVm0d9UrceOUsyj4G7UpJbMO0jtLSt3PWE9xfCqDm7vVPf9sA3/Qa6YtXX
+EkNCfItqqbYBWsNKzNpZXDpiS26DFhpww9JrwEL2KBRp247ANxZxG7dhk5H12+Zv
+2c49np0/zAHAhREzuebnPZiWbEMPOM0y9WxIhbCN+u1E16nxZWDeNagDsOCkiNrR
+c02C3U+MZd6S5oYT2h9kc5qq9NyCkJQTFO4012sBYn9LZ0aFUXIPPCilrr2dRMJG
+CUMGrtMfbauEQ7iZYSCdg1PDNmtrv3sirJWZueMNQhEppdtYiV5gfCPVl0sa3fvP
+4yLiPiMMrTjspyXq66jTR116Sm0ZdffDZHGDFUSFowEJZ5JbigUsQDvRwcGjoZ//
+IKT3PPQ55tukuD2WmI2FT4j9SYr3YSBWcraY0povPanxwAIewZZW9BxrOgEDthkY
+7VPeShzcJ4z8O60ioJTtR7gZYhcy9NoTHM8sbXhHS8QloWa22cwXACtPtuh7ErQZ
+jPHIhb+KLFa7P7O6ceTYwZlqUnA+HFI9VHarrutxqRaUWa37JAEhd1bplmxBXDZo
+/8S4pNVlxT9xQmuYpN5JvWCeUadV5SwHCGVHcIDVsDVAWF6zLYwb6zWF1i3uRYTP
+FNwZx4DiskQhQu34QAnvvajZ1wZf3xJ6LS+exOoAZ8Z/qyxWmDwffSvf6Nx/Tw1r
+wLFmKTcMGxBUzGMJ4txp0tiJIHYE8IQMYWNOBeB/GjRWxTEBl5doFpXpDdFz+mfP
+k/wRUTkbuRg1KxXV2GPGB94m9elePD+Rf+m0Hl9rWPGsPR2JE6lr6k/kUYAWXuSh
+o/3w85skoGcAe51EVvtrtqbPTTcd/ndigKI7U4shQnZCm8nUISYlIukor2vNlIuw
+1MC94zP2sfWBreka5VAC3IsP67lkdJx6DLvv80GJ50O7u1oKjQCroEKGC22puTb9
+NZn0h8BepBrgY6eWA9eZyrJ+v0HfMKN0O4lBBhtcedHTGZmBhKffjS0KTqvztFyo
+vnx86mIoiskANpfTn1QWSHxVJm5fNlRNK3DdCDzsQ8OcweIGc/omcg1MYp3Qav17
+E6HoYWVrYUhVbzrOPiW3SorE5c0Xk1tTZQXH212mt3RhMTPmrqc6+PVIwFfU/lzi
+SABjj1Jws9QLbb74J8O5eP4+ZxAvkZtKaLTBibJhYOGtGIrsWzfmcsEzCH+YUPYz
+3vMT6E6wB/KazfI3TWc2g0eHklu7mx1HDlR1V6BOfJ3tOMOqqOOpvAp8A3J+TULB
+ZlQIOlMYH+fnQfRg2FcVHGCik32h7HdjeoZha/Qsogrg5j4LL0NkJf3k5E8I/c4L
+o7yY0rPMKt6qmmZe4msO9wFGomWCms5LBV9K3H4bEtNdPs5rdP8wO8C9NaGRuUgZ
+3pPm1AYRdxJNW3TGR+D7nTuDrRIKrxMkWyKOkwQteWAqI4OAiMezhpv8X0+pq5K8
+8rPROuQkq/znG8wktQ0V6P+JjL1oBayhrpadgYY/tc1+S8U/zeeCPnFtUXLLdk/K
+stzs8gsvZCWWn6M5mlSrsyLaB1sgbxbuOlaH4FlUAYZC
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/rsa-des-ede3-cbc.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/rsa-des-ede3-cbc.key
new file mode 100644
index 000000000000..a2144172fdda
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/rsa-des-ede3-cbc.key
@@ -0,0 +1,54 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+MIIJnDBOBgkqhkiG9w0BBQ0wQTApBgkqhkiG9w0BBQwwHAQInDrn6GvZlw8CAggA
+MAwGCCqGSIb3DQIJBQAwFAYIKoZIhvcNAwcECNwtOc3eKItmBIIJSNZzJs9X5vLX
+1PNFSOjg2bDJFXWJ4X8vtnA8g0JsK0+kGmRxg+FlWXTqAGJbm0imoE4YSQ0NXkfj
+6eFtgebm2zdRRfbABJX/drZMSIDYl5Le7B/zqIeI9cO16GDEbyDam+MXD+mRt3Bk
+J4JyJPCin22nAtKb+D0zHs2F/9Iwi+3IYL1awvHspfOQVwARoj9Jc0K98qtGSa9M
+KikC+0LGr4zw0fOSMzrhijg5mqi8wTsYn/9u5+TkQ8cwg6cBKCGCjNOi7ml6uyC5
+LE4dYAcSkFbbRRNOuM/RYiKxFpGAFrfxUVfHI3dLjDe6DUAN18ZmLitij2WI5TcU
+azfFRYMnkmI12Tu6JeKrFrhQOt4gUq1W2h5KwvKvZAikRvc1pE+X8h08S/kAwsUb
+PuxgAN54myeHIGoaxG/C1ImaRoSPquKVoayJoIwDQ2kKJJ5EdXFJ56SyA1kYk4y4
+Ohv+kvk47ZAwFZnNz+Lt+22uFOnrMZobv/jKsTRfJsz0QhwmTIaB9C5QehkAeZJD
+6M/JVjRWoLALcNu/S7imzbzNBS4r1ctYLv2dkakzBhR4o5Yfn4sg48p1x5EBTTre
+6X3/qWH9z1vtpJsjC7vA4ACkLaz0Vb9Tb9No+Sjjp2xi6arJphMwjuZ0LPf0EuVd
+wzbkRHXzpYMACuaypNaTQrNCgpR19eV98SThrf8QkyyKD0qwtzwoTmGX45FnGFWy
+H8HL+lZzfpA9zzRCjqLGeTkgQJLIMP0orD9kkVYCHtUorUQPr8MIu/o45PKCZtNd
+++kaTm8x+wjXYNK+cyOnyda5rj3XsMYqPnMtdg42cHu4oODEV1JH2fl8sXpuGB4A
+7qsSBORrZJiPEdayT9Gsve0A6Vi7gGK+9a5WRxqS2Hlbgr6lgqUr8zX1YYi6Ace3
+d2GhEqNvyAX+6Cp9FYH7JauKtrMf40LxCNTpTmhhrhwS61nd+ka/5e8v6G05eSTA
+ESbBKo+QiE2Ek1xfrfKlsc8IcBXxmPF5QQ7Gb9eGI69myPRNTv/XN4mnvA8bZF8k
+KqLp3lO6Y5jGKHToooViTDX0WRApJF94oaefuUbDJpW9foaOfZpBz4yhLnY7syTd
+n8LNRYbMHbFbbe5eoGhb6goHD1R24/SeKumDpzyj1HewcoYGP00toDFxijY/AA96
++4HlKJX7JJEDvORgVEZ+tOGo1xplagrndpKnX+WsFzvuPJ3RNFifbkp7iRP1W6HJ
+Gq+oZ0ewj3z4MKi0s58BgTwQENRmLEdp0G1nyYY1nuRmaq+t1IlvR0bMYhTARqcY
+GAdHdwJN6MXQWFyqTQ8L9N0OuCpENposKBvdUcrSFAIALafX+vJrB0aS0ZVM5w37
+yiskAa1KMqo20XTiH9z3awnKAVZqjY0oE2BTehdK/NWgaLH82AsxFIMFmN7GCyY9
+QOIlzceqRlltJ7PsTmvDN+pRaO6KdcbtO/7hsvUgZWOs6x21JRQHUShDNB765dlE
+usuEl3T72TBlTdxQ1tj4fA0RUCAF1T6B/9aW/rBsPTQIBLyJEr/78m7lBKmgQ+2i
+REjuQC8NYkzLLa+fOiIO9LTjPqdSpzMCqMIbjiR1eYMJHFoP+8E2gAqKZBDOaNoI
+V5tfZk+/Uz5AIYQocd1aUEQCgyf2WJLp2B5boO2lS1qXBb4YML/D2L7Udz4JU3Qk
+fkSlDC4zx01XdmmbGedBCGG3npf/pCerrHcaPJUWOjMk+M1tR3Mpmmm+/yJm4Xyu
+bPcvV4pEqlfBsIjLCt6xyF18vuF458yB7djtZJ3wkx9lLbpLAh/z7ywoCF7B35nQ
++kUjFBsboV+1J+9sK4bULrtYOhjxAU1P7Mpq4BbWbRWBWK9/Kz8maeCrLEW/tpnk
+EureG7aZtwQz6vHyeDDQTpFVdeSFO4qFy7Pouf/VVzX/eph/6y3ZDsjYfxFiUD6z
+TV9aPs8WDDcWhd+voSQlBlBB4ttspQgZhefCScu6hhW5PNVFyNm/TSHleOEBXiEf
+0Ab4FeFkQ+c1GwhtvsmGLBP/2VzkkDSunO2QDbpNYGzR2Lsn56JQjeKwiTHblzRy
+AwSJ4DU/V7S2K4wu9QmP2SefrBJTlbLd8VFbwAGAHDeM5Xi/TnleaQvUPGz56F5l
+moaYsXbDQGlyA+PYs+CCa9rSfvppX4EwDIftDqxAzEAIbQJir4VEiPpE/CrJtUc0
+JMI6ug1Cpp1p2U38y+POyv42Awk8M3KOPUvXxQkfU7leSClLh2zCAkC1CeUGLrZU
+G/Fuob5ZjjJ6I6/+3jF91Y6bDvh9e71jys6eTnYNfXs23f7uwqv7G0VqhY3AjXKs
+9YS81sdBNbZIObMeXA9w2kG+RLWB5C4hpkqgoRJHDyE06Vzy7zzWLBS6AY6/f2lX
+lNVUETpf7j3PU2bGry865V5eGS43lTUnsMUDVQL1OuiWLg30JUH2AP8cqQqgN+O/
+mK44RfZIrGz7o3currsobXBMNaqFqVlCRtdqEVsiRprWiGlHtLEnAh/UNVp3h4ca
+s9nGo0wA9bpHqAYzyJoDKRgjj4V7vk6rDNdJeRow9v4byVOpEXBY2WX+oRWYruyR
++ArU/zAeK/6oUb4yegc25SGTZxEHlZvZs9U8Ft3SmzGDtuiS8tgMgfLe23fN8mUv
+pRwRqkRmPYRZpH23MTWVBGSAos9nlP5MxA7IgclRywpINOJpNsbPbihhcjd5oBhK
+ZK6eqdpYUdSWHpUdSUi3DhirYXWQA3bGjhJ/IuvGpqdXm2P9rpUArEjwuG7De7QS
+t65n15oECLzVg/3vZviKSC0RkW5p+pQEbVifxu3Azs8SrUlXsilcHMTnTVGK2wG2
+Fi4ReqftHwy1JIAsMEeAQAvuR86TFaHKasw2p+/IenHo/cTAyqEJjgGeTJWjUtCK
+dUavMKA5ZYIP1E5/F0/QBNVToghoDgMVV870p7QWjgLVtPVCDPx3I7JKh9GBmNR5
+0dd3JMpO9Gkk8cBGwurrr/Ak8ynRLv0MRdB/8HDp66XiHR6tBl8ZoZ4ZsnME56Bc
+F2+OmOuxVThpOxPlECU8XtI37ZlSxbG0X/s5eBy2mmrVDzGQDYXC3VuEp3lrk8QV
+k1KU6zxzFLXnKEhZraP78TIKhvV7tiggyTBmx1emLigMqmd7AnVseY1LRlFQgUB1
+wloOOrNW1gpIoUUMVuRjFA==
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/rsa-pss.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/rsa-pss.key
new file mode 100644
index 000000000000..a0ecda8b2c11
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/rsa-pss.key
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvAIBADALBgkqhkiG9w0BAQoEggSoMIIEpAIBAAKCAQEApfv1BYcLTl09aBmW
+OnwUEKxByqBZeCXLp6Ck6/plp3GgC9gtFMfSk7mImkIt8BAFWK27A0/vOFDf7SDX
+tFYtlL6ZukxSgaltyEjMVMX/oqB3vMsqzDX96So4UauDlQexWORT0LcUp3IcjA8L
+JrrZS6j278y3ah/Xkex56IRpfFbdqPj/p4rJMw0WFtqvINV6C2xGxeoC1/LcHM/H
+WQLpeRD5PgnIwUw/dqMYtV7nfUDU5wCJLe6I0ogdgmCGrAeogldFilakPs47yU03
+/b6qWFHaj7OwGZRV51R/GChS1HdVN42nsXHiIz26KPIf8BS6O/iAZlUaS8xhw5XB
+je0uIQIDAQABAoIBAAh8WZn3Pfo7JRUJ3dbOmh4CGHj5+qj8Ua2XtmbEDediFTsV
+ybQ6xQa9YQD16jBQOV2/wARa1VGNPO18FNsI3tqwZd6S4VL0rQKkyiF5X+jaCFUU
+E/ONvRXrDScLvDXlx0jSn4BXo8wttszoRfssaUiHclxvHF9mEljI/LCI+HWdTAys
++3l/Yn1ewwA2iFFU+ZcwgvZHXjLjRLfImTfr7oQLeolpP9sxfwb2RdQ24ifgIh9N
+Yv9KzFfFJNl+2o3q6XBKqvjXYWmTam/hwXhGnFNb3LgrOwkSUIVpUJl52F/fu+BD
+AdJu0ELPUNIu2Ll0fBp3Efj80vcSZqtDSJ3Bl/sCgYEA6DZQm1L1Y2tPMcX+JLtV
+BKC19YRTJLI+CQsU5YnD4DN6O3a8PITfRf+SHWI9slGGs4QU0rv6NLMj4c0Vxsbk
+74LQArprdw768+hLH8z3r/fAZ0QrTJZSKMuGvs4To4dHvNSdc2lYDtadDysPxkKZ
+23aL3ApmCqZpHvIUndOGKV8CgYEAtvzWJd6faGWUEbQI2qI4/H2w/t4oxIgVDOeu
+qCjIfw3jj9QUQrzC/ckHEJrb9ILYuzxfe92qPf9qmqHyE3aKMCN4MFIz+PdfwM7F
+P3/QSriS+PdCnS0ysmHrUdJRXOsl6SYDVnCfyhU6HtL4GFO5expMesogpw2xXkYk
+gYOaWH8CgYAP0SNMcSoly3lpeoMFHX19AzVhs9G1/i4bj5WszOV6sAbzZfMMbECJ
+FA9v0PFC5Cq4r5Z7hDJWxJz9FGsXTxTo+5APn4MSaQLO+lOjpuJ4KfgBELOiU9rk
+zHgxJvhPezd3tUPESLimyheIoPZCGuc/+6MrKcopj4w5f2PIHFBXIQKBgQCN7qTn
+8LpyTj/AT4WCl8tdxNxRg93ZOrghL18gnamOKyaz+8rPTPxtvsyVC5jKGeejqxtg
+xzlyJzf3wt8yS4K5/fkOeeRIGxARTBBgxXG5U1rkc10e7tzg0eSlrV1glh/srIhw
+NqEqLLbNC9RVgjNfEbH6l+clzBAkUIGmV36TXwKBgQCI9r8ZYR7xGYYDTpMSbGdL
+XpWuNWwgZQsvBAH+pXaE3A/36tXdggA5nZH3SA+yIoJHGiXHeM8K9LOMAbAzHhsJ
+ia/yFcH7lat92/28mrxoAkHHk5oUdIcP6pcPny3cE874sh/UPG7BNKrS+h2ll21e
+OFsE0r+qLh68/S0HZM1/eA==
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/rsa-scrypt.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/rsa-scrypt.key
new file mode 100644
index 000000000000..775fc9dc662c
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/rsa-scrypt.key
@@ -0,0 +1,54 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+MIIJpTBPBgkqhkiG9w0BBQ0wQjAhBgkrBgEEAdpHBAswFAQIXPgQcyUbFcgCAkAA
+AgEIAgEBMB0GCWCGSAFlAwQBKgQQAEM4E1Gom8Gno5BsBlIbbgSCCVBXC6unomo8
+RViDXg/JbarYH5GAj7PJfOHMtYVGWSuysLnoANxqBLsmxvfnzkoI6hBVkU+FJwyG
+4CZodJn/q7OxuK2VYtE2LV/2QgfBfhDpBSvcfoyfFy6QfBZaIP1rfp4cDucuHxn0
+5sab/On/lw2qALw5gS63uEJQ6GoTvTgkMiRVKLi/ozqg44mn7N/Rvbx58IUexcrm
+ZsFCzJBNPp8Z4fKCTi1CE+Z7BemhAzL6JvE2IHBjaGsZyr6rKOYv7u1QC2KTUyEm
+FLNthxOAqCTaWGYP9cstE38fL0zE24crfOJaRd+7oZu0LwwF2R8kewSKNze7+WbZ
+hbDWu4qWTdHszDeLnEJTCX/xQr5uj/c6Xp89FR8ovsTY/nnt9cEhX+x2tzjxIuR1
+pTGrJvgwL/3PS+4d/O5w7SBpSUSjU8/gPLSU+pZq87+1JRZQpiBUouwpwE96/PjO
+D4iQM7BcDLYaY+f4pP/K9N3fIBr5hDH+QeArfZ5/Fy96yXijlJIm18Z6xs+aoLbe
+iL3coTVUc3sF7beKDE7Y5qlVBw/pTazmcwnUxP509j/W/+WLXFf6WjucH94ZchrG
+5cgi8LGtP+jnrfyoIK7lLBsT66tK3cUxjhpiiHebEQ2RpHpvutoiC933oCZ3FLQf
+3TzuuhWiY3ufIZ67xXZ+i13Gwt/f1OEk0hIecGbLheHKFykA2t36c+/3WZb3niPW
+wdwAzhPaX+nvrk5vsHVgdFJNrXyYwEg/ygO/F/0yXbjYUX7tKyWbb4ikhJdxjgZv
+ieDC+9RIAMpcC7Ac7G4c2vNgiJlLs81apP0LGQ3n+eibCz2VJWzsWohbMXBTcDfc
+a8yJMRaiOSIQlEwes5k3vs993qL9nKSGHHGe2e7PMHuVinzEgkaDHlDfIuFn1KnA
+/aQGbgU8jwK8VpOkjNZllaE2bPZibgQAcgctq/yGRbxfJcmX2HBKUoca1Z0ntLDb
+L4Y/hZb7b577NlWsepxfZRlVqj0KXIzTc2XrMz2U+4D5fj4+KUkk1z75gE3o8wGa
+dkfRSA+LFRCyExtoMSSSxIvlaHRVsL84kkcClh0IjRViGo2708HObkPrBxk8lMaU
+x7lLB0IU1ENjcqrF4rfLwh2K/b9AcOv2zcZ/zgDYEeRXEHhw48+/PzdQ8GRsSanF
+GNjZC9tJ6uCP/uVOcoUoHycD21WjSjZ9naGI0nXWbIUYb6uaQtwPKmNqQElwaXaJ
+y/ncQkxYDrOEoUI8fZFnX1PXHEtP9LmB/MH11RguZn6ha0onFvSMqb9ZWPWZH9FL
+L7tf2jMSHOXjwCKKGugjcj+RYg21P68PUpkCeKnDTpWe62Nx0CHGuylM9UvOS9AG
+O9N7XW/nzPtgoAoWnZafiE4bFea1w65OszHDFuK+k5zrF79dxb8ajJ8XCbhh+Ywc
+DAhNM2jMsK0Tx2rQylFzdl+KcTMiczeTSBxV/g78uoJ2H6/pks4AHkTZ5ZSe1mkM
+qt8DIVhTyV0jUwSj12Fss0yAps51k9UjrQ0iQaeJ6VCCaePRHQ1YyBJT/UyDLlBD
+OPgsUnC5CeN4sFfORAAhUq3jNZ8TmQ8d4RJIaqTrI3ItMz9kiBluq6uDQMJ8a0gl
+JDv2aocsar1dri7TefouEkPUwbkfw+ahlerKiQcQHmaG4V/KWyu30N0axJKXLMHY
+ticksi9RzJGthbkHTCru/Rs+va5b7Tdxla8r9krRamkjxG7RtDet2czWNLJTCC0q
+VZAy4iMT9NeJTgvWkOhYzWPczkNiCDGSPJkyHezK6lYRbnkBR5KITiRsIjXHsH99
+LH/77bewFdyl2mNwy/0+6p+rLGnvPX1ZhxYYoKKKTmNsddVZh2xLSczvKi7Pd/9Q
+kALBSxMPdt7klPXh5BGte9WA/4DEsiyvmUwNsySCtgcMj9xiSJM3COhcs1uhmGch
+uv0Znl7VSE/1Y0yKQhW91QlA6JIGAh319t1VJYcbSiOt3FoCelu4ya/JmGLNtfKF
+AlMJrj1dqxO0pGwpfKHfLeC7G430TPd6ukjFwy+jrPn+LGw0Wzw5BS2fc6E2JaAd
+tfLM+AUlS7I/+O371e6V1g9+55KaGWsN1K99j7W9Md64BsqvGXeJwQtG+JLkPIiv
++ETwbPe4yrcJgslZ0CDocwfLIMfv7sU8PxFXdzYz96NDSYU5HN9l6XACfBkYc5DQ
+tYqdcqDXIRAOpMhviZXkNW6HnaYugHIOIfIVDo1ZoZSNnQKAHt7jvjjQ/Kb+JvMB
+KfCtyJvDl19S3XhslRViEc0LrvE3xZIP91QM1SyhnqagVY142QWyB6vd/yiMjndO
+Tk0bH7wWdk6DiQTBKO3QV2SWPkTy5+O4uHKPO2S2ebIEzi5btsgTkwsBtzS+bo4f
+54t1RvBqg9uHUHKg/PGBX+l/TNQnk8RBVXF3U/Tct2d2W14+YT/UbvxopFNizXHn
+G+kZvv3iOAACfYlGgqarZ5i/O5eiMtOBxE2iCwFq6h7LBH3R28L1pZ0U/pMBWG/n
+vadGNZQ5DGd82n15ieRqXeRNlFt9+jPW3T/eHDUEIqsCEkorGLzlyNWJrtn/0WyH
+yLeiKBSfyBrS/IbDCQ80kVYaFF3+m77kjpFxSN4ugyruPEXL+ofZQyzt/k9cprlr
+nPun523IiG+4bMWHQn8boYEWua28R9Cyn5q9C2HZskFAvEiglyEQTfrU6/lrhhei
+yZU88IL7vwc+8ajToqnNngPedRDkXlOVP/YLuoSry7oEV9Vb5PETypLEvdIwaOcZ
+iTp1EKt+ZD+ryJn+D4vrhNDAGYhEDEPiQY9nHC6/w1/O/oAmbhwiqChSxTXecw2m
+KWZflrm53t0S1NsEvmzWKo5QgJlLbeMO/o9HE4xy1gLINXQQYDUBV4IVjYxsIRYd
+3TERnB5bmL3Efsa+rKXmYy2zNg+RVl5iK56K3mSlCo4dD0yAYT/tD7lX8brCCahz
+O35C+rkwdCIRr6CgCTRStqxJjndC+uyn7/SxfNMc57Mdb87UjO2DHiLAr4OUoq0+
+uOmNwygqpe7kzCD1q2AdueMCII5aaqVpVtJ//r+0iswlRaciWj/VMfTqTQDweSvw
+w2gQo//1NRalhDYYWzr0PfggIIcEhQfXfPCk2kG/6mCBfXKRvBm9+WrQkKS6zdJ4
+oDEneepILtLCVoJewFPvdkpnL1xcdjveuw==
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/rsa.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/rsa.key
new file mode 100644
index 000000000000..fc4a4c7b8393
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/rsa.key
@@ -0,0 +1,52 @@
+-----BEGIN PRIVATE KEY-----
+MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCb/Jbg0D7zw7IF
+DBzn/KZ0kjPf+vkOaZOxNG/PosID2/+OKHK9oXgnZcVbEtvJgR4KKjc4nrSMFtFa
+BbwsvJWxUEqGjLQ5WahSe04R2yZ4LViGbFuBOHAWq9qtxRyDdkghrQvNy8cOr53K
+O8/UAOJCgkYr7x562QLEQ+FUvpGqID2xWI5TUxVezmHdU/sZF8bRyH7TtEZ5zMqZ
+Lt2hjD8CrzFt9aMv2eBldX8c2YTi1/3YPKi8mos21jsyRFa8gPeyi/TnKfA5mows
+DWxCN2dvOw3ynKapFQV1QZief7KRxw3FGnUOdUfw/FqfpddZK7TJ3/AoaM6oNHy5
+aAqcVMqPqb4ZC0eO1R6+xIA4SoZBD+ZTJC2khoVQRRwfRyKQJpp9eWJ9+SOGCkCY
+r+yZbcvGAuz7Y8SPp3kUWZwflofRCeE3p77U+icKkppkkmEJIb1iMm6qYoTjc5M/
+wTpI5qeQmGaftzz4dlWafTAbHT2jbwvSMgYFQQvW4RyV8Iu95PYl6aUg12mXwv6a
+pYf0QuOPGCMP4UzfLfFTYDniS+34aaWkl77Lzmw98BRHa2Xjmn+iGQcHYjKvh7FM
+bRBiFxcSEWjkh+mgDypV+8mqS6FOPC7WeCNvt1XGIRoKnlF4OarZjdI5KP9wKtV5
+hhx6GQpeuZSWyTBLKbVIsuUiGG2VxwIDAQABAoICAD24HM7JNw9mkCqVF17nRcl8
+C9CE0kTUm16TO+ZxJMk4JA7QjE3h9NPJ3ePiO1qonwUwnPbnPNLtOFqhSEp/N8+X
+0FUamTjT89jm9wXzq24DqzJM74vak+c0imsVQen2RCYm/TOpfJKgBBP/xITC8MOW
+HkPF8k5zTTfxD9hjKumgpihkvLPVfPAtQuW7E/BiywU4io4jl3sb/9HKjGEeR9Q9
+E5bJiY8mazZZ3jjBDGZhRgxoO++cSpcg/v0tsxAVC2z3GajZnDZ+oxXPHdW5bFDD
+kgo712mxap5xnPyh1DsAAr/JbyWQXC3K++SNTv72Xys9Ux36EkLVub/2nbQrjJXb
+Vc6eoJRpoeXwvfeqnJOaRUWqluSxqhxI7ngLtVxTAAM79H0GBdAEaB5DvU20zdu8
+7k3ggJ9Xoyu19KiCsC6L+Odix+vTyv+QmIQBGHB4Ts/4YrKMLF49HWpo2qjV9Zef
+bCKjjzz8VrFk5FyFWJQk8o0P9NLn6+epD7n/ndjeUWy04pLc9i3ya7wmojZZbWKE
+UpwcruX/el7A8t7cQjMKHO8/tzFVPsI7o2osqvfY/sZRBWuDe3iHgY40ROwvUvjr
+6k6qwIHPmJqgywmMbv0KD+nnjGqIYThxuvh1n9gp/JlI/QWOs26Mvwk+QFMbA+6g
+XfihMLpzLXWY+Z/7uT0ZAoIBAQDIFiDO/Z2VRS+vNlYOAAEYP0amVTH6Isza3bZe
+O3nvC47Gj4/vcxJQdrMHEtI2/geLXDv9jexox8YvcxQ6KeJVPSNRFVHGIEWLUK29
+pEzPZDUl3xYFl/WTHLn6gxV7uqxO+Xz5TTCaMRCssbz69QZPryfdIZZ1+WXtKX6s
+paRhwwizln9c7vHoN4lPO51Dk1iP6JqcJZdRzPXHSjYSBnuapWBy62+rkHQBbOFn
+yv7WzhnbOEYM8GlvteDNH4xG0gcT4G81dOtGw4frtfpphU8k0Vy3LypjlVQr1Smd
+dZdbC9TT8kC2hyB3saCp9vQUc1U48CHHW7BGBYTSyaRosndrAoIBAQDHk6IQuF1A
+OM/FNwD2nao8I2bOJEYyPgaPFv/lUytC5fmCUuU/FKBdyW+0wtIQDxp/zG2Mq9L0
+le0E/L4WI1Zz4jt0tef7qDLm4tadK9foU4vFuFpfwnvgP8uAgzxgK45CTQU09X3N
+PRfw4Jp6BK1giEqLhuxXrQvhTocswnIgB2s4LUv6g2LEGpyfXCLBoiyBNTYpxHYq
+3E4VtOycxniwUWnR+PQOt9GwIDpjKHzZMHfEOOrOyac85N1s1JDxC1s5XftPII79
+jNxTDeN7O/BP1eEQN1U5Qbw36cjrNzgxNzK3L8NqZP0YlSHpm1s816Am0+TM0oF6
+mKV0VCYYcd4VAoIBAEWPa9iKUz6RzwIa4c/8MGU9mlI5TCap8o4khkI8ayev3PMq
+9d9JIhTXL2ZGJM75gaXxaum7bXT//uaAG4gdB5KarqyBvOwkTAkjA0Pq2sk/DTsd
+U4qeScHbOszcxZs+SqkqE0iYjU0Nwb5IDGsyw/7v5ev6wVRCYC0TP/bFn2BdbakB
+qUWlzHPu2s2w6/uSPjfJpfajGvhVSRz/r8yUdGRPGjjZoPkEP1A/ih2LdQ04mcSc
+y72z1vP/RygIz7vPSKagYAk1nJX9ZEOOAICu19T09Ea7HwF/6MNUWCNlvjjo5BTL
+I7RRRfhWyIROVozFi9s/oH6uYZn2UTb24zGC2gECggEBALiIfECDh82a+hm7Cwv8
+qmwiu6r9hV5tVXk25fNv3D9mDzd+WHPkKYeuergjr0GkBXeHWP/J3CvE+Lw0yboE
+gKpz00/N5qsdUbuEoLYA1Qj/PuzZ0c5bMFkgA5VXQxsVCtupBZh7KQ/9XkaeFped
+/YWVX3/1iFBlM+fmyTwMqqOM2Im/8FG47Diw9oKvGX/66LWrsuIZwr1MqHKPsHwh
+U3SMQpEgZOG6+4qjsfj/dbkIhKUNj6cWc6jtYQOA5GfMfVPk3zrBuxUcCphM7jqD
+KGdZNlndH9LqQhNc+ibrDu0Kwbz5z/FvYUo6knnC6TCvm2hrYlI0jf4CaHHQYM0X
+dCUCggEACvTEoQ7gZMeaqr+j8fhsisLoRRCtslqoU27jqWTriISlnlvjNHqYOWb/
+JXinuvpiYZG5jih6KXxa26H+Q5Pb4amVNPq/d6qBu5yv9qpD3mCJaHNHPByLScZ9
+G+pBW+y5JXHCdFJjo6G4ipLLpPglAPte/TmEnoShGsxtgOmupYSliNteAz0ykvSv
+At+UfdzdSY2uHC7JLnJjB7SeOz8YeXyE8KCBOAokxCjs0CdBeZDK46sti9umMuIr
+cDVIk/azvt5ex5sXP944Ds7tUs/qS1bdm5DsG4XYBkSKRhNqlkdxLumMPnGcwsZC
+JSRSgO3qryi1B/PjFld2fmtKpkffCw==
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/secp224r1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/secp224r1.key
new file mode 100644
index 000000000000..8c74cd9783d8
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/secp224r1.key
@@ -0,0 +1,5 @@
+-----BEGIN PRIVATE KEY-----
+MHgCAQAwEAYHKoZIzj0CAQYFK4EEACEEYTBfAgEBBByDAgBh6UOQqYJSPoiNWrK0
+rA0rTMLw8JdCEveBoTwDOgAEABwjlgBV9PPpMfo8fo6yWRdT+sb1cUdEhobd+V/D
+/i58cWpDqd4CApevJWtkGbwhU4J9mN0aYrk=
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/secp256k1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/secp256k1.key
new file mode 100644
index 000000000000..05ab7d4dd065
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/secp256k1.key
@@ -0,0 +1,5 @@
+-----BEGIN PRIVATE KEY-----
+MIGEAgEAMBAGByqGSM49AgEGBSuBBAAKBG0wawIBAQQgAjoYh3zy6gJciB7F/pHi
+fU+IfPBeEhgQBFT3zNvox5ihRANCAATTIv3AIcnOXMvnURBseRspHsowmPAxPgsx
+LH5+aU2mW06f2PsIe9F8gG/Nf2UOOuO+aqwEStIkfSBR+Fwpl2UR
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/secp256r1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/secp256r1.key
new file mode 100644
index 000000000000..2ff386caba17
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/secp256r1.key
@@ -0,0 +1,5 @@
+-----BEGIN PRIVATE KEY-----
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgSFp08cP6os9Fxmky
+o5VFi6woYEqvbhVNYTp9NvwzBCChRANCAAQo6UQaiHkZr7lxrKDZyL9Qinr4Vy0Q
+W3K2EPFYbVIupvzW0RH8vBy+0/yoaNyUsw/IsmBO+A60mhBB4rTjmq6R
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/secp384r1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/secp384r1.key
new file mode 100644
index 000000000000..f662e85f76c6
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/secp384r1.key
@@ -0,0 +1,6 @@
+-----BEGIN PRIVATE KEY-----
+MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCqdZTmuPvhbOVI/Vkk
+yjowWmBb1+81m+ay2QF15TZns1iX4HQlaOZ1GzaqhJRS0R2hZANiAASYw9dep7R9
+IoC8Dzt6T0NYOOJE/TPdOXOH+M6uJvKAtmXGalFeLQX6HKlUDNPnBDTKp9p6Nu/o
+2k/p2C9m0DaX70sNuyfOmh738Bw2Hlz3If+903Jj+hKSR4kWDkKYb/s=
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/secp521r1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/secp521r1.key
new file mode 100644
index 000000000000..e987b65b0e03
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/secp521r1.key
@@ -0,0 +1,8 @@
+-----BEGIN PRIVATE KEY-----
+MIHuAgEAMBAGByqGSM49AgEGBSuBBAAjBIHWMIHTAgEBBEIAdO/4HyMRu7/QcjYn
+f8vynFJRVOKHVVmRFrAzZwkdusoqf9gkicCxcxhOpOGLezzTH4XBynbcmmMn4PNS
+ZriTYO6hgYkDgYYABAAp8YOO/QeQoVEdzsOwZt1ta0/b5r0ESM/QNkBVgrRdCsJ+
+y3p/xis4wFlhv7lsrtuDoeuMimnvl+fAfptCzMKHugGBvSE0SjLgydEmUjh/y/a4
+O3cGqwUXnnxiLKJ98NFaooGYY+AwH4h77oCbQR1lf8jhe1qsJkR9mXpYuGnkaJ7l
+qg==
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/x25519.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/x25519.key
new file mode 100644
index 000000000000..affde0dce116
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/x25519.key
@@ -0,0 +1,3 @@
+-----BEGIN PRIVATE KEY-----
+MC4CAQAwBQYDK2VuBCIEINAdGSXck38nDXMgbRKqKiBPVuxDirhOs9VDE+NaokZz
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/x448-aes-256-cbc.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/x448-aes-256-cbc.key
new file mode 100644
index 000000000000..bb6e86a68f58
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/x448-aes-256-cbc.key
@@ -0,0 +1,6 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+MIGrMFcGCSqGSIb3DQEFDTBKMCkGCSqGSIb3DQEFDDAcBAjrmjUn6y1PFwICCAAw
+DAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEJuU2Lvx741TqKFa9X8bRGkEUPYI
+SNtLGe+fIcgz7rF8YaTnA0oeMsRp4RxBw/fsaEGrHUTM1ddjuyRzdKKNnghZIs0w
+zy/O8QNXDzrss5bnxZyHZA2XEvftHTH1Mw9jCtwA
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/x448.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/x448.key
new file mode 100644
index 000000000000..04b53654134e
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/pkcs8/x448.key
@@ -0,0 +1,4 @@
+-----BEGIN PRIVATE KEY-----
+MEYCAQAwBQYDK2VvBDoEOFRfvQhj134qZjH0wDbmPc90BADiqrpGZSae/sd8GG84
+au0ISBY6I7BJJZdiLLED+0abd0hLYAyl
+-----END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP256r1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP256r1.key
new file mode 100644
index 000000000000..b30036ae561d
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP256r1.key
@@ -0,0 +1,5 @@
+-----BEGIN EC PRIVATE KEY-----
+MHgCAQEEIBfCkWEWyc2tHIvSAo6hhcj09dnh8NOmtZeqGmcXHnIqoAsGCSskAwMC
+CAEBB6FEA0IABJbsd3pZks2/1qhGV29b3V8f7XCKQmKtr3sBvB9UopDKAAnhVUXI
+7st9YCeEPDO1EJq0ePll0qbVf1EOhpLd17k=
+-----END EC PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP256t1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP256t1.key
new file mode 100644
index 000000000000..2ef7cc4194e7
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP256t1.key
@@ -0,0 +1,5 @@
+-----BEGIN EC PRIVATE KEY-----
+MHgCAQEEIILhgc3joEZDWMDm9TYgrENN7gbqtMpMw1e2MTLwlJhCoAsGCSskAwMC
+CAEBCKFEA0IABIgzdtCT/Dvc0iuOrT+jJFyTzQH8jSENIToHEP/xcOVdMoklPgTx
+ng6CA7FvC5xJUtYSGVqtCVUDWYHuXotm7IU=
+-----END EC PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP320r1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP320r1.key
new file mode 100644
index 000000000000..f0cf5d5bd797
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP320r1.key
@@ -0,0 +1,6 @@
+-----BEGIN EC PRIVATE KEY-----
+MIGQAgEBBCg2J9Odvhrl05HKvxAVUztmTD9/8AyblPk9DqJVMLOufD+RnyCXCX4k
+oAsGCSskAwMCCAEBCaFUA1IABHSnWUC/tKXpNHGEP89QVKEgvetwCQWFoOENAgXO
+RniLiaLdAdsR80ouTsZiFgHG9su0l5ESEnFWQr5xUMj/vPwwhSYm+YP5ucx5Nezu
+BM4d
+-----END EC PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP320t1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP320t1.key
new file mode 100644
index 000000000000..17e60c9549cd
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP320t1.key
@@ -0,0 +1,6 @@
+-----BEGIN EC PRIVATE KEY-----
+MIGQAgEBBCi0aeKsFfr17MTtr3IgFByp4IybuFzlgAbwPjf3rK0Fr/b87+WicOU6
+oAsGCSskAwMCCAEBCqFUA1IABDQHDepa3l/S8Gt9WrNCNpCPZNBXvmkGPnVXZchZ
+I5BtUySwYxHX1tpatGs3jY7drVYm+NyxZE81pecYTvXR8bu7e3BIp2SwZmXEDxdY
+p1fw
+-----END EC PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP384r1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP384r1.key
new file mode 100644
index 000000000000..0d9028ebfc5e
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP384r1.key
@@ -0,0 +1,6 @@
+-----BEGIN EC PRIVATE KEY-----
+MIGoAgEBBDArrLo3J0sxM2YuEM0/JMMoR6ZNZNS4wZ8FDETP6VAoCrP9Kji4D59D
+Oiv+3y2j66KgCwYJKyQDAwIIAQELoWQDYgAEaK8X3KCyRDMpbACw2xG4UUe9Oxyu
+GWFaGKPxhKJDyW5Z56gT5P1Q2y4CblL/X9VcDIMXdcQqRNBkPQfy1+fJXwKO0Clf
+D6MIE3bv6PTZ55J6H2H1dpg38a2soRchz0FN
+-----END EC PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP384t1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP384t1.key
new file mode 100644
index 000000000000..39a94e293b32
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP384t1.key
@@ -0,0 +1,6 @@
+-----BEGIN EC PRIVATE KEY-----
+MIGoAgEBBDAEZRoS9O2fGbx85aPZzZxfj33Wu8aZ8a+K5ZZBFuVKKL+/0UbidDhD
+YiuAHL6GAr2gCwYJKyQDAwIIAQEMoWQDYgAEQL0dQoOTArIx70V/XxipoxxBeKT7
+zmIe7id5pQiw4O4nA2S2BFxQF9eW9ipnm6DaN6jaX/+2k+cC4qIfqzeLcLUFXxz0
+qdec8lNNtr9QmwoQlv11beeHmQu9C1GwHmvG
+-----END EC PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP512r1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP512r1.key
new file mode 100644
index 000000000000..ebd8ce77f7b1
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP512r1.key
@@ -0,0 +1,7 @@
+-----BEGIN EC PRIVATE KEY-----
+MIHaAgEBBECYRA0UwYgMuiMq12DxXvVZ1KzsWIGQToF+3bn7JjyiG6CT+7xop4go
+1W1KKsyBCEYUnL8EgGS8zpXUl+euCWSOoAsGCSskAwMCCAEBDaGBhQOBggAEYbjT
+nA0x42NdM7jVv7jAoZq0iOYopbwejlOEsx8/MqRaYt4Ef83holIsgOHWSeW+kw1o
+MDmieoCrhnkM/3KgGzV+BxCeieAWGxABsj9YhAmbATorRJ4q/pMxRq8gIUv05/dG
+uUttl1gdbKKnGQjxDBM4v5H+/4z00nzzj4Gbfx8=
+-----END EC PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP512t1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP512t1.key
new file mode 100644
index 000000000000..eedbcdb4b81c
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/brainpoolP512t1.key
@@ -0,0 +1,7 @@
+-----BEGIN EC PRIVATE KEY-----
+MIHaAgEBBEBNqbJnTzwGwg9JyyXBrQjXF8LT/q53Jydyv6L9AgU5c2vg5CJVvgoL
+8JL/YU+i1UwVIdl8JLZuCLpi+Dcy1rlIoAsGCSskAwMCCAEBDqGBhQOBggAEm/uI
+qsytMZypsqCuL0jwZh8xCRVEkUd02YPXcOBhMzS9bhAao4CLAuXhWzplr5qk/7tt
+vczl7qFDOvBzNAIieZHwbFrouZ6Pew8pQXRcMDB+FnXwNgpljTNmz/f1ePjVKU1Z
+gbQ+xVf8Qt8OI9S0Pla8siTgbVweGMLtq0A8Wuo=
+-----END EC PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/ssl/ec/key-ec-prime256v1-encrypted.pem b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/prime256v1-aes-128-cbc.key
similarity index 100%
rename from spring-boot-project/spring-boot/src/test/resources/ssl/ec/key-ec-prime256v1-encrypted.pem
rename to spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/prime256v1-aes-128-cbc.key
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/prime256v1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/prime256v1.key
new file mode 100644
index 000000000000..67fe6d1432bd
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/prime256v1.key
@@ -0,0 +1,5 @@
+-----BEGIN EC PRIVATE KEY-----
+MHcCAQEEILnPbqUzEJ/G/paAMenPKttHR2AX91fd++CN5CSA5gtUoAoGCCqGSM49
+AwEHoUQDQgAEZy7jsS+5+xqtds3fb6tp3gL+j2HNYVujNoCSkm3xWBobXt/Nozid
+k4MGk0C/yeSD0n7iSpJgDwSJfAnSSlpcAA==
+-----END EC PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/secp224r1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/secp224r1.key
new file mode 100644
index 000000000000..daf908848dd4
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/secp224r1.key
@@ -0,0 +1,5 @@
+-----BEGIN EC PRIVATE KEY-----
+MGgCAQEEHIMCAGHpQ5CpglI+iI1asrSsDStMwvDwl0IS94GgBwYFK4EEACGhPAM6
+AAQAHCOWAFX08+kx+jx+jrJZF1P6xvVxR0SGht35X8P+LnxxakOp3gICl68la2QZ
+vCFTgn2Y3RpiuQ==
+-----END EC PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/secp256k1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/secp256k1.key
new file mode 100644
index 000000000000..435ae6b0ddd2
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/secp256k1.key
@@ -0,0 +1,5 @@
+-----BEGIN EC PRIVATE KEY-----
+MHQCAQEEIDA00HY8HWvgF6YYE3GNc9cCxlHqL6gmeNXQjyrg7HCGoAcGBSuBBAAK
+oUQDQgAEwTINsajIo/W+G+UG2iAlGPjwl4HcCLix2q7rRmCcNO0Px/dddeFdgKqH
+HLWuyAKpRzn+BxuX8W3TqZnJONcYHw==
+-----END EC PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/secp256r1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/secp256r1.key
new file mode 100644
index 000000000000..b71eb2fe258d
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/secp256r1.key
@@ -0,0 +1,5 @@
+-----BEGIN EC PRIVATE KEY-----
+MHcCAQEEILoEofWgFpdy7y8VmPl31ZVoI0hRFwOStu2PpKlzpgdMoAoGCCqGSM49
+AwEHoUQDQgAEgr3Herakdcrvfr71ncnniKEH2Te6kcfhkHT62MzoL+kveMsY6NDu
+rd7NNt9Px1scfCzkpZZI3fe4m1lHMatQMw==
+-----END EC PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/secp384r1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/secp384r1.key
new file mode 100644
index 000000000000..3b75975ceac1
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/secp384r1.key
@@ -0,0 +1,6 @@
+-----BEGIN EC PRIVATE KEY-----
+MIGkAgEBBDBtqXb+BJ63n8LaSU1UU25vFg8yW998I+yJwXLCgqMuPDaxfY1py4KF
+mBX2kRNlBVygBwYFK4EEACKhZANiAASYI+WvOkNIX6vPYCBx57uUpFeEsK+9jbRG
+FGaqN0ip/aPMWLp3n6JlmO8Ug3xgk3qvy+gJdFBJsIWs/LiCJc/sEilUlg7JAd3J
+vFB22r5EORPqSGgHhdlBUM+9z8L8v2k=
+-----END EC PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/secp521r1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/secp521r1.key
new file mode 100644
index 000000000000..e6fcfe538cad
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/server/sec1/secp521r1.key
@@ -0,0 +1,7 @@
+-----BEGIN EC PRIVATE KEY-----
+MIHcAgEBBEIBbhPg/ddqjdlQ/u0GlA1O6bzbOaKVRn/fxzWVEOySNPMLwiOTddzy
+vfuKFliNFLTx0KV4523h5UQjFB6Kwh4pDuegBwYFK4EEACOhgYkDgYYABAAHrlzH
+UouimWUmKMrMBYlhLNQUzn0FahYNtOt8XxLhTUoo7ySLL4YvwZKYBp3ZMjqHyG+S
+Hcy9pkkQsF3vUP9rJAGzxg/TJUEQNd6k4AQS3qtLxA2p7Ygd2zU1Ed+OM/aaj9SD
+YxRQKIAqe6jZ2M71zy2oa/WZl+MNvgfb+Oq8YaPZqg==
+-----END EC PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-dsa.pem b/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-dsa.pem
deleted file mode 100644
index 1aa27e11c20f..000000000000
--- a/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-dsa.pem
+++ /dev/null
@@ -1,15 +0,0 @@
------BEGIN PRIVATE KEY-----
-MIICXAIBADCCAjUGByqGSM44BAEwggIoAoIBAQCPeTXZuarpv6vtiHrPSVG28y7F
-njuvNxjo6sSWHz79NgbnQ1GpxBgzObgJ58KuHFObp0dbhdARrbi0eYd1SYRpXKwO
-jxSzNggooi/6JxEKPWKpk0U0CaD+aWxGWPhL3SCBnDcJoBBXsZWtzQAjPbpUhLYp
-H51kjviDRIZ3l5zsBLQ0pqwudemYXeI9sCkvwRGMn/qdgYHnM423krcw17njSVkv
-aAmYchU5Feo9a4tGU8YzRY+AOzKkwuDycpAlbk4/ijsIOKHEUOThjBopo33fXqFD
-3ktm/wSQPtXPFiPhWNSHxgjpfyEc2B3KI8tuOAdl+CLjQr5ITAV2OTlgHNZnAh0A
-uvaWpoV499/e5/pnyXfHhe8ysjO65YDAvNVpXQKCAQAWplxYIEhQcE51AqOXVwQN
-NNo6NHjBVNTkpcAtJC7gT5bmHkvQkEq9rI837rHgnzGC0jyQQ8tkL4gAQWDt+coJ
-syB2p5wypifyRz6Rh5uixOdEvSCBVEy1W4AsNo0fqD7UielOD6BojjJCilx4xHjG
-jQUntxyaOrsLC+EsRGiWOefTznTbEBplqiuH9kxoJts+xy9LVZmDS7TtsC98kOmk
-ltOlXVNb6/xF1PYZ9j897buHOSXC8iTgdzEpbaiH7B5HSPh++1/et1SEMWsiMt7l
-U92vAhErDR8C2jCXMiT+J67ai51LKSLZuovjntnhA6Y8UoELxoi34u1DFuHvF9ve
-BB4CHHBQgJ3ST6U8rIxoTqGe42TiVckPf1PoSiJy8GY=
------END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-ec-ed25519.pem b/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-ec-ed25519.pem
deleted file mode 100644
index aa831e825f18..000000000000
--- a/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-ec-ed25519.pem
+++ /dev/null
@@ -1,3 +0,0 @@
------BEGIN PRIVATE KEY-----
-MC4CAQAwBQYDK2VwBCIEIJOKNTaIJQTVuEqZ+yvclnjnlWJG6F+K+VsNCOlWRda+
------END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-ec-nist-p256.pem b/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-ec-nist-p256.pem
deleted file mode 100644
index 8cd5d39294cf..000000000000
--- a/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-ec-nist-p256.pem
+++ /dev/null
@@ -1,6 +0,0 @@
------BEGIN PRIVATE KEY-----
-MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgd6SePFfpaTKFd1Gm
-+WeHZNkORkot5hx6X9elPdICL9ygCgYIKoZIzj0DAQehRANCAASnMAMgeFBv9ks0
-d0jP+utQ3mohwmxY93xljfaBofdg1IeHgDd4I4pBzPxEnvXrU3kcz+SgPZyH1ybl
-P6mSXDXu
------END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-ec-nist-p384.pem b/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-ec-nist-p384.pem
deleted file mode 100644
index 563b519588b7..000000000000
--- a/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-ec-nist-p384.pem
+++ /dev/null
@@ -1,7 +0,0 @@
------BEGIN PRIVATE KEY-----
-MIG/AgEAMBAGByqGSM49AgEGBSuBBAAiBIGnMIGkAgEBBDCexXiWKrtrqV1+d1Tv
-t1n5huuw2A+204mQHRuPL9UC8l0XniJjx/PVELCciyJM/7+gBwYFK4EEACKhZANi
-AASHEELZSdrHiSXqU1B+/jrOCr6yjxCMqQsetTb0q5WZdCXOhggGXfbzlRynqphQ
-i4G7azBUklgLaXfxN5eFk6C+E38SYOR7iippcQsSR2ZsCiTk7rnur4b40gQ7IgLA
-/sU=
------END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-ec-prime256v1.pem b/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-ec-prime256v1.pem
deleted file mode 100644
index 66c626d622ef..000000000000
--- a/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-ec-prime256v1.pem
+++ /dev/null
@@ -1,6 +0,0 @@
------BEGIN PRIVATE KEY-----
-MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg4dVuddgQ6enDvPPw
-Dd1mmS6FMm/kzTJjDVsltrNmRuSgCgYIKoZIzj0DAQehRANCAAR1WMrRADEaVj9m
-uoUfPhUefJK+lS89NHikQ0ZdkHkybyVKLFMLe1hCynhzpKQmnpgud3E10F0P2PZQ
-L9RCEpGf
------END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-ec-secp256r1.pem b/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-ec-secp256r1.pem
deleted file mode 100644
index adffc64637e7..000000000000
--- a/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-ec-secp256r1.pem
+++ /dev/null
@@ -1,6 +0,0 @@
------BEGIN PRIVATE KEY-----
-MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgU9+v5hUNnTKix8fe
-Pfz+NfXFlGxQZMReSCT2Id9PfKagCgYIKoZIzj0DAQehRANCAATeJg+YS4BrJ35A
-KgRlZ59yKLDpmENCMoaYUuWbQ9hqHzdybQGzQsrNJqgH0nzWghPwP4nFaLPN+pgB
-bqiRgbjG
------END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-rsa.pem b/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-rsa.pem
deleted file mode 100644
index 00d439edc6b0..000000000000
--- a/spring-boot-project/spring-boot/src/test/resources/ssl/pkcs8/key-rsa.pem
+++ /dev/null
@@ -1,28 +0,0 @@
------BEGIN PRIVATE KEY-----
-MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDR0KfxUw7MF/8R
-B5/YXOM7yLnoHYb/M/6dyoulMbtEdKKhQhU28o5FiDkHcEG9PJQLgqrRgAjl3VmC
-C9omtfZJQ2EpfkTttkJjnKOOroXhYE51/CYSckapBYCVh8GkjUEJuEfnp07cTfYZ
-FqViIgIWPZyjkzl3w4girS7kCuzNdDntVJVx5F/EsFwMA8n3C0QazHQoM5s00Fer
-6aTwd6AW0JD5QkADavpfzZ554e4HrVGwHlM28WKQQkFzzGu44FFXyVuEF3HeyVPu
-g8GRHAc8UU7ijVgJB5TmbvRGYowIErD5i4VvGLuOv9mgR3aVyN0SdJ1N7aJnXpeS
-QjAgf03jAgMBAAECggEBAIhQyzwj3WJGWOZkkLqOpufJotcmj/Wwf0VfOdkq9WMl
-cB/bAlN/xWVxerPVgDCFch4EWBzi1WUaqbOvJZ2u7QNubmr56aiTmJCFTVI/GyZx
-XqiTGN01N6lKtN7xo6LYTyAUhUsBTWAemrx0FSErvTVb9C/mUBj6hbEZ2XQ5kN5t
-7qYX4Lu0zyn7s1kX5SLtm5I+YRq7HSwB6wLy+DSroO71izZ/VPwME3SwT5SN+c87
-3dkklR7fumNd9dOpSWKrLPnq4aMko00rvIGc63xD1HrEpXUkB5v24YEn7HwCLEH7
-b8jrp79j2nCvvR47inpf+BR8FIWAHEOUUqCEzjQkdiECgYEA6ifjMM0f02KPeIs7
-zXd1lI7CUmJmzkcklCIpEbKWf/t/PHv3QgqIkJzERzRaJ8b+GhQ4zrSwAhrGUmI8
-kDkXIqe2/2ONgIOX2UOHYHyTDQZHnlXyDecvHUTqs2JQZCGBZkXyZ9i0j3BnTymC
-iZ8DvEa0nxsbP+U3rgzPQmXiQVMCgYEA5WN2Y/RndbriNsNrsHYRldbPO5nfV9rp
-cDzcQU66HRdK5VIdbXT9tlMYCJIZsSqE0tkOwTgEB/sFvF/tIHSCY5iO6hpIyk6g
-kkUzPcld4eM0dEPAge7SYUbakB9CMvA7MkDQSXQNFyZ0mH83+UikwT6uYHFh7+ox
-N1P+psDhXzECgYEA1gXLVQnIcy/9LxMkgDMWV8j8uMyUZysDthpbK3/uq+A2dhRg
-9g4msPd5OBQT65OpIjElk1n4HpRWfWqpLLHiAZ0GWPynk7W0D7P3gyuaRSdeQs0P
-x8FtgPVDCN9t13gAjHiWjnC26Py2kNbCKAQeJ/MAmQTvrUFX2VCACJKTcV0CgYAj
-xJWSUmrLfb+GQISLOG3Xim434e9keJsLyEGj4U29+YLRLTOvfJ2PD3fg5j8hU/rw
-Ea5uTHi8cdTcIa0M8X3fX8txD3YoLYh2JlouGTcNYOst8d6TpBSj3HN6I5Wj8beZ
-R2fy/CiKYpGtsbCdq0kdZNO18BgQW9kewncjs1GxEQKBgQCf8q34h6KuHpHSDh9h
-YkDTypk0FReWBAVJCzDNDUMhVLFivjcwtaMd2LiC3FMKZYodr52iKg60cj43vbYI
-frmFFxoL37rTmUocCTBKc0LhWj6MicI+rcvQYe1uwTrpWdFf1aZJMYRLRczeKtev
-OWaE/9hVZ5+9pild1NukGpOydw==
------END PRIVATE KEY-----
diff --git a/spring-boot-project/spring-boot/src/test/resources/test-ec-key.pem b/spring-boot-project/spring-boot/src/test/resources/test-ec-key.pem
deleted file mode 100644
index b3a1ce0bd8ea..000000000000
--- a/spring-boot-project/spring-boot/src/test/resources/test-ec-key.pem
+++ /dev/null
@@ -1,5 +0,0 @@
------BEGIN EC PRIVATE KEY-----
-MHcCAQEEIBEZhSR+d8kwL5L/K0f/eNBm4RfzyyA1jfg+dV1/8WvqoAoGCCqGSM49
-AwEHoUQDQgAEBbfdBTSUWuui7O2R+W9mDPjAHjgdBJsjrjnvkjnq8f/k4U/OqvjK
-qnHEZwYgdaF2WqYdqBYMns0n+tSMgBoonQ==
------END EC PRIVATE KEY-----
diff --git a/spring-boot-system-tests/spring-boot-deployment-tests/src/systemTest/java/org/springframework/boot/deployment/AbstractDeploymentTests.java b/spring-boot-system-tests/spring-boot-deployment-tests/src/systemTest/java/org/springframework/boot/deployment/AbstractDeploymentTests.java
index d10861b83313..1bcb4e948c4e 100644
--- a/spring-boot-system-tests/spring-boot-deployment-tests/src/systemTest/java/org/springframework/boot/deployment/AbstractDeploymentTests.java
+++ b/spring-boot-system-tests/spring-boot-deployment-tests/src/systemTest/java/org/springframework/boot/deployment/AbstractDeploymentTests.java
@@ -29,6 +29,7 @@
 import org.junit.jupiter.api.Test;
 import org.testcontainers.containers.GenericContainer;
 import org.testcontainers.images.builder.ImageFromDockerfile;
+import org.testcontainers.images.builder.dockerfile.DockerfileBuilder;
 
 import org.springframework.boot.test.web.client.TestRestTemplate;
 import org.springframework.boot.web.client.RestTemplateBuilder;
@@ -122,10 +123,18 @@ private void test(Consumer<TestRestTemplate> consumer) {
 	static final class WarDeploymentContainer extends GenericContainer<WarDeploymentContainer> {
 
 		WarDeploymentContainer(String baseImage, String deploymentLocation, int port) {
+			this(baseImage, deploymentLocation, port, null);
+		}
+
+		WarDeploymentContainer(String baseImage, String deploymentLocation, int port,
+				Consumer<DockerfileBuilder> dockerfileCustomizer) {
 			super(new ImageFromDockerfile().withFileFromFile("spring-boot.war", findWarToDeploy())
-				.withDockerfileFromBuilder((builder) -> builder.from(baseImage)
-					.add("spring-boot.war", deploymentLocation + "/spring-boot.war")
-					.build()));
+				.withDockerfileFromBuilder((builder) -> {
+					builder.from(baseImage).add("spring-boot.war", deploymentLocation + "/spring-boot.war");
+					if (dockerfileCustomizer != null) {
+						dockerfileCustomizer.accept(builder);
+					}
+				}));
 			withExposedPorts(port).withStartupTimeout(Duration.ofMinutes(5)).withStartupAttempts(3);
 		}
 
diff --git a/spring-boot-system-tests/spring-boot-deployment-tests/src/systemTest/java/org/springframework/boot/deployment/OpenLibertyDeploymentTests.java b/spring-boot-system-tests/spring-boot-deployment-tests/src/systemTest/java/org/springframework/boot/deployment/OpenLibertyDeploymentTests.java
new file mode 100644
index 000000000000..bb4f48876ba3
--- /dev/null
+++ b/spring-boot-system-tests/spring-boot-deployment-tests/src/systemTest/java/org/springframework/boot/deployment/OpenLibertyDeploymentTests.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.deployment;
+
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+/**
+ * Deployment tests for Open Liberty.
+ *
+ * @author Christoph Dreis
+ * @author Scott Frederick
+ */
+@Testcontainers(disabledWithoutDocker = true)
+class OpenLibertyDeploymentTests extends AbstractDeploymentTests {
+
+	private static final int PORT = 9080;
+
+	@Container
+	static WarDeploymentContainer container = new WarDeploymentContainer(
+			"icr.io/appcafe/open-liberty:full-java17-openj9-ubi", "/config/dropins", PORT,
+			(builder) -> builder.run("sed -i 's/javaee-8.0/jakartaee-10.0/g' /config/server.xml"));
+
+	@Override
+	WarDeploymentContainer getContainer() {
+		return container;
+	}
+
+	@Override
+	protected int getPort() {
+		return PORT;
+	}
+
+}
diff --git a/spring-boot-system-tests/spring-boot-deployment-tests/src/systemTest/java/org/springframework/boot/deployment/TomEEDeploymentTests.java b/spring-boot-system-tests/spring-boot-deployment-tests/src/systemTest/java/org/springframework/boot/deployment/TomEEDeploymentTests.java
new file mode 100644
index 000000000000..7bdc8e7d91c6
--- /dev/null
+++ b/spring-boot-system-tests/spring-boot-deployment-tests/src/systemTest/java/org/springframework/boot/deployment/TomEEDeploymentTests.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.deployment;
+
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+/**
+ * Deployment tests for TomEE.
+ *
+ * @author Christoph Dreis
+ * @author Scott Frederick
+ */
+@Testcontainers(disabledWithoutDocker = true)
+class TomEEDeploymentTests extends AbstractDeploymentTests {
+
+	@Container
+	static WarDeploymentContainer container = new WarDeploymentContainer("tomee:9.1.1-jre17-webprofile",
+			"/usr/local/tomee/webapps", DEFAULT_PORT);
+
+	@Override
+	WarDeploymentContainer getContainer() {
+		return container;
+	}
+
+}
diff --git a/spring-boot-system-tests/spring-boot-deployment-tests/src/systemTest/java/org/springframework/boot/deployment/TomcatDeploymentTests.java b/spring-boot-system-tests/spring-boot-deployment-tests/src/systemTest/java/org/springframework/boot/deployment/TomcatDeploymentTests.java
index 3e02d5618bcd..747959b68891 100644
--- a/spring-boot-system-tests/spring-boot-deployment-tests/src/systemTest/java/org/springframework/boot/deployment/TomcatDeploymentTests.java
+++ b/spring-boot-system-tests/spring-boot-deployment-tests/src/systemTest/java/org/springframework/boot/deployment/TomcatDeploymentTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -29,7 +29,7 @@
 class TomcatDeploymentTests extends AbstractDeploymentTests {
 
 	@Container
-	static WarDeploymentContainer container = new WarDeploymentContainer("tomcat:10.0.13-jdk17-openjdk",
+	static WarDeploymentContainer container = new WarDeploymentContainer("tomcat:10.1.15-jdk17",
 			"/usr/local/tomcat/webapps", DEFAULT_PORT);
 
 	@Override
diff --git a/spring-boot-system-tests/spring-boot-deployment-tests/src/systemTest/java/org/springframework/boot/deployment/WildflyDeploymentTests.java b/spring-boot-system-tests/spring-boot-deployment-tests/src/systemTest/java/org/springframework/boot/deployment/WildflyDeploymentTests.java
new file mode 100644
index 000000000000..3e138ece2c9e
--- /dev/null
+++ b/spring-boot-system-tests/spring-boot-deployment-tests/src/systemTest/java/org/springframework/boot/deployment/WildflyDeploymentTests.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.deployment;
+
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+/**
+ * Deployment tests for Wildfly.
+ *
+ * @author Christoph Dreis
+ * @author Scott Frederick
+ */
+@Testcontainers(disabledWithoutDocker = true)
+class WildflyDeploymentTests extends AbstractDeploymentTests {
+
+	@Container
+	static WarDeploymentContainer container = new WarDeploymentContainer("quay.io/wildfly/wildfly:27.0.0.Final-jdk17",
+			"/opt/jboss/wildfly/standalone/deployments", DEFAULT_PORT);
+
+	@Override
+	WarDeploymentContainer getContainer() {
+		return container;
+	}
+
+}
diff --git a/spring-boot-system-tests/spring-boot-image-tests/build.gradle b/spring-boot-system-tests/spring-boot-image-tests/build.gradle
index 9de3b9c9fbfe..e4236f1b82fc 100644
--- a/spring-boot-system-tests/spring-boot-image-tests/build.gradle
+++ b/spring-boot-system-tests/spring-boot-image-tests/build.gradle
@@ -19,6 +19,11 @@ configurations {
 				if (dependency.requested.group.startsWith("com.fasterxml.jackson")) {
 					dependency.useVersion("2.14.2")
 				}
+				// Downgrade Spring Framework as Gradle cannot cope with 6.1.0-M1's
+				// multi-version jar files with bytecode in META-INF/versions/21
+				if (dependency.requested.group.equals("org.springframework")) {
+					dependency.useVersion("6.0.10")
+				}
 			}
 		}
 	}
diff --git a/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/java/org/springframework/boot/image/paketo/PaketoBuilderTests.java b/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/java/org/springframework/boot/image/paketo/PaketoBuilderTests.java
index 1778b5d8b766..d9af5bd4bf44 100644
--- a/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/java/org/springframework/boot/image/paketo/PaketoBuilderTests.java
+++ b/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/java/org/springframework/boot/image/paketo/PaketoBuilderTests.java
@@ -46,6 +46,7 @@
 import org.springframework.boot.image.junit.GradleBuildInjectionExtension;
 import org.springframework.boot.testsupport.gradle.testkit.GradleBuild;
 import org.springframework.boot.testsupport.gradle.testkit.GradleBuildExtension;
+import org.springframework.boot.testsupport.gradle.testkit.GradleVersions;
 import org.springframework.util.StringUtils;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -60,7 +61,7 @@
  * @author Scott Frederick
  */
 @ExtendWith({ GradleBuildInjectionExtension.class, GradleBuildExtension.class })
-@EnabledForJreRange(max = JRE.JAVA_20)
+@EnabledForJreRange(max = JRE.JAVA_21)
 class PaketoBuilderTests {
 
 	GradleBuild gradleBuild;
@@ -72,6 +73,7 @@ void configureGradleBuild() {
 		this.gradleBuild.scriptPropertyFrom(new File("../../gradle.properties"), "nativeBuildToolsVersion");
 		this.gradleBuild.expectDeprecationMessages("BPL_SPRING_CLOUD_BINDINGS_ENABLED.*true.*Deprecated");
 		this.gradleBuild.expectDeprecationMessages("BOM table is deprecated");
+		this.gradleBuild.gradleVersion(GradleVersions.maximumCompatible());
 	}
 
 	@Test
@@ -91,9 +93,10 @@ void executableJarApp() throws Exception {
 					.contains("paketo-buildpacks/ca-certificates", "paketo-buildpacks/bellsoft-liberica",
 							"paketo-buildpacks/executable-jar", "paketo-buildpacks/dist-zip",
 							"paketo-buildpacks/spring-boot");
-				metadata.processOfType("web").containsExactly("java", "org.springframework.boot.loader.JarLauncher");
+				metadata.processOfType("web")
+					.containsExactly("java", "org.springframework.boot.loader.launch.JarLauncher");
 				metadata.processOfType("executable-jar")
-					.containsExactly("java", "org.springframework.boot.loader.JarLauncher");
+					.containsExactly("java", "org.springframework.boot.loader.launch.JarLauncher");
 			});
 			assertImageHasJvmSbomLayer(imageReference, config);
 			assertImageHasDependenciesSbomLayer(imageReference, config, "executable-jar");
@@ -236,9 +239,10 @@ void executableWarApp() throws Exception {
 					.contains("paketo-buildpacks/ca-certificates", "paketo-buildpacks/bellsoft-liberica",
 							"paketo-buildpacks/executable-jar", "paketo-buildpacks/dist-zip",
 							"paketo-buildpacks/spring-boot");
-				metadata.processOfType("web").containsExactly("java", "org.springframework.boot.loader.WarLauncher");
+				metadata.processOfType("web")
+					.containsExactly("java", "org.springframework.boot.loader.launch.WarLauncher");
 				metadata.processOfType("executable-jar")
-					.containsExactly("java", "org.springframework.boot.loader.WarLauncher");
+					.containsExactly("java", "org.springframework.boot.loader.launch.WarLauncher");
 			});
 			assertImageHasJvmSbomLayer(imageReference, config);
 			assertImageHasDependenciesSbomLayer(imageReference, config, "executable-jar");
@@ -298,6 +302,11 @@ void plainWarApp() throws Exception {
 	@EnabledForJreRange(max = JRE.JAVA_17)
 	void nativeApp() throws Exception {
 		this.gradleBuild.expectDeprecationMessages("uses or overrides a deprecated API");
+		this.gradleBuild.expectDeprecationMessages("has been deprecated and marked for removal");
+		// these deprecations are transitive from the Native Build Tools Gradle plugin
+		this.gradleBuild
+			.expectDeprecationMessages("has been deprecated. This is scheduled to be removed in Gradle 9.0");
+		this.gradleBuild.expectDeprecationMessages("upgrading_version_8.html#deprecated_access_to_convention");
 		writeMainClass();
 		String imageName = "paketo-integration/" + this.gradleBuild.getProjectDir().getName();
 		ImageReference imageReference = ImageReference.of(ImageName.of(imageName));
@@ -466,11 +475,9 @@ private String javaMajorVersion() {
 		if (javaVersion.startsWith("1.")) {
 			return javaVersion.substring(2, 3);
 		}
-		else {
-			int firstDotIndex = javaVersion.indexOf(".");
-			if (firstDotIndex != -1) {
-				return javaVersion.substring(0, firstDotIndex);
-			}
+		int firstDotIndex = javaVersion.indexOf(".");
+		if (firstDotIndex != -1) {
+			return javaVersion.substring(0, firstDotIndex);
 		}
 		return javaVersion;
 	}
diff --git a/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-bootDistZipJarApp.gradle b/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-bootDistZipJarApp.gradle
index 882dc4b51f6e..97b32d1ffa78 100644
--- a/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-bootDistZipJarApp.gradle
+++ b/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-bootDistZipJarApp.gradle
@@ -38,5 +38,5 @@ application {
 
 bootBuildImage {
 	archiveFile = bootDistZip.archiveFile
-	environment = ['BP_JVM_VERSION': project.targetCompatibility.getMajorVersion()]
+	environment = ['BP_JVM_VERSION': java.targetCompatibility.getMajorVersion()]
 }
\ No newline at end of file
diff --git a/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-plainDistZipJarApp.gradle b/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-plainDistZipJarApp.gradle
index 6a69d7963417..91a0707f0f81 100644
--- a/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-plainDistZipJarApp.gradle
+++ b/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-plainDistZipJarApp.gradle
@@ -38,5 +38,5 @@ application {
 
 bootBuildImage {
 	archiveFile = distZip.archiveFile
-	environment = ['BP_JVM_VERSION': project.targetCompatibility.getMajorVersion()]
+	environment = ['BP_JVM_VERSION': java.targetCompatibility.getMajorVersion()]
 }
\ No newline at end of file
diff --git a/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-plainWarApp.gradle b/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-plainWarApp.gradle
index e7e85487056d..ec964e92520f 100644
--- a/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-plainWarApp.gradle
+++ b/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-plainWarApp.gradle
@@ -31,5 +31,5 @@ war {
 
 bootBuildImage {
 	archiveFile = war.archiveFile
-	environment = ['BP_JVM_VERSION': project.targetCompatibility.getMajorVersion(), 'BP_TOMCAT_VERSION': '10.*']
+	environment = ['BP_JVM_VERSION': java.targetCompatibility.getMajorVersion(), 'BP_TOMCAT_VERSION': '10.*']
 }
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/build.gradle b/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/build.gradle
index fc08e3080ed3..759f19a60aa8 100644
--- a/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/build.gradle
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/build.gradle
@@ -2,10 +2,14 @@ plugins {
 	id "java"
 	id "org.springframework.boot.conventions"
 	id "org.springframework.boot.integration-test"
+	id "de.undercouch.download"
 }
 
 description = "Spring Boot Launch Script Integration Tests"
 
+def jdkVersion = "17.0.8.1+1"
+def jdkArch = "aarch64".equalsIgnoreCase(System.getProperty("os.arch")) ? "aarch64" : "amd64"
+
 configurations {
 	app
 }
@@ -38,6 +42,26 @@ task buildApp(type: GradleBuild) {
 	tasks  = ["build"]
 }
 
+task downloadJdk(type: Download) {
+	def destFolder = new File(project.gradle.gradleUserHomeDir, "caches/springboot/downloads/jdk/bellsoft")
+	destFolder.mkdirs()
+	src "https://download.bell-sw.com/java/${jdkVersion}/bellsoft-jdk${jdkVersion}-linux-${jdkArch}.tar.gz"
+	dest destFolder
+	tempAndMove true
+	overwrite false
+}
+
+task syncJdkDownloads(type: Sync) {
+	dependsOn downloadJdk
+	from "${project.gradle.gradleUserHomeDir}/caches/springboot/downloads/jdk/bellsoft/"
+	include "bellsoft-jdk${jdkVersion}-linux-${jdkArch}.tar.gz"
+	into "${project.buildDir}/downloads/jdk/bellsoft/"
+}
+
+processIntTestResources {
+	dependsOn syncJdkDownloads
+}
+
 intTest {
 	dependsOn buildApp
 }
\ No newline at end of file
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/java/org/springframework/boot/launchscript/AbstractLaunchScriptIntegrationTests.java b/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/java/org/springframework/boot/launchscript/AbstractLaunchScriptIntegrationTests.java
index 9dfc5cbd4adc..621808beb922 100644
--- a/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/java/org/springframework/boot/launchscript/AbstractLaunchScriptIntegrationTests.java
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/java/org/springframework/boot/launchscript/AbstractLaunchScriptIntegrationTests.java
@@ -43,6 +43,7 @@
  * @author Andy Wilkinson
  * @author Ali Shahbour
  * @author Alexey Vinogradov
+ * @author Moritz Halbritter
  */
 abstract class AbstractLaunchScriptIntegrationTests {
 
@@ -99,9 +100,7 @@ protected String doTest(String os, String version, String script) throws Excepti
 	private static final class LaunchScriptTestContainer extends GenericContainer<LaunchScriptTestContainer> {
 
 		private LaunchScriptTestContainer(String os, String version, String scriptsDir, String testScript) {
-			super(new ImageFromDockerfile("spring-boot-launch-script/" + os.toLowerCase() + "-" + version)
-				.withFileFromFile("Dockerfile",
-						new File("src/intTest/resources/conf/" + os + "/" + version + "/Dockerfile")));
+			super(createImage(os, version));
 			withCopyFileToContainer(MountableFile.forHostPath(findApplication().getAbsolutePath()), "/app.jar");
 			withCopyFileToContainer(
 					MountableFile.forHostPath("src/intTest/resources/scripts/" + scriptsDir + "test-functions.sh"),
@@ -114,6 +113,17 @@ private LaunchScriptTestContainer(String os, String version, String scriptsDir,
 			withStartupCheckStrategy(new OneShotStartupCheckStrategy().withTimeout(Duration.ofMinutes(5)));
 		}
 
+		private static ImageFromDockerfile createImage(String os, String version) {
+			ImageFromDockerfile image = new ImageFromDockerfile(
+					"spring-boot-launch-script/" + os.toLowerCase() + "-" + version);
+			image.withFileFromFile("Dockerfile",
+					new File("src/intTest/resources/conf/" + os + "/" + version + "/Dockerfile"));
+			for (File file : new File("build/downloads/jdk/bellsoft").listFiles()) {
+				image.withFileFromFile("downloads/" + file.getName(), file);
+			}
+			return image;
+		}
+
 		private static File findApplication() {
 			String name = String.format("build/%1$s/build/libs/%1$s.jar", "spring-boot-launch-script-tests-app");
 			File jar = new File(name);
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/java/org/springframework/boot/launchscript/JarLaunchScriptIntegrationTests.java b/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/java/org/springframework/boot/launchscript/JarLaunchScriptIntegrationTests.java
index 511fbdf12d74..e0f23f04bc8a 100644
--- a/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/java/org/springframework/boot/launchscript/JarLaunchScriptIntegrationTests.java
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/java/org/springframework/boot/launchscript/JarLaunchScriptIntegrationTests.java
@@ -18,11 +18,9 @@
 
 import java.util.List;
 
-import org.junit.jupiter.api.condition.OS;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.MethodSource;
 
-import org.springframework.boot.testsupport.junit.DisabledOnOs;
 import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -34,8 +32,6 @@
  * @author Andy Wilkinson
  */
 @DisabledIfDockerUnavailable
-@DisabledOnOs(os = { OS.LINUX, OS.MAC }, architecture = "aarch64",
-		disabledReason = "The docker images have no ARM support")
 class JarLaunchScriptIntegrationTests extends AbstractLaunchScriptIntegrationTests {
 
 	JarLaunchScriptIntegrationTests() {
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/java/org/springframework/boot/launchscript/SysVinitLaunchScriptIntegrationTests.java b/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/java/org/springframework/boot/launchscript/SysVinitLaunchScriptIntegrationTests.java
index 26f10fb169ac..6fb4b23a4be7 100644
--- a/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/java/org/springframework/boot/launchscript/SysVinitLaunchScriptIntegrationTests.java
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/java/org/springframework/boot/launchscript/SysVinitLaunchScriptIntegrationTests.java
@@ -19,13 +19,10 @@
 import java.util.List;
 import java.util.regex.Pattern;
 
-import org.junit.jupiter.api.Assumptions;
-import org.junit.jupiter.api.condition.OS;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.MethodSource;
 
 import org.springframework.boot.ansi.AnsiColor;
-import org.springframework.boot.testsupport.junit.DisabledOnOs;
 import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -36,10 +33,9 @@
  * @author Andy Wilkinson
  * @author Ali Shahbour
  * @author Alexey Vinogradov
+ * @author Moritz Halbritter
  */
 @DisabledIfDockerUnavailable
-@DisabledOnOs(os = { OS.LINUX, OS.MAC }, architecture = "aarch64",
-		disabledReason = "The docker images have no ARM support")
 class SysVinitLaunchScriptIntegrationTests extends AbstractLaunchScriptIntegrationTests {
 
 	SysVinitLaunchScriptIntegrationTests() {
@@ -47,7 +43,7 @@ class SysVinitLaunchScriptIntegrationTests extends AbstractLaunchScriptIntegrati
 	}
 
 	static List<Object[]> parameters() {
-		return filterParameters((file) -> !file.getName().contains("CentOS"));
+		return filterParameters((file) -> !file.getName().contains("RedHat"));
 	}
 
 	@ParameterizedTest(name = "{0} {1}")
@@ -194,8 +190,6 @@ void launchWithMultipleJavaOpts(String os, String version) throws Exception {
 	@ParameterizedTest(name = "{0} {1}")
 	@MethodSource("parameters")
 	void launchWithUseOfStartStopDaemonDisabled(String os, String version) throws Exception {
-		// CentOS doesn't have start-stop-daemon
-		Assumptions.assumeFalse(os.equals("CentOS"));
 		doLaunch(os, version, "launch-with-use-of-start-stop-daemon-disabled.sh");
 	}
 
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/resources/conf/CentOS/7.9-e4ca2ed0/Dockerfile b/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/resources/conf/CentOS/7.9-e4ca2ed0/Dockerfile
deleted file mode 100644
index e4b9c943d526..000000000000
--- a/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/resources/conf/CentOS/7.9-e4ca2ed0/Dockerfile
+++ /dev/null
@@ -1,7 +0,0 @@
-# CentOS 7.9 from 18/11/2020
-FROM centos@sha256:e4ca2ed0202e76be184e75fb26d14bf974193579039d5573fb2348664deef76e
-RUN mkdir -p /opt/openjdk && \
-    cd /opt/openjdk && \
-    curl -L https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.1%2B12/OpenJDK17U-jdk_x64_linux_hotspot_17.0.1_12.tar.gz | tar zx --strip-components=1
-ENV JAVA_HOME /opt/openjdk
-ENV PATH $JAVA_HOME/bin:$PATH
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/resources/conf/RedHat/ubi9-9.2-722/Dockerfile b/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/resources/conf/RedHat/ubi9-9.2-722/Dockerfile
new file mode 100644
index 000000000000..777f1a3cd937
--- /dev/null
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/resources/conf/RedHat/ubi9-9.2-722/Dockerfile
@@ -0,0 +1,10 @@
+FROM redhat/ubi9:9.2-722 as prepare
+COPY downloads/* /opt/download/
+RUN mkdir -p /opt/jdk && \
+    cd /opt/jdk && \
+    tar xzf  /opt/download/* --strip-components=1
+
+FROM redhat/ubi9:9.2-722
+COPY --from=prepare /opt/jdk /opt/jdk
+ENV JAVA_HOME /opt/jdk
+ENV PATH $JAVA_HOME/bin:$PATH
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/resources/conf/Ubuntu/jammy-20230624/Dockerfile b/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/resources/conf/Ubuntu/jammy-20230624/Dockerfile
new file mode 100644
index 000000000000..9aa2d4f2bbe9
--- /dev/null
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/resources/conf/Ubuntu/jammy-20230624/Dockerfile
@@ -0,0 +1,11 @@
+FROM ubuntu:jammy-20231004 as prepare
+COPY downloads/* /opt/download/
+RUN mkdir -p /opt/jdk && \
+    cd /opt/jdk && \
+    tar xzf  /opt/download/* --strip-components=1
+
+FROM ubuntu:jammy-20231004
+RUN apt-get update && apt-get install -y software-properties-common curl
+COPY --from=prepare /opt/jdk /opt/jdk
+ENV JAVA_HOME /opt/jdk
+ENV PATH $JAVA_HOME/bin:$PATH
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/resources/conf/Ubuntu/trusty-20160914/Dockerfile b/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/resources/conf/Ubuntu/trusty-20160914/Dockerfile
deleted file mode 100644
index ad8888e1f5d3..000000000000
--- a/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/resources/conf/Ubuntu/trusty-20160914/Dockerfile
+++ /dev/null
@@ -1,8 +0,0 @@
-FROM ubuntu:trusty-20160914
-RUN apt-get update && \
-    apt-get install -y software-properties-common curl && \
-    mkdir -p /opt/openjdk && \
-    cd /opt/openjdk && \
-    curl -L https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.1%2B12/OpenJDK17U-jdk_x64_linux_hotspot_17.0.1_12.tar.gz | tar zx --strip-components=1
-ENV JAVA_HOME /opt/openjdk
-ENV PATH $JAVA_HOME/bin:$PATH
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/resources/conf/Ubuntu/xenial-20160914/Dockerfile b/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/resources/conf/Ubuntu/xenial-20160914/Dockerfile
deleted file mode 100644
index 7f8eaacf2e77..000000000000
--- a/spring-boot-tests/spring-boot-integration-tests/spring-boot-launch-script-tests/src/intTest/resources/conf/Ubuntu/xenial-20160914/Dockerfile
+++ /dev/null
@@ -1,8 +0,0 @@
-FROM ubuntu:xenial-20160914
-RUN apt-get update && \
-    apt-get install -y software-properties-common curl && \
-    mkdir -p /opt/openjdk && \
-    cd /opt/openjdk && \
-    curl -L https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.1%2B12/OpenJDK17U-jdk_x64_linux_hotspot_17.0.1_12.tar.gz | tar zx --strip-components=1
-ENV JAVA_HOME /opt/openjdk
-ENV PATH $JAVA_HOME/bin:$PATH
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/build.gradle b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/build.gradle
new file mode 100644
index 000000000000..d05a3d6c9e09
--- /dev/null
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/build.gradle
@@ -0,0 +1,44 @@
+plugins {
+	id "java"
+	id "org.springframework.boot.conventions"
+	id "org.springframework.boot.integration-test"
+}
+
+description = "Spring Boot Classic Loader Integration Tests"
+
+configurations {
+	app
+}
+
+dependencies {
+	app project(path: ":spring-boot-project:spring-boot-dependencies", configuration: "mavenRepository")
+	app project(path: ":spring-boot-project:spring-boot-tools:spring-boot-gradle-plugin", configuration: "mavenRepository")
+	app project(path: ":spring-boot-project:spring-boot-starters:spring-boot-starter-web", configuration: "mavenRepository")
+
+	intTestImplementation(enforcedPlatform(project(":spring-boot-project:spring-boot-parent")))
+	intTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support"))
+	intTestImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test"))
+	intTestImplementation("org.testcontainers:junit-jupiter")
+	intTestImplementation("org.testcontainers:testcontainers")
+}
+
+task syncMavenRepository(type: Sync) {
+	from configurations.app
+	into "${buildDir}/int-test-maven-repository"
+}
+
+task syncAppSource(type: org.springframework.boot.build.SyncAppSource) {
+	sourceDirectory = file("spring-boot-loader-classic-tests-app")
+	destinationDirectory = file("${buildDir}/spring-boot-loader-classic-tests-app")
+}
+
+task buildApp(type: GradleBuild) {
+	dependsOn syncAppSource, syncMavenRepository
+	dir = "${buildDir}/spring-boot-loader-classic-tests-app"
+	startParameter.buildCacheEnabled = false
+	tasks  = ["build"]
+}
+
+intTest {
+	dependsOn buildApp
+}
\ No newline at end of file
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/spring-boot-loader-classic-tests-app/build.gradle b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/spring-boot-loader-classic-tests-app/build.gradle
new file mode 100644
index 000000000000..16f7a57ebe55
--- /dev/null
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/spring-boot-loader-classic-tests-app/build.gradle
@@ -0,0 +1,22 @@
+plugins {
+	id "java"
+	id "org.springframework.boot"
+}
+
+apply plugin: "io.spring.dependency-management"
+
+repositories {
+	maven { url "file:${rootDir}/../int-test-maven-repository"}
+	mavenCentral()
+	maven { url "https://repo.spring.io/snapshot" }
+	maven { url "https://repo.spring.io/milestone" }
+}
+
+dependencies {
+	implementation("org.springframework.boot:spring-boot-starter-web")
+	implementation("org.webjars:jquery:3.5.0")
+}
+
+bootJar {
+	loaderImplementation = org.springframework.boot.loader.tools.LoaderImplementation.CLASSIC
+}
\ No newline at end of file
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/spring-boot-loader-classic-tests-app/settings.gradle b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/spring-boot-loader-classic-tests-app/settings.gradle
new file mode 100644
index 000000000000..06d9554ad0d6
--- /dev/null
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/spring-boot-loader-classic-tests-app/settings.gradle
@@ -0,0 +1,15 @@
+pluginManagement {
+	repositories {
+		maven { url "file:${rootDir}/../int-test-maven-repository"}
+		mavenCentral()
+		maven { url "https://repo.spring.io/snapshot" }
+		maven { url "https://repo.spring.io/milestone" }
+	}
+	resolutionStrategy {
+		eachPlugin {
+			if (requested.id.id == "org.springframework.boot") {
+				useModule "org.springframework.boot:spring-boot-gradle-plugin:${requested.version}"
+			}
+		}
+	}
+}
\ No newline at end of file
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/spring-boot-loader-classic-tests-app/src/main/java/org/springframework/boot/loaderapp/LoaderTestApplication.java b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/spring-boot-loader-classic-tests-app/src/main/java/org/springframework/boot/loaderapp/LoaderTestApplication.java
new file mode 100644
index 000000000000..0c9d429350d8
--- /dev/null
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/spring-boot-loader-classic-tests-app/src/main/java/org/springframework/boot/loaderapp/LoaderTestApplication.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loaderapp;
+
+import java.io.File;
+import java.net.JarURLConnection;
+import java.net.URL;
+import java.util.Arrays;
+
+import jakarta.servlet.ServletContext;
+
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+import org.springframework.util.FileCopyUtils;
+
+@SpringBootApplication
+public class LoaderTestApplication {
+
+	@Bean
+	public CommandLineRunner commandLineRunner(ServletContext servletContext) {
+		return (args) -> {
+			File temp = new File(System.getProperty("java.io.tmpdir"));
+			URL resourceUrl = servletContext.getResource("webjars/jquery/3.5.0/jquery.js");
+			JarURLConnection connection = (JarURLConnection) resourceUrl.openConnection();
+			String jarName = connection.getJarFile().getName();
+			System.out.println(">>>>> jar file " + jarName);
+			if(jarName.contains(temp.getAbsolutePath())) {
+				System.out.println(">>>>> jar written to temp");
+			}
+			byte[] resourceContent = FileCopyUtils.copyToByteArray(resourceUrl.openStream());
+			URL directUrl = new URL(resourceUrl.toExternalForm());
+			byte[] directContent = FileCopyUtils.copyToByteArray(directUrl.openStream());
+			String message = (!Arrays.equals(resourceContent, directContent)) ? "NO MATCH"
+					: directContent.length + " BYTES";
+			System.out.println(">>>>> " + message + " from " + resourceUrl);
+		};
+	}
+
+	public static void main(String[] args) {
+		SpringApplication.run(LoaderTestApplication.class, args).close();
+	}
+
+}
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/java/org/springframework/boot/loader/LoaderIntegrationTests.java b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/java/org/springframework/boot/loader/LoaderIntegrationTests.java
new file mode 100644
index 000000000000..b11478b61c7f
--- /dev/null
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/java/org/springframework/boot/loader/LoaderIntegrationTests.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader;
+
+import java.io.File;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Supplier;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.output.ToStringConsumer;
+import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy;
+import org.testcontainers.images.builder.ImageFromDockerfile;
+import org.testcontainers.utility.DockerImageName;
+import org.testcontainers.utility.MountableFile;
+
+import org.springframework.boot.system.JavaVersion;
+import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable;
+import org.springframework.util.Assert;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests loader that supports uber jars.
+ *
+ * @author Phillip Webb
+ * @author Moritz Halbritter
+ */
+@DisabledIfDockerUnavailable
+class LoaderIntegrationTests {
+
+	private final ToStringConsumer output = new ToStringConsumer();
+
+	@ParameterizedTest
+	@MethodSource("javaRuntimes")
+	void readUrlsWithoutWarning(JavaRuntime javaRuntime) {
+		try (GenericContainer<?> container = createContainer(javaRuntime)) {
+			container.start();
+			System.out.println(this.output.toUtf8String());
+			assertThat(this.output.toUtf8String()).contains(">>>>> 287649 BYTES from")
+				.doesNotContain("WARNING:")
+				.doesNotContain("illegal")
+				.doesNotContain("jar written to temp");
+		}
+	}
+
+	private GenericContainer<?> createContainer(JavaRuntime javaRuntime) {
+		return javaRuntime.getContainer()
+			.withLogConsumer(this.output)
+			.withCopyFileToContainer(MountableFile.forHostPath(findApplication().toPath()), "/app.jar")
+			.withStartupCheckStrategy(new OneShotStartupCheckStrategy().withTimeout(Duration.ofMinutes(5)))
+			.withCommand("java", "-jar", "app.jar");
+	}
+
+	private File findApplication() {
+		String name = String.format("build/%1$s/build/libs/%1$s.jar", "spring-boot-loader-classic-tests-app");
+		File jar = new File(name);
+		Assert.state(jar.isFile(), () -> "Could not find " + name + ". Have you built it?");
+		return jar;
+	}
+
+	static Stream<JavaRuntime> javaRuntimes() {
+		List<JavaRuntime> javaRuntimes = new ArrayList<>();
+		javaRuntimes.add(JavaRuntime.openJdk(JavaVersion.SEVENTEEN));
+		javaRuntimes.add(JavaRuntime.openJdk(JavaVersion.TWENTY_ONE));
+		javaRuntimes.add(JavaRuntime.oracleJdk17());
+		return javaRuntimes.stream().filter(JavaRuntime::isCompatible);
+	}
+
+	static final class JavaRuntime {
+
+		private final String name;
+
+		private final JavaVersion version;
+
+		private final Supplier<GenericContainer<?>> container;
+
+		private JavaRuntime(String name, JavaVersion version, Supplier<GenericContainer<?>> container) {
+			this.name = name;
+			this.version = version;
+			this.container = container;
+		}
+
+		private boolean isCompatible() {
+			return this.version.isEqualOrNewerThan(JavaVersion.getJavaVersion());
+		}
+
+		GenericContainer<?> getContainer() {
+			return this.container.get();
+		}
+
+		@Override
+		public String toString() {
+			return this.name;
+		}
+
+		static JavaRuntime openJdk(JavaVersion version) {
+			String imageVersion = version.toString();
+			DockerImageName image = DockerImageName.parse("bellsoft/liberica-openjdk-debian:" + imageVersion);
+			return new JavaRuntime("OpenJDK " + imageVersion, version, () -> new GenericContainer<>(image));
+		}
+
+		static JavaRuntime oracleJdk17() {
+			String arch = System.getProperty("os.arch");
+			String dockerFile = ("aarch64".equals(arch)) ? "Dockerfile-aarch64" : "Dockerfile";
+			ImageFromDockerfile image = new ImageFromDockerfile("spring-boot-loader/oracle-jdk-17")
+				.withFileFromFile("Dockerfile", new File("src/intTest/resources/conf/oracle-jdk-17/" + dockerFile));
+			return new JavaRuntime("Oracle JDK 17", JavaVersion.SEVENTEEN, () -> new GenericContainer<>(image));
+		}
+
+	}
+
+}
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/conf/oracle-jdk-17/Dockerfile b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/conf/oracle-jdk-17/Dockerfile
new file mode 100644
index 000000000000..2a50709dc5a5
--- /dev/null
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/conf/oracle-jdk-17/Dockerfile
@@ -0,0 +1,8 @@
+FROM ubuntu:jammy-20230624
+RUN apt-get update && \
+    apt-get install -y software-properties-common curl && \
+    mkdir -p /opt/oraclejdk && \
+    cd /opt/oraclejdk && \
+    curl -L https://download.oracle.com/java/17/latest/jdk-17_linux-x64_bin.tar.gz | tar zx --strip-components=1
+ENV JAVA_HOME /opt/oraclejdk
+ENV PATH $JAVA_HOME/bin:$PATH
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/conf/oracle-jdk-17/Dockerfile-aarch64 b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/conf/oracle-jdk-17/Dockerfile-aarch64
new file mode 100644
index 000000000000..3f8614c7a219
--- /dev/null
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/conf/oracle-jdk-17/Dockerfile-aarch64
@@ -0,0 +1,8 @@
+FROM ubuntu:jammy-20230624
+RUN apt-get update && \
+    apt-get install -y software-properties-common curl && \
+    mkdir -p /opt/oraclejdk && \
+    cd /opt/oraclejdk && \
+    curl -L https://download.oracle.com/java/17/archive/jdk-17.0.8_linux-aarch64_bin.tar.gz | tar zx --strip-components=1
+ENV JAVA_HOME /opt/oraclejdk
+ENV PATH $JAVA_HOME/bin:$PATH
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/conf/oracle-jdk-17/README.adoc b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/conf/oracle-jdk-17/README.adoc
new file mode 100644
index 000000000000..28704af225f5
--- /dev/null
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/conf/oracle-jdk-17/README.adoc
@@ -0,0 +1,5 @@
+This folder contains a Dockerfile that will create an Oracle JDK instance for use in integration tests.
+The resulting Docker image should not be published.
+
+Oracle JDK is subject to the https://www.oracle.com/downloads/licenses/no-fee-license.html["Oracle No-Fee Terms and Conditions" License (NFTC)] license.
+We are specifically using the unmodified JDK for the purposes of developing and testing.
diff --git a/spring-boot-tests/spring-boot-deployment-tests/src/intTest/resources/logback.xml b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/logback.xml
similarity index 100%
rename from spring-boot-tests/spring-boot-deployment-tests/src/intTest/resources/logback.xml
rename to spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/logback.xml
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/build.gradle b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/build.gradle
index 7c4095f73b1a..9292480aaa63 100644
--- a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/build.gradle
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/build.gradle
@@ -2,10 +2,14 @@ plugins {
 	id "java"
 	id "org.springframework.boot.conventions"
 	id "org.springframework.boot.integration-test"
+	id "de.undercouch.download"
 }
 
 description = "Spring Boot Loader Integration Tests"
 
+def oracleJdkVersion = "17.0.8"
+def oracleJdkArch = "aarch64".equalsIgnoreCase(System.getProperty("os.arch")) ? "aarch64" : "x64"
+
 configurations {
 	app
 }
@@ -14,6 +18,8 @@ dependencies {
 	app project(path: ":spring-boot-project:spring-boot-dependencies", configuration: "mavenRepository")
 	app project(path: ":spring-boot-project:spring-boot-tools:spring-boot-gradle-plugin", configuration: "mavenRepository")
 	app project(path: ":spring-boot-project:spring-boot-starters:spring-boot-starter-web", configuration: "mavenRepository")
+	app project(path: ":spring-boot-project:spring-boot-starters:spring-boot-starter", configuration: "mavenRepository")
+	app("org.bouncycastle:bcprov-jdk18on:1.76")
 
 	intTestImplementation(enforcedPlatform(project(":spring-boot-project:spring-boot-parent")))
 	intTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support"))
@@ -39,6 +45,38 @@ task buildApp(type: GradleBuild) {
 	tasks  = ["build"]
 }
 
+task syncSignedJarAppSource(type: org.springframework.boot.build.SyncAppSource) {
+	sourceDirectory = file("spring-boot-loader-tests-signed-jar")
+	destinationDirectory = file("${buildDir}/spring-boot-loader-tests-signed-jar")
+}
+
+task buildSignedJarApp(type: GradleBuild) {
+	dependsOn syncSignedJarAppSource, syncMavenRepository
+	dir = "${buildDir}/spring-boot-loader-tests-signed-jar"
+	startParameter.buildCacheEnabled = false
+	tasks  = ["build"]
+}
+
+task downloadJdk(type: Download) {
+	def destFolder = new File(project.gradle.gradleUserHomeDir, "caches/springboot/downloads/jdk/oracle")
+	destFolder.mkdirs()
+	src "https://download.oracle.com/java/17/archive/jdk-${oracleJdkVersion}_linux-${oracleJdkArch}_bin.tar.gz"
+	dest destFolder
+	tempAndMove true
+	overwrite false
+}
+
+task syncJdkDownloads(type: Sync) {
+	dependsOn downloadJdk
+	from "${project.gradle.gradleUserHomeDir}/caches/springboot/downloads/jdk/oracle/"
+	include "jdk-${oracleJdkVersion}_linux-${oracleJdkArch}_bin.tar.gz"
+	into "${project.buildDir}/downloads/jdk/oracle/"
+}
+
+processIntTestResources {
+	dependsOn syncJdkDownloads
+}
+
 intTest {
-	dependsOn buildApp
-}
\ No newline at end of file
+	dependsOn buildApp, buildSignedJarApp
+}
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/build.gradle b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/build.gradle
index 37596c620634..8f8cf37e3aae 100644
--- a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/build.gradle
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/build.gradle
@@ -1,6 +1,8 @@
 plugins {
 	id "java"
 	id "org.springframework.boot"
+//  id 'org.springframework.boot' version '3.1.4'
+//  id 'io.spring.dependency-management' version '1.1.3'
 }
 
 apply plugin: "io.spring.dependency-management"
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/src/main/java/org/springframework/boot/loaderapp/LoaderTestApplication.java b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/src/main/java/org/springframework/boot/loaderapp/LoaderTestApplication.java
index 81b7c41cbd3a..245b471b7900 100644
--- a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/src/main/java/org/springframework/boot/loaderapp/LoaderTestApplication.java
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/src/main/java/org/springframework/boot/loaderapp/LoaderTestApplication.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -17,8 +17,13 @@
 package org.springframework.boot.loaderapp;
 
 import java.io.File;
+import java.io.IOException;
+import java.io.UncheckedIOException;
 import java.net.JarURLConnection;
 import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.util.Arrays;
 
 import jakarta.servlet.ServletContext;
@@ -27,6 +32,8 @@
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
 import org.springframework.context.annotation.Bean;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.core.io.Resource;
 import org.springframework.util.FileCopyUtils;
 
 @SpringBootApplication
@@ -49,11 +56,22 @@ public CommandLineRunner commandLineRunner(ServletContext servletContext) {
 			String message = (!Arrays.equals(resourceContent, directContent)) ? "NO MATCH"
 					: directContent.length + " BYTES";
 			System.out.println(">>>>> " + message + " from " + resourceUrl);
+			testGh7161();
 		};
 	}
 
+	private void testGh7161() {
+		try {
+			Resource resource = new ClassPathResource("gh-7161");
+			Path path = Paths.get(resource.getURI());
+			System.out.println(">>>>> gh-7161 " + Files.list(path).toList());
+		} catch (IOException ex) {
+			throw new UncheckedIOException(ex);
+		}
+	}
+
 	public static void main(String[] args) {
-		SpringApplication.run(LoaderTestApplication.class, args).stop();
+		SpringApplication.run(LoaderTestApplication.class, args).close();
 	}
 
 }
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/src/main/resources/gh-7161/example.txt b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/src/main/resources/gh-7161/example.txt
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-signed-jar/build.gradle b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-signed-jar/build.gradle
new file mode 100644
index 000000000000..7ca8a2712496
--- /dev/null
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-signed-jar/build.gradle
@@ -0,0 +1,30 @@
+import org.springframework.boot.gradle.tasks.bundling.BootJar
+
+plugins {
+	id "java"
+	id "org.springframework.boot"
+}
+
+apply plugin: "io.spring.dependency-management"
+
+repositories {
+	maven { url "file:${rootDir}/../int-test-maven-repository"}
+	mavenCentral()
+	maven { url "https://repo.spring.io/snapshot" }
+	maven { url "https://repo.spring.io/milestone" }
+}
+
+dependencies {
+	implementation("org.springframework.boot:spring-boot-starter")
+	implementation("org.bouncycastle:bcprov-jdk18on:1.76")
+}
+
+tasks.register("bootJarUnpack", BootJar.class) {
+	mainClass = "org.springframework.boot.loaderapp.LoaderSignedJarTestApplication"
+	classpath = bootJar.classpath
+	requiresUnpack '**/bcprov-jdk18on-*.jar'
+	archiveClassifier.set("unpack")
+	targetJavaVersion = targetCompatibility
+}
+
+build.dependsOn bootJarUnpack
\ No newline at end of file
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-signed-jar/settings.gradle b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-signed-jar/settings.gradle
new file mode 100644
index 000000000000..06d9554ad0d6
--- /dev/null
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-signed-jar/settings.gradle
@@ -0,0 +1,15 @@
+pluginManagement {
+	repositories {
+		maven { url "file:${rootDir}/../int-test-maven-repository"}
+		mavenCentral()
+		maven { url "https://repo.spring.io/snapshot" }
+		maven { url "https://repo.spring.io/milestone" }
+	}
+	resolutionStrategy {
+		eachPlugin {
+			if (requested.id.id == "org.springframework.boot") {
+				useModule "org.springframework.boot:spring-boot-gradle-plugin:${requested.version}"
+			}
+		}
+	}
+}
\ No newline at end of file
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-signed-jar/src/main/java/org/springframework/boot/loaderapp/LoaderSignedJarTestApplication.java b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-signed-jar/src/main/java/org/springframework/boot/loaderapp/LoaderSignedJarTestApplication.java
new file mode 100644
index 000000000000..627a6c3996d3
--- /dev/null
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-signed-jar/src/main/java/org/springframework/boot/loaderapp/LoaderSignedJarTestApplication.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loaderapp;
+
+import java.security.Security;
+import javax.crypto.Cipher;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class LoaderSignedJarTestApplication {
+
+	public static void main(String[] args) throws Exception {
+		Security.addProvider(new BouncyCastleProvider());
+		Cipher.getInstance("AES/CBC/PKCS5Padding","BC");
+		System.out.println("Legion of the Bouncy Castle");
+		SpringApplication.run(LoaderSignedJarTestApplication.class, args);
+	}
+
+}
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/java/org/springframework/boot/loader/LoaderIntegrationTests.java b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/java/org/springframework/boot/loader/LoaderIntegrationTests.java
index c01b5f40c343..84b46b8f9a53 100644
--- a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/java/org/springframework/boot/loader/LoaderIntegrationTests.java
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/java/org/springframework/boot/loader/LoaderIntegrationTests.java
@@ -23,7 +23,6 @@
 import java.util.function.Supplier;
 import java.util.stream.Stream;
 
-import org.junit.jupiter.api.condition.OS;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.MethodSource;
 import org.testcontainers.containers.GenericContainer;
@@ -34,7 +33,6 @@
 import org.testcontainers.utility.MountableFile;
 
 import org.springframework.boot.system.JavaVersion;
-import org.springframework.boot.testsupport.junit.DisabledOnOs;
 import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable;
 import org.springframework.util.Assert;
 
@@ -44,39 +42,66 @@
  * Integration tests loader that supports fat jars.
  *
  * @author Phillip Webb
+ * @author Moritz Halbritter
  */
 @DisabledIfDockerUnavailable
-@DisabledOnOs(os = { OS.LINUX, OS.MAC }, architecture = "aarch64",
-		disabledReason = "Not all docker images have ARM support")
 class LoaderIntegrationTests {
 
 	private final ToStringConsumer output = new ToStringConsumer();
 
 	@ParameterizedTest
 	@MethodSource("javaRuntimes")
-	void readUrlsWithoutWarning(JavaRuntime javaRuntime) {
-		try (GenericContainer<?> container = createContainer(javaRuntime)) {
+	void runJar(JavaRuntime javaRuntime) {
+		try (GenericContainer<?> container = createContainer(javaRuntime, "spring-boot-loader-tests-app", null)) {
 			container.start();
 			System.out.println(this.output.toUtf8String());
 			assertThat(this.output.toUtf8String()).contains(">>>>> 287649 BYTES from")
+				.contains(">>>>> gh-7161 [/gh-7161/example.txt]")
 				.doesNotContain("WARNING:")
 				.doesNotContain("illegal")
 				.doesNotContain("jar written to temp");
 		}
 	}
 
-	private GenericContainer<?> createContainer(JavaRuntime javaRuntime) {
+	@ParameterizedTest
+	@MethodSource("javaRuntimes")
+	void runSignedJar(JavaRuntime javaRuntime) {
+		try (GenericContainer<?> container = createContainer(javaRuntime, "spring-boot-loader-tests-signed-jar",
+				null)) {
+			container.start();
+			System.out.println(this.output.toUtf8String());
+			assertThat(this.output.toUtf8String()).contains("Legion of the Bouncy Castle");
+		}
+	}
+
+	@ParameterizedTest
+	@MethodSource("javaRuntimes")
+	void runSignedJarWhenUnpack(JavaRuntime javaRuntime) {
+		try (GenericContainer<?> container = createContainer(javaRuntime, "spring-boot-loader-tests-signed-jar",
+				"unpack")) {
+			container.start();
+			System.out.println(this.output.toUtf8String());
+			assertThat(this.output.toUtf8String()).contains("Legion of the Bouncy Castle");
+		}
+	}
+
+	private GenericContainer<?> createContainer(JavaRuntime javaRuntime, String name, String classifier) {
 		return javaRuntime.getContainer()
 			.withLogConsumer(this.output)
-			.withCopyFileToContainer(MountableFile.forHostPath(findApplication().toPath()), "/app.jar")
+			.withCopyFileToContainer(findApplication(name, classifier), "/app.jar")
 			.withStartupCheckStrategy(new OneShotStartupCheckStrategy().withTimeout(Duration.ofMinutes(5)))
 			.withCommand("java", "-jar", "app.jar");
 	}
 
-	private File findApplication() {
-		String name = String.format("build/%1$s/build/libs/%1$s.jar", "spring-boot-loader-tests-app");
-		File jar = new File(name);
-		Assert.state(jar.isFile(), () -> "Could not find " + name + ". Have you built it?");
+	private MountableFile findApplication(String name, String classifier) {
+		return MountableFile.forHostPath(findJarFile(name, classifier).toPath());
+	}
+
+	private File findJarFile(String name, String classifier) {
+		classifier = (classifier != null) ? "-" + classifier : "";
+		String path = String.format("build/%1$s/build/libs/%1$s%2$s.jar", name, classifier);
+		File jar = new File(path);
+		Assert.state(jar.isFile(), () -> "Could not find " + path + ". Have you built it?");
 		return jar;
 	}
 
@@ -85,6 +110,7 @@ static Stream<JavaRuntime> javaRuntimes() {
 		javaRuntimes.add(JavaRuntime.openJdk(JavaVersion.SEVENTEEN));
 		javaRuntimes.add(JavaRuntime.openJdk(JavaVersion.TWENTY));
 		javaRuntimes.add(JavaRuntime.oracleJdk17());
+		javaRuntimes.add(JavaRuntime.openJdkEarlyAccess(JavaVersion.TWENTY_ONE));
 		return javaRuntimes.stream().filter(JavaRuntime::isCompatible);
 	}
 
@@ -115,6 +141,13 @@ public String toString() {
 			return this.name;
 		}
 
+		static JavaRuntime openJdkEarlyAccess(JavaVersion version) {
+			String imageVersion = version.toString();
+			DockerImageName image = DockerImageName.parse("openjdk:%s-ea-jdk".formatted(imageVersion));
+			return new JavaRuntime("OpenJDK Early Access " + imageVersion, version,
+					() -> new GenericContainer<>(image));
+		}
+
 		static JavaRuntime openJdk(JavaVersion version) {
 			String imageVersion = version.toString();
 			DockerImageName image = DockerImageName.parse("bellsoft/liberica-openjdk-debian:" + imageVersion);
@@ -122,8 +155,11 @@ static JavaRuntime openJdk(JavaVersion version) {
 		}
 
 		static JavaRuntime oracleJdk17() {
-			ImageFromDockerfile image = new ImageFromDockerfile("spring-boot-loader/oracle-jdk-17")
-				.withFileFromFile("Dockerfile", new File("src/intTest/resources/conf/oracle-jdk-17/Dockerfile"));
+			ImageFromDockerfile image = new ImageFromDockerfile("spring-boot-loader/oracle-jdk");
+			image.withFileFromFile("Dockerfile", new File("src/intTest/resources/conf/oracle-jdk-17/Dockerfile"));
+			for (File file : new File("build/downloads/jdk/oracle").listFiles()) {
+				image.withFileFromFile("downloads/" + file.getName(), file);
+			}
 			return new JavaRuntime("Oracle JDK 17", JavaVersion.SEVENTEEN, () -> new GenericContainer<>(image));
 		}
 
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/resources/conf/oracle-jdk-17/Dockerfile b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/resources/conf/oracle-jdk-17/Dockerfile
index 18106a8864c8..594958d179a4 100644
--- a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/resources/conf/oracle-jdk-17/Dockerfile
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/resources/conf/oracle-jdk-17/Dockerfile
@@ -1,8 +1,10 @@
-FROM ubuntu:focal-20211006
-RUN apt-get update && \
-    apt-get install -y software-properties-common curl && \
-    mkdir -p /opt/oraclejdk && \
-    cd /opt/oraclejdk && \
-    curl -L https://download.oracle.com/java/17/latest/jdk-17_linux-x64_bin.tar.gz | tar zx --strip-components=1
-ENV JAVA_HOME /opt/oraclejdk
+FROM ubuntu:jammy-20231004 as prepare
+COPY downloads/* /opt/download/
+RUN mkdir -p /opt/jdk && \
+    cd /opt/jdk && \
+    tar xzf  /opt/download/* --strip-components=1
+
+FROM ubuntu:jammy-20231004
+COPY --from=prepare /opt/jdk /opt/jdk
+ENV JAVA_HOME /opt/jdk
 ENV PATH $JAVA_HOME/bin:$PATH
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/build.gradle b/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/build.gradle
index 3224269e041e..2ae5a9de73c0 100644
--- a/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/build.gradle
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/build.gradle
@@ -56,5 +56,16 @@ task buildApps(type: GradleBuild) {
 }
 
 intTest {
+	inputs.files(
+			"${buildDir}/spring-boot-server-tests-app/build/libs/spring-boot-server-tests-app-jetty.jar",
+			"${buildDir}/spring-boot-server-tests-app/build/libs/spring-boot-server-tests-app-jetty.war",
+			"${buildDir}/spring-boot-server-tests-app/build/libs/spring-boot-server-tests-app-resources.jar",
+			"${buildDir}/spring-boot-server-tests-app/build/libs/spring-boot-server-tests-app-tomcat.jar",
+			"${buildDir}/spring-boot-server-tests-app/build/libs/spring-boot-server-tests-app-tomcat.war",
+			"${buildDir}/spring-boot-server-tests-app/build/libs/spring-boot-server-tests-app-undertow.jar",
+			"${buildDir}/spring-boot-server-tests-app/build/libs/spring-boot-server-tests-app-undertow.war")
+		.withPropertyName("applicationArchives")
+		.withPathSensitivity(PathSensitivity.RELATIVE)
+		.withNormalizer(ClasspathNormalizer)
 	dependsOn buildApps
 }
\ No newline at end of file
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/spring-boot-server-tests-app/build.gradle b/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/spring-boot-server-tests-app/build.gradle
index b72b482864ed..bd73d368e598 100644
--- a/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/spring-boot-server-tests-app/build.gradle
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/spring-boot-server-tests-app/build.gradle
@@ -12,13 +12,13 @@ apply plugin: "io.spring.dependency-management"
 repositories {
 	maven { url "file:${rootDir}/../test-repository"}
 	mavenCentral()
-	maven { 
+	maven {
 		url "https://repo.spring.io/milestone"
 		content {
 			excludeGroup "org.springframework.boot"
 		}
 	}
-	maven { 
+	maven {
 		url "https://repo.spring.io/snapshot"
 		content {
 			excludeGroup "org.springframework.boot"
@@ -41,15 +41,7 @@ configurations {
 	}
 }
 
-dependencyManagement {
-	jetty {
-		dependencies {
-			dependency "jakarta.servlet:jakarta.servlet-api:5.0.0"
-		}
-	}
-}
-
-tasks.register("resourcesJar", Jar) { jar -> 
+tasks.register("resourcesJar", Jar) { jar ->
 	def nested = project.resources.text.fromString("nested")
 	from(nested) {
 		into "META-INF/resources/"
@@ -66,7 +58,7 @@ tasks.register("resourcesJar", Jar) { jar ->
 }
 
 dependencies {
-	compileOnly("org.eclipse.jetty:jetty-server")
+	compileOnly("org.eclipse.jetty.ee10:jetty-ee10-servlet")
 	compileOnly("org.springframework:spring-web")
 
 	implementation("org.springframework.boot:spring-boot-starter")
@@ -84,7 +76,7 @@ def boolean isWindows() {
 }
 
 ["jetty", "tomcat", "undertow"].each { webServer ->
-	def configurer = { task -> 
+	def configurer = { task ->
 		task.dependsOn resourcesJar
 		task.mainClass = "com.example.ResourceHandlingApplication"
 		task.classpath = configurations.getByName(webServer)
diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/src/intTest/java/org/springframework/boot/context/embedded/ExplodedApplicationLauncher.java b/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/src/intTest/java/org/springframework/boot/context/embedded/ExplodedApplicationLauncher.java
index dc8fb37a0cb9..b066ac9be081 100644
--- a/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/src/intTest/java/org/springframework/boot/context/embedded/ExplodedApplicationLauncher.java
+++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/src/intTest/java/org/springframework/boot/context/embedded/ExplodedApplicationLauncher.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2021 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -55,8 +55,8 @@ protected String getDescription(String packaging) {
 
 	@Override
 	protected List<String> getArguments(File archive, File serverPortFile) {
-		String mainClass = (archive.getName().endsWith(".war") ? "org.springframework.boot.loader.WarLauncher"
-				: "org.springframework.boot.loader.JarLauncher");
+		String mainClass = (archive.getName().endsWith(".war") ? "org.springframework.boot.loader.launch.WarLauncher"
+				: "org.springframework.boot.loader.launch.JarLauncher");
 		try {
 			explodeArchive(archive);
 			return Arrays.asList("-cp", this.exploded.getAbsolutePath(), mainClass, serverPortFile.getAbsolutePath());
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-activemq/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-activemq/build.gradle
index 5ad092f62cb1..963f63814ac7 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-activemq/build.gradle
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-activemq/build.gradle
@@ -8,6 +8,7 @@ description = "Spring Boot Actuator ActiveMQ smoke test"
 dependencies {
 	implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-activemq"))
 
+	testImplementation("org.awaitility:awaitility")
 	testImplementation("org.testcontainers:junit-jupiter")
 	testImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test"))
 	testImplementation(project(":spring-boot-project:spring-boot-testcontainers"))
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-activemq/src/test/java/smoketest/activemq/SampleActiveMqTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-activemq/src/test/java/smoketest/activemq/SampleActiveMqTests.java
index 7637a28f8869..9d68eda78d2b 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-activemq/src/test/java/smoketest/activemq/SampleActiveMqTests.java
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-activemq/src/test/java/smoketest/activemq/SampleActiveMqTests.java
@@ -16,6 +16,9 @@
 
 package smoketest.activemq;
 
+import java.time.Duration;
+
+import org.awaitility.Awaitility;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.testcontainers.junit.jupiter.Container;
@@ -25,9 +28,8 @@
 import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.boot.test.system.CapturedOutput;
 import org.springframework.boot.test.system.OutputCaptureExtension;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
 import org.springframework.boot.testsupport.testcontainers.ActiveMQContainer;
-import org.springframework.test.context.DynamicPropertyRegistry;
-import org.springframework.test.context.DynamicPropertySource;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -43,21 +45,16 @@
 class SampleActiveMqTests {
 
 	@Container
+	@ServiceConnection
 	private static final ActiveMQContainer container = new ActiveMQContainer();
 
-	@DynamicPropertySource
-	static void activeMqProperties(DynamicPropertyRegistry registry) {
-		registry.add("spring.activemq.broker-url", container::getBrokerUrl);
-	}
-
 	@Autowired
 	private Producer producer;
 
 	@Test
-	void sendSimpleMessage(CapturedOutput output) throws InterruptedException {
+	void sendSimpleMessage(CapturedOutput output) {
 		this.producer.send("Test message");
-		Thread.sleep(1000L);
-		assertThat(output).contains("Test message");
+		Awaitility.waitAtMost(Duration.ofMinutes(1)).untilAsserted(() -> assertThat(output).contains("Test message"));
 	}
 
 }
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator-log4j2/src/main/resources/application.properties b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator-log4j2/src/main/resources/application.properties
index 2583f7fcefd9..d622bb1f7ca4 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator-log4j2/src/main/resources/application.properties
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator-log4j2/src/main/resources/application.properties
@@ -1,3 +1,4 @@
+spring.application.name=sample
 spring.security.user.name=user
 spring.security.user.password=password
 management.endpoint.shutdown.enabled=true
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator-log4j2/src/main/resources/log4j2.xml b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator-log4j2/src/main/resources/log4j2.xml
index 5320cd61c746..1c84d286b093 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator-log4j2/src/main/resources/log4j2.xml
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator-log4j2/src/main/resources/log4j2.xml
@@ -2,7 +2,7 @@
 <Configuration status="WARN" monitorInterval="30">
     <Properties>
         <Property name="PID">????</Property>
-        <Property name="LOG_PATTERN">%clr{%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX}}{faint} %clr{%5p} %clr{${sys:PID}}{magenta} %clr{---}{faint} %clr{[%15.15t]}{faint} %clr{%-40.40c{1.}}{cyan} %clr{:}{faint} %m%n%xwEx</Property>
+        <Property name="LOG_PATTERN">%clr{%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX}}{faint} %clr{%5p} %clr{${sys:PID}}{magenta} %clr{---}{faint} %clr{${sys:LOGGED_APPLICATION_NAME:-}[%15.15t]}{faint} %clr{%-40.40c{1.}}{cyan} %clr{:}{faint} %m%n%xwEx</Property>
     </Properties>
     <Appenders>
         <Console name="Console" target="SYSTEM_OUT" follow="true">
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator/build.gradle
index b616d3a9697a..c5157df03e02 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator/build.gradle
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator/build.gradle
@@ -11,6 +11,7 @@ dependencies {
 	implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-security"))
 	implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-web"))
 	implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-validation"))
+	implementation("io.micrometer:micrometer-tracing-bridge-brave")
 
 	runtimeOnly("com.h2database:h2")
 
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator/src/main/resources/application.properties b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator/src/main/resources/application.properties
index 81cc777bfc89..2c35d22ff033 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator/src/main/resources/application.properties
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator/src/main/resources/application.properties
@@ -1,3 +1,4 @@
+spring.application.name=sample
 service.name=Phil
 
 spring.security.user.name=user
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/build.gradle
index 691266ec9d27..4ef5cf07a468 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/build.gradle
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/build.gradle
@@ -7,4 +7,11 @@ description = "Spring Boot AMQP smoke test"
 
 dependencies {
 	implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-amqp"))
+
+	testImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test"))
+	testImplementation(project(":spring-boot-project:spring-boot-testcontainers"))
+	testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support"))
+	testImplementation("org.awaitility:awaitility")
+	testImplementation("org.testcontainers:junit-jupiter")
+	testImplementation("org.testcontainers:rabbitmq")
 }
\ No newline at end of file
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/docker-compose.yml b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/docker-compose.yml
deleted file mode 100644
index 267fb2ace5ac..000000000000
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/docker-compose.yml
+++ /dev/null
@@ -1,5 +0,0 @@
-rabbitmq:
-  image: rabbitmq
-  ports:
-    - "5672:5672"
-    - "15672:15672"
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/main/java/smoketest/amqp/SampleAmqpSimpleApplication.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/main/java/smoketest/amqp/SampleAmqpSimpleApplication.java
index b13b9fa901d0..356ce74c9d7f 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/main/java/smoketest/amqp/SampleAmqpSimpleApplication.java
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/main/java/smoketest/amqp/SampleAmqpSimpleApplication.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,22 +16,24 @@
 
 package smoketest.amqp;
 
-import java.util.Date;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
 
 import org.springframework.amqp.core.Queue;
 import org.springframework.amqp.rabbit.annotation.RabbitHandler;
 import org.springframework.amqp.rabbit.annotation.RabbitListener;
+import org.springframework.boot.ApplicationRunner;
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
 import org.springframework.context.annotation.Bean;
 import org.springframework.messaging.handler.annotation.Payload;
-import org.springframework.scheduling.annotation.EnableScheduling;
 
 @SpringBootApplication
 @RabbitListener(queues = "foo")
-@EnableScheduling
 public class SampleAmqpSimpleApplication {
 
+	private static final Log logger = LogFactory.getLog(SampleAmqpSimpleApplication.class);
+
 	@Bean
 	public Sender mySender() {
 		return new Sender();
@@ -44,7 +46,12 @@ public Queue fooQueue() {
 
 	@RabbitHandler
 	public void process(@Payload String foo) {
-		System.out.println(new Date() + ": " + foo);
+		logger.info(foo);
+	}
+
+	@Bean
+	public ApplicationRunner runner(Sender sender) {
+		return (args) -> sender.send("Hello");
 	}
 
 	public static void main(String[] args) {
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/main/java/smoketest/amqp/Sender.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/main/java/smoketest/amqp/Sender.java
index dbe11c4b5387..aff5d82da1a9 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/main/java/smoketest/amqp/Sender.java
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/main/java/smoketest/amqp/Sender.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -18,16 +18,14 @@
 
 import org.springframework.amqp.rabbit.core.RabbitTemplate;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.scheduling.annotation.Scheduled;
 
 public class Sender {
 
 	@Autowired
 	private RabbitTemplate rabbitTemplate;
 
-	@Scheduled(fixedDelay = 1000L)
-	public void send() {
-		this.rabbitTemplate.convertAndSend("foo", "hello");
+	public void send(String message) {
+		this.rabbitTemplate.convertAndSend("foo", message);
 	}
 
 }
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/java/smoketest/amqp/SampleAmqpSimpleApplicationSslTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/java/smoketest/amqp/SampleAmqpSimpleApplicationSslTests.java
new file mode 100644
index 000000000000..0ccd87eee4b8
--- /dev/null
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/java/smoketest/amqp/SampleAmqpSimpleApplicationSslTests.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package smoketest.amqp;
+
+import java.time.Duration;
+
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Smoke tests for RabbitMQ with SSL using an SSL bundle for SSL configuration.
+ *
+ * @author Scott Frederick
+ */
+@SpringBootTest(properties = { "spring.rabbitmq.ssl.bundle=client",
+		"spring.ssl.bundle.pem.client.keystore.certificate=classpath:ssl/test-client.crt",
+		"spring.ssl.bundle.pem.client.keystore.private-key=classpath:ssl/test-client.key",
+		"spring.ssl.bundle.pem.client.truststore.certificate=classpath:ssl/test-ca.crt" })
+@Testcontainers(disabledWithoutDocker = true)
+@ExtendWith(OutputCaptureExtension.class)
+class SampleAmqpSimpleApplicationSslTests {
+
+	@Container
+	static final SecureRabbitMqContainer rabbit = new SecureRabbitMqContainer();
+
+	@DynamicPropertySource
+	static void secureRabbitMqProperties(DynamicPropertyRegistry registry) {
+		registry.add("spring.rabbitmq.host", rabbit::getHost);
+		registry.add("spring.rabbitmq.port", rabbit::getAmqpsPort);
+		registry.add("spring.rabbitmq.username", rabbit::getAdminUsername);
+		registry.add("spring.rabbitmq.password", rabbit::getAdminPassword);
+	}
+
+	@Autowired
+	private Sender sender;
+
+	@Test
+	void sendSimpleMessage(CapturedOutput output) {
+		this.sender.send("Test message");
+		Awaitility.waitAtMost(Duration.ofMinutes(1)).untilAsserted(() -> assertThat(output).contains("Test message"));
+	}
+
+}
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/java/smoketest/amqp/SampleAmqpSimpleApplicationTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/java/smoketest/amqp/SampleAmqpSimpleApplicationTests.java
new file mode 100644
index 000000000000..06faa66b15d4
--- /dev/null
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/java/smoketest/amqp/SampleAmqpSimpleApplicationTests.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package smoketest.amqp;
+
+import java.time.Duration;
+
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.testcontainers.containers.RabbitMQContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest
+@Testcontainers(disabledWithoutDocker = true)
+@ExtendWith(OutputCaptureExtension.class)
+class SampleAmqpSimpleApplicationTests {
+
+	@Container
+	@ServiceConnection
+	static final RabbitMQContainer rabbit = new RabbitMQContainer(DockerImageNames.rabbit())
+		.withStartupTimeout(Duration.ofMinutes(4));
+
+	@Autowired
+	private Sender sender;
+
+	@Test
+	void sendSimpleMessage(CapturedOutput output) {
+		this.sender.send("Test message");
+		Awaitility.waitAtMost(Duration.ofMinutes(1)).untilAsserted(() -> assertThat(output).contains("Test message"));
+	}
+
+}
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/java/smoketest/amqp/SecureRabbitMqContainer.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/java/smoketest/amqp/SecureRabbitMqContainer.java
new file mode 100644
index 000000000000..1a8c8203cd06
--- /dev/null
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/java/smoketest/amqp/SecureRabbitMqContainer.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package smoketest.amqp;
+
+import java.time.Duration;
+
+import org.testcontainers.containers.RabbitMQContainer;
+import org.testcontainers.utility.MountableFile;
+
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
+
+/**
+ * A {@link RabbitMQContainer} for RabbitMQ with SSL configuration.
+ *
+ * @author Scott Frederick
+ */
+class SecureRabbitMqContainer extends RabbitMQContainer {
+
+	SecureRabbitMqContainer() {
+		super(DockerImageNames.rabbit());
+		withStartupTimeout(Duration.ofMinutes(4));
+	}
+
+	@Override
+	public void configure() {
+		withCopyFileToContainer(MountableFile.forClasspathResource("/ssl/rabbitmq.conf"),
+				"/etc/rabbitmq/rabbitmq.conf");
+		withCopyFileToContainer(MountableFile.forClasspathResource("/ssl/test-server.crt"),
+				"/etc/rabbitmq/server_cert.pem");
+		withCopyFileToContainer(MountableFile.forClasspathResource("/ssl/test-server.key"),
+				"/etc/rabbitmq/server_key.pem");
+		withCopyFileToContainer(MountableFile.forClasspathResource("/ssl/test-ca.crt"), "/etc/rabbitmq/ca_cert.pem");
+	}
+
+}
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/rabbitmq.conf b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/rabbitmq.conf
new file mode 100644
index 000000000000..3bcc1648bfc2
--- /dev/null
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/rabbitmq.conf
@@ -0,0 +1,7 @@
+listeners.tcp                    = none
+listeners.ssl.default            = 5671
+ssl_options.certfile             = /etc/rabbitmq/server_cert.pem
+ssl_options.keyfile              = /etc/rabbitmq/server_key.pem
+ssl_options.cacertfile           = /etc/rabbitmq/ca_cert.pem
+ssl_options.verify               = verify_peer
+ssl_options.fail_if_no_peer_cert = true
\ No newline at end of file
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-ca.crt b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-ca.crt
new file mode 100644
index 000000000000..c528ec820c91
--- /dev/null
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-ca.crt
@@ -0,0 +1,32 @@
+-----BEGIN CERTIFICATE-----
+MIIFhjCCA26gAwIBAgIUERZP46qinK0dKmJzlCsoD/k1nWYwDQYJKoZIhvcNAQEL
+BQAwOzEZMBcGA1UECgwQU3ByaW5nIEJvb3QgVGVzdDEeMBwGA1UEAwwVQ2VydGlm
+aWNhdGUgQXV0aG9yaXR5MB4XDTIzMDUwMTIwNDkxMFoXDTMzMDQyODIwNDkxMFow
+OzEZMBcGA1UECgwQU3ByaW5nIEJvb3QgVGVzdDEeMBwGA1UEAwwVQ2VydGlmaWNh
+dGUgQXV0aG9yaXR5MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEApWYo
+UQjDY98oVOO5HOjheWeBN+C6gozg4aPY0VdRDTKmZ5SzNjuYtX6jsd8e5UF+ceeL
+Aw9E3FAKG80F/81c6mtFhFUNUaBCbK2/+igs+Ae6r42i6iLImvgYLbZ0rGpPwszT
+KGlwyobsI8n1bRFrVRdtGWVfn3Dfc5k/+dnZ03kOpViv/gd/xNWMcMOlj64F1s8L
+6Nx9bfeJvOcsX+5qMiy/B6dZS0lkvXZISJbFhvX/+5Tb/vkP41AnrYff8hO8OBs+
+G2srr2xNAIcgNBSjedDVUaRO+a2WHdX/1fHOlNqz335XMo79FOqRWDCZET3YW36A
+hqiSPPiDq8AA7hmVxnq7vxWo/qclaqVuk5Dxp+ZD7d8deSGehTPajeCZCDtNhw6C
+jtlU8v/LdwMRhqZp5/fjDlOEkutFh6B/aMjq3ZPYQad4MtQixDifgEs4iwnIMoVS
+Wqpn24qn0qddfP0Y00U1F79UuJ2cJpyqdjtMRvbdNv6udWhD0rtrjdLvGFDOryzD
+W7xQD2NLWW0IC9YNuXR0FzrJFFqWBW+lfF1u1PdW7ITFtUhj8RcIZZgUS/w1Yh8/
+d6ja18UROEgiJ/Isgvl8sNTe2oNQK9HM6XtyEif5G5J7cv5FAH3si98My5h+rKq9
+AMGfQLtDOM+Ivg7D63iiuxB57Rq91xCsKCC2QNECAwEAAaOBgTB/MB0GA1UdDgQW
+BBQuNq1dmybivJy6XnHIFBYqEfqtMDAfBgNVHSMEGDAWgBQuNq1dmybivJy6XnHI
+FBYqEfqtMDAPBgNVHRMBAf8EBTADAQH/MCwGA1UdEQQlMCOCC2V4YW1wbGUuY29t
+gglsb2NhbGhvc3SCCTEyNy4wLjAuMTANBgkqhkiG9w0BAQsFAAOCAgEAJFpeqQB9
+pJLn4idrp7M1lSCrBJu2tu2UsOVcKTGZ3uqgKgKa+bn0kw9yN1CMISLSOV9B1Zt5
+/+2U24/9iC1nGIzEpk2uQ2GwGaxFsy38gP6FF+UFltEvLbhfHWZ/j8gWQhTRQ/sT
+TMd0L0CysmDswoEHcuNgdX+V4WVchPqdHTxp5qLM3GRas5JCuNcVi+vFEWCQsYRh
+iTpsCEVfRsVJKUvPKVLR8PSEjSt8S+SQjIuTVWSmdG358uRVxpBzAzMwz9sQw4G6
+Rv3S4LaQpWXUyHVYM1OxQz0fhEug5qgSR75GTFwG1oVd5rdk7iK/J3WbRJZ9FcKx
+ipZ3jdl5mmI6p87OjgQVtUInv8KK88AhJmypBXaHE64nn8+YUsh/ud6+Vr8vyMPK
+TZJivCtVKoX+nd3Zb3qX2YGORKQmn4GPX551FCk1CFOa+qlGfXtfqV2Z9LEQmqx3
+ygqVnmSf34oTz04sSMdK7m3ULqLyv3RFJJ4F+VsHHAEdJYO+v/GdGz/0FA7ZZ4t+
+7r1qY7uK4NSMRBn+DGlUL9oVp26uss/Qvi1WTI0g9W1YImxYSlaR0tm9jZQckirm
+KMLMDyGJFvHqR8LRa3DU6L5pU99LxZSHRxBAY6oexKSYWt7BSE1kwaL3Exjg/RG/
+ap5/GNJS1STNnbgq5TtWUbvZcXuhuBe8ClI=
+-----END CERTIFICATE-----
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-ca.key b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-ca.key
new file mode 100644
index 000000000000..54a007ea2120
--- /dev/null
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-ca.key
@@ -0,0 +1,51 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIJKAIBAAKCAgEApWYoUQjDY98oVOO5HOjheWeBN+C6gozg4aPY0VdRDTKmZ5Sz
+NjuYtX6jsd8e5UF+ceeLAw9E3FAKG80F/81c6mtFhFUNUaBCbK2/+igs+Ae6r42i
+6iLImvgYLbZ0rGpPwszTKGlwyobsI8n1bRFrVRdtGWVfn3Dfc5k/+dnZ03kOpViv
+/gd/xNWMcMOlj64F1s8L6Nx9bfeJvOcsX+5qMiy/B6dZS0lkvXZISJbFhvX/+5Tb
+/vkP41AnrYff8hO8OBs+G2srr2xNAIcgNBSjedDVUaRO+a2WHdX/1fHOlNqz335X
+Mo79FOqRWDCZET3YW36AhqiSPPiDq8AA7hmVxnq7vxWo/qclaqVuk5Dxp+ZD7d8d
+eSGehTPajeCZCDtNhw6CjtlU8v/LdwMRhqZp5/fjDlOEkutFh6B/aMjq3ZPYQad4
+MtQixDifgEs4iwnIMoVSWqpn24qn0qddfP0Y00U1F79UuJ2cJpyqdjtMRvbdNv6u
+dWhD0rtrjdLvGFDOryzDW7xQD2NLWW0IC9YNuXR0FzrJFFqWBW+lfF1u1PdW7ITF
+tUhj8RcIZZgUS/w1Yh8/d6ja18UROEgiJ/Isgvl8sNTe2oNQK9HM6XtyEif5G5J7
+cv5FAH3si98My5h+rKq9AMGfQLtDOM+Ivg7D63iiuxB57Rq91xCsKCC2QNECAwEA
+AQKCAgEAn3AdtxeyeiiZEVO/ku2uxEARYRMB120ELp6qGAqKuCU2Ia1HICVM7M/Z
+7lG9z5NV12kzKMzkPVfulqQJf2+wfMzRY2I1h5Tr0yWeZP+rcaDJxgbLn9XN+Qzl
+CdPTHo0QvCCEAHW7448yPMGnEu9yvsDpS0zcY68Dx8RX1nq5LtCIXL1kUYVbFhwg
+2GbQxvMi79IAkgVR59px7SYPMZ56wkk+EJuySQ/Dy5skzMyCNroWe6cgduYR+ba/
+uNi8+PcrPg6MzRN/Ngg5JiQb1/h5Kak0qRGxi59YkQRELTF+SSGVuQBp//O0ZSBE
+4XVfaC5szK3iKWyAI8QP8VUR0HPbWr8dum6HQn/tpbQ1AcX9ObWnUz6TgaoHax0w
+3VrnHnsr1kKmTHtqbB0uEeB7/vc6D3IWNIaPnoFT01snyGYDIaWcRLhPWCp/Z3QG
+e1tCEVNqxzb5mtsFri1rVSXsOT8169il1V3qP8Wu9M0C/pXM+9XEdZd6ZecgU+SS
+MEBAl+qYTBfGS7lJDIjqS0V6/NMNBa0bW2Gg35PruriPMgDhoXiYp3NgN0cuf4KQ
+KEinRSwvb2iqfzCevY7D2JRJcTcZ97a518lDd4URIZ+W7o7+8UBObcuns55kBCy1
+NbjkZe2yGBGOODa1gXPaAgG1IBLDmnVPSKPyuHLiS0X+KmC4IAECggEBANCdYFW3
+Nw93w4Olh8tOJA4z9BTsQi64V+q/WOIz5l9aBHXVdyiG7gqFWiK7XsofPvXzU8XA
+jP5y4XArO28Bwn3Ipa7YpoOs4J9KF8Il9dDUfUPTcNKkogEGnH8QHVPXUX28othW
+NZ9urvP+rSYjM4CUQtGG/RiiGPHssHgQoPvgPm4mrmMgKSm3mKdm5xkIYITccGag
+3tmO35cPzBBVap1tDmJ3F8dCMW8OsTKv6ECIjuMSYDbpmSNkxPxBK5YiIEJ8jjdU
+5+7Bf3PLIoQNd+LWoSRzHm114QGFoTLq2wPE9TFoc9j+svZBAmDkCzTE9+KwIL+G
+6dPcvvtT+NiTFgECggEBAMr32v6NgL8aGKK8nBiyibInUjKl0iCE1FcwGR6NOkK0
+3nJKhXiOWkBM3yeK/rq7HXfds6+pfi3w4VCmHXvF4IY5IIu8P4d0g/sMrFexwq2x
+Qs400aomAVtlTQ46iL2vw5XOwMTw1SXvaNX/AgR0b9qiI1UfFZeox9UiHR+KdWPV
+rKYDbHIHOk4Nxe950cK08KOReV3kO15RvBf6bdUAJwGWIdKLUr0y858s4H5GUZK8
+qKuC/toCE7Emy0k+q+NV/CApchhzQ5gwhVdc8qdhKlJtZDouopAOjOOq6l9C3GFT
+qX7CVJppe7YbURni4Y7dXZzi2hn8wb7nSxmQq95FStECggEAY6/gefVMHVsYlY8D
+HfagKh1PdLQVSCgU8vsu6SDt5ACrAvfXsgkQNPzWPqSUvjdCKdt125iQh4K0EZrH
+EtufaeX4rl2e7GsvB08rnT3wgjMYDNI8Jpw/Qgg7vkggC5FnwpLiqkg/5YjJl5TK
+ft/xW279owxDY4MKMojtJuKjWtkkXBSl3n5ezS2Lh+sXYZHsNXD1UUVsWD/6vj/x
+Ppjikomrhwfr1+7cmnpF2LfQXw4iYYXFblggMpaTvwsRXfO+wKaueuha0G+sjNO0
+EbAx6ravWDCeiKX8uHJ3vlIWCG4U0OBeA4JqWFxmW5B9fmDlJ3EMpRk+IVxp8sWE
+s1FOAQKCAQB4UlSloLcZEtxV5N/YmEaesUa+NaUKmBPVF/NcNDa8gsJ4GItlO2Zv
+ReLoazK0+eXvQCOcWCswCuNXTxKdZGHE0CrmC5PRthXjhtDIL94L39CNs6wzZNJb
+HwN+Et8rK/4TWfzXAzoogfOxILpOb8Q7ZPDzLjk7rdfBFrcTEp6ir3Ho/JCWTIiY
+6vtTCvF5rpAVN1EugvVa5bNOt6vSoIN/IkQsr2E+Pe1EiHMRCJilF2gaPM7d6GtK
+EohihF+bpkaPvmIf8ny4xNLXRoenCCfxs12+TBUctzN4Z8MG8/j3TYRmW8eRvkST
+YUBDy0cRzVMIhUbsLvWgOTdBEY2Bd6xxAoIBACQhVhwLXDUSGe96p8QCPQ2SMo8/
+lU4oPQ8MIc/gYEJUUYvJfkvCy0fnot9P/ZPppksJPQidqZDhDmzbPxuaIwiel6RU
+KTEwRbg7M8YtCngAGjUSxTWZp1sklFFXxbtDW438QzLAtMvGCZ1l0QEd6ajG1BHi
+fm96oJqaKEhcg4tthz3NyXihvQ7/ZrLpvcyR25Dzjlx3X6/0DTT4hdUiQOW5a3Uo
+/YjAC2J8MeKJK6UYW2spcmQ5NmVhG/+8UoGN94DWRWpgl2dtB2HGssLPmB27TOdQ
+wezcsubDEHZCtTc2y22l/MMwCwLZu5GBUNUy4EzDjPxoC7FtHSdsJ9sUdsg=
+-----END RSA PRIVATE KEY-----
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-client.crt b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-client.crt
new file mode 100644
index 000000000000..40a184bdf322
--- /dev/null
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-client.crt
@@ -0,0 +1,24 @@
+-----BEGIN CERTIFICATE-----
+MIIEGjCCAgKgAwIBAgIUCNvMLf/1EZcO6R9L/PQVWN8agbkwDQYJKoZIhvcNAQEL
+BQAwOzEZMBcGA1UECgwQU3ByaW5nIEJvb3QgVGVzdDEeMBwGA1UEAwwVQ2VydGlm
+aWNhdGUgQXV0aG9yaXR5MB4XDTIzMDUwMTIwNDkxMFoXDTI0MDQzMDIwNDkxMFow
+LzEZMBcGA1UECgwQU3ByaW5nIEJvb3QgVGVzdDESMBAGA1UEAwwJbG9jYWxob3N0
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzP5NGFAhk6hAVr3YshRJ
+YGxS2IGphFaq/c99QZQ62JbcSwceFo0p8Px9JiaT38n7NejEy6t6U0PQP2B9r3pJ
+p0RwplvITLd1lp96DdMQeGXKa2rqJ62u9//u/XxFboVU6QYC90Pnqi6sRWejKEI8
+Yowg6erjNMCQiIAKqhWPfdsJOxf79102gdahuTT8A89p551u7a84oTRtX4fLksP2
+x0BVFb0/Dirz5ngwm6YHpN+8z7BYIyj4dLzzFjaqU1gptxtGygap1GtD1X9fJ61l
+k6K8vMww4+/zYOoGratUTNeKHOvvXf9SnjoqyMTvJFyTX+5snkyL81q3+XgXJOYL
+ZQIDAQABoyIwIDALBgNVHQ8EBAMCBaAwEQYJYIZIAYb4QgEBBAQDAgeAMA0GCSqG
+SIb3DQEBCwUAA4ICAQAq5Em7EVkGhPgIMDmxhm398Kv8OivFxX6x5aGnJ+m8+mZV
++wrkjRvpqN/+CtTsid2q4+qYdlov8hJ2oxwVhfnrF5b7Xj7caC2FJifPXPiaMogT
+5VI4uCABBuVQR0kDtnPF8bRiTWCKC3DC84GqMp0cUs3Qyf1dLcjhcc9dSROn00y8
+/qmIz8roJ2esnqG12rTGdIAaWSgBCMKFjrV8YmxLf+z72VHSx6uC5CARG+UYa5Mu
+vga0Q77QmwSstKBvGUBtvzQoML3/UFCikdfOxDgvJbr8Q0yEEw8hK7vGZLaj00zB
+U4B5+DfV285RW09ihp2YMxuz3mL2tM5++RYJphB9/VTN3/f+geKt2pPA3Rkk11Ug
+LP3NdpT5ZnQL9ehtmIExk2NVBi+RmGCcP7KcMtlq44FdyRF7p6qdg/Eq5n/sOMxQ
+DnamgWDQltm6cuZ49haCXLZIbfqM2cHARIw/Sv3Dgd9SSDL2pooWI2U82fQ9A71q
+u/hUlNDZm0v51IfgzJcbAtlAYd2OVlgCkkkFtbgdOaQUShIkcCKcpxtgQzpynNMO
+DJoO41VXpMzBN7/ppVi0JrF7RkaXGeoNsqfvcmjQEuXUOluge2q8kHDf7gEUddKa
+ijPHtkFQF2ujCGr/AVYjCMSlOk5WhRh8ZVxN0KbiWZJUN8akX4gU4KIpTe1big==
+-----END CERTIFICATE-----
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-client.key b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-client.key
new file mode 100644
index 000000000000..a31717ac4d53
--- /dev/null
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-client.key
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEowIBAAKCAQEAzP5NGFAhk6hAVr3YshRJYGxS2IGphFaq/c99QZQ62JbcSwce
+Fo0p8Px9JiaT38n7NejEy6t6U0PQP2B9r3pJp0RwplvITLd1lp96DdMQeGXKa2rq
+J62u9//u/XxFboVU6QYC90Pnqi6sRWejKEI8Yowg6erjNMCQiIAKqhWPfdsJOxf7
+9102gdahuTT8A89p551u7a84oTRtX4fLksP2x0BVFb0/Dirz5ngwm6YHpN+8z7BY
+Iyj4dLzzFjaqU1gptxtGygap1GtD1X9fJ61lk6K8vMww4+/zYOoGratUTNeKHOvv
+Xf9SnjoqyMTvJFyTX+5snkyL81q3+XgXJOYLZQIDAQABAoIBAFNG/Arkgr95mqmi
+dmXh1+1UFFPgWP1qOAzkPf5mOYHDx7qzKYX/0woTiMP26BwB8gv0g/45q3goFHGq
+wWSISWOqahkrMDP6U8rc/rifBhHjSFhbFsUHygz17CEOWyaLA/OmfY32CCcazuFj
+OOUiA2YFh1mAEs1bbVwGqE5wc9qsZtBlJxudSWtSZoJuFECDNqLfQXkJ39KnKhp4
+D337nOR/xww81202mlfF/vvhRMfUIUS2Ij9USndp9huBHFSxf1mYjD1ljjx6U7el
+new8TPf76J7nuy/6SxZ9wF6P2dk/eQcN5AnIcDGq0WzS3VcJc/KG/+maflCvH0dB
+SLfx4AECgYEA7e+5/UhWZ62BfF1/Nat95+t+bh8UYN8gPEUos7oS/cUrme7YAPQT
+MTWNulpmgGCRDxeXU9XBaPGyF7cU5bx28sK64ZUe8D1ySgGpVeSEQtjCLFEf6eat
+801TQVNaH2WlDZTm+Onfr7ppFN1pLrBY+83m9TDJd6v4qHsvtNkcx38CgYEA3I5U
+OvvoTEj8+Xc0U296NU+aWJLNrkDH6lFtdXsLyoumxh0DDbKSw8ia28Z5+8tz0mdB
+33sIsnnsQ+83YoiXyopM9GFZdZH3luKrXgOGH8QFygJI8xGqqcLjeWNkW0b0KCkv
+AoiedqOOmCdRMUfy3v5irH+4O90ZmW6VxNKbfxsCgYEAtjjFOQwAWHCR3TwBo4nN
+6CL7dbzJr5LSLjZNAK/9wWoShVZdCQXj+OjpvRFktOa/0U4g7+yhrgyEdxMYpwUa
+F7s4wnCg/B4i/Difhg93l3ZH5wbOKSUojU/n9fyu5aLDsE4cQf9i90MNHRSgbEhU
+Law4OAmAEe2bhvSoyZkJKGMCgYBgW25BNr0OVvTuqD2cFh/2Goj8GWbysiqlHF4N
+7WwBWXHLK/Ghklq8XnAJhHTWpNQ9IA+Pa1kpYErwgxpXWgW23yUvvzguPU9GBFGK
+CVAXoLRGxSjJyPYepJ5s8hduKVmSEiwPl1Bj1KD/qG24cg6RjeHeKw56WOZOOhoE
+m16D8QKBgBHXU31OJ2KMDnwjsMW2SlpYKoIQlJyTg3qvN7gu+ZGo5B7hviqh5wN1
+y577N/NT9No8qGNEGTZl35hkyw8DmB4RAZp7G1qbVCGszUBt/vS6Guv82/EgMVo2
+ZgiQBkI1kEOtj5LMVBfOKTRBEpyAm5fSZ+eQtSIc5LCbQ8aEvio4
+-----END RSA PRIVATE KEY-----
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-server.crt b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-server.crt
new file mode 100644
index 000000000000..06c8906b9157
--- /dev/null
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-server.crt
@@ -0,0 +1,24 @@
+-----BEGIN CERTIFICATE-----
+MIIEGjCCAgKgAwIBAgIUCNvMLf/1EZcO6R9L/PQVWN8agbgwDQYJKoZIhvcNAQEL
+BQAwOzEZMBcGA1UECgwQU3ByaW5nIEJvb3QgVGVzdDEeMBwGA1UEAwwVQ2VydGlm
+aWNhdGUgQXV0aG9yaXR5MB4XDTIzMDUwMTIwNDkxMFoXDTI0MDQzMDIwNDkxMFow
+LzEZMBcGA1UECgwQU3ByaW5nIEJvb3QgVGVzdDESMBAGA1UEAwwJbG9jYWxob3N0
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqgji6StU0UkWfYmZumQO
+L7SnFg7/xBM5ubMtXJsBOS0RaRWJ0WwIsQ2ksDOf2ybDyXiePplbtR/4GsnXPyNp
+H1vgY/Mt/PeiP/lHw9dDTdSx6YMMxGVoILsHkblaeHwh8yVGCg2gdoRROscgjS+e
+j7gTr4H2UBlepHsjZBKc+hamDrIC3vK42iQUyzbClJ8lpY+KbL4R4KhsuhTb2jRL
+wye3m2w+YU1jvE+IioQfozlZTAw0SX7whcCw0B3hLVQg6hsdSeSYkCUZiZY72ySR
+fI+mDcnJVcetH2ShK1zVFBpDs9qkJSA9YumO1ZKVDdDseeuHHsEUG2/pszQ2cHH6
+EwIDAQABoyIwIDALBgNVHQ8EBAMCBaAwEQYJYIZIAYb4QgEBBAQDAgZAMA0GCSqG
+SIb3DQEBCwUAA4ICAQACFGGWNTEDCvkfEuZZT84zT8JQ9O5wDzgYDX/xRSXbB1Jv
+fd9QQfwlVFXg3jewIgWZG0TgQt/7yF6RYOtU+GRP6meJhSm9/11KnYYLNlHQU1QE
+7imreHAnsJiueHXPmpe9EL4jv2mQt7GSccABMf1pfBQ+C0dETnUoH68oO3LttU16
+f43H1royvOm3G6LnJb83rLYVe07P1PTjk/37gaFCf54J1eDfqntVDiSq8H6fV+nL
+9ZvsVuC4BcREnB3oY7vsJFBhGeK/3+QFX4Zr3DTwLxiWe2pqSQfUbn4+d6+uwIY7
+pixgNorpebKQn0vX/G4llVjOmBNjlgSzDyVTYObBz316GojF7yRk3oBbxK//3w/t
+XVhLwrPpqB5Jehh2HsKKZrdfnjB1Gn+pDpSEMVDrCbWxzAJz4WOu2ihCYYsF3Gts
+lzI1ZzD+UpFyeHG/1wQHzyQwADBiaYfh1oAnpNcOvJhT1S6IVGImcOBNa8u14aVG
+NjvnJWVn3v3dcvAVO1ZUwX9TdHP11oIpn7fGYZzSxCDrhGaFeW0tscxddHRrXdwk
+IHyHZ3o2RgivhaSc4C04nuZEX00ohTgtKo2rpK1SP+gn64Yh+u+O6AH8r+q7cZy2
+gZNscwHAmkEalP78D5vnOFRUYEVrNc/X2f+rwFoQD7B8GNGa/visAkD7myg7JQ==
+-----END CERTIFICATE-----
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-server.key b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-server.key
new file mode 100644
index 000000000000..8dcb542a2ebf
--- /dev/null
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-server.key
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEpAIBAAKCAQEAqgji6StU0UkWfYmZumQOL7SnFg7/xBM5ubMtXJsBOS0RaRWJ
+0WwIsQ2ksDOf2ybDyXiePplbtR/4GsnXPyNpH1vgY/Mt/PeiP/lHw9dDTdSx6YMM
+xGVoILsHkblaeHwh8yVGCg2gdoRROscgjS+ej7gTr4H2UBlepHsjZBKc+hamDrIC
+3vK42iQUyzbClJ8lpY+KbL4R4KhsuhTb2jRLwye3m2w+YU1jvE+IioQfozlZTAw0
+SX7whcCw0B3hLVQg6hsdSeSYkCUZiZY72ySRfI+mDcnJVcetH2ShK1zVFBpDs9qk
+JSA9YumO1ZKVDdDseeuHHsEUG2/pszQ2cHH6EwIDAQABAoIBAQCLTuiJ3OSK63Sv
+udLncR5mW34hhnxqas36pSBfJOflrlT7YZgeqoKcfO8XJdSsup/iKx6Lbx5B0UV2
+vTPLGPoBpUa83PoqrcCS5Wu0umL8G20AQkxthB/B4TocXF4RJLK0AS/XAL8dGt9q
+Zsb2pbMlUM1gF/x0N7Tg0bp3PQC7rAgYe7JFvArxRrmDP38FE9Cg5EIAVMN8Fw2b
+dxKZxJ+mqj1t1bU4/bsrYBs9QpNrBjQc0KTFOamwkvWI7FhHXQtIZfJvvBj8mN7z
+He7B5j/JcfGC5LN1UpL4tziOrKwMGGIvpAnpbVEv29SWxOG5Vbccb4ghBN+VJqSH
+6WON791hAoGBAN7Q5nuCk+L/8BQh29WHZuP6dbLyMMjWMyuDm2xEYD0fjjacvU7r
+KIQDcQY3E7bXu6OXKQmxARFY7HuZUyGg8R4QBeAEVfDPjRKzGZgA1+gF325eQwAQ
+giXqg0paE2ePfbawi21NfQPCMMhb4n3QzpYd4eEsFFwMvt4oZCPkHubJAoGBAMNb
+pGajPKW19dFWP5OsKc1U6itej78RQRjO7zpQ3JWvNuMa/SZzEa2blFuks585u6M2
+XdVPhhspc0TwS+asizNEMDYaPpAjmg9X9LY87hcYTC0FXT0Axx+7A/JtmMAVF3Pn
+4lvhfdB5XSV5jo/BtUJ3vDx5FSFIHQbbj1agGpv7AoGAdv6pmJyLzldRJ+9NMCQ3
+1tkTspWVaCy89yg6AQAjRYFsuc3LbDI6WQZdfiw74xIjq6I20G4vW8xZv0iLFRKW
+sq9r889c9lZhyPLNYFhS9h7szEybC5XFa+pqY3Lnmg8P3Fk8nQsdELzMwLQRqY+y
+RImA8HhSBzbnWE3J7UEPH8ECgYAXyNGEOX2Jw1SRTwnghcZ1HFCCRToFDim5xn/z
+vqKMis+I6OFHTB0r4NQ4MB46VYIVxem4rbzrE6nYC9WB2SH9dODVxW42iE8abR/7
+DAIEx82Gca+/XJfhshgx7Mv7HtZDI0k43IQ/3HbNuDX2JKRX2lINnsRG0AvQqOyT
+pFx4/wKBgQCXU0LGSCgNwuqdhXHoaFEzAzzspDjCI+9KDuchkvoYWfCWElX035O9
+TbEybMjCuv08eAqeJv++a1jnTmJwf+w+WhBG+DpYcro1JXmo8Lu9KAbiq0lJGQP6
+tX9gr0XY3IC+L5ndOANuFH6mjGlnp7Z+J8i7HFFoSa+MI2JkoQ5yVA==
+-----END RSA PRIVATE KEY-----
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/build.gradle
index 7ff1740185fb..7f90497a4ff2 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/build.gradle
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/build.gradle
@@ -30,7 +30,7 @@ dependencies {
 	antDependencies "org.apache.ant:ant-launcher:1.10.7"
 	antDependencies "org.apache.ant:ant:1.10.7"
 
-	testRepository(project(path: ":spring-boot-project:spring-boot-tools:spring-boot-loader", configuration: "mavenRepository"))
+	testRepository(project(path: ":spring-boot-project:spring-boot-tools:spring-boot-loader-classic", configuration: "mavenRepository"))
 	testRepository(project(path: ":spring-boot-project:spring-boot-starters:spring-boot-starter", configuration: "mavenRepository"))
 
 	testImplementation(project(path: ":spring-boot-project:spring-boot-tools:spring-boot-loader-tools"))
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/build.xml b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/build.xml
index 418a7501f05f..a03067231cef 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/build.xml
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/build.xml
@@ -65,9 +65,9 @@
 				<fileset dir="${lib.dir}/runtime" />
 				<globmapper from="*" to="BOOT-INF/lib/*"/>
 			</mappedresources>
-			<zipfileset src="${lib.dir}/loader/spring-boot-loader-jar-${ant-spring-boot.version}.jar" />
+			<zipfileset src="${lib.dir}/loader/spring-boot-loader-classic-jar-${ant-spring-boot.version}.jar" />
 			<manifest>
-				<attribute name="Main-Class" value="org.springframework.boot.loader.JarLauncher" />
+				<attribute name="Main-Class" value="org.springframework.boot.loader.launch.JarLauncher" />
 				<attribute name="Start-Class" value="${start-class}" />
 			</manifest>
 		</jar>
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/ivy.xml b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/ivy.xml
index 192d5281fcda..2ecb5cc31a2b 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/ivy.xml
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/ivy.xml
@@ -7,6 +7,6 @@
 	</configurations>
 	<dependencies>
 		<dependency org="org.springframework.boot" name="spring-boot-starter" rev="${ant-spring-boot.version}" conf="compile" />
-		<dependency org="org.springframework.boot" name="spring-boot-loader" rev="${ant-spring-boot.version}" conf="loader->default" />
+		<dependency org="org.springframework.boot" name="spring-boot-loader-classic" rev="${ant-spring-boot.version}" conf="loader->default" />
 	</dependencies>
 </ivy-module>
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-cassandra/src/test/java/smoketest/data/cassandra/SampleCassandraApplicationReactiveSslTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-cassandra/src/test/java/smoketest/data/cassandra/SampleCassandraApplicationReactiveSslTests.java
index 2362aa4e55d7..c8bfd7bdadb9 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-cassandra/src/test/java/smoketest/data/cassandra/SampleCassandraApplicationReactiveSslTests.java
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-cassandra/src/test/java/smoketest/data/cassandra/SampleCassandraApplicationReactiveSslTests.java
@@ -80,7 +80,7 @@ static class KeyspaceTestConfiguration {
 		CqlSession cqlSession(CqlSessionBuilder cqlSessionBuilder) {
 			try (CqlSession session = cqlSessionBuilder.build()) {
 				session.execute("CREATE KEYSPACE IF NOT EXISTS boot_test"
-						+ "  WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 };");
+						+ " WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 };");
 			}
 			return cqlSessionBuilder.withKeyspace("boot_test").build();
 		}
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-cassandra/src/test/java/smoketest/data/cassandra/SampleCassandraApplicationSslTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-cassandra/src/test/java/smoketest/data/cassandra/SampleCassandraApplicationSslTests.java
index 46dd49437a7f..48080633ba60 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-cassandra/src/test/java/smoketest/data/cassandra/SampleCassandraApplicationSslTests.java
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-cassandra/src/test/java/smoketest/data/cassandra/SampleCassandraApplicationSslTests.java
@@ -80,7 +80,7 @@ static class KeyspaceTestConfiguration {
 		CqlSession cqlSession(CqlSessionBuilder cqlSessionBuilder) {
 			try (CqlSession session = cqlSessionBuilder.build()) {
 				session.execute("CREATE KEYSPACE IF NOT EXISTS boot_test"
-						+ "  WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 };");
+						+ " WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 };");
 			}
 			return cqlSessionBuilder.withKeyspace("boot_test").build();
 		}
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-couchbase/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-couchbase/build.gradle
index e66d4db03d24..fba550646efa 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-couchbase/build.gradle
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-couchbase/build.gradle
@@ -13,9 +13,9 @@ dependencies {
 	testImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test"))
 	testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support"))
 	testImplementation(project(":spring-boot-project:spring-boot-testcontainers"))
-	testImplementation("com.squareup.okhttp3:okhttp")
 	testImplementation("io.projectreactor:reactor-core")
 	testImplementation("io.projectreactor:reactor-test")
+	testImplementation("org.apache.httpcomponents.client5:httpclient5")
 	testImplementation("org.junit.jupiter:junit-jupiter")
 	testImplementation("org.junit.platform:junit-platform-engine")
 	testImplementation("org.junit.platform:junit-platform-launcher")
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-couchbase/src/test/java/smoketest/data/couchbase/SecureCouchbaseContainer.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-couchbase/src/test/java/smoketest/data/couchbase/SecureCouchbaseContainer.java
index 1b40442edc74..70141b45403f 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-couchbase/src/test/java/smoketest/data/couchbase/SecureCouchbaseContainer.java
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-couchbase/src/test/java/smoketest/data/couchbase/SecureCouchbaseContainer.java
@@ -17,13 +17,14 @@
 package smoketest.data.couchbase;
 
 import java.time.Duration;
+import java.util.Base64;
 
 import com.github.dockerjava.api.command.InspectContainerResponse;
-import okhttp3.Credentials;
-import okhttp3.OkHttpClient;
-import okhttp3.Request;
-import okhttp3.RequestBody;
-import okhttp3.Response;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.HttpClients;
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
 import org.testcontainers.couchbase.CouchbaseContainer;
 import org.testcontainers.utility.MountableFile;
 
@@ -33,6 +34,7 @@
  * A {@link CouchbaseContainer} for Couchbase with SSL configuration.
  *
  * @author Scott Frederick
+ * @author Stephane Nicoll
  */
 public class SecureCouchbaseContainer extends CouchbaseContainer {
 
@@ -69,20 +71,26 @@ protected void containerIsStarting(InspectContainerResponse containerInfo) {
 	}
 
 	private void doHttpRequest(String path) {
-		Response response;
-		try {
+		HttpResponse response = post(path);
+		if (response.getCode() != 200) {
+			throw new IllegalStateException("Error calling Couchbase HTTP endpoint: " + response);
+		}
+	}
+
+	private HttpResponse post(String path) {
+		try (CloseableHttpClient httpclient = HttpClients.createDefault()) {
+			String basicAuth = "Basic "
+					+ Base64.getEncoder().encodeToString("%s:%s".formatted(ADMIN_USER, ADMIN_PASSWORD).getBytes());
 			String url = "http://%s:%d/%s".formatted(getHost(), getMappedPort(MANAGEMENT_PORT), path);
-			Request.Builder requestBuilder = new Request.Builder().url(url)
-				.header("Authorization", Credentials.basic(ADMIN_USER, ADMIN_PASSWORD))
-				.post(RequestBody.create("".getBytes()));
-			response = new OkHttpClient().newCall(requestBuilder.build()).execute();
+			ClassicHttpRequest httpPost = ClassicRequestBuilder.post(url)
+				.addHeader("Authorization", basicAuth)
+				.setEntity("")
+				.build();
+			return httpclient.execute(httpPost, (response) -> response);
 		}
 		catch (Exception ex) {
 			throw new IllegalStateException("Error calling Couchbase HTTP endpoint", ex);
 		}
-		if (!response.isSuccessful()) {
-			throw new IllegalStateException("Error calling Couchbase HTTP endpoint: " + response);
-		}
 	}
 
 }
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-jpa/src/main/java/smoketest/data/jpa/service/HotelRepository.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-jpa/src/main/java/smoketest/data/jpa/service/HotelRepository.java
index 488b362f0673..0ca78fd2bd85 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-jpa/src/main/java/smoketest/data/jpa/service/HotelRepository.java
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-jpa/src/main/java/smoketest/data/jpa/service/HotelRepository.java
@@ -32,7 +32,8 @@ interface HotelRepository extends Repository<Hotel, Long> {
 
 	Hotel findByCityAndName(City city, String name);
 
-	@Query("select h.city as city, h.name as name " + "from Hotel h  where h.city = ?1 group by h")
+	@Query("select h.city as city, h.name as name, avg(cast(r.rating as Integer)) as averageRating "
+			+ "from Hotel h left outer join h.reviews r where h.city = ?1 group by h")
 	Page<HotelSummary> findByCity(City city, Pageable pageable);
 
 	@Query("select r.rating as rating, count(r) as count "
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-r2dbc-liquibase/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-r2dbc-liquibase/build.gradle
index 376dda13dd7a..bd8a4c685dad 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-r2dbc-liquibase/build.gradle
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-r2dbc-liquibase/build.gradle
@@ -9,7 +9,6 @@ dependencies {
 	implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-data-r2dbc"))
 
 	runtimeOnly("org.liquibase:liquibase-core") {
-		exclude group: "javax.activation", module: "javax.activation-api"
 		exclude group: "javax.xml.bind", module: "jaxb-api"
 	}
 	runtimeOnly("org.postgresql:postgresql")
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-jsp/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-jsp/build.gradle
index 140f0a3c9a0c..d740445850ed 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-jsp/build.gradle
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-jsp/build.gradle
@@ -11,10 +11,6 @@ configurations {
 	}
 }
 
-configurations.all {
-	resolutionStrategy.force("jakarta.servlet:jakarta.servlet-api:5.0.0")
-}
-
 dependencies {
 	compileOnly(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-jetty"))
 
@@ -22,10 +18,7 @@ dependencies {
 		exclude module: "spring-boot-starter-tomcat"
 	}
 
-	providedRuntime("org.eclipse.jetty:apache-jsp") {
-		exclude group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-servlet-api"
-		exclude group: "org.eclipse.jetty.toolchain", module: "jetty-schemas"
-	}
+	providedRuntime("org.eclipse.jetty.ee10:jetty-ee10-apache-jsp")
 
 	runtimeOnly("org.glassfish.web:jakarta.servlet.jsp.jstl")
 
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-jsp/src/main/resources/application.properties b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-jsp/src/main/resources/application.properties
index f18efd166420..b3b89e953ed8 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-jsp/src/main/resources/application.properties
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-jsp/src/main/resources/application.properties
@@ -1,3 +1,4 @@
+application.message: Hello Spring Boot
+server.servlet.jsp.class-name=org.eclipse.jetty.ee10.jsp.JettyJspServlet
 spring.mvc.view.prefix: /WEB-INF/jsp/
 spring.mvc.view.suffix: .jsp
-application.message: Hello Spring Boot
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-ssl/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-ssl/build.gradle
index 8333e2118933..72c93ab6c314 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-ssl/build.gradle
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-ssl/build.gradle
@@ -5,10 +5,6 @@ plugins {
 
 description = "Spring Boot Jetty SSL smoke test"
 
-configurations.all {
-	resolutionStrategy.force("jakarta.servlet:jakarta.servlet-api:5.0.0")
-}
-
 dependencies {
 	implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-jetty"))
 	implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-web")) {
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/build.gradle
index 94173aa1ba18..b204e4a35e31 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/build.gradle
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/build.gradle
@@ -5,10 +5,6 @@ plugins {
 
 description = "Spring Boot Jetty smoke test"
 
-configurations.all {
-	resolutionStrategy.force("jakarta.servlet:jakarta.servlet-api:5.0.0")
-}
-
 dependencies {
 	implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-web")) {
 		exclude module: "spring-boot-starter-tomcat"
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/src/main/resources/application.properties b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/src/main/resources/application.properties
index 877098676857..0bb34b330311 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/src/main/resources/application.properties
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/src/main/resources/application.properties
@@ -2,4 +2,4 @@ server.compression.enabled: true
 server.compression.min-response-size: 1
 server.max-http-request-header-size=1000
 server.jetty.threads.acceptors=2
-server.jetty.max-http-response-header-size=1000
+server.jetty.max-http-response-header-size=4096
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/src/test/java/smoketest/jetty/SampleJettyApplicationTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/src/test/java/smoketest/jetty/SampleJettyApplicationTests.java
index e21fadb0802f..f1fb7c1cb8b2 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/src/test/java/smoketest/jetty/SampleJettyApplicationTests.java
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/src/test/java/smoketest/jetty/SampleJettyApplicationTests.java
@@ -16,10 +16,6 @@
 
 package smoketest.jetty;
 
-import java.io.ByteArrayInputStream;
-import java.nio.charset.StandardCharsets;
-import java.util.zip.GZIPInputStream;
-
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import smoketest.jetty.util.RandomStringUtil;
@@ -35,7 +31,6 @@
 import org.springframework.http.HttpMethod;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
-import org.springframework.util.StreamUtils;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -47,7 +42,7 @@
  * @author Florian Storz
  * @author Michael Weidmann
  */
-@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
+@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, properties = "logging.level.org.eclipse:trace")
 @ExtendWith(OutputCaptureExtension.class)
 class SampleJettyApplicationTests {
 
@@ -65,16 +60,13 @@ void testHome() {
 	}
 
 	@Test
-	void testCompression() throws Exception {
-		HttpHeaders requestHeaders = new HttpHeaders();
-		requestHeaders.set("Accept-Encoding", "gzip");
-		HttpEntity<?> requestEntity = new HttpEntity<>(requestHeaders);
-		ResponseEntity<byte[]> entity = this.restTemplate.exchange("/", HttpMethod.GET, requestEntity, byte[].class);
+	void testCompression() {
+		// Jetty HttpClient sends Accept-Encoding: gzip by default
+		ResponseEntity<String> entity = this.restTemplate.getForEntity("/", String.class);
 		assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK);
-		assertThat(entity.getBody()).isNotNull();
-		try (GZIPInputStream inflater = new GZIPInputStream(new ByteArrayInputStream(entity.getBody()))) {
-			assertThat(StreamUtils.copyToString(inflater, StandardCharsets.UTF_8)).isEqualTo("Hello World");
-		}
+		assertThat(entity.getBody()).isEqualTo("Hello World");
+		// Jetty HttpClient decodes gzip responses automatically and removes the
+		// Content-Encoding header. We have to assume that the response was gzipped.
 	}
 
 	@Test
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/build.gradle
index 33a82ff144bd..24d8f33d79d1 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/build.gradle
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/build.gradle
@@ -10,6 +10,11 @@ dependencies {
 	implementation("org.springframework.kafka:spring-kafka")
 
 	testImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test"))
+	testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support"))
 	testImplementation("org.awaitility:awaitility")
-	testImplementation("org.springframework.kafka:spring-kafka-test")
+	testImplementation("org.springframework.kafka:spring-kafka-test") {
+		exclude group: "commons-logging", module: "commons-logging"
+	}
+	testImplementation("org.testcontainers:junit-jupiter")
+	testImplementation("org.testcontainers:kafka")
 }
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/main/java/smoketest/kafka/Consumer.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/main/java/smoketest/kafka/Consumer.java
index 4beb1a980ff1..c388e2ac70ff 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/main/java/smoketest/kafka/Consumer.java
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/main/java/smoketest/kafka/Consumer.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2012-2019 the original author or authors.
+ * Copyright 2012-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -23,7 +23,7 @@
 import org.springframework.stereotype.Component;
 
 @Component
-class Consumer {
+public class Consumer {
 
 	private final List<SampleMessage> messages = new CopyOnWriteArrayList<>();
 
@@ -33,7 +33,7 @@ void processMessage(SampleMessage message) {
 		System.out.println("Received sample message [" + message + "]");
 	}
 
-	List<SampleMessage> getMessages() {
+	public List<SampleMessage> getMessages() {
 		return this.messages;
 	}
 
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/main/java/smoketest/kafka/ssl/SampleKafkaSslApplication.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/main/java/smoketest/kafka/ssl/SampleKafkaSslApplication.java
new file mode 100644
index 000000000000..30b8c8ad2e30
--- /dev/null
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/main/java/smoketest/kafka/ssl/SampleKafkaSslApplication.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package smoketest.kafka.ssl;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class SampleKafkaSslApplication {
+
+	public static void main(String[] args) {
+		SpringApplication.run(SampleKafkaSslApplication.class, args);
+	}
+
+}
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/java/smoketest/kafka/ssl/SampleKafkaSslApplicationTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/java/smoketest/kafka/ssl/SampleKafkaSslApplicationTests.java
new file mode 100644
index 000000000000..433c9e0a0f9b
--- /dev/null
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/java/smoketest/kafka/ssl/SampleKafkaSslApplicationTests.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package smoketest.kafka.ssl;
+
+import java.time.Duration;
+
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.Test;
+import org.testcontainers.containers.KafkaContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.MountableFile;
+import smoketest.kafka.Consumer;
+import smoketest.kafka.Producer;
+import smoketest.kafka.SampleMessage;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.not;
+
+/**
+ * Smoke tests for Apache Kafka with SSL.
+ *
+ * @author Scott Frederick
+ * @author EddĂș MelĂ©ndez
+ */
+@Testcontainers(disabledWithoutDocker = true)
+@SpringBootTest(classes = { SampleKafkaSslApplication.class, Producer.class, Consumer.class },
+		properties = { "spring.kafka.security.protocol=SSL",
+				"spring.kafka.properties.ssl.endpoint.identification.algorithm=", "spring.kafka.ssl.bundle=client",
+				"spring.ssl.bundle.jks.client.keystore.location=classpath:ssl/test-client.p12",
+				"spring.ssl.bundle.jks.client.keystore.password=password",
+				"spring.ssl.bundle.jks.client.truststore.location=classpath:ssl/test-ca.p12",
+				"spring.ssl.bundle.jks.client.truststore.password=password" })
+class SampleKafkaSslApplicationTests {
+
+	@Container
+	public static KafkaContainer kafka = new KafkaContainer(DockerImageNames.kafka())
+		.withEnv("KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", "PLAINTEXT:SSL,BROKER:PLAINTEXT")
+		.withEnv("KAFKA_AUTO_CREATE_TOPICS_ENABLE", "true")
+		.withEnv("KAFKA_SSL_CLIENT_AUTH", "required")
+		.withEnv("KAFKA_SSL_KEYSTORE_LOCATION", "/etc/kafka/secrets/certs/test-server.p12")
+		.withEnv("KAFKA_SSL_KEYSTORE_PASSWORD", "password")
+		.withEnv("KAFKA_SSL_KEY_PASSWORD", "password")
+		.withEnv("KAFKA_SSL_TRUSTSTORE_LOCATION", "/etc/kafka/secrets/certs/test-ca.p12")
+		.withEnv("KAFKA_SSL_TRUSTSTORE_PASSWORD", "password")
+		.withEnv("KAFKA_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM", "")
+		.withCopyFileToContainer(MountableFile.forClasspathResource("ssl/test-server.p12"),
+				"/etc/kafka/secrets/certs/test-server.p12")
+		.withCopyFileToContainer(MountableFile.forClasspathResource("ssl/credentials"),
+				"/etc/kafka/secrets/certs/credentials")
+		.withCopyFileToContainer(MountableFile.forClasspathResource("ssl/test-ca.p12"),
+				"/etc/kafka/secrets/certs/test-ca.p12");
+
+	@DynamicPropertySource
+	static void kafkaProperties(DynamicPropertyRegistry registry) {
+		registry.add("spring.kafka.bootstrap-servers",
+				() -> String.format("%s:%s", kafka.getHost(), kafka.getMappedPort(9093)));
+	}
+
+	@Autowired
+	private Producer producer;
+
+	@Autowired
+	private Consumer consumer;
+
+	@Test
+	void testVanillaExchange() {
+		this.producer.send(new SampleMessage(1, "A simple test message"));
+
+		Awaitility.waitAtMost(Duration.ofSeconds(30)).until(this.consumer::getMessages, not(empty()));
+		assertThat(this.consumer.getMessages()).extracting("message").containsOnly("A simple test message");
+	}
+
+}
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/credentials b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/credentials
new file mode 100644
index 000000000000..7aa311adf93f
--- /dev/null
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/credentials
@@ -0,0 +1 @@
+password
\ No newline at end of file
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/test-ca.p12 b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/test-ca.p12
new file mode 100644
index 000000000000..fd0a5d99b0c0
Binary files /dev/null and b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/test-ca.p12 differ
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/test-client.p12 b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/test-client.p12
new file mode 100644
index 000000000000..d2fd1d0f3228
Binary files /dev/null and b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/test-client.p12 differ
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/test-server.p12 b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/test-server.p12
new file mode 100644
index 000000000000..5f1bd89eccfc
Binary files /dev/null and b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/test-server.p12 differ
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-liquibase/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-liquibase/build.gradle
index 1126e59d0b42..292368536ed1 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-liquibase/build.gradle
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-liquibase/build.gradle
@@ -11,7 +11,6 @@ dependencies {
 	implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-web"))
 	implementation("jakarta.xml.bind:jakarta.xml.bind-api")
 	implementation("org.liquibase:liquibase-core") {
-		exclude group: "javax.activation", module: "javax.activation-api"
 		exclude group: "javax.xml.bind", module: "jaxb-api"
 	}
 
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-oauth2-authorization-server/src/test/java/smoketest/oauth2/server/SampleOAuth2AuthorizationServerApplicationTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-oauth2-authorization-server/src/test/java/smoketest/oauth2/server/SampleOAuth2AuthorizationServerApplicationTests.java
index 0ecf890c9f43..dbc0bff4e5b0 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-oauth2-authorization-server/src/test/java/smoketest/oauth2/server/SampleOAuth2AuthorizationServerApplicationTests.java
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-oauth2-authorization-server/src/test/java/smoketest/oauth2/server/SampleOAuth2AuthorizationServerApplicationTests.java
@@ -62,14 +62,14 @@ void openidConfigurationShouldAllowAccess() {
 		assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK);
 
 		OidcProviderConfiguration config = OidcProviderConfiguration.withClaims(entity.getBody()).build();
-		assertThat(config.getIssuer().toString()).isEqualTo("https://provider.com");
-		assertThat(config.getAuthorizationEndpoint().toString()).isEqualTo("https://provider.com/authorize");
-		assertThat(config.getTokenEndpoint().toString()).isEqualTo("https://provider.com/token");
-		assertThat(config.getJwkSetUrl().toString()).isEqualTo("https://provider.com/jwks");
-		assertThat(config.getTokenRevocationEndpoint().toString()).isEqualTo("https://provider.com/revoke");
-		assertThat(config.getEndSessionEndpoint().toString()).isEqualTo("https://provider.com/logout");
-		assertThat(config.getTokenIntrospectionEndpoint().toString()).isEqualTo("https://provider.com/introspect");
-		assertThat(config.getUserInfoEndpoint().toString()).isEqualTo("https://provider.com/user");
+		assertThat(config.getIssuer()).hasToString("https://provider.com");
+		assertThat(config.getAuthorizationEndpoint()).hasToString("https://provider.com/authorize");
+		assertThat(config.getTokenEndpoint()).hasToString("https://provider.com/token");
+		assertThat(config.getJwkSetUrl()).hasToString("https://provider.com/jwks");
+		assertThat(config.getTokenRevocationEndpoint()).hasToString("https://provider.com/revoke");
+		assertThat(config.getEndSessionEndpoint()).hasToString("https://provider.com/logout");
+		assertThat(config.getTokenIntrospectionEndpoint()).hasToString("https://provider.com/introspect");
+		assertThat(config.getUserInfoEndpoint()).hasToString("https://provider.com/user");
 		// OIDC Client Registration is disabled by default
 		assertThat(config.getClientRegistrationEndpoint()).isNull();
 	}
@@ -82,12 +82,12 @@ void authServerMetadataShouldAllowAccess() {
 
 		OAuth2AuthorizationServerMetadata config = OAuth2AuthorizationServerMetadata.withClaims(entity.getBody())
 			.build();
-		assertThat(config.getIssuer().toString()).isEqualTo("https://provider.com");
-		assertThat(config.getAuthorizationEndpoint().toString()).isEqualTo("https://provider.com/authorize");
-		assertThat(config.getTokenEndpoint().toString()).isEqualTo("https://provider.com/token");
-		assertThat(config.getJwkSetUrl().toString()).isEqualTo("https://provider.com/jwks");
-		assertThat(config.getTokenRevocationEndpoint().toString()).isEqualTo("https://provider.com/revoke");
-		assertThat(config.getTokenIntrospectionEndpoint().toString()).isEqualTo("https://provider.com/introspect");
+		assertThat(config.getIssuer()).hasToString("https://provider.com");
+		assertThat(config.getAuthorizationEndpoint()).hasToString("https://provider.com/authorize");
+		assertThat(config.getTokenEndpoint()).hasToString("https://provider.com/token");
+		assertThat(config.getJwkSetUrl()).hasToString("https://provider.com/jwks");
+		assertThat(config.getTokenRevocationEndpoint()).hasToString("https://provider.com/revoke");
+		assertThat(config.getTokenIntrospectionEndpoint()).hasToString("https://provider.com/introspect");
 		// OIDC Client Registration is disabled by default
 		assertThat(config.getClientRegistrationEndpoint()).isNull();
 	}
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/build.gradle
new file mode 100644
index 000000000000..a0051d3f4ea1
--- /dev/null
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/build.gradle
@@ -0,0 +1,17 @@
+plugins {
+	id "java"
+	id "org.springframework.boot.conventions"
+}
+
+description = "Spring Boot Pulsar smoke test"
+
+dependencies {
+	implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-pulsar"))
+	implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-pulsar-reactive"))
+	testImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test"))
+	testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support"))
+	testImplementation(project(":spring-boot-project:spring-boot-testcontainers"))
+	testImplementation("org.awaitility:awaitility")
+	testImplementation("org.testcontainers:junit-jupiter")
+	testImplementation("org.testcontainers:pulsar")
+}
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/ImperativeAppConfig.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/ImperativeAppConfig.java
new file mode 100644
index 000000000000..e7482711fbac
--- /dev/null
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/ImperativeAppConfig.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package smoketest.pulsar;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+import org.springframework.pulsar.annotation.PulsarListener;
+import org.springframework.pulsar.core.PulsarTemplate;
+import org.springframework.pulsar.core.PulsarTopic;
+
+@Configuration(proxyBeanMethods = false)
+@Profile("smoketest.pulsar.imperative")
+class ImperativeAppConfig {
+
+	private static final Log logger = LogFactory.getLog(ImperativeAppConfig.class);
+
+	private static final String TOPIC = "pulsar-smoke-test-topic";
+
+	@Bean
+	PulsarTopic pulsarTestTopic() {
+		return PulsarTopic.builder(TOPIC).numberOfPartitions(1).build();
+	}
+
+	@Bean
+	ApplicationRunner sendMessagesToPulsarTopic(PulsarTemplate<SampleMessage> template) {
+		return (args) -> {
+			for (int i = 0; i < 10; i++) {
+				template.send(TOPIC, new SampleMessage(i, "message:" + i));
+				logger.info("++++++PRODUCE IMPERATIVE:(" + i + ")------");
+			}
+		};
+	}
+
+	@PulsarListener(topics = TOPIC)
+	void consumeMessagesFromPulsarTopic(SampleMessage msg) {
+		logger.info("++++++CONSUME IMPERATIVE:(" + msg.id() + ")------");
+	}
+
+}
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/ReactiveAppConfig.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/ReactiveAppConfig.java
new file mode 100644
index 000000000000..844178bd44be
--- /dev/null
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/ReactiveAppConfig.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package smoketest.pulsar;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.apache.pulsar.reactive.client.api.MessageSpec;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+import org.springframework.pulsar.core.PulsarTopic;
+import org.springframework.pulsar.reactive.config.annotation.ReactivePulsarListener;
+import org.springframework.pulsar.reactive.core.ReactivePulsarTemplate;
+
+@Configuration(proxyBeanMethods = false)
+@Profile("smoketest.pulsar.reactive")
+class ReactiveAppConfig {
+
+	private static final Log logger = LogFactory.getLog(ReactiveAppConfig.class);
+
+	private static final String TOPIC = "pulsar-reactive-smoke-test-topic";
+
+	@Bean
+	PulsarTopic pulsarTestTopic() {
+		return PulsarTopic.builder(TOPIC).numberOfPartitions(1).build();
+	}
+
+	@Bean
+	ApplicationRunner sendMessagesToPulsarTopic(ReactivePulsarTemplate<SampleMessage> template) {
+		return (args) -> Flux.range(0, 10)
+			.map((i) -> new SampleMessage(i, "message:" + i))
+			.map(MessageSpec::of)
+			.as((msgs) -> template.send(TOPIC, msgs))
+			.doOnNext((sendResult) -> logger
+				.info("++++++PRODUCE REACTIVE:(" + sendResult.getMessageSpec().getValue().id() + ")------"))
+			.subscribe();
+	}
+
+	@ReactivePulsarListener(topics = TOPIC)
+	Mono<Void> consumeMessagesFromPulsarTopic(SampleMessage msg) {
+		logger.info("++++++CONSUME REACTIVE:(" + msg.id() + ")------");
+		return Mono.empty();
+	}
+
+}
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/SampleMessage.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/SampleMessage.java
new file mode 100644
index 000000000000..3887ce61f13a
--- /dev/null
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/SampleMessage.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package smoketest.pulsar;
+
+record SampleMessage(Integer id, String content) {
+}
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/SamplePulsarApplication.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/SamplePulsarApplication.java
new file mode 100644
index 000000000000..560967bb2d0d
--- /dev/null
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/SamplePulsarApplication.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package smoketest.pulsar;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class SamplePulsarApplication {
+
+	public static void main(String[] args) {
+		SpringApplication.run(SamplePulsarApplication.class, args);
+	}
+
+}
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/resources/application.properties b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/resources/application.properties
new file mode 100644
index 000000000000..b1ae3ec6f4ee
--- /dev/null
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/resources/application.properties
@@ -0,0 +1 @@
+spring.pulsar.consumer.subscription.initial-position=earliest
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/test/java/smoketest/pulsar/SamplePulsarApplicationTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/test/java/smoketest/pulsar/SamplePulsarApplicationTests.java
new file mode 100644
index 000000000000..c58c743cc8d9
--- /dev/null
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/test/java/smoketest/pulsar/SamplePulsarApplicationTests.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package smoketest.pulsar;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.IntStream;
+
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.testcontainers.containers.PulsarContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.boot.testsupport.testcontainers.DockerImageNames;
+import org.springframework.test.context.ActiveProfiles;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@Testcontainers(disabledWithoutDocker = true)
+@ExtendWith(OutputCaptureExtension.class)
+class SamplePulsarApplicationTests {
+
+	@Container
+	@ServiceConnection
+	static final PulsarContainer container = new PulsarContainer(DockerImageNames.pulsar()).withStartupAttempts(2)
+		.withStartupTimeout(Duration.ofMinutes(3));
+
+	abstract class PulsarApplication {
+
+		private final String type;
+
+		PulsarApplication(String type) {
+			this.type = type;
+		}
+
+		@Test
+		void appProducesAndConsumesMessages(CapturedOutput output) {
+			List<String> expectedOutput = new ArrayList<>();
+			IntStream.range(0, 10).forEachOrdered((i) -> {
+				expectedOutput.add("++++++PRODUCE %s:(%s)------".formatted(this.type, i));
+				expectedOutput.add("++++++CONSUME %s:(%s)------".formatted(this.type, i));
+			});
+			Awaitility.waitAtMost(Duration.ofSeconds(30))
+				.untilAsserted(() -> assertThat(output).contains(expectedOutput));
+		}
+
+	}
+
+	@Nested
+	@SpringBootTest
+	@ActiveProfiles("smoketest.pulsar.imperative")
+	class ImperativePulsarApplication extends PulsarApplication {
+
+		ImperativePulsarApplication() {
+			super("IMPERATIVE");
+		}
+
+	}
+
+	@Nested
+	@SpringBootTest
+	@ActiveProfiles("smoketest.pulsar.reactive")
+	class ReactivePulsarApplication extends PulsarApplication {
+
+		ReactivePulsarApplication() {
+			super("REACTIVE");
+		}
+
+	}
+
+}
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-test/src/test/java/smoketest/test/web/UserVehicleControllerTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-test/src/test/java/smoketest/test/web/UserVehicleControllerTests.java
index e3f2840e3eba..2db1475ad5bb 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-test/src/test/java/smoketest/test/web/UserVehicleControllerTests.java
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-test/src/test/java/smoketest/test/web/UserVehicleControllerTests.java
@@ -16,7 +16,6 @@
 
 package smoketest.test.web;
 
-import org.assertj.core.api.Assertions;
 import org.junit.jupiter.api.Test;
 import smoketest.test.WelcomeCommandLineRunner;
 import smoketest.test.domain.VehicleIdentificationNumber;
@@ -31,6 +30,7 @@
 import org.springframework.http.MediaType;
 import org.springframework.test.web.servlet.MockMvc;
 
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
 import static org.hamcrest.Matchers.containsString;
 import static org.mockito.BDDMockito.given;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -96,8 +96,8 @@ void getVehicleWhenVinNotFoundShouldReturnNotFound() throws Exception {
 	@Test
 	void welcomeCommandLineRunnerShouldNotBeAvailable() {
 		// Since we're a @WebMvcTest WelcomeCommandLineRunner should not be available.
-		Assertions.assertThatThrownBy(() -> this.applicationContext.getBean(WelcomeCommandLineRunner.class))
-			.isInstanceOf(NoSuchBeanDefinitionException.class);
+		assertThatExceptionOfType(NoSuchBeanDefinitionException.class)
+			.isThrownBy(() -> this.applicationContext.getBean(WelcomeCommandLineRunner.class));
 	}
 
 }
diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/build.gradle
index e09d6e6806b5..58e1f903b5c6 100644
--- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/build.gradle
+++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/build.gradle
@@ -5,10 +5,6 @@ plugins {
 
 description = "Spring Boot WebSocket Jetty smoke test"
 
-configurations.all {
-	resolutionStrategy.force("jakarta.servlet:jakarta.servlet-api:5.0.0")
-}
-
 dependencies {
 	implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-jetty"))
 	implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-websocket")) {
diff --git a/src/checkstyle/checkstyle-suppressions.xml b/src/checkstyle/checkstyle-suppressions.xml
index 9062ad5e2b4f..fdec73a16394 100644
--- a/src/checkstyle/checkstyle-suppressions.xml
+++ b/src/checkstyle/checkstyle-suppressions.xml
@@ -10,6 +10,7 @@
 	<suppress files="LogbackInitializer\.java" checks="IllegalImport" />
 	<suppress files="LogbackLoggingSystem\.java" checks="IllegalImport" />
 	<suppress files="LogbackLoggingSystemTests\.java" checks="IllegalImport" />
+	<suppress files="LogbackLoggingSystemParallelInitializationTests\.java" checks="IllegalImport" />
 	<suppress files="LogbackConfigurationAotContributionTests\.java" checks="IllegalImport" />
 	<suppress files="MetricsAutoConfigurationMeterRegistryPostProcessorIntegrationTests\.java" checks="IllegalImport" message="LoggerFactory"/>
 	<suppress files="SpringApplicationTests\.java" checks="FinalClass" />
@@ -32,6 +33,7 @@
 	<suppress files="[\\/]spring-boot-docs[\\/].*MyConfiguration__BeanDefinitions" checks="JavadocMethod|SpringHideUtilityClassConstructor" />
 	<suppress files="[\\/]spring-boot-smoke-tests[\\/]" checks="JavadocPackage|JavadocType" />
 	<suppress files="[\\/]spring-boot-smoke-tests[\\/]" checks="ImportControl" />
+	<suppress files="[\\/]spring-boot-smoke-tests[\\/].*SamplePulsarApplicationTests" checks="SpringHideUtilityClassConstructor" />
 	<suppress files="[\\/]spring-boot-smoke-tests[\\/]" id="mainCodeIllegalImportCheck" />
 	<suppress files="[\\/]spring-boot-deployment-tests[\\/]" checks="JavadocPackage|JavadocType" />
 	<suppress files="[\\/]spring-boot-integration-tests[\\/]" checks="JavadocType" />
@@ -70,6 +72,7 @@
 	<suppress files="DeprecatedReactiveElasticsearchRestClientProperties\.java" checks="SpringMethodVisibility" />
 	<suppress files="DevToolsTestApplication\.java" checks="SpringMethodVisibility" />
 	<suppress files="DevToolsR2dbcAutoConfigurationTests" checks="HideUtilityClassConstructor" />
+	<suppress files="ConfigurationMetadataChangelogGenerator" checks="SpringMethodVisibility" />
 	<suppress files="AbstractLaunchScriptIntegrationTests" checks="IllegalImport" />
 	<suppress files="FailureAnalyzers\.java" checks="RedundantModifier" />
 	<suppress files="SpringBootVersion" checks="JavadocPackage" />
@@ -78,4 +81,5 @@
 	<suppress files="ConversionServiceTest\.java" checks="SpringTestFileName" />
 	<suppress files="ImportTestcontainersTests\.java" checks="InterfaceIsType" />
 	<suppress files="MyContainers\.java" checks="InterfaceIsType" />
+	<suppress files="CertificateMatchingTest\.java" checks="SpringTestFileName" />
 </suppressions>
diff --git a/src/checkstyle/checkstyle.xml b/src/checkstyle/checkstyle.xml
index d1d3c1e747f5..b42030407e23 100644
--- a/src/checkstyle/checkstyle.xml
+++ b/src/checkstyle/checkstyle.xml
@@ -23,7 +23,9 @@
 			<property name="id" value="mainCodeIllegalImportCheck"/>
 			<property name="regexp" value="true" />
 			<property name="illegalClasses"
-				value="^javax.annotation.PostConstruct"/>
+				value="javax.annotation.PostConstruct, jakarta.annotation.PostConstruct"/>
+			<property name="illegalPkgs"
+				value="^io\.opentelemetry\.semconv.*"/>
 		</module>
 		<module
 			name="com.puppycrawl.tools.checkstyle.checks.imports.ImportControlCheck">
@@ -33,7 +35,7 @@
 		</module>
 		<module name="com.puppycrawl.tools.checkstyle.checks.regexp.RegexpSinglelineJavaCheck">
 			<property name="maximum" value="0"/>
-			<property name="format" value="org\.junit\.Assert\.assert" />
+			<property name="format" value="org\.junit\.Assert|org\.junit\.jupiter\.api\.Assertions" />
 			<property name="message"
 				value="Please use AssertJ imports." />
 			<property name="ignoreComments" value="true" />
@@ -47,6 +49,15 @@
 				value="Please use specialized AssertJ assertThat*Exception method." />
 			<property name="ignoreComments" value="true" />
 		</module>
+		<module
+			name="com.puppycrawl.tools.checkstyle.checks.regexp.RegexpSinglelineJavaCheck">
+			<property name="maximum" value="0" />
+			<property name="format"
+					  value="assertThatThrownBy\(" />
+			<property name="message"
+					  value="Please use AssertJ assertThatExceptionOfType method." />
+			<property name="ignoreComments" value="true" />
+		</module>
  		<module name="com.puppycrawl.tools.checkstyle.checks.regexp.RegexpSinglelineJavaCheck">
  			<property name="maximum" value="0"/>
 			<property name="format" value="org\.mockito\.(Mockito|BDDMockito)\.(when|doThrow|doAnswer|doReturn|verify|verifyNoInteractions|verifyNoMoreInteractions)" />
diff --git a/src/checkstyle/import-control.xml b/src/checkstyle/import-control.xml
index d0e94c775366..78d5bbabeab6 100644
--- a/src/checkstyle/import-control.xml
+++ b/src/checkstyle/import-control.xml
@@ -101,6 +101,7 @@
 		</subpackage>
 		<subpackage name="server">
 			<disallow pkg="org.springframework.context" />
+			<allow pkg="org.springframework.boot.web.server"/>
 			<subpackage name="context">
 				<allow pkg="org.springframework.context" />
 			</subpackage>