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:
@@ -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/)
|
||||
Reference in New Issue
Block a user