Files
fete/docs/agents/research/2026-03-04-spa-springboot-docker-patterns.md
nitrix 316137bf1c 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>
2026-03-04 19:16:50 +01:00

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