Basic Architecture of Sport Web

This commit is contained in:
Nông Đức Huy
2026-08-11 13:37:25 +07:00
commit 8032fff6ac
262 changed files with 20348 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
# ---------------------------------------------------------------------------
# @sport/admin production image (Next.js standalone output).
# ---------------------------------------------------------------------------
FROM node:22-alpine AS base
RUN corepack enable
WORKDIR /app
FROM base AS pruner
RUN apk add --no-cache libc6-compat
COPY . .
RUN pnpm dlx turbo@2.10.9 prune @sport/admin --docker
FROM base AS builder
RUN apk add --no-cache libc6-compat
COPY --from=pruner /app/out/json/ .
RUN pnpm install --frozen-lockfile
COPY --from=pruner /app/out/full/ .
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_APP_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm turbo run build --filter=@sport/admin
FROM base AS runner
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3001
ENV HOSTNAME=0.0.0.0
RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S nextjs -G nodejs
COPY --from=builder --chown=nextjs:nodejs /app/apps/admin/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/admin/.next/static ./apps/admin/.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/admin/public ./apps/admin/public
USER nextjs
EXPOSE 3001
CMD ["node", "apps/admin/server.js"]
+55
View File
@@ -0,0 +1,55 @@
# ---------------------------------------------------------------------------
# @sport/api production image.
#
# `turbo prune` produces a subset of the monorepo containing only this app and
# its workspace dependencies. That keeps the build context small AND makes the
# Docker layer cache work properly: editing the storefront no longer busts the
# API's dependency layer.
# ---------------------------------------------------------------------------
FROM node:22-alpine AS base
RUN corepack enable
WORKDIR /app
# --- Stage 1: prune the workspace ------------------------------------------
FROM base AS pruner
RUN apk add --no-cache libc6-compat
COPY . .
RUN pnpm dlx turbo@2.10.9 prune @sport/api --docker
# --- Stage 2: install + build ----------------------------------------------
FROM base AS builder
RUN apk add --no-cache libc6-compat openssl
# Lockfile and manifests first: this layer is cached until dependencies change.
COPY --from=pruner /app/out/json/ .
RUN pnpm install --frozen-lockfile
COPY --from=pruner /app/out/full/ .
RUN pnpm turbo run build --filter=@sport/api
# Drop dev dependencies from the tree we are about to copy.
RUN pnpm prune --prod
# --- Stage 3: runtime -------------------------------------------------------
FROM base AS runner
RUN apk add --no-cache openssl tini
ENV NODE_ENV=production
# Never run as root.
RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S nestjs -G nodejs
COPY --from=builder --chown=nestjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nestjs:nodejs /app/packages ./packages
COPY --from=builder --chown=nestjs:nodejs /app/apps/api/node_modules ./apps/api/node_modules
COPY --from=builder --chown=nestjs:nodejs /app/apps/api/dist ./apps/api/dist
COPY --from=builder --chown=nestjs:nodejs /app/apps/api/prisma ./apps/api/prisma
COPY --from=builder --chown=nestjs:nodejs /app/apps/api/package.json ./apps/api/package.json
USER nestjs
WORKDIR /app/apps/api
EXPOSE 4000
# tini reaps zombies and forwards SIGTERM, so Nest's shutdown hooks actually run.
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/main.js"]
@@ -0,0 +1,48 @@
# ---------------------------------------------------------------------------
# @sport/storefront production image (Next.js standalone output).
# ---------------------------------------------------------------------------
FROM node:22-alpine AS base
RUN corepack enable
WORKDIR /app
FROM base AS pruner
RUN apk add --no-cache libc6-compat
COPY . .
RUN pnpm dlx turbo@2.10.9 prune @sport/storefront --docker
FROM base AS builder
RUN apk add --no-cache libc6-compat
COPY --from=pruner /app/out/json/ .
RUN pnpm install --frozen-lockfile
COPY --from=pruner /app/out/full/ .
# NEXT_PUBLIC_* values are inlined into the client bundle at build time, so they
# must be present here — they cannot be injected at container start.
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm turbo run build --filter=@sport/storefront
FROM base AS runner
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S nextjs -G nodejs
# `output: 'standalone'` emits a self-contained server with only the modules it
# actually imports — a fraction of the size of copying node_modules.
COPY --from=builder --chown=nextjs:nodejs /app/apps/storefront/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/storefront/.next/static ./apps/storefront/.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/storefront/public ./apps/storefront/public
USER nextjs
EXPOSE 3000
CMD ["node", "apps/storefront/server.js"]
+81
View File
@@ -0,0 +1,81 @@
# ---------------------------------------------------------------------------
# Edge routing.
#
# In production Cloudflare terminates TLS and sits in front of this; Nginx
# handles routing, buffering, compression and the per-IP rate ceiling. TLS
# config is intentionally absent here because the local stack is plain HTTP.
# ---------------------------------------------------------------------------
upstream storefront_upstream {
server storefront:3000;
keepalive 32;
}
upstream admin_upstream {
server admin:3001;
keepalive 32;
}
upstream api_upstream {
server api:4000;
keepalive 32;
}
# --- Admin dashboard: separate hostname ------------------------------------
# A distinct origin means an XSS on the storefront cannot reach admin cookies,
# and the whole host can be IP-restricted or put behind Cloudflare Access.
server {
listen 80;
server_name admin.localhost;
add_header X-Robots-Tag "noindex, nofollow" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
proxy_pass http://api_upstream;
}
location / {
limit_req zone=web_limit burst=40 nodelay;
proxy_pass http://admin_upstream;
}
}
# --- Storefront + public API -----------------------------------------------
server {
listen 80 default_server;
server_name localhost _;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
proxy_pass http://api_upstream;
# Payment webhooks and image uploads must not be truncated by a short
# read timeout; everything else here is fast anyway.
proxy_read_timeout 60s;
proxy_buffering off;
}
location = /health {
access_log off;
proxy_pass http://api_upstream/api/v1/health/live;
}
# Next.js build output is content-hashed and therefore immutable.
location /_next/static/ {
proxy_pass http://storefront_upstream;
proxy_cache_valid 200 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
limit_req zone=web_limit burst=40 nodelay;
proxy_pass http://storefront_upstream;
}
}
+73
View File
@@ -0,0 +1,73 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 2048;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# `request_id` is generated here when the client did not supply one and is
# forwarded to the API, which echoes it back. One id ties the browser's
# network tab, this access log and the application log together.
log_format json_combined escape=json
'{'
'"time":"$time_iso8601",'
'"request_id":"$http_x_request_id",'
'"remote_addr":"$remote_addr",'
'"method":"$request_method",'
'"uri":"$request_uri",'
'"status":$status,'
'"bytes":$body_bytes_sent,'
'"duration":$request_time,'
'"upstream_time":"$upstream_response_time",'
'"referer":"$http_referer",'
'"user_agent":"$http_user_agent"'
'}';
access_log /var/log/nginx/access.log json_combined;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
server_tokens off;
client_max_body_size 25m;
client_body_timeout 30s;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_types
application/javascript
application/json
application/xml
image/svg+xml
text/css
text/plain
text/xml;
# Defence in depth. The API rate-limits per actor; this is a blunt
# per-IP ceiling that protects the app tier from ever seeing a flood.
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=web_limit:10m rate=60r/s;
limit_req_status 429;
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 X-Request-Id $request_id;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
include /etc/nginx/conf.d/*.conf;
}
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
#
# One-command local setup. Idempotent: safe to re-run at any time.
#
# pnpm setup
#
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
blue() { printf "\033[34m%s\033[0m\n" "$1"; }
green() { printf "\033[32m%s\033[0m\n" "$1"; }
warn() { printf "\033[33m%s\033[0m\n" "$1"; }
# --- 1. Environment files ---------------------------------------------------
blue "→ Preparing environment files"
for example in .env.example apps/api/.env.example apps/storefront/.env.example apps/admin/.env.example; do
target="${example%.example}"
if [ -f "$target" ]; then
echo " · $target already exists, leaving it alone"
else
cp "$example" "$target"
echo " · created $target"
fi
done
# --- 2. Real JWT secrets ----------------------------------------------------
# The committed examples contain obvious placeholders. Replace them with real
# random values so that no environment — not even a laptop — runs on a secret
# that exists in the repository.
if grep -q "dev-only-access-secret-change-me" apps/api/.env 2>/dev/null; then
blue "→ Generating JWT secrets"
access_secret="$(openssl rand -base64 48 | tr -d '\n/+=' | cut -c1-48)"
refresh_secret="$(openssl rand -base64 48 | tr -d '\n/+=' | cut -c1-48)"
# BSD and GNU sed disagree about -i; write through a temp file instead.
tmp="$(mktemp)"
sed -e "s|^JWT_ACCESS_SECRET=.*|JWT_ACCESS_SECRET=${access_secret}|" \
-e "s|^JWT_REFRESH_SECRET=.*|JWT_REFRESH_SECRET=${refresh_secret}|" \
apps/api/.env > "$tmp" && mv "$tmp" apps/api/.env
echo " · wrote fresh secrets to apps/api/.env"
fi
# --- 3. Dependencies --------------------------------------------------------
blue "→ Installing dependencies"
pnpm install
# --- 4. Backing services ----------------------------------------------------
blue "→ Starting PostgreSQL, Redis, MinIO and Mailpit"
docker compose up -d postgres redis minio minio-init mailpit
blue "→ Waiting for PostgreSQL"
for _ in $(seq 1 30); do
if docker compose exec -T postgres pg_isready -q 2>/dev/null; then break; fi
sleep 1
done
# --- 5. Database ------------------------------------------------------------
blue "→ Applying migrations and seeding"
pnpm --filter @sport/api run db:generate
if [ -d apps/api/prisma/migrations ]; then
pnpm --filter @sport/api exec prisma migrate deploy
else
warn " · no migrations yet — run: pnpm db:migrate --name init"
pnpm --filter @sport/api exec prisma db push
fi
pnpm --filter @sport/api run db:seed
green ""
green "Ready. Start everything with: pnpm dev"
green ""
echo " Storefront http://localhost:3000"
echo " Admin http://localhost:3001"
echo " API http://localhost:4000/api/v1/health"
echo " API docs http://localhost:4000/docs"
echo " MinIO http://localhost:9001"
echo " Mailpit http://localhost:8025"
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
#
# Destroy and rebuild the local database. Local development only.
#
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
printf "\033[33mThis deletes every row in the local database. Continue? [y/N] \033[0m"
read -r reply
[[ "$reply" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
pnpm --filter @sport/api exec prisma migrate reset --force
printf "\033[32mDatabase reset and reseeded.\033[0m\n"