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

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

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

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