mirror of
https://github.com/vee1e/workorder-desk.git
synced 2026-09-01 09:50:13 +00:00
chore(infra): add Docker, nginx, and CI workflows
This commit is contained in:
parent
47093a0ebf
commit
1fc5200a09
4 changed files with 374 additions and 0 deletions
86
.github/workflows/ci.yml
vendored
Normal file
86
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# Continuous integration — SPEC §12
|
||||
# Node 20. Steps: lint → typecheck → test + coverage → build → docker build.
|
||||
# Runs on every PR and on pushes to main.
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
|
||||
typecheck:
|
||||
name: Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
|
||||
test:
|
||||
name: Test (coverage)
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:7
|
||||
ports:
|
||||
- "27017:27017"
|
||||
env:
|
||||
# Backend integration tests run against the GitHub-hosted Mongo service
|
||||
# (SPEC §11); falls back to mongodb-memory-server when unset.
|
||||
MONGODB_URI: mongodb://127.0.0.1:27017/workorders
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test -w @workorders/shared
|
||||
- run: npm run test:coverage -w backend
|
||||
- run: npm run test:coverage -w frontend
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, typecheck, test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
|
||||
docker:
|
||||
name: Docker build
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build backend image
|
||||
run: docker build -f backend/Dockerfile --target prod -t workorders-api:ci .
|
||||
- name: Build frontend/nginx image
|
||||
run: docker build -f frontend/Dockerfile --target prod -t workorders-nginx:ci .
|
||||
77
docker-compose.prod.yml
Normal file
77
docker-compose.prod.yml
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# =============================================================================
|
||||
# Work Order Management — PRODUCTION compose stack
|
||||
#
|
||||
# cp .env.example .env # fill in REAL secrets first
|
||||
# docker compose -f docker-compose.prod.yml up --build -d
|
||||
#
|
||||
# mongo + api live on an internal network only; nginx :80 is the only public
|
||||
# entry point. The API reads secrets from .env (never baked into the image).
|
||||
# nginx.conf is mounted read-only from ./nginx/nginx.conf.
|
||||
# =============================================================================
|
||||
|
||||
name: workorders-prod
|
||||
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:7
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
networks:
|
||||
- internal
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
target: prod
|
||||
image: workorders-api:prod
|
||||
restart: unless-stopped
|
||||
# Secrets come from .env — overrides below only fix container-local wiring.
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
MONGODB_URI: mongodb://mongo:27017/workorders
|
||||
# nginx is the only proxy in front of the API; one trusted hop.
|
||||
TRUST_PROXY_HOPS: "1"
|
||||
expose:
|
||||
- "4000"
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- internal
|
||||
|
||||
nginx:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: frontend/Dockerfile
|
||||
target: prod
|
||||
args:
|
||||
# Baked into the bundle at build time; empty => same-origin (relative /api).
|
||||
VITE_APP_URL: ${VITE_APP_URL:-}
|
||||
VITE_API_URL: ${VITE_API_URL:-}
|
||||
image: workorders-nginx:prod
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- api
|
||||
networks:
|
||||
- internal
|
||||
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
mongo-data:
|
||||
102
docker-compose.yml
Normal file
102
docker-compose.yml
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# =============================================================================
|
||||
# Work Order Management — DEVELOPMENT compose stack
|
||||
#
|
||||
# docker compose up --build
|
||||
#
|
||||
# Runs Mongo 7, the Express API (tsx watch hot reload), and the Vite dev
|
||||
# server (HMR) with bind-mounts so source edits hot-apply. nginx is NOT used
|
||||
# in dev: Vite proxies /api, /health, /ready to the api service (SPEC §4).
|
||||
#
|
||||
# IMPORTANT (dev proxy): the Vite dev proxy target inside this compose network
|
||||
# must be http://api:4000 (the api service name). `frontend/vite.config.ts`
|
||||
# reads that from VITE_PROXY_TARGET (defaults to http://localhost:4000 on the
|
||||
# host, set to http://api:4000 in this file). See README.md.
|
||||
#
|
||||
# Dockerfile targets: backend/Dockerfile:dev and frontend/Dockerfile:dev run
|
||||
# the dev stages (full deps + source). Both containers also run a `tsc --watch`
|
||||
# on packages/shared so shared schema/type edits rebuild dist/ live.
|
||||
# =============================================================================
|
||||
|
||||
name: workorders-dev
|
||||
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:7
|
||||
restart: unless-stopped
|
||||
# Named volume so data survives `docker compose down`.
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
# Published so host-side tooling (`npm run seed` with
|
||||
# MONGODB_URI=mongodb://localhost:27017/workorders) can reach Mongo.
|
||||
ports:
|
||||
- "27017:27017"
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
target: dev
|
||||
image: workorders-api:dev
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
working_dir: /app/backend
|
||||
# Rebuild packages/shared dist/ on change, then run tsx watch (hot reload).
|
||||
command: >
|
||||
sh -c "npx tsc -p ../packages/shared/tsconfig.json --watch --preserveWatchOutput &
|
||||
exec npx tsx watch src/server.ts"
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
PORT: "4000"
|
||||
MONGODB_URI: mongodb://mongo:27017/workorders
|
||||
# ── dev only: long random-looking values so backend validation passes.
|
||||
# These are NOT secrets; production reads real values from .env.
|
||||
JWT_SECRET: dev-only-e7c1a9f34d5b8e2c0a6f9d1b4c7e3a85
|
||||
COOKIE_SECRET: dev-only-b2f6a4d9c8e1b3f7a5c0d2e4f8b6a1c7
|
||||
CORS_ORIGIN: http://localhost:5173
|
||||
APP_URL: http://localhost:5173
|
||||
TRUST_PROXY_HOPS: "0"
|
||||
DEBUG_ERRORS: "true"
|
||||
LOG_LEVEL: debug
|
||||
volumes:
|
||||
- ./backend/src:/app/backend/src
|
||||
- ./packages/shared/src:/app/packages/shared/src
|
||||
ports:
|
||||
- "4000:4000"
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
|
||||
vite:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: frontend/Dockerfile
|
||||
target: dev
|
||||
image: workorders-vite:dev
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
working_dir: /app/frontend
|
||||
# Keep packages/shared dist/ fresh for Vite, then start the dev server.
|
||||
command: >
|
||||
sh -c "npx tsc -p ../packages/shared/tsconfig.json --watch --preserveWatchOutput &
|
||||
exec npm run dev -- --host 0.0.0.0"
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
VITE_APP_URL: http://localhost:5173
|
||||
VITE_PROXY_TARGET: http://api:4000
|
||||
# Unset / empty => Vite dev proxy handles /api (SPEC §10).
|
||||
volumes:
|
||||
- ./frontend/src:/app/frontend/src
|
||||
- ./packages/shared/src:/app/packages/shared/src
|
||||
ports:
|
||||
- "5173:5173"
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
volumes:
|
||||
mongo-data:
|
||||
109
nginx/nginx.conf
Normal file
109
nginx/nginx.conf
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# nginx reverse proxy — single-origin deployment (SPEC §4).
|
||||
# Serves the built SPA and proxies /api, /health, /ready to the Express API
|
||||
# so the browser talks to exactly one origin (cookies stay SameSite=Lax safe).
|
||||
# Mounted read-only at /etc/nginx/nginx.conf by docker-compose.prod.yml.
|
||||
|
||||
worker_processes auto;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
# Hide nginx version from error pages and Server headers.
|
||||
server_tokens off;
|
||||
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
keepalive_timeout 65;
|
||||
client_max_body_size 1m;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
access_log /var/log/nginx/access.log main;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
|
||||
# ---- Compression ----
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript
|
||||
application/javascript application/json
|
||||
application/xml application/rss+xml
|
||||
image/svg+xml;
|
||||
|
||||
# ---- Security headers (inherited by every location unless a location sets
|
||||
# its own add_header, in which case it is re-declared there) ----
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
upstream api {
|
||||
server api:4000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# ---- API: forward the original URI untouched (/api/... -> /api/...) ----
|
||||
# X-Forwarded-* headers let the API's TRUST_PROXY_HOPS resolve the real
|
||||
# client IP for rate limiting (SPEC §8.4).
|
||||
location /api/ {
|
||||
proxy_pass http://api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# ---- Public health / readiness (unversioned) ----
|
||||
location = /health {
|
||||
proxy_pass http://api/health;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /ready {
|
||||
proxy_pass http://api/ready;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# ---- Hashed build assets: cache aggressively (immutable), never HTML ----
|
||||
location ~* \.(?:js|css|map|svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# ---- SPA fallback: everything else -> index.html (never cached) ----
|
||||
location / {
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue