diff --git a/backend/pom.xml b/backend/pom.xml
index dd0bc03c..0dd83185 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -48,6 +48,11 @@
org.springframework.boot
spring-boot-starter-actuator
+
+
+ org.springframework.boot
+ spring-boot-starter-micrometer-metrics
+
org.springframework.boot
spring-boot-starter-validation
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/FamilienarchivApplication.java b/backend/src/main/java/org/raddatz/familienarchiv/FamilienarchivApplication.java
index 09227d27..4fef338f 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/FamilienarchivApplication.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/FamilienarchivApplication.java
@@ -1,11 +1,9 @@
package org.raddatz.familienarchiv;
import org.springframework.boot.SpringApplication;
-import org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration;
import org.springframework.boot.autoconfigure.SpringBootApplication;
-// Excluded: management port (8081) is network-isolated inside archiv-net; no app-level auth needed.
-@SpringBootApplication(exclude = {ManagementWebSecurityAutoConfiguration.class})
+@SpringBootApplication
public class FamilienarchivApplication {
public static void main(String[] args) {
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java
index 298d9fa6..2cf85573 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java
@@ -54,8 +54,14 @@ public class SecurityConfig {
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> {
- // Health endpoint must be open so CI/Docker health checks work without credentials
- auth.requestMatchers("/actuator/health").permitAll();
+ // Both /actuator/health and /actuator/prometheus must be open.
+ // In Spring Boot 4.0 the management server (port 8081) shares the security filter chain;
+ // network isolation (port 8081 not published in docker-compose) is the security boundary.
+ // Health and Prometheus must be open — no credentials for Docker health checks or Prometheus scraping.
+ // Note: in Spring Boot 4.0 the management port shares the security filter chain,
+ // so these paths must be explicitly permitted here even though they are served on port 8081.
+ // Network isolation (port 8081 not published in docker-compose) is the outer security boundary.
+ auth.requestMatchers("/actuator/health", "/actuator/prometheus").permitAll();
// Password reset endpoints are unauthenticated by nature
auth.requestMatchers("/api/auth/forgot-password", "/api/auth/reset-password").permitAll();
// Invite-based registration endpoints are public
diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml
index ead3d9e8..5f4fb207 100644
--- a/backend/src/main/resources/application.yaml
+++ b/backend/src/main/resources/application.yaml
@@ -49,7 +49,8 @@ management:
# Management port is separate from the app port so that:
# (a) Caddy never proxies /actuator/* (it only routes :8080 → the app port)
# (b) Prometheus scrapes backend:8081 directly inside archiv-net, not via Caddy
- # (c) Spring Security's session-authenticated filter chain on :8080 never sees actuator requests
+ # Note: in Spring Boot 4.0 the management port shares the security filter chain; /actuator/health
+ # and /actuator/prometheus must be explicitly permitted in SecurityConfig — see SecurityConfig.java.
port: 8081
endpoints:
web:
@@ -58,6 +59,11 @@ management:
endpoint:
prometheus:
enabled: true
+ # Spring Boot 4.0: metrics export is disabled by default — explicitly opt in for Prometheus
+ prometheus:
+ metrics:
+ export:
+ enabled: true
health:
mail:
enabled: false
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/ActuatorPrometheusIT.java b/backend/src/test/java/org/raddatz/familienarchiv/ActuatorPrometheusIT.java
new file mode 100644
index 00000000..36d91bfa
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/ActuatorPrometheusIT.java
@@ -0,0 +1,48 @@
+package org.raddatz.familienarchiv;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalManagementPort;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.web.client.DefaultResponseErrorHandler;
+import org.springframework.web.client.RestTemplate;
+import software.amazon.awssdk.services.s3.S3Client;
+
+import java.io.IOException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@ActiveProfiles("test")
+@Import(PostgresContainerConfig.class)
+class ActuatorPrometheusIT {
+
+ @LocalManagementPort
+ private int managementPort;
+
+ @MockitoBean
+ S3Client s3Client;
+
+ @Test
+ void prometheus_endpoint_returns_jvm_metrics_without_credentials() {
+ ResponseEntity response = noThrowTemplate().getForEntity(
+ "http://localhost:" + managementPort + "/actuator/prometheus", String.class);
+
+ assertThat(response.getStatusCode().value()).isEqualTo(200);
+ assertThat(response.getBody()).contains("jvm_memory_used_bytes");
+ }
+
+ private RestTemplate noThrowTemplate() {
+ RestTemplate template = new RestTemplate();
+ template.setErrorHandler(new DefaultResponseErrorHandler() {
+ @Override
+ public boolean hasError(org.springframework.http.client.ClientHttpResponse response) throws IOException {
+ return false;
+ }
+ });
+ return template;
+ }
+}