- Move cross-cutting docs (personas, design system, implementation phases, Ideen.md) to .specify/memory/ - Move cross-cutting research and plans to .specify/memory/research/ and .specify/memory/plans/ - Extract 5 setup tasks from spec/setup-tasks.md into individual specs/001-005/spec.md files with spec-kit template format - Extract 20 user stories from spec/userstories.md into individual specs/006-026/spec.md files with spec-kit template format - Relocate feature-specific research and plan docs into specs/[feature]/ - Add spec-kit constitution, templates, scripts, and slash commands - Slim down CLAUDE.md to Claude-Code-specific config, delegate principles to .specify/memory/constitution.md - Update ralph.sh with stream-json output and per-iteration logging - Delete old spec/ and docs/agents/ directories - Gitignore Ralph iteration JSONL logs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
5.5 KiB
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, georgwittberger/spring-boot-embedded-spa-example, 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:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.addPathPrefix("/api",
c -> c.isAnnotationPresent(RestController.class));
}
}
Result:
- API:
/api/...(only@RestControllerclasses) - Static files:
/(fromclasspath:/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
@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
# 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.yamlfor type generation) npm cibeforeCOPY frontend/for Docker layer caching- Frontend output copied into
backend/src/main/resources/static/before Maven build → ends up in JAR -DskipTestsin 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.