From 2a75f47693e107b0a889b150f8c1071d1c6700b1 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 10 May 2026 19:19:56 -0400 Subject: [PATCH] update production setup --- .env.docker.dev | 10 +-- .env.docker.production.example | 65 ++++++++++++++++--- .env_traefik | 3 + apps/api/package.json | 2 +- apps/api/src/index.ts | 6 ++ apps/api/src/middleware/rateLimiter.ts | 18 +++--- docker-compose.production.yml | 89 +++++++++++++++++++++++--- traefik.yaml | 32 +++++++++ 8 files changed, 191 insertions(+), 34 deletions(-) create mode 100644 .env_traefik create mode 100644 traefik.yaml diff --git a/.env.docker.dev b/.env.docker.dev index 6885798..89bfd92 100644 --- a/.env.docker.dev +++ b/.env.docker.dev @@ -24,14 +24,14 @@ CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,h # Email - disable Resend (placeholder), use SMTP instead RESEND_API_KEY=re_... -EMAIL_FROM=moulay.elabidi@gmail.com +EMAIL_FROM=rentaldrivego@gmail.com EMAIL_FROM_NAME=RentalDriveGo MAIL_HOST=smtp.gmail.com MAIL_PORT=587 MAIL_SCHEME=smtp -MAIL_USERNAME=moulay.elabidi@gmail.com -MAIL_PASSWORD=seld rlvl bzuy npar -MAIL_FROM_ADDRESS=moulay.elabidi@gmail.com +MAIL_USERNAME=rentaldrivego@gmail.com +MAIL_PASSWORD=xmhg ibqy muoc rntc +MAIL_FROM_ADDRESS=rentaldrivego@gmail.com MAIL_FROM_NAME=RentalDriveGo -MAIL_REPLY_TO_ADDRESS=moulay.elabidi@gmail.com +MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com MAIL_REPLY_TO_NAME=RentalDriveGo diff --git a/.env.docker.production.example b/.env.docker.production.example index f8d01e1..7831f94 100644 --- a/.env.docker.production.example +++ b/.env.docker.production.example @@ -1,17 +1,66 @@ +# ── Traefik domains — used in docker-compose labels ────────────────────────── +ACME_EMAIL=admin@rentaldrivego.ma +MARKETPLACE_DOMAIN=rentaldrivego.ma +API_DOMAIN=api.rentaldrivego.ma +DASHBOARD_DOMAIN=dashboard.rentaldrivego.ma +ADMIN_DOMAIN=admin.rentaldrivego.ma +PUBLIC_SITE_DOMAIN=rentaldrivego.ma +PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma + DATABASE_URL=postgresql://postgres:change-me@postgres:5432/rentaldrivego REDIS_URL=redis://redis:6379 + +# ── API ──────────────────────────────────────────────────────────────────────── API_PORT=4000 -API_URL=http://localhost:4000 +# SSR server-side calls go via the internal Docker network API_INTERNAL_URL=http://api:4000/api/v1 -NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1 -NEXT_PUBLIC_MARKETING_URL=http://localhost:3000 -NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000/explore -NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3001 -NEXT_PUBLIC_ADMIN_URL=http://localhost:3002 -NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003 -DASHBOARD_URL=http://localhost:3001 +# Public-facing API URL visible to browsers — MUST be your real domain, not localhost +API_URL=https://api.rentaldrivego.ma +# Baked into Next.js bundles at build time — MUST be set before running `npm run build` +NEXT_PUBLIC_API_URL=https://api.rentaldrivego.ma/api/v1 + +# ── Frontend public URLs ─────────────────────────────────────────────────────── +NEXT_PUBLIC_MARKETING_URL=https://rentaldrivego.ma +NEXT_PUBLIC_MARKETPLACE_URL=https://rentaldrivego.ma/explore +NEXT_PUBLIC_DASHBOARD_URL=https://dashboard.rentaldrivego.ma +NEXT_PUBLIC_ADMIN_URL=https://admin.rentaldrivego.ma +NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=rentaldrivego.ma +DASHBOARD_URL=https://dashboard.rentaldrivego.ma + +# ── CORS ────────────────────────────────────────────────────────────────────── +# Comma-separated list of allowed browser origins. REQUIRED in production. +CORS_ORIGINS=https://rentaldrivego.ma,https://dashboard.rentaldrivego.ma,https://admin.rentaldrivego.ma + +# ── Auth ────────────────────────────────────────────────────────────────────── JWT_SECRET=replace-with-a-long-random-secret JWT_EXPIRY=8h RENTER_JWT_EXPIRY=7d + +# ── Database ────────────────────────────────────────────────────────────────── POSTGRES_PASSWORD=replace-with-a-real-password NODE_ENV=production + +# ── Email (choose one: Resend API or SMTP) ──────────────────────────────────── +# Option A — Resend +RESEND_API_KEY=re_your_resend_api_key +EMAIL_FROM=noreply@rentaldrivego.ma +EMAIL_FROM_NAME=RentalDriveGo +# Option B — SMTP +# MAIL_HOST=smtp.rentaldrivego.ma +# MAIL_PORT=587 +# MAIL_USERNAME=smtp-user +# MAIL_PASSWORD=smtp-password +# MAIL_SCHEME=smtp +# MAIL_FROM_ADDRESS=noreply@rentaldrivego.ma +# MAIL_FROM_NAME=RentalDriveGo + +# ── Firebase push notifications (optional) ──────────────────────────────────── +# FIREBASE_PROJECT_ID=your-firebase-project-id +# FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com +# FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" + +# ── Twilio SMS / WhatsApp (optional) ───────────────────────────────────────── +# TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +# TWILIO_AUTH_TOKEN=your_auth_token +# TWILIO_PHONE_NUMBER=+1234567890 +# TWILIO_WHATSAPP_NUMBER=+1234567890 diff --git a/.env_traefik b/.env_traefik new file mode 100644 index 0000000..94c0f4d --- /dev/null +++ b/.env_traefik @@ -0,0 +1,3 @@ +ACME_EMAIL=rentaldrivego@gmail.com +TRAEFIK_DOMAIN=rentaldrivego.ma +TRAEFIK_DOMAIN_WWW=www.rentaldrivego.ma \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 80c043a..cda46ec 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "node --env-file=../../.env.local ../../node_modules/.bin/ts-node-dev --respawn --transpile-only src/index.ts", "build": "tsc", - "start": "node --env-file=../../.env.local dist/index.js", + "start": "node dist/index.js", "type-check": "tsc --noEmit" }, "dependencies": { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index b7a1b1a..59cbbd4 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -32,6 +32,12 @@ import paymentsRouter from './routes/payments' const app = express() const server = http.createServer(app) + +// Trust the first hop from a reverse proxy so req.ip and rate-limiting +// use the real client IP (from X-Forwarded-For) rather than the proxy's IP. +if (process.env.NODE_ENV === 'production') { + app.set('trust proxy', 1) +} const v1 = '/api/v1' const defaultCorsOrigins = [ 'http://localhost:3000', diff --git a/apps/api/src/middleware/rateLimiter.ts b/apps/api/src/middleware/rateLimiter.ts index f6e8420..edadaa8 100644 --- a/apps/api/src/middleware/rateLimiter.ts +++ b/apps/api/src/middleware/rateLimiter.ts @@ -1,15 +1,13 @@ import rateLimit, { ipKeyGenerator } from 'express-rate-limit' +import type { Request } from 'express' -const trustProxy = process.env.NODE_ENV === 'production' -const getClientIpKey = (req: Parameters[0]) => - trustProxy - ? ipKeyGenerator((req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ?? req.ip ?? '') - : ipKeyGenerator(req.ip ?? '') +// req.ip is already the real client IP when app.set('trust proxy', 1) is configured +const getClientIpKey = (req: Request) => ipKeyGenerator(req.ip ?? '') // Strict limiter for auth endpoints — prevents brute-force and credential stuffing export const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes - max: 960, + max: 20, standardHeaders: 'draft-7', legacyHeaders: false, skipSuccessfulRequests: false, @@ -20,7 +18,7 @@ export const authLimiter = rateLimit({ // Standard limiter for general authenticated API endpoints export const apiLimiter = rateLimit({ windowMs: 60 * 1000, // 1 minute - max: 960, + max: 120, standardHeaders: 'draft-7', legacyHeaders: false, keyGenerator: (req) => { @@ -32,10 +30,10 @@ export const apiLimiter = rateLimit({ message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 }, }) -// Tight limiter for public marketplace and site endpoints (no auth) +// Limiter for public marketplace and site endpoints (no auth) export const publicLimiter = rateLimit({ windowMs: 60 * 1000, - max: 960, + max: 60, standardHeaders: 'draft-7', legacyHeaders: false, keyGenerator: (req) => getClientIpKey(req), @@ -45,7 +43,7 @@ export const publicLimiter = rateLimit({ // Tight limiter for admin endpoints export const adminLimiter = rateLimit({ windowMs: 15 * 60 * 1000, - max: 960, + max: 100, standardHeaders: 'draft-7', legacyHeaders: false, keyGenerator: (req) => getClientIpKey(req), diff --git a/docker-compose.production.yml b/docker-compose.production.yml index d43d1fc..d4a6405 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -2,6 +2,8 @@ services: postgres: image: postgres:16-alpine restart: unless-stopped + networks: + - internal environment: POSTGRES_DB: rentaldrivego POSTGRES_USER: postgres @@ -17,6 +19,8 @@ services: redis: image: redis:7-alpine restart: unless-stopped + networks: + - internal volumes: - redis_prod_data:/data @@ -25,6 +29,8 @@ services: context: . dockerfile: Dockerfile.production command: ["sh", "-c", "npm run db:deploy"] + networks: + - internal depends_on: postgres: condition: service_healthy @@ -37,6 +43,9 @@ services: context: . dockerfile: Dockerfile.production command: ["npm", "run", "start", "--workspace", "@rentaldrivego/api"] + networks: + - internal + - traefik-proxy depends_on: postgres: condition: service_healthy @@ -46,62 +55,122 @@ services: condition: service_completed_successfully env_file: - .env.docker.production - ports: - - "4000:4000" restart: unless-stopped + labels: + - traefik.enable=true + - traefik.http.routers.api.rule=Host(`${API_DOMAIN}`) + - traefik.http.routers.api.entrypoints=websecure + - traefik.http.routers.api.tls.certresolver=letsencrypt + - traefik.http.services.api.loadbalancer.server.port=4000 marketplace: build: context: . dockerfile: Dockerfile.production command: ["npm", "run", "start", "--workspace", "@rentaldrivego/marketplace"] + networks: + - internal + - traefik-proxy depends_on: - api env_file: - .env.docker.production - ports: - - "3000:3000" restart: unless-stopped + labels: + - traefik.enable=true + - traefik.http.routers.marketplace.rule=Host(`${MARKETPLACE_DOMAIN}`) + - traefik.http.routers.marketplace.entrypoints=websecure + - traefik.http.routers.marketplace.tls.certresolver=letsencrypt + - traefik.http.services.marketplace.loadbalancer.server.port=3000 dashboard: build: context: . dockerfile: Dockerfile.production command: ["npm", "run", "start", "--workspace", "@rentaldrivego/dashboard"] + networks: + - internal + - traefik-proxy depends_on: - api env_file: - .env.docker.production - ports: - - "3001:3001" restart: unless-stopped + labels: + - traefik.enable=true + - traefik.http.routers.dashboard.rule=Host(`${DASHBOARD_DOMAIN}`) + - traefik.http.routers.dashboard.entrypoints=websecure + - traefik.http.routers.dashboard.tls.certresolver=letsencrypt + - traefik.http.services.dashboard.loadbalancer.server.port=3001 admin: build: context: . dockerfile: Dockerfile.production command: ["npm", "run", "start", "--workspace", "@rentaldrivego/admin"] + networks: + - internal + - traefik-proxy depends_on: - api env_file: - .env.docker.production - ports: - - "3002:3002" restart: unless-stopped + labels: + - traefik.enable=true + - traefik.http.routers.admin.rule=Host(`${ADMIN_DOMAIN}`) + - traefik.http.routers.admin.entrypoints=websecure + - traefik.http.routers.admin.tls.certresolver=letsencrypt + - traefik.http.services.admin.loadbalancer.server.port=3002 public-site: build: context: . dockerfile: Dockerfile.production command: ["npm", "run", "start", "--workspace", "@rentaldrivego/public-site"] + networks: + - internal + - traefik-proxy depends_on: - api env_file: - .env.docker.production - ports: - - "3003:3003" restart: unless-stopped + labels: + - traefik.enable=true + - traefik.http.routers.public-site.rule=Host(`${PUBLIC_SITE_DOMAIN}`) + - traefik.http.routers.public-site.entrypoints=websecure + - traefik.http.routers.public-site.tls.certresolver=letsencrypt + - traefik.http.services.public-site.loadbalancer.server.port=3003 + + pgmanage: + image: cmdpromptinc/pgmanage-enterprise:latest + restart: unless-stopped + user: "0:0" + networks: + - internal + - traefik-proxy + depends_on: + postgres: + condition: service_healthy + environment: + PGMANAGE_LISTEN_PORT: "8000" + volumes: + - pgmanage_prod_data:/appdata + labels: + - traefik.enable=true + - traefik.http.routers.pgmanage.rule=Host(`${PGMANAGE_DOMAIN}`) + - traefik.http.routers.pgmanage.entrypoints=websecure + - traefik.http.routers.pgmanage.tls.certresolver=letsencrypt + - traefik.http.services.pgmanage.loadbalancer.server.port=8000 + +networks: + internal: + driver: bridge + traefik-proxy: + external: true volumes: postgres_prod_data: redis_prod_data: + pgmanage_prod_data: diff --git a/traefik.yaml b/traefik.yaml new file mode 100644 index 0000000..0c32277 --- /dev/null +++ b/traefik.yaml @@ -0,0 +1,32 @@ +services: + traefik: + image: traefik:latest + restart: unless-stopped + command: + - --api.dashboard=false + - --api.insecure=false + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --certificatesresolvers.letsencrypt.acme.tlschallenge=true + - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL} + - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + - --providers.docker.network=traefik-proxy + ports: + - "80:80" + - "443:443" + networks: + - traefik-proxy + volumes: + - traefik-letsencrypt:/letsencrypt + - /var/run/docker.sock:/var/run/docker.sock:ro + +volumes: + traefik-letsencrypt: + +networks: + traefik-proxy: + external: true