feat: add single-port production docker topology
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/.angular
|
||||
**/coverage
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
99
compose.yml
Normal file
99
compose.yml
Normal 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
20
docker/api.Dockerfile
Normal 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
15
docker/edge.Dockerfile
Normal 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
|
||||
29
docker/edge/default.conf.template
Normal file
29
docker/edge/default.conf.template
Normal 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
16
docker/edge/nginx.conf
Normal 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
19
docker/worker.Dockerfile
Normal 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"]
|
||||
32
docs/architecture/deployment.md
Normal file
32
docs/architecture/deployment.md
Normal 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.
|
||||
Reference in New Issue
Block a user