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>
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.