---
date: 2026-03-04T20:09:31.044992+00:00
git_commit: cb0bcad145b03fec63be0ee3c1fca46ee545329e
branch: master
topic: "T-4: Development Infrastructure Setup"
tags: [plan, database, liquibase, testcontainers, configuration, docker-compose]
status: draft
---

# T-4: Development Infrastructure Setup — Implementation Plan

## Overview

Set up the remaining development infrastructure needed before the first user story (US-1) can be implemented with TDD. This adds JPA + Liquibase for database migrations, PostgreSQL connectivity via environment variables, Testcontainers for integration tests, app-specific configuration properties, and README deployment documentation with a docker-compose example.

## Current State Analysis

**Already complete (no work needed):**
- SPA router: Vue Router with `createWebHistory`, backend SPA fallback in `WebConfig.java`
- Frontend test infrastructure: Vitest + `@vue/test-utils`, sample test passing
- Both test suites executable: `./mvnw test` (3 tests) and `npm run test:unit` (1 test)

**Missing (all work in this plan):**
- JPA, Liquibase, PostgreSQL driver — no database dependencies in `pom.xml`
- Testcontainers — not configured
- Database connectivity — no datasource properties
- App-specific config — no `@ConfigurationProperties`
- Profile separation — no `application-prod.properties`
- Deployment docs — no docker-compose in README

### Key Discoveries:
- `backend/pom.xml:1-170` — Spring Boot 3.5.11, no DB dependencies
- `backend/src/main/resources/application.properties:1-4` — Only app name + actuator
- `HexagonalArchitectureTest.java:22` — `config` is already an adapter in ArchUnit rules
- `FeteApplicationTest.java` — Uses `@SpringBootTest` + MockMvc; will need datasource after JPA is added
- `Dockerfile:26` — No `SPRING_PROFILES_ACTIVE` set
- `.gitignore:47-51` — `.env*` patterns exist but no `application-local.properties`

## Desired End State

After this plan is complete:
- `./mvnw test` runs all backend tests (including new Testcontainers-backed integration tests) against a real PostgreSQL without external setup
- `./mvnw spring-boot:run -Dspring-boot.run.profiles=local` starts the app against a local PostgreSQL
- Docker container starts with `DATABASE_URL`/`DATABASE_USERNAME`/`DATABASE_PASSWORD` env vars, runs Liquibase migrations, and responds to health checks
- README contains a copy-paste-ready docker-compose example for deployment
- `FeteProperties` scaffolds `fete.unsplash.api-key` and `fete.max-active-events` (no business logic yet)

### Verification:
- `cd backend && ./mvnw verify` — all tests green, checkstyle + spotbugs pass
- `cd frontend && npm run test:unit -- --run` — unchanged, still green
- `docker build .` — succeeds
- docker-compose (app + postgres) — container starts, `/actuator/health` returns `{"status":"UP"}`

## What We're NOT Doing

- No JPA entities or repositories — those come with US-1
- No domain model classes — those come with US-1
- No business logic for `FeteProperties` (Unsplash, max events) — US-13/US-16
- No standalone `docker-compose.yml` file in repo — inline in README per CLAUDE.md
- No `application-local.properties` committed — only the `.example` template
- No changes to frontend code — AC 4/6/7 are already met

---

## Phase 1: JPA + Liquibase + PostgreSQL Dependencies

### Overview
Add all database-related dependencies to `pom.xml`, create the Liquibase changelog structure with an empty baseline changeset, and update `application.properties` with JPA and Liquibase settings.

### Changes Required:

#### [x] 1. Add database dependencies to `pom.xml`
**File**: `backend/pom.xml`
**Changes**: Add four dependencies after the existing `spring-boot-starter-validation` block.

```xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<dependency>
    <groupId>org.liquibase</groupId>
    <artifactId>liquibase-core</artifactId>
</dependency>

<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>
```

Add Testcontainers dependencies in test scope (after `archunit-junit5`):

```xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
```

Spring Boot's dependency management handles versions for all of these — no explicit version tags needed (except `archunit-junit5` which is already versioned).

#### [x] 2. Create Liquibase master changelog
**File**: `backend/src/main/resources/db/changelog/db.changelog-master.xml` (new)
**Changes**: Create the master changelog that includes individual changesets.

```xml
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
    xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">

    <include file="db/changelog/000-baseline.xml"/>

</databaseChangeLog>
```

#### [x] 3. Create empty baseline changeset
**File**: `backend/src/main/resources/db/changelog/000-baseline.xml` (new)
**Changes**: Empty changeset that proves the tooling works. Liquibase creates its tracking tables (`databasechangelog`, `databasechangeloglock`) automatically.

```xml
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
    xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">

    <!-- T-4: Baseline changeset. Proves Liquibase tooling works.
         First real schema comes with US-1. -->
    <changeSet id="000-baseline" author="fete">
        <comment>Baseline changeset — Liquibase tooling verification</comment>
    </changeSet>

</databaseChangeLog>
```

#### [x] 4. Update application.properties with JPA and Liquibase settings
**File**: `backend/src/main/resources/application.properties`
**Changes**: Add JPA and Liquibase configuration (environment-independent, always active).

```properties
spring.application.name=fete

# JPA
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false

# Liquibase
spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml

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

### Success Criteria:

#### Automated Verification:
- [ ] `cd backend && ./mvnw compile` succeeds (dependencies resolve, checkstyle passes)
- [ ] Changelog XML files are well-formed (Maven compile does not fail on resource processing)

#### Manual Verification:
- [ ] Verify `pom.xml` has all six new dependencies with correct scopes
- [ ] Verify changelog directory structure: `db/changelog/db.changelog-master.xml` includes `000-baseline.xml`

**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: Profile-Based Configuration and App Properties

### Overview
Create the profile-based property files for production and local development, add the `FeteProperties` configuration class, update `.gitignore`, and set the production profile in the Dockerfile.

### Changes Required:

#### [x] 1. Create production properties file
**File**: `backend/src/main/resources/application-prod.properties` (new)
**Changes**: Production profile with environment variable placeholders. Activated in Docker via `SPRING_PROFILES_ACTIVE=prod`.

```properties
# Database (required)
spring.datasource.url=${DATABASE_URL}
spring.datasource.username=${DATABASE_USERNAME}
spring.datasource.password=${DATABASE_PASSWORD}

# App-specific (optional)
fete.unsplash.api-key=${UNSPLASH_API_KEY:}
fete.max-active-events=${MAX_ACTIVE_EVENTS:0}
```

#### [x] 2. Create local development properties template
**File**: `backend/src/main/resources/application-local.properties.example` (new)
**Changes**: Template that developers copy to `application-local.properties` (which is gitignored).

```properties
# Local development database
# Copy this file to application-local.properties and adjust as needed.
# Start with: ./mvnw spring-boot:run -Dspring-boot.run.profiles=local
spring.datasource.url=jdbc:postgresql://localhost:5432/fete
spring.datasource.username=fete
spring.datasource.password=fete
```

#### [x] 3. Add `application-local.properties` to `.gitignore`
**File**: `.gitignore`
**Changes**: Add the gitignore entry for the local properties file (under the Environment section).

```
# Spring Boot local profile (developer-specific, not committed)
backend/src/main/resources/application-local.properties
```

#### ~~4. Create `FeteProperties` configuration properties class~~ (deferred)
**File**: `backend/src/main/java/de/fete/config/FeteProperties.java` (new)
**Changes**: Type-safe configuration for app-specific settings. Both properties are only scaffolded — business logic comes with US-13/US-16.

```java
package de.fete.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

/**
 * Application-specific configuration properties.
 *
 * <p>Mapped from {@code fete.*} properties. Both properties are optional:
 * <ul>
 *   <li>{@code fete.unsplash.api-key} — Unsplash API key (empty = feature disabled)
 *   <li>{@code fete.max-active-events} — Maximum active events (0 = unlimited)
 * </ul>
 */
@ConfigurationProperties(prefix = "fete")
public class FeteProperties {

  private final Unsplash unsplash;
  private final int maxActiveEvents;

  /** Creates FeteProperties with the given values. */
  public FeteProperties(Unsplash unsplash, int maxActiveEvents) {
    this.unsplash = unsplash != null ? unsplash : new Unsplash("");
    this.maxActiveEvents = maxActiveEvents;
  }

  /** Returns the Unsplash configuration. */
  public Unsplash getUnsplash() {
    return unsplash;
  }

  /** Returns the maximum number of active events (0 = unlimited). */
  public int getMaxActiveEvents() {
    return maxActiveEvents;
  }

  /** Unsplash-related configuration. */
  public record Unsplash(String apiKey) {

    /** Creates Unsplash config with the given API key. */
    public Unsplash {
      if (apiKey == null) {
        apiKey = "";
      }
    }

    /** Returns true if an API key is configured. */
    public boolean isEnabled() {
      return !apiKey.isBlank();
    }
  }
}
```

#### ~~5. Create `FetePropertiesConfig` configuration class~~ (deferred)
**File**: `backend/src/main/java/de/fete/config/FetePropertiesConfig.java` (new)
**Changes**: Separate `@Configuration` that enables `FeteProperties`.

```java
package de.fete.config;

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

/** Activates {@link FeteProperties} binding. */
@Configuration
@EnableConfigurationProperties(FeteProperties.class)
public class FetePropertiesConfig {
}
```

#### [x] 6. Set production profile in Dockerfile
**File**: `Dockerfile`
**Changes**: Add `ENV SPRING_PROFILES_ACTIVE=prod` in the runtime stage, before `ENTRYPOINT`.

```dockerfile
# Stage 3: Runtime
FROM eclipse-temurin:25-jre-alpine
WORKDIR /app
COPY --from=backend-build /app/backend/target/*.jar app.jar
EXPOSE 8080
ENV SPRING_PROFILES_ACTIVE=prod
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"]
```

### Success Criteria:

#### Automated Verification:
- [ ] `cd backend && ./mvnw compile` succeeds (FeteProperties compiles, checkstyle passes)
- [ ] `docker build .` succeeds

#### Manual Verification:
- [ ] `application-prod.properties` contains all five env-var placeholders
- [ ] `application-local.properties.example` is committed; `application-local.properties` is gitignored
- [ ] `FeteProperties` fields: `unsplash.apiKey` (String), `maxActiveEvents` (int)
- [ ] Dockerfile has `ENV SPRING_PROFILES_ACTIVE=prod` before `ENTRYPOINT`

**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: Testcontainers Integration

### Overview
Set up the TestApplication pattern so that all `@SpringBootTest` tests automatically get a Testcontainers-managed PostgreSQL instance. This is critical: once JPA is on the classpath, every `@SpringBootTest` needs a datasource. Without this, all three existing `@SpringBootTest` tests break.

### Changes Required:

#### [x] 1. Create Testcontainers configuration
**File**: `backend/src/test/java/de/fete/TestcontainersConfig.java` (new)
**Changes**: Registers a PostgreSQL Testcontainer with `@ServiceConnection` for automatic datasource wiring.

```java
package de.fete;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;

/** Provides a Testcontainers PostgreSQL instance for integration tests. */
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {

  @Bean
  @ServiceConnection
  PostgreSQLContainer<?> postgresContainer() {
    return new PostgreSQLContainer<>("postgres:17-alpine");
  }
}
```

#### [x] 2. Create TestFeteApplication for `spring-boot:test-run`
**File**: `backend/src/test/java/de/fete/TestFeteApplication.java` (new)
**Changes**: Entry point that imports `TestcontainersConfig`. Enables `./mvnw spring-boot:test-run` for local development with Testcontainers (no external PostgreSQL needed).

```java
package de.fete;

import org.springframework.boot.SpringApplication;

/** Test entry point — starts the app with Testcontainers PostgreSQL. */
public class TestFeteApplication {

  public static void main(String[] args) {
    SpringApplication.from(FeteApplication::main)
        .with(TestcontainersConfig.class)
        .run(args);
  }
}
```

#### [x] 3. Import TestcontainersConfig in existing `@SpringBootTest` tests
**File**: `backend/src/test/java/de/fete/FeteApplicationTest.java`
**Changes**: Add `@Import(TestcontainersConfig.class)` so the test gets a datasource.

```java
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfig.class)
class FeteApplicationTest {
  // ... existing tests unchanged
}
```

**File**: `backend/src/test/java/de/fete/config/WebConfigTest.java`
**Changes**: Same — add `@Import(TestcontainersConfig.class)`.

Note: `HexagonalArchitectureTest` uses `@AnalyzeClasses` (ArchUnit), not `@SpringBootTest` — it needs no changes.

#### [x] 4. Add SpotBugs exclusion for Testcontainers resource management
**File**: `backend/spotbugs-exclude.xml`
**Changes**: Testcontainers `PostgreSQLContainer` bean intentionally has container lifecycle managed by Spring, not try-with-resources. SpotBugs may flag this. Add exclusion if needed — check after running `./mvnw verify`.

### Success Criteria:

#### Automated Verification:
- [ ] `cd backend && ./mvnw test` — all existing tests pass (context loads, health endpoint, ArchUnit)
- [ ] `cd backend && ./mvnw verify` — full verify including SpotBugs passes
- [ ] Testcontainers starts a PostgreSQL container during test execution (visible in test output)
- [ ] Liquibase baseline migration runs against Testcontainers PostgreSQL

#### Manual Verification:
- [ ] `./mvnw spring-boot:test-run` starts the app with a Testcontainers PostgreSQL (for local dev without external DB)

**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 4: README Deployment Documentation

### Overview
Add a deployment section to the README with a docker-compose example, environment variable documentation, and local development setup instructions.

### Changes Required:

#### [x] 1. Add deployment section to README
**File**: `README.md`
**Changes**: Add a `## Deployment` section after the existing `## Code quality` section and before `## License`. Contains the docker-compose example, environment variable table, and notes.

```markdown
## Deployment

### Docker Compose

The app ships as a single Docker image. It requires an external PostgreSQL database.

```yaml
services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_DB: fete
      POSTGRES_USER: fete
      POSTGRES_PASSWORD: changeme
    volumes:
      - fete-db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U fete"]
      interval: 5s
      timeout: 3s
      retries: 5

  app:
    image: gitea.example.com/user/fete:latest
    ports:
      - "8080:8080"
    environment:
      DATABASE_URL: jdbc:postgresql://db:5432/fete
      DATABASE_USERNAME: fete
      DATABASE_PASSWORD: changeme
      # MAX_ACTIVE_EVENTS: 100
      # UNSPLASH_API_KEY: your-key-here
    depends_on:
      db:
        condition: service_healthy

volumes:
  fete-db:
```

### Environment variables

| Variable             | Required | Default   | Description                                    |
|----------------------|----------|-----------|------------------------------------------------|
| `DATABASE_URL`       | Yes      | —         | JDBC connection string for PostgreSQL          |
| `DATABASE_USERNAME`  | Yes      | —         | Database username                              |
| `DATABASE_PASSWORD`  | Yes      | —         | Database password                              |
| `MAX_ACTIVE_EVENTS`  | No       | Unlimited | Maximum number of simultaneously active events |
| `UNSPLASH_API_KEY`   | No       | —         | Unsplash API key for header image search       |
```

#### [x] 2. Add local development setup section to README
**File**: `README.md`
**Changes**: Extend the `## Development` section with database setup instructions.

```markdown
### Local database setup

**Option A: Testcontainers (no external PostgreSQL needed)**

```bash
cd backend && ./mvnw spring-boot:test-run
```

This starts the app with a Testcontainers-managed PostgreSQL that is created and destroyed automatically.

**Option B: External PostgreSQL**

```bash
cd backend
cp src/main/resources/application-local.properties.example \
   src/main/resources/application-local.properties
# Edit application-local.properties if your PostgreSQL uses different credentials
./mvnw spring-boot:run -Dspring-boot.run.profiles=local
```
```

### Success Criteria:

#### Automated Verification:
- [ ] `cd frontend && npm run test:unit -- --run` — frontend tests still pass (no regression)

#### Manual Verification:
- [ ] README docker-compose example is syntactically correct YAML
- [ ] Environment variable table lists all five variables with correct Required/Default values
- [ ] Local development section documents both Testcontainers and external PostgreSQL options
- [ ] docker-compose startup: `docker compose up` starts app + postgres, `/actuator/health` returns `{"status":"UP"}`

**Implementation Note**: After completing this phase, all T-4 acceptance criteria should be met. Run the full verification checklist below.

---

## Testing Strategy

### Unit Tests:
- `FeteProperties` — verify defaults (empty API key = disabled, maxActiveEvents=0 = unlimited)
- No other new unit tests in T-4 — the infrastructure is verified by integration tests

### Integration Tests:
- Existing `FeteApplicationTest.contextLoads()` — validates that Spring context starts with JPA + Liquibase + Testcontainers
- Existing `FeteApplicationTest.healthEndpointReturns200()` — validates health check includes DB health
- Existing `WebConfigTest` — validates SPA routing still works with JPA on classpath
- ArchUnit rules — validate `FeteProperties`/`FetePropertiesConfig` in `config` adapter is properly isolated

### Manual Testing Steps:
1. `cd backend && ./mvnw verify` — full backend pipeline green
2. `cd frontend && npm run test:unit -- --run` — frontend unchanged
3. `docker build .` — image builds successfully
4. docker-compose (app + postgres) — start, wait for health, verify `/actuator/health` returns UP

## Performance Considerations

- Testcontainers PostgreSQL startup adds ~3-5 seconds to backend test execution. This is acceptable for integration tests.
- Testcontainers reuses the container across all `@SpringBootTest` classes in a single Maven run (Spring's test context caching).
- The empty baseline changeset adds negligible startup time.

## Migration Notes

- **Existing tests**: `FeteApplicationTest` and `WebConfigTest` need `@Import(TestcontainersConfig.class)` — without it, they fail because JPA requires a datasource.
- **CI pipeline**: `./mvnw -B verify` now requires Docker for Testcontainers. Gitea Actions `ubuntu-latest` runners have Docker available. If the runner uses Docker-in-Docker, `DOCKER_HOST` may need configuration — verify after implementation.
- **Local development**: Developers now need either Docker (for Testcontainers via `./mvnw spring-boot:test-run`) or a local PostgreSQL (with `application-local.properties`).

## References

- Research: `docs/agents/research/2026-03-04-t4-development-infrastructure.md`
- T-4 spec: `spec/setup-tasks.md` (lines 79-98)
- Spring Boot Testcontainers: `@ServiceConnection` pattern (Spring Boot 3.1+)
- Liquibase Spring Boot integration: auto-configured when `liquibase-core` is on classpath
