feat: add single-port production docker topology

This commit is contained in:
Bastian Wagner
2026-08-17 13:43:52 +02:00
parent 0815f702d2
commit 5edb16de73
8 changed files with 239 additions and 0 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
**/node_modules
**/dist
**/.angular
**/coverage
.git
.env
.env.*
!.env.example
*.log

99
compose.yml Normal file
View File

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

20
docker/api.Dockerfile Normal file
View File

@@ -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"]

15
docker/edge.Dockerfile Normal file
View File

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

View File

@@ -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;
}
}

16
docker/edge/nginx.conf Normal file
View File

@@ -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;
}

19
docker/worker.Dockerfile Normal file
View File

@@ -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"]

View File

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