# 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 { 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) => Promise; export async function bootstrapWorker( createContext: ContextFactory = (module) => NestFactory.createApplicationContext(module), ): Promise { 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

Travel Planner

Reisen planen, gemeinsam entscheiden.

``` 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 { 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.