- 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>
136 lines
5.5 KiB
Markdown
136 lines
5.5 KiB
Markdown
# 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/)
|