--- 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