Files
fete/specs/002-docker-deployment/research.md
nitrix 6aeb4b8bca Migrate project artifacts to spec-kit format
- Move cross-cutting docs (personas, design system, implementation phases,
  Ideen.md) to .specify/memory/
- Move cross-cutting research and plans to .specify/memory/research/ and
  .specify/memory/plans/
- Extract 5 setup tasks from spec/setup-tasks.md into individual
  specs/001-005/spec.md files with spec-kit template format
- Extract 20 user stories from spec/userstories.md into individual
  specs/006-026/spec.md files with spec-kit template format
- Relocate feature-specific research and plan docs into specs/[feature]/
- Add spec-kit constitution, templates, scripts, and slash commands
- Slim down CLAUDE.md to Claude-Code-specific config, delegate principles
  to .specify/memory/constitution.md
- Update ralph.sh with stream-json output and per-iteration logging
- Delete old spec/ and docs/agents/ directories
- Gitignore Ralph iteration JSONL logs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 20:19:41 +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