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