From 316137bf1c391577e884ce525af780f45e34da86 Mon Sep 17 00:00:00 2001 From: nitrix Date: Wed, 4 Mar 2026 19:16:50 +0100 Subject: [PATCH] T-2: add multi-stage Dockerfile and SPA-serving Spring Boot config Replace server.servlet.context-path=/api with addPathPrefix so API endpoints stay under /api while static resources and SPA routes are served at /. Spring Boot falls back to index.html for unknown paths (SPA forwarding). Multi-stage Dockerfile builds frontend (Node 24) and backend (Temurin 25) into a single 250MB JRE-alpine image with Docker-native HEALTHCHECK. Co-Authored-By: Claude Opus 4.6 --- .dockerignore | 38 ++ Dockerfile | 26 ++ .../main/java/de/fete/config/WebConfig.java | 40 ++ .../src/main/resources/application.properties | 1 - .../java/de/fete/config/WebConfigTest.java | 33 ++ .../plan/2026-03-04-t2-docker-deployment.md | 366 ++++++++++++++++++ ...26-03-04-spa-springboot-docker-patterns.md | 135 +++++++ spec/setup-tasks.md | 8 +- 8 files changed, 642 insertions(+), 5 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 backend/src/main/java/de/fete/config/WebConfig.java create mode 100644 backend/src/test/java/de/fete/config/WebConfigTest.java create mode 100644 docs/agents/plan/2026-03-04-t2-docker-deployment.md create mode 100644 docs/agents/research/2026-03-04-spa-springboot-docker-patterns.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dcd338c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,38 @@ +# Build artifacts +**/target/ +**/dist/ +**/build/ + +# Dependencies (rebuilt inside Docker) +**/node_modules/ + +# IDE +.idea/ +.vscode/ +**/*.iml + +# Git +.git/ +.gitignore + +# CI/CD +.gitea/ + +# Agent/dev files +.claude/ +.ralph/ +.rodney/ +.agent-tests/ +docs/ +spec/ + +# OS files +.DS_Store +Thumbs.db + +# Environment +.env +.env.* + +# Generated files (rebuilt in Docker) +frontend/src/api/schema.d.ts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1dad1e0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# Stage 1: Build frontend +FROM node:24-alpine AS frontend-build +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +# OpenAPI spec needed for type generation (npm run build runs generate:api) +COPY backend/src/main/resources/openapi/api.yaml \ + ../backend/src/main/resources/openapi/api.yaml +RUN npm run build + +# Stage 2: Build backend with frontend assets baked in +FROM eclipse-temurin:25-jdk-alpine AS backend-build +WORKDIR /app/backend +COPY backend/ ./ +COPY --from=frontend-build /app/frontend/dist src/main/resources/static/ +RUN ./mvnw -B -DskipTests -Dcheckstyle.skip -Dspotbugs.skip package + +# Stage 3: Runtime +FROM eclipse-temurin:25-jre-alpine +WORKDIR /app +COPY --from=backend-build /app/backend/target/*.jar app.jar +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD wget -qO- http://localhost:8080/actuator/health || exit 1 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/backend/src/main/java/de/fete/config/WebConfig.java b/backend/src/main/java/de/fete/config/WebConfig.java new file mode 100644 index 0000000..e333e40 --- /dev/null +++ b/backend/src/main/java/de/fete/config/WebConfig.java @@ -0,0 +1,40 @@ +package de.fete.config; + +import java.io.IOException; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.resource.PathResourceResolver; + +/** Configures API path prefix and SPA static resource serving. */ +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void configurePathMatch(PathMatchConfigurer configurer) { + configurer.addPathPrefix("/api", c -> c.isAnnotationPresent(RestController.class)); + } + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/**") + .addResourceLocations("classpath:/static/") + .resourceChain(true) + .addResolver(new PathResourceResolver() { + @Override + protected Resource getResource(String resourcePath, + Resource location) throws IOException { + Resource requested = location.createRelative(resourcePath); + if (requested.exists() && requested.isReadable()) { + return requested; + } + Resource index = new ClassPathResource("/static/index.html"); + return (index.exists() && index.isReadable()) ? index : null; + } + }); + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index b6ea848..ea9dbcd 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1,5 +1,4 @@ spring.application.name=fete -server.servlet.context-path=/api management.endpoints.web.exposure.include=health management.endpoint.health.show-details=never diff --git a/backend/src/test/java/de/fete/config/WebConfigTest.java b/backend/src/test/java/de/fete/config/WebConfigTest.java new file mode 100644 index 0000000..ea2687f --- /dev/null +++ b/backend/src/test/java/de/fete/config/WebConfigTest.java @@ -0,0 +1,33 @@ +package de.fete.config; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +class WebConfigTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void actuatorHealthIsOutsideApiPrefix() throws Exception { + mockMvc.perform(get("/actuator/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")); + } + + @Test + void apiPrefixNotAccessibleWithoutIt() throws Exception { + // /health without /api prefix should not resolve to the API endpoint + mockMvc.perform(get("/health")) + .andExpect(status().isNotFound()); + } +} diff --git a/docs/agents/plan/2026-03-04-t2-docker-deployment.md b/docs/agents/plan/2026-03-04-t2-docker-deployment.md new file mode 100644 index 0000000..30e6e05 --- /dev/null +++ b/docs/agents/plan/2026-03-04-t2-docker-deployment.md @@ -0,0 +1,366 @@ +--- +date: 2026-03-04T17:40:16+00:00 +git_commit: 96ef8656bd87032696ae82198620d99f20d80d3b +branch: master +topic: "T-2: Docker Deployment Setup" +tags: [plan, docker, deployment, spring-boot, spa] +status: draft +--- + +# T-2: Docker Deployment Setup + +## Overview + +Create a multi-stage Dockerfile that builds both backend (Spring Boot) and frontend (Vue 3) and produces a single runnable container. Spring Boot serves the SPA's static files directly — one process, one port, one JAR. This requires migrating from `server.servlet.context-path=/api` to `addPathPrefix` and adding SPA forwarding so Vue Router's history mode works. + +## Current State Analysis + +**What exists:** +- Backend: Spring Boot 3.5.11, Java 25, Maven with wrapper, Actuator health endpoint +- Frontend: Vue 3, Vite, TypeScript, Vue Router (history mode), openapi-fetch client +- `server.servlet.context-path=/api` scopes everything under `/api` (including static resources) +- Health test at `backend/src/test/java/de/fete/FeteApplicationTest.java:27` uses `get("/actuator/health")` +- OpenAPI spec at `backend/src/main/resources/openapi/api.yaml` has `servers: [{url: /api}]` +- Frontend client at `frontend/src/api/client.ts` uses `baseUrl: "/api"` +- ArchUnit registers `de.fete.config..` as an adapter package — config classes belong there +- `package-lock.json` exists (needed for `npm ci`) +- Maven wrapper `.properties` present (JAR downloads automatically) +- No Dockerfile, no `.dockerignore` exist yet + +**What's missing:** +- Dockerfile (multi-stage build) +- `.dockerignore` +- `WebConfig` to replace context-path with `addPathPrefix` +- SPA forwarding config (so Vue Router history mode works when served by Spring Boot) + +### Key Discoveries: +- `FeteApplicationTest.java:27`: MockMvc test uses `get("/actuator/health")` — path is relative to context path in MockMvc, so removing context-path does NOT break this test +- `api.yaml:11`: OpenAPI `servers: [{url: /api}]` — stays unchanged, `addPathPrefix` produces the same routing +- `client.ts:4`: `baseUrl: "/api"` — stays unchanged +- `HexagonalArchitectureTest.java:22`: `config` is an adapter in ArchUnit → new config classes go in `de.fete.config` +- `router/index.ts:5`: Uses `createWebHistory` → history mode, needs server-side SPA forwarding + +## Desired End State + +After implementation: +1. `docker build -t fete .` succeeds at the repo root +2. `docker run -p 8080:8080 fete` starts the container +3. `curl http://localhost:8080/actuator/health` returns `{"status":"UP"}` +4. `curl http://localhost:8080/` returns the Vue SPA's `index.html` +5. `curl http://localhost:8080/some/spa/route` also returns `index.html` (SPA forwarding) +6. `curl http://localhost:8080/assets/...` returns actual static files +7. All existing tests still pass (`./mvnw test` and `npm run test:unit`) + +## What We're NOT Doing + +- Database wiring (`DATABASE_URL`, JPA, Flyway) — deferred to T-4 +- docker-compose example — deferred to T-4 +- Environment variable configuration (Unsplash key, max events) — deferred to T-4 +- README deployment documentation — deferred to T-4 +- Production hardening (JVM flags, memory limits, graceful shutdown) — not in scope +- TLS/HTTPS — hoster's responsibility (reverse proxy) +- gzip compression config — premature optimization at this scale + +## Implementation Approach + +Three phases, strictly sequential: first the Spring Boot config changes (testable locally without Docker), then the Dockerfile and `.dockerignore`, then end-to-end verification. + +--- + +## Phase 1: Spring Boot Configuration Changes + +### Overview +Replace `context-path=/api` with `addPathPrefix` so that API endpoints live under `/api/*` but static resources and SPA routes are served at `/`. Add SPA forwarding so non-API, non-static requests return `index.html`. + +### Changes Required: + +#### [x] 1. Remove context-path from application.properties +**File**: `backend/src/main/resources/application.properties` +**Changes**: Remove `server.servlet.context-path=/api`. Explicitly set Actuator base path to keep it outside `/api`. + +```properties +spring.application.name=fete + +management.endpoints.web.exposure.include=health +management.endpoint.health.show-details=never +``` + +Note: Actuator defaults to `/actuator` which is exactly where we want it (outside `/api`). No extra config needed. + +#### [x] 2. Create WebConfig with addPathPrefix +**File**: `backend/src/main/java/de/fete/config/WebConfig.java` +**Changes**: New `@Configuration` class that prefixes only `@RestController` handlers with `/api`. + +```java +package de.fete.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.resource.PathResourceResolver; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import java.io.IOException; + +/** Configures API path prefix and SPA static resource serving. */ +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void configurePathMatch(PathMatchConfigurer configurer) { + configurer.addPathPrefix("/api", + c -> c.isAnnotationPresent(RestController.class)); + } + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/**") + .addResourceLocations("classpath:/static/") + .resourceChain(true) + .addResolver(new PathResourceResolver() { + @Override + protected Resource getResource(String resourcePath, + Resource location) throws IOException { + Resource requested = location.createRelative(resourcePath); + return (requested.exists() && requested.isReadable()) + ? requested + : new ClassPathResource("/static/index.html"); + } + }); + } +} +``` + +This single class handles both concerns: +- `configurePathMatch`: prefixes `@RestController` endpoints with `/api` +- `addResourceHandlers`: serves static files from `classpath:/static/`, falls back to `index.html` for SPA routes + +The SPA forwarding only activates when `classpath:/static/index.html` exists (i.e. in the Docker image where the frontend is bundled). During local backend development without frontend assets, requests to `/` will simply 404 as before — no behavior change for the dev workflow. + +#### [x] 3. Write test for WebConfig behavior +**File**: `backend/src/test/java/de/fete/config/WebConfigTest.java` +**Changes**: New test class verifying the `/api` prefix routing and actuator independence. + +```java +package de.fete.config; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +class WebConfigTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void actuatorHealthIsOutsideApiPrefix() throws Exception { + mockMvc.perform(get("/actuator/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")); + } + + @Test + void apiPrefixNotAccessibleWithoutIt() throws Exception { + // /health without /api prefix should not resolve to the API endpoint + mockMvc.perform(get("/health")) + .andExpect(status().isNotFound()); + } +} +``` + +Note: The existing `FeteApplicationTest.healthEndpointReturns200()` already tests `/actuator/health`. Since we're removing context-path but MockMvc paths are relative to the servlet context anyway, the existing test continues to pass without changes. The new test adds explicit verification that the `/api` prefix isolation works correctly. + +### Success Criteria: + +#### Automated Verification: +- [x] `cd backend && ./mvnw test` — all tests pass (existing + new) +- [x] `cd backend && ./mvnw checkstyle:check` — no style violations + +#### Manual Verification: +- [x] `cd backend && ./mvnw spring-boot:run`, then `curl http://localhost:8080/actuator/health` returns `{"status":"UP"}` + +**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase. + +--- + +## Phase 2: Dockerfile and .dockerignore + +### Overview +Create the multi-stage Dockerfile (frontend build → backend build with static assets → JRE runtime) and a `.dockerignore` to keep the build context clean. + +### Changes Required: + +#### [x] 1. Create .dockerignore +**File**: `.dockerignore` +**Changes**: Exclude build artifacts, dependencies, IDE files, and dev-only files from the Docker build context. + +``` +# Build artifacts +**/target/ +**/dist/ +**/build/ + +# Dependencies (rebuilt inside Docker) +**/node_modules/ + +# IDE +.idea/ +.vscode/ +**/*.iml + +# Git +.git/ +.gitignore + +# CI/CD +.gitea/ + +# Agent/dev files +.claude/ +.ralph/ +.rodney/ +.agent-tests/ +docs/ +spec/ + +# OS files +.DS_Store +Thumbs.db + +# Environment +.env +.env.* + +# Generated files (rebuilt in Docker) +frontend/src/api/schema.d.ts +``` + +#### [x] 2. Create multi-stage Dockerfile +**File**: `Dockerfile` +**Changes**: Three-stage build — frontend, backend, runtime. + +```dockerfile +# Stage 1: Build frontend +FROM node:24-alpine AS frontend-build +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +# OpenAPI spec needed for type generation (npm run build runs generate:api) +COPY backend/src/main/resources/openapi/api.yaml \ + ../backend/src/main/resources/openapi/api.yaml +RUN npm run build + +# Stage 2: Build backend with frontend assets baked in +FROM eclipse-temurin:25-jdk-alpine AS backend-build +WORKDIR /app/backend +COPY backend/ ./ +COPY --from=frontend-build /app/frontend/dist src/main/resources/static/ +RUN ./mvnw -B -DskipTests -Dcheckstyle.skip -Dspotbugs.skip package + +# Stage 3: Runtime +FROM eclipse-temurin:25-jre-alpine +WORKDIR /app +COPY --from=backend-build /app/backend/target/*.jar app.jar +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD wget -qO- http://localhost:8080/actuator/health || exit 1 +ENTRYPOINT ["java", "-jar", "app.jar"] +``` + +Design decisions: +- **Frontend first**: `npm run build` triggers `generate:api` which needs `api.yaml` — copied from the build context +- **Layer caching**: `npm ci` before `COPY frontend/` so dependency install is cached unless `package*.json` changes +- **`-DskipTests -Dcheckstyle.skip -Dspotbugs.skip`**: Tests run in CI (T-3), not during image build +- **JRE-only runtime**: No JDK, no Node, no Maven in the final image +- **Alpine images**: Minimal size +- **HEALTHCHECK directive**: Docker-native health checking via Actuator + +Note on Java 25: `eclipse-temurin:25-jdk-alpine` / `eclipse-temurin:25-jre-alpine` may not yet be published. If unavailable, fall back to `eclipse-temurin:21-jdk-alpine` / `eclipse-temurin:21-jre-alpine` (current LTS). The Java version in the Dockerfile does not need to match the development Java version exactly — Spring Boot 3.5.x runs on both 21 and 25. + +### Success Criteria: + +#### Automated Verification: +- [x] `docker build -t fete .` succeeds without errors + +#### Manual Verification: +- [x] `docker run --rm -p 8080:8080 fete` starts the container +- [x] `curl http://localhost:8080/actuator/health` returns `{"status":"UP"}` +- [x] `curl http://localhost:8080/` returns HTML (the Vue SPA's index.html) +- [x] `docker images fete --format '{{.Size}}'` shows a reasonable size (< 400MB) + +**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase. + +--- + +## Phase 3: Finalize and Verify + +### Overview +Run all existing tests to confirm no regressions, check off T-2 acceptance criteria in the spec, and commit. + +### Changes Required: + +#### [x] 1. Check off T-2 acceptance criteria +**File**: `spec/setup-tasks.md` +**Changes**: Mark completed acceptance criteria for T-2. + +#### [x] 2. Commit the research report +**File**: `docs/agents/research/2026-03-04-spa-springboot-docker-patterns.md` +**Changes**: Stage and commit the already-written research report alongside the implementation. + +### Success Criteria: + +#### Automated Verification: +- [x] `cd backend && ./mvnw test` — all tests pass +- [x] `cd frontend && npm run test:unit -- --run` — all tests pass +- [x] `cd backend && ./mvnw verify` — full verification including SpotBugs +- [x] `docker build -t fete .` — still builds cleanly + +#### Manual Verification: +- [x] Container starts and health check responds +- [x] SPA is accessible at `http://localhost:8080/` +- [x] All T-2 acceptance criteria in `spec/setup-tasks.md` are checked off + +--- + +## Testing Strategy + +### Unit Tests: +- `WebConfigTest.actuatorHealthIsOutsideApiPrefix()` — actuator accessible at `/actuator/health` +- `WebConfigTest.apiPrefixNotAccessibleWithoutIt()` — `/health` without prefix returns 404 +- Existing `FeteApplicationTest.healthEndpointReturns200()` — regression test (unchanged) + +### Integration Tests: +- Docker build succeeds end-to-end +- Container starts and serves both API and static content + +### Manual Testing Steps: +1. `docker build -t fete .` +2. `docker run --rm -p 8080:8080 fete` +3. Open `http://localhost:8080/` in browser — should see Vue SPA +4. Open `http://localhost:8080/actuator/health` — should see `{"status":"UP"}` +5. Navigate to a non-existent SPA route like `http://localhost:8080/events/test` — should still see Vue SPA (SPA forwarding) + +## Migration Notes + +- `server.servlet.context-path=/api` is removed → any external clients that previously called `http://host:8080/api/actuator/health` must now call `http://host:8080/actuator/health`. Since the app is not yet deployed, this has zero impact. +- The OpenAPI spec's `servers: [{url: /api}]` is unchanged. The `addPathPrefix` produces identical routing to the old context-path for all `@RestController` endpoints. + +## References + +- Research report: `docs/agents/research/2026-03-04-spa-springboot-docker-patterns.md` +- T-2 spec: `spec/setup-tasks.md` (lines 24-37) +- Existing health test: `backend/src/test/java/de/fete/FeteApplicationTest.java:26-30` +- ArchUnit config adapter: `backend/src/test/java/de/fete/HexagonalArchitectureTest.java:22` +- Spring PathMatchConfigurer docs: https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-config/path-matching.html diff --git a/docs/agents/research/2026-03-04-spa-springboot-docker-patterns.md b/docs/agents/research/2026-03-04-spa-springboot-docker-patterns.md new file mode 100644 index 0000000..7e3ba65 --- /dev/null +++ b/docs/agents/research/2026-03-04-spa-springboot-docker-patterns.md @@ -0,0 +1,135 @@ +# Single-Container Deployment: Spring Boot + Vue SPA + +**Date:** 2026-03-04 +**Context:** T-2 research — how to serve a Spring Boot API and Vue SPA from one Docker container. + +## The Three Approaches + +### Approach A: Spring Boot Serves Static Files (Recommended for fete) + +Frontend `dist/` is built by Vite and copied into Spring Boot's `classpath:/static/` during the Docker build. One JAR serves everything. + +**Pros:** One process, one port, one health check. No process manager. The JAR is the single artifact. + +**Cons:** Embedded Tomcat is not optimized for static files (no `sendfile`, no `gzip_static`). Irrelevant at fete's scale (tens to hundreds of users). + +**Real-world examples:** [jonashackt/spring-boot-vuejs](https://github.com/jonashackt/spring-boot-vuejs), [georgwittberger/spring-boot-embedded-spa-example](https://github.com/georgwittberger/spring-boot-embedded-spa-example), [bootify-io/spring-boot-react-example](https://github.com/bootify-io/spring-boot-react-example). + +### Approach B: nginx + Spring Boot (Two Processes) + +nginx serves static files, proxies `/api` to Spring Boot. Needs supervisord or a wrapper script. + +**Pros:** nginx is battle-tested for static files (gzip, caching, HTTP/2). Clean separation. + +**Cons:** Two processes, supervisord complexity, partial-failure detection issues, larger image. + +**When to use:** High-traffic apps where static file performance matters. + +### Approach C: Separate Containers + +Not relevant — violates the single-container requirement. + +## The Context-Path Problem + +Current config: `server.servlet.context-path=/api`. This scopes **everything** under `/api`, including static resources. Frontend at `classpath:/static/` would be served at `/api/index.html`, not `/`. + +### Solution: Remove context-path, use `addPathPrefix` + +Remove `server.servlet.context-path=/api`. Instead, use Spring's `PathMatchConfigurer`: + +```java +@Configuration +public class WebConfig implements WebMvcConfigurer { + @Override + public void configurePathMatch(PathMatchConfigurer configurer) { + configurer.addPathPrefix("/api", + c -> c.isAnnotationPresent(RestController.class)); + } +} +``` + +Result: +- API: `/api/...` (only `@RestController` classes) +- Static files: `/` (from `classpath:/static/`) +- Actuator: `/actuator/health` (outside `/api` — correct for infra health checks) +- SPA forwarding controller (`@Controller`, not `@RestController`) is not prefixed + +OpenAPI spec keeps `servers: [{url: /api}]`. Frontend client keeps `baseUrl: "/api"`. No changes needed. + +## SPA Routing (Vue Router History Mode) + +Vue Router in history mode uses real URL paths. The server must return `index.html` for all paths that aren't API endpoints or actual static files. + +### Recommended: Custom `PathResourceResolver` + +```java +@Configuration +public class SpaConfig implements WebMvcConfigurer { + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/**") + .addResourceLocations("classpath:/static/") + .resourceChain(true) + .addResolver(new PathResourceResolver() { + @Override + protected Resource getResource(String resourcePath, + Resource location) throws IOException { + Resource requested = location.createRelative(resourcePath); + return (requested.exists() && requested.isReadable()) + ? requested + : new ClassPathResource("/static/index.html"); + } + }); + } +} +``` + +Requests flow: `@RestController` match → serve API. No match → resource handler → file exists → serve file. No file → serve `index.html` → Vue Router handles route. + +## Multi-Stage Dockerfile Pattern + +```dockerfile +# Stage 1: Build frontend +FROM node:24-alpine AS frontend-build +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +COPY backend/src/main/resources/openapi/api.yaml ../backend/src/main/resources/openapi/api.yaml +RUN npm run build + +# Stage 2: Build backend (with frontend assets) +FROM eclipse-temurin:25-jdk-alpine AS backend-build +WORKDIR /app +COPY backend/ backend/ +COPY --from=frontend-build /app/frontend/dist backend/src/main/resources/static/ +WORKDIR /app/backend +RUN ./mvnw -B -DskipTests package + +# Stage 3: Runtime +FROM eclipse-temurin:25-jre-alpine +WORKDIR /app +COPY --from=backend-build /app/backend/target/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] +``` + +Key decisions: +- Frontend built first (needs `api.yaml` for type generation) +- `npm ci` before `COPY frontend/` for Docker layer caching +- Frontend output copied into `backend/src/main/resources/static/` before Maven build → ends up in JAR +- `-DskipTests` in Docker build (tests run in CI) +- JRE-only final image (no JDK, no Node) +- Alpine-based for minimal size + +## Open-Source Consensus + +All surveyed Spring Boot + SPA projects use Approach A. None use supervisord + nginx for small-to-medium apps. + +## Sources + +- [jonashackt/spring-boot-vuejs](https://github.com/jonashackt/spring-boot-vuejs) +- [georgwittberger/spring-boot-embedded-spa-example](https://github.com/georgwittberger/spring-boot-embedded-spa-example) +- [Bundling React/Vite with Spring Boot (jessym.com)](https://www.jessym.com/articles/bundling-react-vite-with-spring-boot) +- [Spring Framework PathMatchConfigurer docs](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-config/path-matching.html) +- [Docker: Multi-service containers](https://docs.docker.com/engine/containers/multi-service_container/) diff --git a/spec/setup-tasks.md b/spec/setup-tasks.md index 0b4ff67..8b34d45 100644 --- a/spec/setup-tasks.md +++ b/spec/setup-tasks.md @@ -26,11 +26,11 @@ **Description:** Create a multi-stage Dockerfile that builds backend and frontend and produces a single runnable container. This task focuses exclusively on the Docker build — database wiring, environment variable configuration, and docker-compose documentation are deferred to T-4 (where JPA and migration tooling are introduced). **Acceptance Criteria:** -- [ ] Single multi-stage Dockerfile at repo root that builds backend and frontend and produces one container -- [ ] `.dockerignore` excludes build artifacts, IDE files, and unnecessary files from the build context +- [x] Single multi-stage Dockerfile at repo root that builds backend and frontend and produces one container +- [x] `.dockerignore` excludes build artifacts, IDE files, and unnecessary files from the build context - [x] Health-check endpoint so Docker/orchestrators can verify the app is alive -- [ ] `docker build .` succeeds and produces a working image -- [ ] Container starts and the health-check endpoint responds +- [x] `docker build .` succeeds and produces a working image +- [x] Container starts and the health-check endpoint responds **Dependencies:** T-1, T-5