From 5edb16de73d2f327cc6a670983148f11b3ca534a Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Mon, 17 Aug 2026 13:43:52 +0200 Subject: [PATCH] feat: add single-port production docker topology --- .dockerignore | 9 +++ compose.yml | 99 +++++++++++++++++++++++++++++++ docker/api.Dockerfile | 20 +++++++ docker/edge.Dockerfile | 15 +++++ docker/edge/default.conf.template | 29 +++++++++ docker/edge/nginx.conf | 16 +++++ docker/worker.Dockerfile | 19 ++++++ docs/architecture/deployment.md | 32 ++++++++++ 8 files changed, 239 insertions(+) create mode 100644 .dockerignore create mode 100644 compose.yml create mode 100644 docker/api.Dockerfile create mode 100644 docker/edge.Dockerfile create mode 100644 docker/edge/default.conf.template create mode 100644 docker/edge/nginx.conf create mode 100644 docker/worker.Dockerfile create mode 100644 docs/architecture/deployment.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..53e28b4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +**/node_modules +**/dist +**/.angular +**/coverage +.git +.env +.env.* +!.env.example +*.log diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..3c9e928 --- /dev/null +++ b/compose.yml @@ -0,0 +1,99 @@ +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 diff --git a/docker/api.Dockerfile b/docker/api.Dockerfile new file mode 100644 index 0000000..ab39ea4 --- /dev/null +++ b/docker/api.Dockerfile @@ -0,0 +1,20 @@ +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/src/main.js"] diff --git a/docker/edge.Dockerfile b/docker/edge.Dockerfile new file mode 100644 index 0000000..e005ac2 --- /dev/null +++ b/docker/edge.Dockerfile @@ -0,0 +1,15 @@ +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 diff --git a/docker/edge/default.conf.template b/docker/edge/default.conf.template new file mode 100644 index 0000000..4cb8315 --- /dev/null +++ b/docker/edge/default.conf.template @@ -0,0 +1,29 @@ +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; + } +} diff --git a/docker/edge/nginx.conf b/docker/edge/nginx.conf new file mode 100644 index 0000000..3ccde3d --- /dev/null +++ b/docker/edge/nginx.conf @@ -0,0 +1,16 @@ +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; +} diff --git a/docker/worker.Dockerfile b/docker/worker.Dockerfile new file mode 100644 index 0000000..c78552e --- /dev/null +++ b/docker/worker.Dockerfile @@ -0,0 +1,19 @@ +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/src/main.js"] diff --git a/docs/architecture/deployment.md b/docs/architecture/deployment.md new file mode 100644 index 0000000..e816a46 --- /dev/null +++ b/docs/architecture/deployment.md @@ -0,0 +1,32 @@ +# Deployment Architecture + +## Single published port + +Production Docker Compose (`compose.yml`) publishes **exactly one** host port: `edge` (Nginx), mapped via `APP_HTTPS_PORT` (default `443`). No other service (`api`, `worker`, `postgres`, `redis`) defines a Compose `ports:` mapping — they are reachable only over the internal `travel` bridge network via their service DNS names. `scripts/verify-compose-invariants.mjs` (wired into `pnpm test:compose`) fails the build if this invariant regresses. + +## TLS + +The edge container listens on container port 443 only and requires a certificate and private key mounted at the paths configured by `TLS_CERT_FILE`/`TLS_KEY_FILE` (bind-mounted read-only to `/run/tls/tls.crt` and `/run/tls/tls.key`). There is no port-80 fallback in this phase. + +## Internal reachability + +- PostgreSQL and Redis are not reachable from the host through Compose-published ports; only containers on the `travel` network can reach them. +- The API is not reachable from the host directly; all external traffic reaches it through `edge`'s `/api/` and `/health/` proxy locations. +- The worker process has neither `ports` nor `expose` — it accepts no inbound traffic at all. +- Outbound egress from `api` and `worker` (e.g. to Mistral, web research, SMTP providers in later phases) remains allowed. + +## Image tags + +TeamCity supplies immutable `IMAGE_TAG` values (see `scripts/teamcity/build-images.sh`); `latest`/floating tags are refused. `REGISTRY` and `IMAGE_TAG` together select the exact image digest-equivalent tag deployed to a host. + +## TeamCity wiring + +| 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` | + +The existing TeamCity project configures these as command-line/SSH build steps; all deployment logic stays in version control, not in TeamCity step configuration.