Files
travel-planner/docs/superpowers/plans/travel-planner-phase-01-foundation-plan.md
Bastian Wagner feff7c717e initial
2026-08-17 12:49:36 +02:00

49 KiB

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

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:

{
  "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:

packages:
  - frontend
  - backend

Create .editorconfig:

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:

node_modules/
dist/
.angular/
coverage/
.env
.env.*
!.env.example
/data/
*.log
.DS_Store
  • Step 3: Define non-secret environment names

Create .env.example:

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:

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

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:

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:

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:

{
  "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:

{
  "$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:

{
  "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:

{
  "extends": "../../tsconfig.json",
  "compilerOptions": {"declaration": false},
  "exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
}

Create backend/eslint.config.mjs:

import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  { ignores: ['dist/**', 'coverage/**'] },
  eslint.configs.recommended,
  ...tseslint.configs.recommended,
);

Run:

pnpm install
  • Step 4: Implement the API and worker bootstraps

Create backend/apps/api/src/api.module.ts:

import { Module } from '@nestjs/common';

@Module({})
export class ApiModule {}

Create backend/apps/api/src/main.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:

import { Module } from '@nestjs/common';

@Module({})
export class WorkerModule {}

Create backend/apps/worker/src/main.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:

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
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:

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
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:

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:

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
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
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:

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:

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
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:

<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
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
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:

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
pnpm dev:infra
docker compose -f compose.dev.yml ps

Expected: both services show healthy.

  • Step 3: Verify connectivity
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:

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
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:

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
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:

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:

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:

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:

@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:

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
pnpm --filter backend test -- apps/api/src/health/health.controller.spec.ts

Expected: PASS.

  • Step 6: Run API against development infrastructure
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
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:

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:

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:

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:

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:

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:

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
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

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:

pnpm add -Dw yaml

Create tests/compose-invariants.test.mjs:

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
pnpm test:compose

Expected: FAIL because verifyComposeInvariants does not exist.

  • Step 3: Implement the invariant validator

Create scripts/verify-compose-invariants.mjs:

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
pnpm test:compose

Expected: PASS.

  • Step 5: Commit
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:

#!/usr/bin/env bash
set -euo pipefail

corepack enable
pnpm install --frozen-lockfile
pnpm lint
pnpm test
pnpm test:compose
pnpm build

Make executable:

chmod +x scripts/teamcity/validate.sh
  • Step 2: Create immutable image build script

Create scripts/teamcity/build-images.sh:

#!/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:

#!/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:

#!/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:

#!/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:

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
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:

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
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:

{
  appVersion: '1.0.0',
  teamCityBuildNumber: '123',
  sourceRevision: 'abc123'
}

and expects exactly:

{
  "appVersion": "1.0.0",
  "teamCityBuildNumber": "123",
  "sourceRevision": "abc123"
}

Assert that databaseUrl and redisUrl are absent.

  • Step 2: Run and verify failure
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
pnpm install --frozen-lockfile
pnpm lint
pnpm test
pnpm test:compose
pnpm build

Expected: all PASS.

  • Step 5: Verify production Compose rendering
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:

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:

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

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:

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.