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 <noreply@anthropic.com>
This commit is contained in:
38
.dockerignore
Normal file
38
.dockerignore
Normal file
@@ -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
|
||||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@@ -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"]
|
||||||
40
backend/src/main/java/de/fete/config/WebConfig.java
Normal file
40
backend/src/main/java/de/fete/config/WebConfig.java
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
spring.application.name=fete
|
spring.application.name=fete
|
||||||
server.servlet.context-path=/api
|
|
||||||
|
|
||||||
management.endpoints.web.exposure.include=health
|
management.endpoints.web.exposure.include=health
|
||||||
management.endpoint.health.show-details=never
|
management.endpoint.health.show-details=never
|
||||||
|
|||||||
33
backend/src/test/java/de/fete/config/WebConfigTest.java
Normal file
33
backend/src/test/java/de/fete/config/WebConfigTest.java
Normal file
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
366
docs/agents/plan/2026-03-04-t2-docker-deployment.md
Normal file
366
docs/agents/plan/2026-03-04-t2-docker-deployment.md
Normal file
@@ -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
|
||||||
@@ -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/)
|
||||||
@@ -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).
|
**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:**
|
**Acceptance Criteria:**
|
||||||
- [ ] Single multi-stage Dockerfile at repo root that builds backend and frontend and produces one container
|
- [x] 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] `.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
|
- [x] Health-check endpoint so Docker/orchestrators can verify the app is alive
|
||||||
- [ ] `docker build .` succeeds and produces a working image
|
- [x] `docker build .` succeeds and produces a working image
|
||||||
- [ ] Container starts and the health-check endpoint responds
|
- [x] Container starts and the health-check endpoint responds
|
||||||
|
|
||||||
**Dependencies:** T-1, T-5
|
**Dependencies:** T-1, T-5
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user