chore(infra): add Docker, nginx, and CI workflows

This commit is contained in:
lakshit verma 2026-08-17 01:31:48 +05:30
parent 47093a0ebf
commit 1fc5200a09
No known key found for this signature in database
4 changed files with 374 additions and 0 deletions

86
.github/workflows/ci.yml vendored Normal file
View 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
View 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
View 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
View 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;
}
}
}