diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f91515f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +**/node_modules +**/dist +**/coverage +.git +.env +.env.* +*.log diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..cf5409c --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,29 @@ +# Build context is the repository root (workspace build): +# docker build -f apps/api/Dockerfile . + +FROM node:22.15-alpine AS build +WORKDIR /repo +RUN npm install -g pnpm@11 +COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./ +COPY packages/shared ./packages/shared +COPY apps/api ./apps/api +RUN pnpm install --frozen-lockfile --filter @dorfteich/api... \ + && pnpm --filter @dorfteich/shared build \ + && pnpm --filter @dorfteich/api build \ + # Self-contained production bundle (prod deps only, incl. the prisma CLI + # needed for migrate-on-start) at /out. + && pnpm --filter @dorfteich/api deploy --prod --legacy /out \ + && cp -r apps/api/dist /out/dist + +FROM node:22.15-alpine +ARG APP_VERSION=0.0.0-dev +ENV NODE_ENV=production APP_VERSION=${APP_VERSION} +WORKDIR /app +COPY --from=build --chown=node:node /out /app +# Generate the Prisma client for this image's platform. +RUN node node_modules/prisma/build/index.js generate +USER node +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/v1/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +CMD ["node", "dist/main.js"] diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..1ac36cf --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,21 @@ +# Build context is the repository root (workspace build): +# docker build -f apps/web/Dockerfile . + +FROM node:22.15-alpine AS build +ARG APP_VERSION=0.0.0-dev +WORKDIR /repo +RUN npm install -g pnpm@11 +COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./ +COPY packages/shared ./packages/shared +COPY apps/web ./apps/web +RUN pnpm install --frozen-lockfile --filter @dorfteich/web... \ + && pnpm --filter @dorfteich/shared build \ + && VITE_APP_VERSION=${APP_VERSION} pnpm --filter @dorfteich/web build + +# nginx-unprivileged runs as uid 101 and listens on 8080 — no root needed. +FROM nginxinc/nginx-unprivileged:1.27-alpine +COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /repo/apps/web/dist /usr/share/nginx/html +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1 diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf new file mode 100644 index 0000000..65b8260 --- /dev/null +++ b/apps/web/nginx.conf @@ -0,0 +1,28 @@ +# SPA serving: static assets with long-lived caching, everything else +# falls back to index.html (client-side routing). +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + gzip_min_length 1024; + + location /healthz { + add_header Content-Type text/plain; + return 200 'ok'; + } + + location /assets/ { + # Vite emits content-hashed filenames — safe to cache forever. + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + location / { + add_header Cache-Control "no-cache"; + try_files $uri /index.html; + } +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index e35c1c4..64de2b9 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -4,10 +4,10 @@ import { defineConfig } from 'vite'; export default defineConfig({ plugins: [react()], server: { - // The api dev server runs on 3001 (3000 may be occupied by other - // projects on developer machines); see compose.dev.yml usage notes. + // Native dev: api on localhost:3001 (3000 may be occupied by other + // projects). Containerized dev overrides this via VITE_API_PROXY_TARGET. proxy: { - '/api': 'http://localhost:3001', + '/api': process.env.VITE_API_PROXY_TARGET ?? 'http://localhost:3001', }, }, }); diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example new file mode 100644 index 0000000..2207d8d --- /dev/null +++ b/deploy/compose/.env.example @@ -0,0 +1,26 @@ +# Dorfteich stage configuration. Copy to `.env` (mode 600, never in git) +# next to docker-compose.yml and adjust the values. + +# --- required --------------------------------------------------------------- +# PostgreSQL password for the `dorfteich` database user. +POSTGRES_PASSWORD=change-me + +# --- images ----------------------------------------------------------------- +# Image name prefix. Stages pull from the Gitea registry, e.g. +# gitea.101010.cloud/stwaidele/dorfteich — local builds use the default. +IMAGE_PREFIX=dorfteich +# Image tag to run: a git SHA, `test`, `int`, or a release tag like v1.2.0. +TAG=latest + +# --- ports (localhost only; the host reverse proxy routes to these) --------- +# Suggested per stage on the shared VPS: test 8100/8101, int 8110/8111, +# prod 8120/8121. +WEB_PORT=8100 +API_PORT=8101 + +# --- behavior ---------------------------------------------------------------- +# pino log level: fatal|error|warn|info|debug|trace +LOG_LEVEL=info + +# Compose project name; set per stage (dorfteich-test, dorfteich-int, …). +COMPOSE_PROJECT_NAME=dorfteich diff --git a/deploy/compose/compose.dev.yml b/deploy/compose/compose.dev.yml index 0198ac3..967ef87 100644 --- a/deploy/compose/compose.dev.yml +++ b/deploy/compose/compose.dev.yml @@ -1,28 +1,66 @@ -# Local development overlay. For now this only provides the database; -# issue #6 extends it with hot-reloading web/api services layered over the -# production docker-compose.yml. +# Development overlay: hot-reloading web and api containers with the +# repository mounted, layered over the production stack definition. # -# Usage (from the repo root): -# docker compose -f deploy/compose/compose.dev.yml up -d db -# DATABASE_URL=postgresql://dorfteich:dorfteich@localhost:5434/dorfteich pnpm --filter @dorfteich/api start:dev +# Full containerized dev environment (first start installs dependencies): +# cd deploy/compose && cp .env.example .env +# docker compose -f docker-compose.yml -f compose.dev.yml up +# → web http://localhost:5173, api http://localhost:3001, db localhost:5434 +# +# Database-only (run web/api natively for the fastest feedback): +# docker compose -f docker-compose.yml -f compose.dev.yml up -d db +# DATABASE_URL=postgresql://dorfteich:dorfteich@localhost:5434/dorfteich \ +# PORT=3001 pnpm --filter @dorfteich/api start:dev +# pnpm --filter @dorfteich/web dev services: - db: - image: postgres:17.5-alpine + web: + image: node:22.15-alpine + build: !reset null + working_dir: /repo + command: sh -c "npm i -g pnpm@11 && pnpm install && pnpm --filter @dorfteich/web dev -- --host 0.0.0.0" environment: - POSTGRES_USER: dorfteich - POSTGRES_PASSWORD: dorfteich - POSTGRES_DB: dorfteich - ports: - # 5434 on the host to avoid colliding with other local PostgreSQL instances (5432 system, 5433 wochenplan-staging). - - '5434:5432' + VITE_API_PROXY_TARGET: http://api:3000 + ports: !override + - '127.0.0.1:5173:5173' volumes: - - db-data:/var/lib/postgresql/data - healthcheck: - test: ['CMD-SHELL', 'pg_isready -U dorfteich -d dorfteich'] - interval: 5s - timeout: 3s - retries: 10 + - ../..:/repo + # Container-local node_modules: the host directories contain + # macOS binaries and must not leak into the Linux containers. + - web-root-modules:/repo/node_modules + - web-app-modules:/repo/apps/web/node_modules + - shared-modules:/repo/packages/shared/node_modules + - pnpm-store:/root/.local/share/pnpm/store + depends_on: !reset [] + + api: + image: node:22.15-alpine + build: !reset null + working_dir: /repo + command: sh -c "npm i -g pnpm@11 && pnpm install && pnpm --filter @dorfteich/shared build && pnpm --filter @dorfteich/api start:dev" + environment: + NODE_ENV: development + PORT: '3000' + DATABASE_URL: postgresql://dorfteich:${POSTGRES_PASSWORD:-dorfteich}@db:5432/dorfteich + ports: !override + - '127.0.0.1:3001:3000' + volumes: + - ../..:/repo + - api-root-modules:/repo/node_modules + - api-app-modules:/repo/apps/api/node_modules + - api-shared-modules:/repo/packages/shared/node_modules + - pnpm-store:/root/.local/share/pnpm/store + + db: + ports: + # 5434 on the host to avoid colliding with other local PostgreSQL + # instances (5432 system, 5433 wochenplan-staging). + - '127.0.0.1:5434:5432' volumes: - db-data: + web-root-modules: + web-app-modules: + shared-modules: + api-root-modules: + api-app-modules: + api-shared-modules: + pnpm-store: diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml new file mode 100644 index 0000000..ebf8c76 --- /dev/null +++ b/deploy/compose/docker-compose.yml @@ -0,0 +1,81 @@ +# Production Compose stack — one file for every stage and for self-hosters. +# Configuration comes from .env (see .env.example); the host reverse proxy +# routes to the two published localhost ports (deployment.md §Compose). +# +# Networks: `frontend` is what the reverse proxy reaches (via published +# ports); `internal` connects api/collab to db and (later) the converter +# sidecars, which are never exposed. + +name: ${COMPOSE_PROJECT_NAME:-dorfteich} + +x-logging: &logging + logging: + driver: json-file + options: + max-size: '10m' + max-file: '5' + +services: + web: + image: ${IMAGE_PREFIX:-dorfteich}-web:${TAG:-latest} + build: + context: ../.. + dockerfile: apps/web/Dockerfile + args: + APP_VERSION: ${TAG:-latest} + restart: unless-stopped + ports: + - '127.0.0.1:${WEB_PORT:-8100}:8080' + networks: [frontend] + depends_on: + api: + condition: service_started + <<: *logging + + api: + image: ${IMAGE_PREFIX:-dorfteich}-api:${TAG:-latest} + build: + context: ../.. + dockerfile: apps/api/Dockerfile + args: + APP_VERSION: ${TAG:-latest} + restart: unless-stopped + environment: + NODE_ENV: production + PORT: '3000' + LOG_LEVEL: ${LOG_LEVEL:-info} + DATABASE_URL: postgresql://dorfteich:${POSTGRES_PASSWORD:?set in .env}@db:5432/dorfteich + ports: + - '127.0.0.1:${API_PORT:-8101}:3000' + networks: [frontend, internal] + volumes: + - uploads:/data/uploads + depends_on: + db: + condition: service_healthy + <<: *logging + + db: + image: postgres:17.5-alpine + restart: unless-stopped + environment: + POSTGRES_USER: dorfteich + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in .env} + POSTGRES_DB: dorfteich + networks: [internal] + volumes: + - db-data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U dorfteich -d dorfteich'] + interval: 10s + timeout: 3s + retries: 12 + <<: *logging + +networks: + frontend: + internal: + +volumes: + db-data: + uploads: