Basic Architecture of Sport Web
This commit is contained in:
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user