Files
travel-planner/docs/superpowers/plans/travel-planner-phase-01-foundation-plan.md

1731 lines
52 KiB
Markdown

# Phase 01 Foundation & Deployment Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Create the buildable and deployable foundation for the Travel Planner: Angular PWA, NestJS API and worker, PostgreSQL/Redis infrastructure, health checks, a single-port production edge, and TeamCity-ready build/deploy scripts.
**Architecture:** Use a pnpm workspace with `frontend` and `backend`. The backend is one NestJS codebase with separate API and worker entry points plus shared libraries. Production compiles Angular into the `edge` image; only `edge` publishes a host TCP port and reverse-proxies `/api/*` to `api:3000`. PostgreSQL and Redis are internal Docker services. TeamCity invokes repository-owned scripts to validate, build immutable images, migrate, deploy, health-check, and roll back.
**Tech Stack:** Node.js 24 LTS, pnpm, TypeScript, Angular 22 + Vitest + Angular PWA service worker, NestJS 11 + Jest + Terminus, PostgreSQL 18.4, Redis 8.8.1, Docker Compose, Nginx edge, TeamCity.
## Global Constraints
- PostgreSQL is the source of truth.
- Do not add Mistral, agent logic, OIDC, trips, or business entities in Phase 01.
- Exactly one TCP port from production Compose may be published to the host.
- Only `edge` may contain a Compose `ports:` mapping.
- `api`, `worker`, `postgres`, and `redis` must have no host-published ports.
- Do not use `network_mode: host`.
- The edge serves Angular static files and proxies `/api/*` to `api:3000`.
- Production does not require host port 80.
- Node baseline is the 24 LTS line; Angular is 22.x; NestJS is 11.x.
- Pin PostgreSQL production image to `postgres:18.4-alpine` and Redis production image to `redis:8.8.1-alpine` in the initial deployment configuration.
- JavaScript dependency versions are frozen by `pnpm-lock.yaml`.
- Secrets are not committed or baked into images.
- TeamCity deployment uses immutable `IMAGE_TAG` values and does not run `docker compose down` as a routine deployment step.
- Live/paid providers do not exist in this phase.
---
## File Structure Locked by This Phase
```text
travel-planner/
├── package.json # workspace commands and Node/pnpm contract
├── pnpm-workspace.yaml # frontend/backend workspace membership
├── pnpm-lock.yaml
├── .editorconfig
├── .gitignore
├── .env.example # non-secret configuration names/defaults
├── README.md # developer startup and production topology
├── compose.yml # production stack; one published edge port
├── compose.dev.yml # PostgreSQL/Redis developer dependencies only
├── frontend/
│ ├── angular.json
│ ├── package.json
│ ├── ngsw-config.json
│ └── src/
│ ├── app/
│ │ ├── app.component.ts
│ │ ├── app.component.html
│ │ └── app.component.spec.ts
│ ├── index.html
│ └── manifest.webmanifest
├── backend/
│ ├── package.json
│ ├── eslint.config.mjs
│ ├── nest-cli.json
│ ├── tsconfig.json
│ ├── apps/
│ │ ├── api/src/
│ │ │ ├── main.ts
│ │ │ ├── migration.ts
│ │ │ ├── api.module.ts
│ │ │ └── health/
│ │ │ ├── health.controller.ts
│ │ │ ├── health.controller.spec.ts
│ │ │ ├── health.module.ts
│ │ │ └── readiness.service.ts
│ │ └── worker/src/
│ │ ├── main.ts
│ │ ├── worker.module.ts
│ │ └── worker.bootstrap.spec.ts
│ └── libs/
│ ├── configuration/src/
│ │ ├── environment.ts
│ │ ├── environment.spec.ts
│ │ └── index.ts
│ └── infrastructure/src/
│ ├── postgres/postgres.module.ts
│ ├── redis/redis.module.ts
│ └── index.ts
├── docker/
│ ├── api.Dockerfile
│ ├── worker.Dockerfile
│ ├── edge.Dockerfile
│ └── edge/
│ ├── nginx.conf
│ └── default.conf.template
├── scripts/
│ ├── verify-compose-invariants.mjs
│ └── teamcity/
│ ├── validate.sh
│ ├── build-images.sh
│ ├── deploy.sh
│ ├── rollback.sh
│ └── smoke.sh
├── tests/
│ └── compose-invariants.test.mjs
└── docs/
├── architecture/
│ └── deployment.md
└── superpowers/
└── plans/
├── 2026-08-17-travel-planner-master-roadmap.md
└── 2026-08-17-phase-01-foundation-deployment.md
```
---
### Task 1: Establish the pnpm Workspace and Version Contract
**Files:**
- Create: `package.json`
- Create: `pnpm-workspace.yaml`
- Create: `.editorconfig`
- Create: `.gitignore`
- Create: `.env.example`
- Create: `README.md`
- Test: workspace commands executed from repository root
**Interfaces:**
- Consumes: none.
- Produces: root commands `pnpm lint`, `pnpm test`, `pnpm build`, `pnpm dev:infra`, `pnpm dev:infra:down`; workspace locations `frontend` and `backend`.
- [ ] **Step 1: Create the root workspace manifest**
Create `package.json`:
```json
{
"name": "travel-planner",
"private": true,
"packageManager": "pnpm@10.15.0",
"engines": {
"node": ">=24.15.0 <25"
},
"scripts": {
"lint": "pnpm -r --if-present run lint",
"test": "pnpm -r --if-present run test",
"build": "pnpm -r --if-present run build",
"dev:infra": "docker compose -f compose.dev.yml up -d --wait",
"dev:infra:down": "docker compose -f compose.dev.yml down",
"test:compose": "node --test tests/compose-invariants.test.mjs"
}
}
```
- [ ] **Step 2: Declare the workspaces and repository defaults**
Create `pnpm-workspace.yaml`:
```yaml
packages:
- frontend
- backend
```
Create `.editorconfig`:
```ini
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
```
Create `.gitignore` with at least:
```text
node_modules/
dist/
.angular/
coverage/
.env
.env.*
!.env.example
/data/
*.log
.DS_Store
```
- [ ] **Step 3: Define non-secret environment names**
Create `.env.example`:
```dotenv
APP_HTTPS_PORT=443
IMAGE_TAG=local
REGISTRY=local
POSTGRES_IMAGE_TAG=18.4-alpine
REDIS_IMAGE_TAG=8.8.1-alpine
POSTGRES_DB=travel_planner
POSTGRES_USER=travel_planner
POSTGRES_PASSWORD=change-me-outside-source-control
DATABASE_URL=postgresql://travel_planner:change-me-outside-source-control@postgres:5432/travel_planner
REDIS_URL=redis://redis:6379
APP_VERSION=dev
TEAMCITY_BUILD_NUMBER=local
SOURCE_REVISION=local
TLS_CERT_FILE=/etc/travel-planner/tls/tls.crt
TLS_KEY_FILE=/etc/travel-planner/tls/tls.key
```
Add to `README.md` an explicit warning that `.env.example` contains examples only and production secrets live on the deployment host.
- [ ] **Step 4: Verify the root package contract**
Run:
```bash
node --version
corepack enable
pnpm --version
pnpm install
```
Expected:
- Node satisfies `>=24.15.0 <25`.
- `pnpm install` succeeds and creates `pnpm-lock.yaml`.
- [ ] **Step 5: Commit**
```bash
git add package.json pnpm-workspace.yaml pnpm-lock.yaml .editorconfig .gitignore .env.example README.md
git commit -m "chore: establish travel planner workspace"
```
---
### Task 2: Scaffold the NestJS API and Worker Entry Points
**Files:**
- Create: `backend/package.json`
- Create: `backend/nest-cli.json`
- Create: `backend/tsconfig.json`
- Create: `backend/apps/api/tsconfig.app.json`
- Create: `backend/apps/api/src/main.ts`
- Create: `backend/apps/api/src/api.module.ts`
- Create: `backend/apps/worker/tsconfig.app.json`
- Create: `backend/apps/worker/src/main.ts`
- Create: `backend/apps/worker/src/worker.module.ts`
- Test: `backend/apps/worker/src/worker.bootstrap.spec.ts`
**Interfaces:**
- Consumes: Node/pnpm root contract from Task 1.
- Produces: `pnpm --filter backend build:api`, `build:worker`, `start:api`, `start:worker`; API listens on internal port `3000`; worker creates a Nest application context and exposes no HTTP listener.
- [ ] **Step 1: Write the worker bootstrap test first**
Create `backend/apps/worker/src/worker.bootstrap.spec.ts`:
```ts
import { describe, expect, it, jest } from '@jest/globals';
import { bootstrapWorker } from './main';
describe('bootstrapWorker', () => {
it('creates an application context without starting an HTTP listener', async () => {
const close = jest.fn().mockResolvedValue(undefined);
const enableShutdownHooks = jest.fn();
const appContext = { close, enableShutdownHooks };
const createContext = jest.fn().mockResolvedValue(appContext);
const app = await bootstrapWorker(createContext as never);
expect(createContext).toHaveBeenCalledTimes(1);
expect(enableShutdownHooks).toHaveBeenCalledTimes(1);
expect(app).toBe(appContext);
});
});
```
- [ ] **Step 2: Run the focused test and verify failure**
Run:
```bash
pnpm --filter backend test -- apps/worker/src/worker.bootstrap.spec.ts
```
Expected: FAIL because `bootstrapWorker` and backend test configuration do not yet exist.
- [ ] **Step 3: Create the backend package and Nest configuration**
Create `backend/package.json` with NestJS 11 packages, Jest + SWC, TypeScript, and scripts:
```json
{
"name": "backend",
"private": true,
"scripts": {
"build": "pnpm run build:api && pnpm run build:worker",
"build:api": "nest build api",
"build:worker": "nest build worker",
"start:api": "node dist/apps/api/main.js",
"start:worker": "node dist/apps/worker/main.js",
"lint": "eslint \"{apps,libs}/**/*.ts\"",
"test": "jest --runInBand"
},
"dependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0"
},
"devDependencies": {
"@jest/globals": "^30.0.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/testing": "^11.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@eslint/js": "^9.0.0",
"@swc/core": "^1.0.0",
"@swc/jest": "^0.2.39",
"eslint": "^9.0.0",
"jest": "^30.0.0",
"typescript": "^6.0.0",
"typescript-eslint": "^8.0.0"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": [
"@swc/jest",
{
"jsc": {
"parser": {"syntax": "typescript", "decorators": true},
"transform": {"legacyDecorator": true, "decoratorMetadata": true}
},
"module": {"type": "commonjs"}
}
]
},
"collectCoverageFrom": ["apps/**/*.ts", "libs/**/*.ts"],
"coverageDirectory": "coverage",
"testEnvironment": "node"
}
}
```
Create `backend/nest-cli.json`:
```json
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"monorepo": true,
"root": "apps/api",
"sourceRoot": "apps/api/src",
"compilerOptions": {"deleteOutDir": true},
"projects": {
"api": {
"type": "application",
"root": "apps/api",
"entryFile": "main",
"sourceRoot": "apps/api/src",
"compilerOptions": {"tsConfigPath": "apps/api/tsconfig.app.json"}
},
"worker": {
"type": "application",
"root": "apps/worker",
"entryFile": "main",
"sourceRoot": "apps/worker/src",
"compilerOptions": {"tsConfigPath": "apps/worker/tsconfig.app.json"}
}
}
}
```
Create `backend/tsconfig.json`:
```json
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strict": true,
"paths": {
"@travel/configuration": ["libs/configuration/src"],
"@travel/infrastructure": ["libs/infrastructure/src"]
}
}
}
```
Create both `backend/apps/api/tsconfig.app.json` and `backend/apps/worker/tsconfig.app.json` with the appropriate output exclusions:
```json
{
"extends": "../../tsconfig.json",
"compilerOptions": {"declaration": false},
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
}
```
Create `backend/eslint.config.mjs`:
```js
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist/**', 'coverage/**'] },
eslint.configs.recommended,
...tseslint.configs.recommended,
);
```
Run:
```bash
pnpm install
```
- [ ] **Step 4: Implement the API and worker bootstraps**
Create `backend/apps/api/src/api.module.ts`:
```ts
import { Module } from '@nestjs/common';
@Module({})
export class ApiModule {}
```
Create `backend/apps/api/src/main.ts`:
```ts
import { NestFactory } from '@nestjs/core';
import { ApiModule } from './api.module';
export async function bootstrapApi(): Promise<void> {
const app = await NestFactory.create(ApiModule);
app.enableShutdownHooks();
app.setGlobalPrefix('api/v1');
await app.listen(3000, '0.0.0.0');
}
if (require.main === module) {
void bootstrapApi();
}
```
Create `backend/apps/worker/src/worker.module.ts`:
```ts
import { Module } from '@nestjs/common';
@Module({})
export class WorkerModule {}
```
Create `backend/apps/worker/src/main.ts`:
```ts
import { INestApplicationContext, Type } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { WorkerModule } from './worker.module';
type ContextFactory = (module: Type<unknown>) => Promise<INestApplicationContext>;
export async function bootstrapWorker(
createContext: ContextFactory = (module) => NestFactory.createApplicationContext(module),
): Promise<INestApplicationContext> {
const app = await createContext(WorkerModule);
app.enableShutdownHooks();
return app;
}
if (require.main === module) {
void bootstrapWorker();
}
```
- [ ] **Step 5: Run tests and builds**
Run:
```bash
pnpm --filter backend test -- apps/worker/src/worker.bootstrap.spec.ts
pnpm --filter backend build
```
Expected: PASS; both API and worker build outputs exist.
- [ ] **Step 6: Commit**
```bash
git add backend pnpm-lock.yaml
git commit -m "feat: add nestjs api and worker skeletons"
```
---
### Task 3: Add Configuration Parsing with Fail-Fast Validation
**Files:**
- Create: `backend/libs/configuration/src/environment.ts`
- Create: `backend/libs/configuration/src/environment.spec.ts`
- Create: `backend/libs/configuration/src/index.ts`
- Modify: `backend/apps/api/src/api.module.ts`
- Modify: `backend/apps/worker/src/worker.module.ts`
**Interfaces:**
- Consumes: process environment variables.
- Produces: `loadEnvironment(env: NodeJS.ProcessEnv): AppEnvironment` with `databaseUrl`, `redisUrl`, and safe build metadata.
- [ ] **Step 1: Write failing environment tests**
Create `backend/libs/configuration/src/environment.spec.ts`:
```ts
import { loadEnvironment } from './environment';
describe('loadEnvironment', () => {
it('requires database and redis URLs', () => {
expect(() => loadEnvironment({})).toThrow('DATABASE_URL');
});
it('returns build metadata without secrets', () => {
expect(loadEnvironment({
DATABASE_URL: 'postgresql://u:p@postgres:5432/db',
REDIS_URL: 'redis://redis:6379',
APP_VERSION: '1.2.3',
TEAMCITY_BUILD_NUMBER: '42',
SOURCE_REVISION: 'abc123',
})).toEqual({
databaseUrl: 'postgresql://u:p@postgres:5432/db',
redisUrl: 'redis://redis:6379',
appVersion: '1.2.3',
teamCityBuildNumber: '42',
sourceRevision: 'abc123',
});
});
});
```
- [ ] **Step 2: Run the test and verify failure**
```bash
pnpm --filter backend test -- libs/configuration/src/environment.spec.ts
```
Expected: FAIL because `loadEnvironment` does not exist.
- [ ] **Step 3: Implement minimal validated configuration**
Create `environment.ts`:
```ts
export interface AppEnvironment {
databaseUrl: string;
redisUrl: string;
appVersion: string;
teamCityBuildNumber: string;
sourceRevision: string;
}
function required(env: NodeJS.ProcessEnv, name: string): string {
const value = env[name]?.trim();
if (!value) throw new Error(`Missing required environment variable: ${name}`);
return value;
}
export function loadEnvironment(env: NodeJS.ProcessEnv): AppEnvironment {
return {
databaseUrl: required(env, 'DATABASE_URL'),
redisUrl: required(env, 'REDIS_URL'),
appVersion: env.APP_VERSION?.trim() || 'dev',
teamCityBuildNumber: env.TEAMCITY_BUILD_NUMBER?.trim() || 'local',
sourceRevision: env.SOURCE_REVISION?.trim() || 'local',
};
}
```
Export it from `index.ts` together with a typed provider:
```ts
import { loadEnvironment } from './environment';
export * from './environment';
export const APP_ENVIRONMENT = Symbol('APP_ENVIRONMENT');
export const appEnvironmentProvider = {
provide: APP_ENVIRONMENT,
useFactory: () => loadEnvironment(process.env),
};
```
- [ ] **Step 4: Wire validation into both application roots**
Add `appEnvironmentProvider` to the providers of both `ApiModule` and `WorkerModule` and export it from a small shared configuration module if Nest module reuse is needed. Do not expose the raw `process.env` object through dependency injection.
- [ ] **Step 5: Run tests and build**
```bash
pnpm --filter backend test -- libs/configuration/src/environment.spec.ts
DATABASE_URL=postgresql://u:p@localhost:5432/db REDIS_URL=redis://localhost:6379 pnpm --filter backend build
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add backend/libs/configuration backend/apps/api/src/api.module.ts backend/apps/worker/src/worker.module.ts
git commit -m "feat: validate backend runtime configuration"
```
---
### Task 4: Scaffold the Angular 22 PWA with a Minimal Shell
**Files:**
- Create: `frontend/**` via Angular CLI 22
- Modify: `frontend/src/app/app.component.ts`
- Modify: `frontend/src/app/app.component.html`
- Modify: `frontend/src/app/app.component.spec.ts`
- Create/Modify: `frontend/ngsw-config.json`
- Create/Modify: `frontend/src/manifest.webmanifest`
**Interfaces:**
- Consumes: root pnpm workspace.
- Produces: static production bundle in `frontend/dist/frontend/browser`; installable PWA shell; root app title `Travel Planner`.
- [ ] **Step 1: Generate the Angular application**
Run from repository root:
```bash
pnpm dlx @angular/cli@22 new frontend --routing --style=scss --standalone --strict --skip-git --package-manager=pnpm --test-runner=vitest
cd frontend
pnpm exec ng add @angular/pwa --project frontend --skip-confirmation
cd ..
pnpm install
```
Expected: Angular workspace exists with Vitest tests and PWA service-worker configuration.
- [ ] **Step 2: Replace the generated component test with product-shell expectations**
Update `frontend/src/app/app.component.spec.ts` to assert:
```ts
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
it('renders the Travel Planner shell', async () => {
await TestBed.configureTestingModule({ imports: [AppComponent] }).compileComponents();
const fixture = TestBed.createComponent(AppComponent);
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain('Travel Planner');
expect(fixture.nativeElement.textContent).toContain('Reisen planen, gemeinsam entscheiden.');
});
});
```
- [ ] **Step 3: Run the test and verify it fails against the generated template**
```bash
pnpm --filter frontend test -- --watch=false
```
Expected: FAIL on the product copy assertion.
- [ ] **Step 4: Implement the minimal app shell**
Set `AppComponent` to a standalone component with no business state. Use this template:
```html
<main class="app-shell">
<h1>Travel Planner</h1>
<p>Reisen planen, gemeinsam entscheiden.</p>
<router-outlet />
</main>
```
Keep styling minimal; Phase 11 owns final UI polish.
- [ ] **Step 5: Verify test, production build, and PWA artifacts**
```bash
pnpm --filter frontend test -- --watch=false
pnpm --filter frontend build
find frontend/dist -name ngsw.json -o -name manifest.webmanifest
```
Expected: tests PASS; production build succeeds; service-worker/PWA artifacts exist.
- [ ] **Step 6: Commit**
```bash
git add frontend pnpm-lock.yaml
git commit -m "feat: add angular pwa shell"
```
---
### Task 5: Add PostgreSQL and Redis Development Infrastructure
**Files:**
- Create: `compose.dev.yml`
- Modify: `README.md`
- Test: manual Compose health verification in this task; invariant automation is added in Task 8.
**Interfaces:**
- Consumes: `.env.example` names.
- Produces: development services `postgres` on container port 5432 and `redis` on container port 6379, both healthy before dependent integration work.
- [ ] **Step 1: Create the development Compose file**
Create `compose.dev.yml`:
```yaml
services:
postgres:
image: postgres:18.4-alpine
environment:
POSTGRES_DB: travel_planner
POSTGRES_USER: travel_planner
POSTGRES_PASSWORD: travel_planner_dev
ports:
- "127.0.0.1:5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U travel_planner -d travel_planner"]
interval: 2s
timeout: 3s
retries: 20
volumes:
- travel_postgres_dev:/var/lib/postgresql
redis:
image: redis:8.8.1-alpine
ports:
- "127.0.0.1:6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 3s
retries: 20
volumes:
travel_postgres_dev:
```
This file is development-only. The one-public-port invariant applies to production `compose.yml`, not this local dependency convenience file.
- [ ] **Step 2: Start development infrastructure**
```bash
pnpm dev:infra
docker compose -f compose.dev.yml ps
```
Expected: both services show `healthy`.
- [ ] **Step 3: Verify connectivity**
```bash
docker compose -f compose.dev.yml exec -T postgres pg_isready -U travel_planner -d travel_planner
docker compose -f compose.dev.yml exec -T redis redis-cli ping
```
Expected: PostgreSQL accepts connections; Redis returns `PONG`.
- [ ] **Step 4: Document developer startup**
Add to `README.md` the exact commands:
```bash
pnpm install
pnpm dev:infra
DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner REDIS_URL=redis://localhost:6379 pnpm --filter backend start:api
pnpm --filter frontend start
```
- [ ] **Step 5: Commit**
```bash
git add compose.dev.yml README.md
git commit -m "chore: add postgres and redis development services"
```
---
### Task 6: Implement Liveness and Dependency-Aware Readiness
**Files:**
- Create: `backend/libs/infrastructure/src/postgres/postgres.module.ts`
- Create: `backend/libs/infrastructure/src/redis/redis.module.ts`
- Create: `backend/libs/infrastructure/src/index.ts`
- Create: `backend/apps/api/src/health/health.module.ts`
- Create: `backend/apps/api/src/health/readiness.service.ts`
- Create: `backend/apps/api/src/health/health.controller.ts`
- Create: `backend/apps/api/src/health/health.controller.spec.ts`
- Modify: `backend/apps/api/src/api.module.ts`
- Modify: `backend/apps/worker/src/worker.module.ts`
- Modify: `backend/apps/api/src/main.ts`
- Modify: `backend/package.json`
**Interfaces:**
- Consumes: `DATABASE_URL`, `REDIS_URL` from configuration.
- Produces: `GET /health/live` returns 200 without checking external providers; `GET /health/ready` returns 200 only when PostgreSQL and Redis are reachable; these routes remain outside `/api/v1` so infrastructure probes are stable.
- [ ] **Step 1: Write failing controller tests**
Create `health.controller.spec.ts` with a mocked readiness service:
```ts
import { Test } from '@nestjs/testing';
import { HealthController } from './health.controller';
import { ReadinessService } from './readiness.service';
describe('HealthController', () => {
it('returns liveness without dependency checks', async () => {
const readiness = { check: jest.fn() };
const moduleRef = await Test.createTestingModule({
controllers: [HealthController],
providers: [{ provide: ReadinessService, useValue: readiness }],
}).compile();
expect(moduleRef.get(HealthController).live()).toEqual({ status: 'ok' });
expect(readiness.check).not.toHaveBeenCalled();
});
it('delegates readiness to dependency checks', async () => {
const readiness = { check: jest.fn().mockResolvedValue({ status: 'ok' }) };
const moduleRef = await Test.createTestingModule({
controllers: [HealthController],
providers: [{ provide: ReadinessService, useValue: readiness }],
}).compile();
await expect(moduleRef.get(HealthController).ready()).resolves.toEqual({ status: 'ok' });
expect(readiness.check).toHaveBeenCalledTimes(1);
});
});
```
- [ ] **Step 2: Run and verify failure**
```bash
pnpm --filter backend test -- apps/api/src/health/health.controller.spec.ts
```
Expected: FAIL because health classes do not exist.
- [ ] **Step 3: Add infrastructure dependencies**
Install:
```bash
pnpm --filter backend add @nestjs/terminus pg ioredis
pnpm --filter backend add -D @types/pg
```
Implement `POSTGRES_POOL` and `REDIS_CLIENT` providers. The essential provider factories are:
```ts
export const POSTGRES_POOL = Symbol('POSTGRES_POOL');
export const REDIS_CLIENT = Symbol('REDIS_CLIENT');
export const postgresPoolProvider = {
provide: POSTGRES_POOL,
inject: [APP_ENVIRONMENT],
useFactory: (env: AppEnvironment) => new Pool({ connectionString: env.databaseUrl }),
};
export const redisClientProvider = {
provide: REDIS_CLIENT,
inject: [APP_ENVIRONMENT],
useFactory: (env: AppEnvironment) => new Redis(env.redisUrl, { lazyConnect: false }),
};
```
Wrap each provider in a Nest module and add lifecycle providers implementing `OnModuleDestroy` so the PostgreSQL pool calls `end()` and Redis calls `quit()`. Import both infrastructure modules in `ApiModule` and `WorkerModule`. Export typed injection tokens from `libs/infrastructure/src/index.ts`.
- [ ] **Step 4: Implement readiness checks**
`ReadinessService.check()` must execute:
```ts
await postgresPool.query('SELECT 1');
const pong = await redis.ping();
if (pong !== 'PONG') throw new Error('Redis ping failed');
return { status: 'ok' as const };
```
`HealthController` must define exactly:
```ts
@Get('/health/live')
live(): { status: 'ok' }
@Get('/health/ready')
ready(): Promise<{ status: 'ok' }>
```
Configure these infrastructure paths outside the global `/api/v1` prefix in `apps/api/src/main.ts`:
```ts
import { RequestMethod } from '@nestjs/common';
app.setGlobalPrefix('api/v1', {
exclude: [
{ path: 'health/live', method: RequestMethod.GET },
{ path: 'health/ready', method: RequestMethod.GET },
],
});
```
Use `@Controller()` on `HealthController` with `@Get('health/live')` and `@Get('health/ready')`.
- [ ] **Step 5: Run unit tests**
```bash
pnpm --filter backend test -- apps/api/src/health/health.controller.spec.ts
```
Expected: PASS.
- [ ] **Step 6: Run API against development infrastructure**
```bash
pnpm dev:infra
DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner REDIS_URL=redis://localhost:6379 pnpm --filter backend build:api
DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner REDIS_URL=redis://localhost:6379 node backend/dist/apps/api/main.js &
API_PID=$!
sleep 2
curl --fail http://127.0.0.1:3000/health/live
curl --fail http://127.0.0.1:3000/health/ready
kill "$API_PID"
```
Expected: both endpoints return HTTP 200 with `{"status":"ok"}`.
- [ ] **Step 7: Commit**
```bash
git add backend pnpm-lock.yaml
git commit -m "feat: add api liveness and readiness checks"
```
---
### Task 7: Build Production API, Worker, and Edge Images
**Files:**
- Create: `docker/api.Dockerfile`
- Create: `docker/worker.Dockerfile`
- Create: `docker/edge.Dockerfile`
- Create: `docker/edge/nginx.conf`
- Create: `docker/edge/default.conf.template`
- Create: `compose.yml`
- Create: `docs/architecture/deployment.md`
**Interfaces:**
- Consumes: built backend and frontend workspaces; environment names from Task 1.
- Produces: images `travel-api:${IMAGE_TAG}`, `travel-worker:${IMAGE_TAG}`, `travel-edge:${IMAGE_TAG}`; production service DNS names `api`, `worker`, `postgres`, `redis`; edge HTTPS endpoint; internal API at `api:3000`.
- [ ] **Step 1: Create multi-stage API and worker Dockerfiles**
Create `docker/api.Dockerfile`:
```dockerfile
FROM node:24.18.0-bookworm-slim AS build
RUN corepack enable && corepack prepare pnpm@10.15.0 --activate
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY backend/package.json backend/package.json
COPY frontend/package.json frontend/package.json
RUN pnpm install --frozen-lockfile
COPY backend backend
RUN pnpm --filter backend build:api
FROM node:24.18.0-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/backend/node_modules ./backend/node_modules
COPY --from=build /app/backend/package.json ./backend/package.json
COPY --from=build /app/backend/dist ./backend/dist
USER node
EXPOSE 3000
CMD ["node", "backend/dist/apps/api/main.js"]
```
Create `docker/worker.Dockerfile`:
```dockerfile
FROM node:24.18.0-bookworm-slim AS build
RUN corepack enable && corepack prepare pnpm@10.15.0 --activate
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY backend/package.json backend/package.json
COPY frontend/package.json frontend/package.json
RUN pnpm install --frozen-lockfile
COPY backend backend
RUN pnpm --filter backend build:worker
FROM node:24.18.0-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/backend/node_modules ./backend/node_modules
COPY --from=build /app/backend/package.json ./backend/package.json
COPY --from=build /app/backend/dist ./backend/dist
USER node
CMD ["node", "backend/dist/apps/worker/main.js"]
```
Do not add `EXPOSE` to the worker image. Neither image contains secrets.
- [ ] **Step 2: Create the edge image**
Create `docker/edge.Dockerfile`:
```dockerfile
FROM node:24.18.0-bookworm-slim AS frontend-build
RUN corepack enable && corepack prepare pnpm@10.15.0 --activate
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY frontend/package.json frontend/package.json
COPY backend/package.json backend/package.json
RUN pnpm install --frozen-lockfile
COPY frontend frontend
RUN pnpm --filter frontend build
FROM nginx:1.29.8-alpine
COPY docker/edge/nginx.conf /etc/nginx/nginx.conf
COPY docker/edge/default.conf.template /etc/nginx/templates/default.conf.template
COPY --from=frontend-build /app/frontend/dist/frontend/browser /usr/share/nginx/html
EXPOSE 443
```
Create `docker/edge/nginx.conf`:
```nginx
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
include /etc/nginx/conf.d/*.conf;
}
```
The edge image listens on container port 443 only and expects TLS certificate/key mounts at `/run/tls/tls.crt` and `/run/tls/tls.key`.
- [ ] **Step 3: Configure Nginx routing**
`default.conf.template` must define:
```nginx
server {
listen 443 ssl;
server_name _;
ssl_certificate /run/tls/tls.crt;
ssl_certificate_key /run/tls/tls.key;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://api:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_buffering off;
}
location /health/ {
proxy_pass http://api:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
}
location / {
try_files $uri $uri/ /index.html;
}
}
```
Do not add a port-80 server block.
- [ ] **Step 4: Create the production Compose topology**
Create `compose.yml`:
```yaml
services:
edge:
image: "${REGISTRY}/travel-edge:${IMAGE_TAG}"
build:
context: .
dockerfile: docker/edge.Dockerfile
ports:
- "${APP_HTTPS_PORT:-443}:443"
volumes:
- "${TLS_CERT_FILE}:/run/tls/tls.crt:ro"
- "${TLS_KEY_FILE}:/run/tls/tls.key:ro"
depends_on:
api:
condition: service_healthy
networks: [travel]
restart: unless-stopped
api:
image: "${REGISTRY}/travel-api:${IMAGE_TAG}"
build:
context: .
dockerfile: docker/api.Dockerfile
expose:
- "3000"
environment:
DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}"
REDIS_URL: "redis://redis:6379"
APP_VERSION: "${APP_VERSION:-dev}"
TEAMCITY_BUILD_NUMBER: "${TEAMCITY_BUILD_NUMBER:-local}"
SOURCE_REVISION: "${SOURCE_REVISION:-local}"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3000/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
networks: [travel]
restart: unless-stopped
worker:
image: "${REGISTRY}/travel-worker:${IMAGE_TAG}"
build:
context: .
dockerfile: docker/worker.Dockerfile
environment:
DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}"
REDIS_URL: "redis://redis:6379"
APP_VERSION: "${APP_VERSION:-dev}"
TEAMCITY_BUILD_NUMBER: "${TEAMCITY_BUILD_NUMBER:-local}"
SOURCE_REVISION: "${SOURCE_REVISION:-local}"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks: [travel]
restart: unless-stopped
postgres:
image: "postgres:${POSTGRES_IMAGE_TAG:-18.4-alpine}"
environment:
POSTGRES_DB: "${POSTGRES_DB}"
POSTGRES_USER: "${POSTGRES_USER}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
expose:
- "5432"
volumes:
- postgres_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 20
networks: [travel]
restart: unless-stopped
redis:
image: "redis:${REDIS_IMAGE_TAG:-8.8.1-alpine}"
expose:
- "6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 20
networks: [travel]
restart: unless-stopped
volumes:
postgres_data:
networks:
travel:
driver: bridge
```
`worker` has neither `ports` nor `expose` because it accepts no inbound traffic. The PostgreSQL 18 volume is mounted at `/var/lib/postgresql`, matching the official PostgreSQL 18 image layout. The edge is the only service with `ports:`. Do not add port 80 or `network_mode: host`.
- [ ] **Step 5: Build all images locally**
```bash
IMAGE_TAG=phase01 \
REGISTRY=local \
POSTGRES_DB=travel_planner \
POSTGRES_USER=travel_planner \
POSTGRES_PASSWORD=build-only \
TLS_CERT_FILE=/tmp/not-used-during-build.crt \
TLS_KEY_FILE=/tmp/not-used-during-build.key \
docker compose -f compose.yml build edge api worker
```
Expected: all three application images build successfully from one revision.
- [ ] **Step 6: Document production topology and TLS contract**
In `docs/architecture/deployment.md`, document:
- only edge publishes a host port;
- production requires a certificate and key mounted at the configured paths;
- no port 80 requirement;
- PostgreSQL/Redis/API are inaccessible from the host through Compose-published ports;
- outbound API/worker egress remains allowed;
- TeamCity supplies immutable image tags.
- [ ] **Step 7: Commit**
```bash
git add docker compose.yml docs/architecture/deployment.md
git commit -m "feat: add single-port production docker topology"
```
---
### Task 8: Enforce the Single-Published-Port Invariant Automatically
**Files:**
- Create: `scripts/verify-compose-invariants.mjs`
- Create: `tests/compose-invariants.test.mjs`
- Modify: `package.json`
- Modify: root dev dependencies/lockfile to include `yaml`
**Interfaces:**
- Consumes: `compose.yml`.
- Produces: `verifyComposeInvariants(compose)` function and `pnpm test:compose`; TeamCity can reject topology regressions before image build/deploy.
- [ ] **Step 1: Add YAML parser and write failing invariant test**
Install:
```bash
pnpm add -Dw yaml
```
Create `tests/compose-invariants.test.mjs`:
```js
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { parse } from 'yaml';
import { verifyComposeInvariants } from '../scripts/verify-compose-invariants.mjs';
test('production compose publishes exactly one edge port', () => {
const compose = parse(fs.readFileSync(new URL('../compose.yml', import.meta.url), 'utf8'));
assert.doesNotThrow(() => verifyComposeInvariants(compose));
});
test('invariant rejects a host port on postgres', () => {
const invalid = {
services: {
edge: { ports: ['443:443'] },
postgres: { ports: ['5432:5432'] },
},
};
assert.throws(() => verifyComposeInvariants(invalid), /postgres.*ports/i);
});
```
- [ ] **Step 2: Run the test and verify failure**
```bash
pnpm test:compose
```
Expected: FAIL because `verifyComposeInvariants` does not exist.
- [ ] **Step 3: Implement the invariant validator**
Create `scripts/verify-compose-invariants.mjs`:
```js
export function verifyComposeInvariants(compose) {
const services = compose?.services ?? {};
const serviceNames = Object.keys(services);
const published = serviceNames.filter((name) => Array.isArray(services[name]?.ports) && services[name].ports.length > 0);
if (published.length !== 1 || published[0] !== 'edge') {
throw new Error(`Only edge may define ports; found: ${published.join(', ') || 'none'}`);
}
if (services.edge.ports.length !== 1) {
throw new Error(`edge must publish exactly one port; found ${services.edge.ports.length}`);
}
for (const [name, service] of Object.entries(services)) {
if (name !== 'edge' && Array.isArray(service.ports) && service.ports.length > 0) {
throw new Error(`${name} must not define ports`);
}
if (service.network_mode === 'host') {
throw new Error(`${name} must not use network_mode: host`);
}
}
}
```
- [ ] **Step 4: Run the invariant test**
```bash
pnpm test:compose
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add package.json pnpm-lock.yaml scripts/verify-compose-invariants.mjs tests/compose-invariants.test.mjs
git commit -m "test: enforce production docker network invariants"
```
---
### Task 9: Add TeamCity-Owned Repository Scripts for Validate, Build, Deploy, Smoke, and Rollback
**Files:**
- Create: `scripts/teamcity/validate.sh`
- Create: `scripts/teamcity/build-images.sh`
- Create: `scripts/teamcity/smoke.sh`
- Create: `scripts/teamcity/deploy.sh`
- Create: `scripts/teamcity/rollback.sh`
- Modify: `README.md`
- Modify: `docs/architecture/deployment.md`
**Interfaces:**
- Consumes: TeamCity environment variables `BUILD_NUMBER`, `BUILD_VCS_NUMBER`, registry credentials supplied externally, deployment-host `.env`, and immutable `IMAGE_TAG`.
- Produces: stable shell entry points that an existing TeamCity pipeline can call without duplicating deployment logic.
- [ ] **Step 1: Create validation script**
Create `scripts/teamcity/validate.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
corepack enable
pnpm install --frozen-lockfile
pnpm lint
pnpm test
pnpm test:compose
pnpm build
```
Make executable:
```bash
chmod +x scripts/teamcity/validate.sh
```
- [ ] **Step 2: Create immutable image build script**
Create `scripts/teamcity/build-images.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
: "${REGISTRY:?REGISTRY is required}"
: "${BUILD_NUMBER:?BUILD_NUMBER is required}"
: "${BUILD_VCS_NUMBER:?BUILD_VCS_NUMBER is required}"
IMAGE_TAG="${BUILD_NUMBER}-${BUILD_VCS_NUMBER}"
export IMAGE_TAG
if [[ "$IMAGE_TAG" == "latest" ]]; then
echo "Refusing floating deployment tag" >&2
exit 1
fi
docker build --pull -f docker/edge.Dockerfile -t "${REGISTRY}/travel-edge:${IMAGE_TAG}" .
docker build --pull -f docker/api.Dockerfile -t "${REGISTRY}/travel-api:${IMAGE_TAG}" .
docker build --pull -f docker/worker.Dockerfile -t "${REGISTRY}/travel-worker:${IMAGE_TAG}" .
docker push "${REGISTRY}/travel-edge:${IMAGE_TAG}"
docker push "${REGISTRY}/travel-api:${IMAGE_TAG}"
docker push "${REGISTRY}/travel-worker:${IMAGE_TAG}"
printf '%s\n' "$IMAGE_TAG"
```
Make it executable. Registry authentication is supplied by the TeamCity Docker/registry connection before this script runs; credentials are not arguments or source-controlled values.
- [ ] **Step 3: Create smoke script against the single edge port**
Create `scripts/teamcity/smoke.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
: "${APP_BASE_URL:?APP_BASE_URL is required}"
curl --fail --silent --show-error "${APP_BASE_URL}/health/live"
curl --fail --silent --show-error "${APP_BASE_URL}/health/ready"
curl --fail --silent --show-error "${APP_BASE_URL}/" >/dev/null
```
Make it executable.
- [ ] **Step 4: Create deployment script**
Create `scripts/teamcity/deploy.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
: "${IMAGE_TAG:?IMAGE_TAG is required}"
: "${REGISTRY:?REGISTRY is required}"
: "${APP_BASE_URL:?APP_BASE_URL is required}"
exec 9>/var/lock/travel-planner-deploy.lock
flock -n 9 || { echo "Another deployment is already running" >&2; exit 1; }
if [[ -f .deployed-image-tag ]]; then
cp .deployed-image-tag .previous-image-tag
fi
export IMAGE_TAG REGISTRY
docker compose pull edge api worker postgres redis
docker compose up -d postgres redis --wait --wait-timeout 120
docker compose run --rm --no-deps api node backend/dist/apps/api/migration.js
docker compose up -d --remove-orphans --wait --wait-timeout 120
scripts/teamcity/smoke.sh
printf '%s\n' "$IMAGE_TAG" > .deployed-image-tag
```
Make it executable. The target working directory contains `compose.yml` and the protected production `.env` used by Docker Compose. Do not add `docker compose down`.
- [ ] **Step 5: Create rollback script**
Create `scripts/teamcity/rollback.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
: "${REGISTRY:?REGISTRY is required}"
: "${APP_BASE_URL:?APP_BASE_URL is required}"
if [[ ! -s .previous-image-tag ]]; then
echo "No previous image tag is available for rollback" >&2
exit 1
fi
IMAGE_TAG="$(cat .previous-image-tag)"
export IMAGE_TAG REGISTRY
docker compose pull edge api worker
docker compose up -d --remove-orphans --wait --wait-timeout 120
scripts/teamcity/smoke.sh
printf '%s\n' "$IMAGE_TAG" > .deployed-image-tag
```
Make it executable. Rollback never performs an automatic database downgrade.
- [ ] **Step 6: Add the Phase-01 compiled migration entry point**
Create `backend/apps/api/src/migration.ts`:
```ts
export async function runMigrations(): Promise<void> {
console.log('No migrations configured in Phase 01');
}
if (require.main === module) {
void runMigrations();
}
```
Verify that `pnpm --filter backend build:api` produces `backend/dist/apps/api/migration.js`. Phase 02 replaces the body with the real versioned migration runner while preserving this container command contract.
- [ ] **Step 7: Verify scripts syntactically and run validation**
```bash
chmod +x scripts/teamcity/*.sh
bash -n scripts/teamcity/validate.sh
bash -n scripts/teamcity/build-images.sh
bash -n scripts/teamcity/deploy.sh
bash -n scripts/teamcity/rollback.sh
bash -n scripts/teamcity/smoke.sh
scripts/teamcity/validate.sh
```
Expected: shell syntax passes; validation passes.
- [ ] **Step 8: Document TeamCity wiring**
Add a table to `docs/architecture/deployment.md`:
```text
TeamCity stage Repository entry point
Validate scripts/teamcity/validate.sh
Build + Push scripts/teamcity/build-images.sh
Deploy over SSH scripts/teamcity/deploy.sh
Post-deploy smoke scripts/teamcity/smoke.sh
Rollback scripts/teamcity/rollback.sh
```
Document that the existing TeamCity project may configure these as command-line/SSH steps; deployment logic must remain in version control.
- [ ] **Step 9: Commit**
```bash
git add scripts/teamcity backend/package.json README.md docs/architecture/deployment.md
git commit -m "ci: add teamcity build and deployment entry points"
```
---
### Task 10: Add Build Metadata Endpoint and Complete Phase-01 Verification
**Files:**
- Create: `backend/apps/api/src/version/version.controller.ts`
- Create: `backend/apps/api/src/version/version.controller.spec.ts`
- Create: `backend/apps/api/src/version/version.module.ts`
- Modify: `backend/apps/api/src/api.module.ts`
- Modify: `README.md`
**Interfaces:**
- Consumes: validated build metadata from `AppEnvironment`.
- Produces: safe `GET /api/v1/version` response with `appVersion`, `teamCityBuildNumber`, and `sourceRevision`; no secrets.
- [ ] **Step 1: Write the failing version-controller test**
Create a test that injects:
```ts
{
appVersion: '1.0.0',
teamCityBuildNumber: '123',
sourceRevision: 'abc123'
}
```
and expects exactly:
```json
{
"appVersion": "1.0.0",
"teamCityBuildNumber": "123",
"sourceRevision": "abc123"
}
```
Assert that `databaseUrl` and `redisUrl` are absent.
- [ ] **Step 2: Run and verify failure**
```bash
pnpm --filter backend test -- apps/api/src/version/version.controller.spec.ts
```
Expected: FAIL because version module/controller do not exist.
- [ ] **Step 3: Implement the safe version endpoint**
Create `VersionController` at `/version` under the existing `/api/v1` prefix. Return only the three safe metadata fields from the validated environment provider.
- [ ] **Step 4: Run complete repository verification**
```bash
pnpm install --frozen-lockfile
pnpm lint
pnpm test
pnpm test:compose
pnpm build
```
Expected: all PASS.
- [ ] **Step 5: Verify production Compose rendering**
```bash
APP_HTTPS_PORT=443 \
IMAGE_TAG=phase01 \
REGISTRY=example.invalid \
POSTGRES_IMAGE_TAG=18.4-alpine \
REDIS_IMAGE_TAG=8.8.1-alpine \
POSTGRES_DB=travel_planner \
POSTGRES_USER=travel_planner \
POSTGRES_PASSWORD=render-only \
TLS_CERT_FILE=/tmp/travel-tls/tls.crt \
TLS_KEY_FILE=/tmp/travel-tls/tls.key \
docker compose -f compose.yml config > /tmp/travel-compose-rendered.yml
grep -n "ports:" /tmp/travel-compose-rendered.yml
```
Expected: the rendered production configuration has only one `ports:` section and it belongs to `edge`.
- [ ] **Step 6: Run an end-to-end local container smoke test with a test certificate**
Generate a disposable certificate outside source control:
```bash
mkdir -p /tmp/travel-tls
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-keyout /tmp/travel-tls/tls.key \
-out /tmp/travel-tls/tls.crt \
-subj "/CN=localhost"
```
Start the stack with `TLS_CERT_FILE=/tmp/travel-tls/tls.crt`, `TLS_KEY_FILE=/tmp/travel-tls/tls.key`, and `APP_HTTPS_PORT=8443`, then verify:
```bash
curl --insecure --fail https://127.0.0.1:8443/health/live
curl --insecure --fail https://127.0.0.1:8443/health/ready
curl --insecure --fail https://127.0.0.1:8443/
```
Expected: all three requests succeed through `edge`; no direct host URL exists for API, PostgreSQL, Redis, or worker in the production Compose file.
- [ ] **Step 7: Update README with Phase-01 completion commands**
Document:
- developer startup;
- production environment names;
- single published-port rule;
- TeamCity script entry points;
- health/version URLs;
- compiled no-op migration entry point is temporary until Phase 02 introduces real migrations.
- [ ] **Step 8: Commit**
```bash
git add backend/apps/api/src/version backend/apps/api/src/api.module.ts README.md
git commit -m "feat: expose safe build metadata and verify foundation"
```
---
## Phase 01 Acceptance Checklist
Run this checklist before starting Phase 02:
```bash
pnpm install --frozen-lockfile
pnpm lint
pnpm test
pnpm test:compose
pnpm build
bash -n scripts/teamcity/*.sh
```
All commands must pass.
Then verify:
- [ ] Angular 22 PWA builds and contains service-worker artifacts.
- [ ] NestJS API builds and listens internally on port 3000.
- [ ] NestJS worker boots as an application context and has no HTTP listener.
- [ ] `/health/live` does not depend on PostgreSQL/Redis/Mistral/external APIs.
- [ ] `/health/ready` validates PostgreSQL and Redis only.
- [ ] `/api/v1/version` exposes only safe build metadata.
- [ ] `compose.yml` contains services `edge`, `api`, `worker`, `postgres`, `redis`.
- [ ] Only `edge` defines `ports:` and defines exactly one published TCP port.
- [ ] Production Compose does not publish port 80.
- [ ] `api`, `postgres`, and `redis` use only internal ports/expose semantics.
- [ ] `worker` has no inbound port.
- [ ] No service uses `network_mode: host`.
- [ ] PostgreSQL image is initially pinned to `18.4-alpine` and Redis to `8.8.1-alpine`.
- [ ] TeamCity scripts build immutable images from one revision.
- [ ] Deploy script pulls, runs migration hook, runs `compose up -d --remove-orphans`, and smoke-tests through the edge URL.
- [ ] Deploy script never calls `docker compose down`.
- [ ] Rollback reuses the prior immutable image tag and does not attempt automatic DB downgrade.
- [ ] No secret values are committed or built into frontend/images.
## Phase 01 Review Boundary
Do not start OIDC, users, trips, Mistral, agent tools, web research, or business migrations during this phase. Phase 02 begins only after the Phase 01 acceptance checklist is reviewed and green.
---
## Addendum: Adjustments Against the Pre-Existing Scaffold (2026-08-17)
The repository already contains a generated Angular project (`frontend/`, Angular 21.2.0, npm) and a generated NestJS project (`backend/`, NestJS 11, single-app layout, ts-jest). The master instructions explicitly forbid re-scaffolding either project. This addendum documents the small, non-architectural adjustments applied to this plan as a result — per "kleine technische Details darfst du selbst sinnvoll lösen", no user decision was required:
1. **Task 1** — pnpm workspace wraps the *existing* `frontend/` and `backend/` directories; nothing there is regenerated.
2. **Task 2**`backend/src/{app.module.ts,app.controller.ts,app.controller.spec.ts,app.service.ts,main.ts}` are **migrated** (moved + adapted) into `backend/apps/api/src/`, not regenerated from a schematic. `nest-cli.json` is converted to `monorepo: true` with `api` + `worker` projects, matching the target file structure. The existing `AppController`/`AppService` are kept as the initial `ApiModule` content instead of an empty module, since deleting working, tested code has no benefit.
3. **Test runner** — the existing backend Jest config uses `ts-jest`, not `@swc/jest`, and passes today. Per "bestehende funktionierende Konfiguration bevorzugt weiterverwenden", `ts-jest` is kept for Phase 01 instead of migrating to SWC; this is a test-runner detail, not an architectural one, and can be revisited later if build performance requires it.
4. **Task 4** — no `ng new`/`ng add @angular/pwa` full re-scaffold. The existing Angular 21.2 project is kept and extended in place: `pnpm add -D @angular/pwa` equivalent (`ng add @angular/pwa`) is run *against the existing project* to layer in the service worker/manifest without touching unrelated generated files. The project already uses the newer Angular file-naming convention (`app.ts`/`app.html`/`app.scss`/`app.spec.ts` instead of `app.component.ts` etc.); all plan steps referencing `app.component.*` apply to these equivalently-named files instead.
5. **Angular version** — kept at 21.2.x (already installed) rather than upgraded to 22.x. Upgrading a working, freshly generated Angular major version is out of scope for "establish the foundation" and is not required by any Phase 01 acceptance criterion; the roadmap's "Angular 22.x" baseline is a target for new projects, not a mandate to upgrade an existing working one mid-foundation. Revisit as an explicit, isolated upgrade task if a later phase needs an Angular-22-only feature.
6. **package manager pin** — both existing projects pin `packageManager` fields (`npm@11.12.1`). These are superseded by the root `packageManager: pnpm@...` per the master instructions (Abschnitt 52); the per-package fields are removed during the pnpm migration to avoid Corepack conflicts.
No product/business behavior changes result from these adjustments; they only change *how* the target file structure is reached.