---
date: 2026-03-04T17:40:16+00:00
git_commit: 96ef8656bd87032696ae82198620d99f20d80d3b
branch: master
topic: "T-2: Docker Deployment Setup"
tags: [plan, docker, deployment, spring-boot, spa]
status: draft
---

# T-2: Docker Deployment Setup

## Overview

Create a multi-stage Dockerfile that builds both backend (Spring Boot) and frontend (Vue 3) and produces a single runnable container. Spring Boot serves the SPA's static files directly — one process, one port, one JAR. This requires migrating from `server.servlet.context-path=/api` to `addPathPrefix` and adding SPA forwarding so Vue Router's history mode works.

## Current State Analysis

**What exists:**
- Backend: Spring Boot 3.5.11, Java 25, Maven with wrapper, Actuator health endpoint
- Frontend: Vue 3, Vite, TypeScript, Vue Router (history mode), openapi-fetch client
- `server.servlet.context-path=/api` scopes everything under `/api` (including static resources)
- Health test at `backend/src/test/java/de/fete/FeteApplicationTest.java:27` uses `get("/actuator/health")`
- OpenAPI spec at `backend/src/main/resources/openapi/api.yaml` has `servers: [{url: /api}]`
- Frontend client at `frontend/src/api/client.ts` uses `baseUrl: "/api"`
- ArchUnit registers `de.fete.config..` as an adapter package — config classes belong there
- `package-lock.json` exists (needed for `npm ci`)
- Maven wrapper `.properties` present (JAR downloads automatically)
- No Dockerfile, no `.dockerignore` exist yet

**What's missing:**
- Dockerfile (multi-stage build)
- `.dockerignore`
- `WebConfig` to replace context-path with `addPathPrefix`
- SPA forwarding config (so Vue Router history mode works when served by Spring Boot)

### Key Discoveries:
- `FeteApplicationTest.java:27`: MockMvc test uses `get("/actuator/health")` — path is relative to context path in MockMvc, so removing context-path does NOT break this test
- `api.yaml:11`: OpenAPI `servers: [{url: /api}]` — stays unchanged, `addPathPrefix` produces the same routing
- `client.ts:4`: `baseUrl: "/api"` — stays unchanged
- `HexagonalArchitectureTest.java:22`: `config` is an adapter in ArchUnit → new config classes go in `de.fete.config`
- `router/index.ts:5`: Uses `createWebHistory` → history mode, needs server-side SPA forwarding

## Desired End State

After implementation:
1. `docker build -t fete .` succeeds at the repo root
2. `docker run -p 8080:8080 fete` starts the container
3. `curl http://localhost:8080/actuator/health` returns `{"status":"UP"}`
4. `curl http://localhost:8080/` returns the Vue SPA's `index.html`
5. `curl http://localhost:8080/some/spa/route` also returns `index.html` (SPA forwarding)
6. `curl http://localhost:8080/assets/...` returns actual static files
7. All existing tests still pass (`./mvnw test` and `npm run test:unit`)

## What We're NOT Doing

- Database wiring (`DATABASE_URL`, JPA, Flyway) — deferred to T-4
- docker-compose example — deferred to T-4
- Environment variable configuration (Unsplash key, max events) — deferred to T-4
- README deployment documentation — deferred to T-4
- Production hardening (JVM flags, memory limits, graceful shutdown) — not in scope
- TLS/HTTPS — hoster's responsibility (reverse proxy)
- gzip compression config — premature optimization at this scale

## Implementation Approach

Three phases, strictly sequential: first the Spring Boot config changes (testable locally without Docker), then the Dockerfile and `.dockerignore`, then end-to-end verification.

---

## Phase 1: Spring Boot Configuration Changes

### Overview
Replace `context-path=/api` with `addPathPrefix` so that API endpoints live under `/api/*` but static resources and SPA routes are served at `/`. Add SPA forwarding so non-API, non-static requests return `index.html`.

### Changes Required:

#### [x] 1. Remove context-path from application.properties
**File**: `backend/src/main/resources/application.properties`
**Changes**: Remove `server.servlet.context-path=/api`. Explicitly set Actuator base path to keep it outside `/api`.

```properties
spring.application.name=fete

management.endpoints.web.exposure.include=health
management.endpoint.health.show-details=never
```

Note: Actuator defaults to `/actuator` which is exactly where we want it (outside `/api`). No extra config needed.

#### [x] 2. Create WebConfig with addPathPrefix
**File**: `backend/src/main/java/de/fete/config/WebConfig.java`
**Changes**: New `@Configuration` class that prefixes only `@RestController` handlers with `/api`.

```java
package de.fete.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.config.annotation.PathMatchConfigurer;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import java.io.IOException;

/** Configures API path prefix and SPA static resource serving. */
@Configuration
public class WebConfig implements WebMvcConfigurer {

  @Override
  public void configurePathMatch(PathMatchConfigurer configurer) {
    configurer.addPathPrefix("/api",
        c -> c.isAnnotationPresent(RestController.class));
  }

  @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");
          }
        });
  }
}
```

This single class handles both concerns:
- `configurePathMatch`: prefixes `@RestController` endpoints with `/api`
- `addResourceHandlers`: serves static files from `classpath:/static/`, falls back to `index.html` for SPA routes

The SPA forwarding only activates when `classpath:/static/index.html` exists (i.e. in the Docker image where the frontend is bundled). During local backend development without frontend assets, requests to `/` will simply 404 as before — no behavior change for the dev workflow.

#### [x] 3. Write test for WebConfig behavior
**File**: `backend/src/test/java/de/fete/config/WebConfigTest.java`
**Changes**: New test class verifying the `/api` prefix routing and actuator independence.

```java
package de.fete.config;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest
@AutoConfigureMockMvc
class WebConfigTest {

  @Autowired
  private MockMvc mockMvc;

  @Test
  void actuatorHealthIsOutsideApiPrefix() throws Exception {
    mockMvc.perform(get("/actuator/health"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.status").value("UP"));
  }

  @Test
  void apiPrefixNotAccessibleWithoutIt() throws Exception {
    // /health without /api prefix should not resolve to the API endpoint
    mockMvc.perform(get("/health"))
        .andExpect(status().isNotFound());
  }
}
```

Note: The existing `FeteApplicationTest.healthEndpointReturns200()` already tests `/actuator/health`. Since we're removing context-path but MockMvc paths are relative to the servlet context anyway, the existing test continues to pass without changes. The new test adds explicit verification that the `/api` prefix isolation works correctly.

### Success Criteria:

#### Automated Verification:
- [x] `cd backend && ./mvnw test` — all tests pass (existing + new)
- [x] `cd backend && ./mvnw checkstyle:check` — no style violations

#### Manual Verification:
- [x] `cd backend && ./mvnw spring-boot:run`, then `curl http://localhost:8080/actuator/health` returns `{"status":"UP"}`

**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.

---

## Phase 2: Dockerfile and .dockerignore

### Overview
Create the multi-stage Dockerfile (frontend build → backend build with static assets → JRE runtime) and a `.dockerignore` to keep the build context clean.

### Changes Required:

#### [x] 1. Create .dockerignore
**File**: `.dockerignore`
**Changes**: Exclude build artifacts, dependencies, IDE files, and dev-only files from the Docker build context.

```
# Build artifacts
**/target/
**/dist/
**/build/

# Dependencies (rebuilt inside Docker)
**/node_modules/

# IDE
.idea/
.vscode/
**/*.iml

# Git
.git/
.gitignore

# CI/CD
.gitea/

# Agent/dev files
.claude/
.ralph/
.rodney/
.agent-tests/
docs/
spec/

# OS files
.DS_Store
Thumbs.db

# Environment
.env
.env.*

# Generated files (rebuilt in Docker)
frontend/src/api/schema.d.ts
```

#### [x] 2. Create multi-stage Dockerfile
**File**: `Dockerfile`
**Changes**: Three-stage build — frontend, backend, runtime.

```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/ ./
# OpenAPI spec needed for type generation (npm run build runs generate:api)
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 baked in
FROM eclipse-temurin:25-jdk-alpine AS backend-build
WORKDIR /app/backend
COPY backend/ ./
COPY --from=frontend-build /app/frontend/dist src/main/resources/static/
RUN ./mvnw -B -DskipTests -Dcheckstyle.skip -Dspotbugs.skip package

# Stage 3: Runtime
FROM eclipse-temurin:25-jre-alpine
WORKDIR /app
COPY --from=backend-build /app/backend/target/*.jar app.jar
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]
```

Design decisions:
- **Frontend first**: `npm run build` triggers `generate:api` which needs `api.yaml` — copied from the build context
- **Layer caching**: `npm ci` before `COPY frontend/` so dependency install is cached unless `package*.json` changes
- **`-DskipTests -Dcheckstyle.skip -Dspotbugs.skip`**: Tests run in CI (T-3), not during image build
- **JRE-only runtime**: No JDK, no Node, no Maven in the final image
- **Alpine images**: Minimal size
- **HEALTHCHECK directive**: Docker-native health checking via Actuator

Note on Java 25: `eclipse-temurin:25-jdk-alpine` / `eclipse-temurin:25-jre-alpine` may not yet be published. If unavailable, fall back to `eclipse-temurin:21-jdk-alpine` / `eclipse-temurin:21-jre-alpine` (current LTS). The Java version in the Dockerfile does not need to match the development Java version exactly — Spring Boot 3.5.x runs on both 21 and 25.

### Success Criteria:

#### Automated Verification:
- [x] `docker build -t fete .` succeeds without errors

#### Manual Verification:
- [x] `docker run --rm -p 8080:8080 fete` starts the container
- [x] `curl http://localhost:8080/actuator/health` returns `{"status":"UP"}`
- [x] `curl http://localhost:8080/` returns HTML (the Vue SPA's index.html)
- [x] `docker images fete --format '{{.Size}}'` shows a reasonable size (< 400MB)

**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.

---

## Phase 3: Finalize and Verify

### Overview
Run all existing tests to confirm no regressions, check off T-2 acceptance criteria in the spec, and commit.

### Changes Required:

#### [x] 1. Check off T-2 acceptance criteria
**File**: `spec/setup-tasks.md`
**Changes**: Mark completed acceptance criteria for T-2.

#### [x] 2. Commit the research report
**File**: `docs/agents/research/2026-03-04-spa-springboot-docker-patterns.md`
**Changes**: Stage and commit the already-written research report alongside the implementation.

### Success Criteria:

#### Automated Verification:
- [x] `cd backend && ./mvnw test` — all tests pass
- [x] `cd frontend && npm run test:unit -- --run` — all tests pass
- [x] `cd backend && ./mvnw verify` — full verification including SpotBugs
- [x] `docker build -t fete .` — still builds cleanly

#### Manual Verification:
- [x] Container starts and health check responds
- [x] SPA is accessible at `http://localhost:8080/`
- [x] All T-2 acceptance criteria in `spec/setup-tasks.md` are checked off

---

## Testing Strategy

### Unit Tests:
- `WebConfigTest.actuatorHealthIsOutsideApiPrefix()` — actuator accessible at `/actuator/health`
- `WebConfigTest.apiPrefixNotAccessibleWithoutIt()` — `/health` without prefix returns 404
- Existing `FeteApplicationTest.healthEndpointReturns200()` — regression test (unchanged)

### Integration Tests:
- Docker build succeeds end-to-end
- Container starts and serves both API and static content

### Manual Testing Steps:
1. `docker build -t fete .`
2. `docker run --rm -p 8080:8080 fete`
3. Open `http://localhost:8080/` in browser — should see Vue SPA
4. Open `http://localhost:8080/actuator/health` — should see `{"status":"UP"}`
5. Navigate to a non-existent SPA route like `http://localhost:8080/events/test` — should still see Vue SPA (SPA forwarding)

## Migration Notes

- `server.servlet.context-path=/api` is removed → any external clients that previously called `http://host:8080/api/actuator/health` must now call `http://host:8080/actuator/health`. Since the app is not yet deployed, this has zero impact.
- The OpenAPI spec's `servers: [{url: /api}]` is unchanged. The `addPathPrefix` produces identical routing to the old context-path for all `@RestController` endpoints.

## References

- Research report: `docs/agents/research/2026-03-04-spa-springboot-docker-patterns.md`
- T-2 spec: `spec/setup-tasks.md` (lines 24-37)
- Existing health test: `backend/src/test/java/de/fete/FeteApplicationTest.java:26-30`
- ArchUnit config adapter: `backend/src/test/java/de/fete/HexagonalArchitectureTest.java:22`
- Spring PathMatchConfigurer docs: https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-config/path-matching.html
