From 12015792d162a7944b247209f08fc1bad7b7f1ef Mon Sep 17 00:00:00 2001 From: Makar Dzhehur Date: Fri, 17 Apr 2026 23:56:49 +0300 Subject: [PATCH 01/12] feat: add Dockerfile for api --- apps/api/.env.docker.example | 31 ++++++++++++++++++++ apps/api/Dockerfile | 56 ++++++++++++++++++++++++++++++++++++ package-lock.json | 20 ------------- 3 files changed, 87 insertions(+), 20 deletions(-) create mode 100644 apps/api/.env.docker.example create mode 100644 apps/api/Dockerfile diff --git a/apps/api/.env.docker.example b/apps/api/.env.docker.example new file mode 100644 index 0000000..ec88ed1 --- /dev/null +++ b/apps/api/.env.docker.example @@ -0,0 +1,31 @@ +# Environment +NODE_ENV="development" +ENABLE_SWAGGER_IN_PROD="false" + +# App setup +HOST="0.0.0.0" +PORT="8000" +CORS_ORIGINS="http://localhost,http://localhost:5173" + +# Database +DATABASE_URL="postgresql://fintrack:fintrack@postgres:5432/fintrack?schema=public" + +# JWT/access token +ACCESS_TOKEN_SECRET="your_jwt_access_token_secret_here" + +# Google OAuth verification (must match Google Cloud OAuth client) +GOOGLE_CLIENT_ID="your_google_client_id.apps.googleusercontent.com" + +# AI apis +GROQ_API_KEY_1=your_groq_api_key +API_KEY_ENCRYPTION_SECRET=your-32-char-secret-here # 32+ symb + +# Stripe donation +STRIPE_SECRET_KEY=sk_test_xxx +STRIPE_WEBHOOK_SECRET=whsec_xxx +STRIPE_DONATION_PRICE_ID=price_xxx # optional if amount/currency is used +STRIPE_DONATION_AMOUNT=300 # in minor units, e.g. 300 = $3.00 +STRIPE_DONATION_CURRENCY=usd +STRIPE_DONATION_SUCCESS_URL=http://localhost:5173/FinTrack/donation?status=success +STRIPE_DONATION_CANCEL_URL=http://localhost:5173/FinTrack/donation?status=cancel +STRIPE_DONATION_DURATION_DAYS=0 # 0 or empty = permanent donor diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..cef8253 --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,56 @@ +FROM node:22-alpine AS base +WORKDIR /app + +# ── deps ────────────────────────────────────────────────────────────────────── +FROM base AS deps +RUN apk add --no-cache libc6-compat +ENV HUSKY=0 + +COPY package.json package-lock.json turbo.json ./ +COPY apps/api/package.json apps/api/package.json +COPY packages/types/package.json packages/types/package.json + +RUN npm ci --include=dev --install-strategy=nested + +# ── builder ─────────────────────────────────────────────────────────────────── +FROM base AS builder + +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/package.json ./package.json +COPY --from=deps /app/package-lock.json ./package-lock.json +COPY --from=deps /app/apps/api/package.json ./apps/api/package.json +COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules +COPY --from=deps /app/packages/types/package.json ./packages/types/package.json + +COPY packages/types ./packages/types +COPY apps/api ./apps/api + +RUN npm --prefix packages/types run build +RUN npm --prefix apps/api run build + +# ── runner ──────────────────────────────────────────────────────────────────── +FROM node:22-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production + +RUN addgroup -g 1001 -S nodejs && adduser -S apiuser -u 1001 + +# Runtime dependencies only +COPY --from=deps /app/apps/api/node_modules ./node_modules + +# Built output +COPY --from=builder /app/apps/api/dist ./dist + +# Prisma client + schema (needed at runtime for migrations and client generation) +COPY --from=builder /app/apps/api/prisma ./prisma +COPY --from=builder /app/apps/api/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/apps/api/node_modules/@prisma ./node_modules/@prisma + +# package.json is needed by Prisma CLI +COPY --from=builder /app/apps/api/package.json ./package.json + +USER apiuser +EXPOSE 8000 + +CMD ["node", "dist/server.js"] \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 782f288..706caae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2590,7 +2590,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -2837,7 +2836,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -13973,24 +13971,6 @@ } } }, - "node_modules/vitest/node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/void-elements": { "version": "3.1.0", "license": "MIT", From 6bb5b80512c1fc595bfb206a5162145a814029da Mon Sep 17 00:00:00 2001 From: Makar Dzhehur Date: Sat, 18 Apr 2026 00:03:24 +0300 Subject: [PATCH 02/12] feat: add Dockerfile for bot --- apps/bot/.env.docker.example | 9 ++++++++ apps/bot/Dockerfile | 45 ++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 apps/bot/.env.docker.example create mode 100644 apps/bot/Dockerfile diff --git a/apps/bot/.env.docker.example b/apps/bot/.env.docker.example new file mode 100644 index 0000000..3a27c64 --- /dev/null +++ b/apps/bot/.env.docker.example @@ -0,0 +1,9 @@ +# Environment +NODE_ENV="development" + +# App setup +HOST="localhost" +PORT="8000" + +# Telegram bot +BOT_API_KEY="your_telegram_bot_token_here" \ No newline at end of file diff --git a/apps/bot/Dockerfile b/apps/bot/Dockerfile new file mode 100644 index 0000000..5949245 --- /dev/null +++ b/apps/bot/Dockerfile @@ -0,0 +1,45 @@ +FROM node:22-alpine AS base +WORKDIR /app + +# ── deps ────────────────────────────────────────────────────────────────────── +FROM base AS deps +RUN apk add --no-cache libc6-compat +ENV HUSKY=0 + +COPY package.json package-lock.json turbo.json ./ +COPY apps/bot/package.json apps/bot/package.json +COPY packages/types/package.json packages/types/package.json + +RUN npm ci --include=dev --install-strategy=nested + +# ── builder ─────────────────────────────────────────────────────────────────── +FROM base AS builder + +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/package.json ./package.json +COPY --from=deps /app/package-lock.json ./package-lock.json +COPY --from=deps /app/apps/bot/package.json ./apps/bot/package.json +COPY --from=deps /app/apps/bot/node_modules ./apps/bot/node_modules +COPY --from=deps /app/packages/types/package.json ./packages/types/package.json + +COPY packages/types ./packages/types +COPY apps/bot ./apps/bot + +RUN npm --prefix packages/types run build +RUN npm --prefix apps/bot run build + +# ── runner ──────────────────────────────────────────────────────────────────── +FROM node:22-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production + +RUN addgroup -g 1001 -S nodejs && adduser -S botuser -u 1001 + +COPY --from=deps /app/apps/bot/node_modules ./node_modules +COPY --from=builder /app/apps/bot/dist ./dist +COPY --from=builder /app/apps/bot/package.json ./package.json + +USER botuser + +CMD ["node", "dist/bot.js"] \ No newline at end of file From 81d798a246d1d623417cfaee0fc4e1a8dbdc4910 Mon Sep 17 00:00:00 2001 From: Makar Dzhehur Date: Sat, 18 Apr 2026 00:06:37 +0300 Subject: [PATCH 03/12] feat: add nginx reverse proxy config --- nginx/nginx.conf | 54 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 nginx/nginx.conf diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..e90aff2 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,54 @@ +upstream api { + server api:8000; +} + +upstream web { + server web:5173; +} + +server { + listen 80; + server_name localhost; + + # ── API ─────────────────────────────────────────────────────────────────── + 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; + + # Needed for cookie-based auth to work through the proxy + proxy_pass_header Set-Cookie; + + # Stripe webhook sends raw bodies up to 512 KB + client_max_body_size 512k; + } + + # ── Swagger docs ────────────────────────────────────────────────────────── + location /api-docs { + 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; + } + + # ── Next.js web app ─────────────────────────────────────────────────────── + location / { + proxy_pass http://web; + proxy_http_version 1.1; + + # Required for Next.js HMR websocket in dev; harmless in prod + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + 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; + } +} \ No newline at end of file From 52f2156a25853f7c68fffa29e0037b69804b553b Mon Sep 17 00:00:00 2001 From: Makar Dzhehur Date: Sat, 18 Apr 2026 00:13:42 +0300 Subject: [PATCH 04/12] feat: add docker compose with postgres and pgadmin --- compose.yaml | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 compose.yaml diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..bbaf3f3 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,106 @@ +services: + + # ── PostgreSQL ───────────────────────────────────────────────────────────── + postgres: + image: postgres:15-alpine + restart: unless-stopped + environment: + POSTGRES_USER: fintrack + POSTGRES_PASSWORD: fintrack + POSTGRES_DB: fintrack + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + # Expose to the host so you can connect with pgAdmin desktop or + # the pgadmin service below: localhost:5432 + - "5432:5432" + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U fintrack -d fintrack" ] + interval: 10s + timeout: 5s + retries: 5 + + # ── pgAdmin ──────────────────────────────────────────────────────────────── + pgadmin: + image: dpage/pgadmin4:latest + restart: unless-stopped + environment: + PGADMIN_DEFAULT_EMAIL: admin@fintrack.local + PGADMIN_DEFAULT_PASSWORD: admin + ports: + # Open http://localhost:5050 in a browser to access pgAdmin + - "5050:80" + volumes: + - pgadmin_data:/var/lib/pgadmin + depends_on: + postgres: + condition: service_healthy + + # ── API ──────────────────────────────────────────────────────────────────── + api: + build: + context: . + dockerfile: apps/api/Dockerfile + restart: unless-stopped + env_file: + # Copy apps/api/.env.docker.example → apps/api/.env.docker and fill in + # your values before running docker compose up. + - apps/api/.env.docker + depends_on: + postgres: + condition: service_healthy + # Run migrations automatically on every startup, then start the server. + command: > + sh -c "node node_modules/.bin/prisma migrate deploy --schema + prisma/schema.prisma && node dist/server.js" + healthcheck: + test: [ "CMD-SHELL", "wget -qO- http://localhost:8000/api/health || exit 1" ] + interval: 15s + timeout: 5s + retries: 5 + start_period: 20s + + # ── Telegram bot ─────────────────────────────────────────────────────────── + bot: + build: + context: . + dockerfile: apps/bot/Dockerfile + restart: unless-stopped + env_file: + # Copy apps/bot/.env.example → apps/bot/.env.docker and set BOT_API_KEY + - apps/bot/.env.docker + depends_on: + api: + condition: service_healthy + + # ── Next.js web ──────────────────────────────────────────────────────────── + web: + build: + context: . + dockerfile: apps/web/Dockerfile + args: + # Nginx forwards /api/* → api:8000, so the browser always calls its + # own origin (/api/…) and never needs the internal service address. + NEXT_PUBLIC_API_URL: /api + restart: unless-stopped + env_file: + - apps/web/.env.docker + depends_on: + api: + condition: service_healthy + + # ── Nginx reverse proxy ──────────────────────────────────────────────────── + nginx: + image: nginx:alpine + restart: unless-stopped + ports: + - "80:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - web + - api + +volumes: + postgres_data: + pgadmin_data: From f4756b50ad13f38d00c45cb3a248537eaeef0905 Mon Sep 17 00:00:00 2001 From: Makar Dzhehur Date: Sat, 18 Apr 2026 00:14:38 +0300 Subject: [PATCH 05/12] chore: add health check endpoint to api router --- apps/api/src/routes/apiRoutes.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/api/src/routes/apiRoutes.ts b/apps/api/src/routes/apiRoutes.ts index 8e88164..2b998b5 100644 --- a/apps/api/src/routes/apiRoutes.ts +++ b/apps/api/src/routes/apiRoutes.ts @@ -20,6 +20,8 @@ apiRouter.use("/user-api-keys", userApiKeyRouter); apiRouter.use("/admin", adminRouter); apiRouter.use("/donations", donationRouter); +apiRouter.get("/health", (_req, res) => res.json({ ok: true })); + // apiRouter.all("*", (req: Request, res: Response, next: NextFunction) => { // res.status(404).json({ error: "Endpoint not found" }); // }); From f5fe7ed7854853cb856b806b2c3f6143280c7b0d Mon Sep 17 00:00:00 2001 From: Makar Dzhehur Date: Tue, 21 Apr 2026 20:26:51 +0300 Subject: [PATCH 06/12] feat: implement universal dockerized dev and prod environments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Infrastructure & Orchestration: * Introduced compose.dev.yaml for isolated development with hot-reload support. * Updated compose.yaml for stable production deployment. * Implemented dx — a universal CLI tool for Docker lifecycle management and command proxying. * Created a specialized Dockerfile.runner for administrative tasks (migrations, seeds, dumps) in an isolated environment. 2. API & Backend: * Refactored Dockerfiles to use multi-stage builds and optimized image sizes. * Configured contextual isolation (working_dir) for each service to enhance in-container development. * Added support for dynamic Swagger URLs via the SWAGGER_SERVER_URL environment variable. * Automated Prisma migrations during production container startup. 3. Frontend (Web): * Configured basePath: "/FinTrack" in Next.js for correct operation behind the reverse proxy. * Optimized healthchecks using 127.0.0.1 to resolve IPv6 issues in Alpine-based images. 4. Network & Proxy (Nginx): * Implemented Nginx as a single entry point (Reverse Proxy) on port 8080. * Refactored shared proxy headers into proxy_headers.conf for a cleaner configuration. * Added automatic redirection from the root / to /FinTrack/. 5. Database & Prisma: * Configured Prisma Studio to bind to all network interfaces (0.0.0.0). * Managed port exposures to allow concurrent command execution without conflicts. --- apps/api/.env.docker.example | 1 + apps/api/.env.example | 1 + apps/api/Dockerfile | 30 ++-- apps/api/package.json | 3 +- apps/api/src/config/env.ts | 1 + apps/api/src/docs/swagger.ts | 11 +- apps/bot/Dockerfile | 5 + apps/bot/package.json | 4 +- compose.dev.yaml | 147 ++++++++++++++++++++ compose.yaml | 56 ++++---- dx | 256 +++++++++++++++++++++++++++++++++++ nginx/nginx.conf | 28 ++-- nginx/proxy_headers.conf | 5 + package-lock.json | 2 + package.json | 1 + scripts/Dockerfile.runner | 14 ++ 16 files changed, 503 insertions(+), 62 deletions(-) create mode 100644 compose.dev.yaml create mode 100644 dx create mode 100644 nginx/proxy_headers.conf create mode 100644 scripts/Dockerfile.runner diff --git a/apps/api/.env.docker.example b/apps/api/.env.docker.example index ec88ed1..ed9a28a 100644 --- a/apps/api/.env.docker.example +++ b/apps/api/.env.docker.example @@ -5,6 +5,7 @@ ENABLE_SWAGGER_IN_PROD="false" # App setup HOST="0.0.0.0" PORT="8000" +SWAGGER_SERVER_URL="http://localhost:8080/api" CORS_ORIGINS="http://localhost,http://localhost:5173" # Database diff --git a/apps/api/.env.example b/apps/api/.env.example index 8018051..1c98374 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -5,6 +5,7 @@ ENABLE_SWAGGER_IN_PROD="false" # App setup HOST="localhost" PORT="8000" +SWAGGER_SERVER_URL="http://localhost:8000/api" CORS_ORIGINS="http://localhost:5173,http://127.0.0.1:5173" # Database diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index cef8253..d5a6369 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -26,6 +26,7 @@ COPY packages/types ./packages/types COPY apps/api ./apps/api RUN npm --prefix packages/types run build +RUN npm --prefix apps/api run prisma:generate RUN npm --prefix apps/api run build # ── runner ──────────────────────────────────────────────────────────────────── @@ -36,21 +37,32 @@ ENV NODE_ENV=production RUN addgroup -g 1001 -S nodejs && adduser -S apiuser -u 1001 -# Runtime dependencies only -COPY --from=deps /app/apps/api/node_modules ./node_modules +# All root node_modules (contains effect, fast-check, pure-rand, etc. for prisma CLI) +COPY --from=builder /app/node_modules ./node_modules + +# Floor — api-specific node_modules +COPY --from=deps /app/apps/api/node_modules ./node_modules + +# Generated Prisma Client +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma + +# FIX: copy the compiled @fintrack/types as a regular package +COPY --from=builder /app/packages/types/dist ./node_modules/@fintrack/types/dist +COPY --from=builder /app/packages/types/package.json ./node_modules/@fintrack/types/package.json # Built output -COPY --from=builder /app/apps/api/dist ./dist +COPY --from=builder /app/apps/api/dist ./dist + +# Copy Swagger definitions +COPY --from=builder /app/apps/api/src/docs/definitions ./dist/docs/definitions -# Prisma client + schema (needed at runtime for migrations and client generation) -COPY --from=builder /app/apps/api/prisma ./prisma -COPY --from=builder /app/apps/api/node_modules/.prisma ./node_modules/.prisma -COPY --from=builder /app/apps/api/node_modules/@prisma ./node_modules/@prisma +# Prisma schema +COPY --from=builder /app/apps/api/prisma ./prisma -# package.json is needed by Prisma CLI +# package.json needed by Prisma CLI COPY --from=builder /app/apps/api/package.json ./package.json USER apiuser EXPOSE 8000 -CMD ["node", "dist/server.js"] \ No newline at end of file +CMD ["npm", "run", "start:prod"] \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index e3a04df..05a987b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,6 +5,7 @@ "main": "server.js", "type": "module", "scripts": { + "start:prod": "node node_modules/prisma/build/index.js migrate deploy --schema prisma/schema.prisma && node dist/server.js", "build": "rimraf dist && npx tsc", "start": "node dist/server.js", "test": "node --experimental-vm-modules ../../node_modules/jest/bin/jest.js --runInBand", @@ -16,7 +17,7 @@ "prisma:migrate:dev": "npm run prisma -- migrate dev", "prisma:migrate:deploy": "npm run prisma -- migrate deploy", "prisma:generate": "npm run prisma -- generate", - "prisma:studio": "npm run prisma -- studio", + "prisma:studio": "npm run prisma -- studio --hostname 0.0.0.0", "prisma:seed": "tsx src/prisma/seed.ts" }, "repository": { diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 36bbd8b..f8c233b 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -49,6 +49,7 @@ export const ENV = { ENABLE_SWAGGER_IN_PROD: process.env.ENABLE_SWAGGER_IN_PROD === "true", HOST: process.env.HOST ?? "localhost", PORT: process.env.PORT ? Number(process.env.PORT) : 8000, + SWAGGER_SERVER_URL: process.env.SWAGGER_SERVER_URL, CORS_ORIGINS: process.env.CORS_ORIGINS ?? "", GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID ?? "", DATABASE_URL: process.env.DATABASE_URL as string, diff --git a/apps/api/src/docs/swagger.ts b/apps/api/src/docs/swagger.ts index dac01d8..cdcf248 100644 --- a/apps/api/src/docs/swagger.ts +++ b/apps/api/src/docs/swagger.ts @@ -12,6 +12,8 @@ const { version } = JSON.parse( fs.readFileSync(path.resolve("./package.json"), "utf-8"), ); +const isDev = fs.existsSync(path.resolve("./src")); + const options: swaggerJsdoc.Options = { definition: { openapi: "3.1.0", @@ -23,7 +25,7 @@ const options: swaggerJsdoc.Options = { }, servers: [ { - url: `http://${HOST}:${PORT}/api`, + url: ENV.SWAGGER_SERVER_URL ?? `http://${HOST}:${PORT}/api`, description: "FinTrack REST API", }, ], @@ -76,7 +78,12 @@ const options: swaggerJsdoc.Options = { }, ], }, - apis: ["./src/docs/definitions/**/*.yml", "./src/modules/**/*.ts"], + apis: [ + isDev + ? "./src/docs/definitions/**/*.yml" + : "./dist/docs/definitions/**/*.yml", + isDev ? "./src/modules/**/*.ts" : "./dist/modules/**/*.js", + ], }; const swaggerSpec = swaggerJsdoc(options); diff --git a/apps/bot/Dockerfile b/apps/bot/Dockerfile index 5949245..f0411a2 100644 --- a/apps/bot/Dockerfile +++ b/apps/bot/Dockerfile @@ -36,7 +36,12 @@ ENV NODE_ENV=production RUN addgroup -g 1001 -S nodejs && adduser -S botuser -u 1001 +# Root node_modules (includes dotenv and other lifting depots) +COPY --from=deps /app/node_modules ./node_modules + +# Floor — bot-specific node_modules COPY --from=deps /app/apps/bot/node_modules ./node_modules + COPY --from=builder /app/apps/bot/dist ./dist COPY --from=builder /app/apps/bot/package.json ./package.json diff --git a/apps/bot/package.json b/apps/bot/package.json index 895ccba..56dcbfc 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -23,9 +23,9 @@ "scripts": { "build": "rimraf dist && npx tsc", "prestart": "npm run build", - "start": "dotenv -e ../../.env -- node dist/bot.js", + "start": "node dist/bot.js", "predev": "npm run build", - "dev": "dotenv -e ../../.env -- concurrently \"npx tsc -w\" \"dotenv -e ../../.env -- nodemon dist/bot.js\"" + "dev": "concurrently \"npx tsc -w\" \"nodemon dist/bot.js\"" }, "dependencies": { "dotenv": "^16.6.1", diff --git a/compose.dev.yaml b/compose.dev.yaml new file mode 100644 index 0000000..a48c95c --- /dev/null +++ b/compose.dev.yaml @@ -0,0 +1,147 @@ +services: + + # ── PostgreSQL ───────────────────────────────────────────────────────────── + postgres: + image: postgres:15-alpine + environment: + POSTGRES_USER: fintrack + POSTGRES_PASSWORD: fintrack + POSTGRES_DB: fintrack + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U fintrack -d fintrack" ] + interval: 10s + timeout: 5s + retries: 5 + + # ── pgAdmin ──────────────────────────────────────────────────────────────── + pgadmin: + image: dpage/pgadmin4:latest + environment: + PGADMIN_DEFAULT_EMAIL: admin@fintrack.dev + PGADMIN_DEFAULT_PASSWORD: admin + ports: + - "5050:80" + volumes: + - pgadmin_data:/var/lib/pgadmin + depends_on: + postgres: + condition: service_healthy + + # ── Init (Dependency Installation & Preparation) ─────────────────────────── + init: + image: node:22-alpine + working_dir: /app + volumes: + - .:/app + - root_node_modules:/app/node_modules + - api_node_modules:/app/apps/api/node_modules + - bot_node_modules:/app/apps/bot/node_modules + - web_node_modules:/app/apps/web/node_modules + - types_node_modules:/app/packages/types/node_modules + # npm install is used instead of ci to benefit from caching in named volumes. + command: sh -c "npm install && npm --prefix packages/types run build && npm + --prefix apps/api run prisma:generate" + + # ── API ──────────────────────────────────────────────────────────────────── + api: + image: node:22-alpine + working_dir: /app/apps/api + volumes: + - .:/app + - root_node_modules:/app/node_modules + - api_node_modules:/app/apps/api/node_modules + - types_node_modules:/app/packages/types/node_modules + ports: + - "8000:8000" + - "5555:5555" # Expose Prisma Studio port + environment: + - NODE_ENV=development + env_file: + - apps/api/.env.docker + # Apply any pending migrations before starting the dev server. + # `migrate deploy` is safe to run repeatedly — it only applies new migrations. + command: sh -c "npm run prisma:migrate:deploy && npm run dev" + depends_on: + postgres: + condition: service_healthy + init: + condition: service_completed_successfully + + # ── Runner (CLI Tooling) ─────────────────────────────────────────────────── + # Utility service for one-off commands (lint, test, seed, scripts). + # Use: docker compose -f compose.dev.yaml run --rm runner + runner: + build: + context: . + dockerfile: scripts/Dockerfile.runner + working_dir: /app + profiles: [ "tools" ] # Prevent starting automatically with 'up' + volumes: + - .:/app + - root_node_modules:/app/node_modules + - api_node_modules:/app/apps/api/node_modules + - bot_node_modules:/app/apps/bot/node_modules + - web_node_modules:/app/apps/web/node_modules + - types_node_modules:/app/packages/types/node_modules + env_file: + - apps/api/.env.docker + # Interactive shell by default + command: bash + + # ── Telegram bot ─────────────────────────────────────────────────────────── + bot: + image: node:22-alpine + working_dir: /app/apps/bot + volumes: + - .:/app + - root_node_modules:/app/node_modules + - bot_node_modules:/app/apps/bot/node_modules + - types_node_modules:/app/packages/types/node_modules + environment: + - NODE_ENV=development + env_file: + - apps/bot/.env.docker + command: npm run dev + depends_on: + init: + condition: service_completed_successfully + api: + condition: service_started + + # ── Next.js web ──────────────────────────────────────────────────────────── + web: + image: node:22-alpine + working_dir: /app/apps/web + volumes: + - .:/app + - root_node_modules:/app/node_modules + - web_node_modules:/app/apps/web/node_modules + - types_node_modules:/app/packages/types/node_modules + - web_next:/app/apps/web/.next + ports: + - "5173:5173" + environment: + - NODE_ENV=development + - NEXT_PUBLIC_API_URL=http://localhost:8000/api + env_file: + - apps/web/.env.docker + command: npm run dev + depends_on: + init: + condition: service_completed_successfully + api: + condition: service_started + +volumes: + postgres_data: + pgadmin_data: + root_node_modules: + api_node_modules: + bot_node_modules: + web_node_modules: + types_node_modules: + web_next: diff --git a/compose.yaml b/compose.yaml index bbaf3f3..14001c0 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,45 +3,25 @@ services: # ── PostgreSQL ───────────────────────────────────────────────────────────── postgres: image: postgres:15-alpine - restart: unless-stopped + restart: "unless-stopped" environment: POSTGRES_USER: fintrack POSTGRES_PASSWORD: fintrack POSTGRES_DB: fintrack volumes: - postgres_data:/var/lib/postgresql/data - ports: - # Expose to the host so you can connect with pgAdmin desktop or - # the pgadmin service below: localhost:5432 - - "5432:5432" healthcheck: test: [ "CMD-SHELL", "pg_isready -U fintrack -d fintrack" ] interval: 10s timeout: 5s retries: 5 - # ── pgAdmin ──────────────────────────────────────────────────────────────── - pgadmin: - image: dpage/pgadmin4:latest - restart: unless-stopped - environment: - PGADMIN_DEFAULT_EMAIL: admin@fintrack.local - PGADMIN_DEFAULT_PASSWORD: admin - ports: - # Open http://localhost:5050 in a browser to access pgAdmin - - "5050:80" - volumes: - - pgadmin_data:/var/lib/pgadmin - depends_on: - postgres: - condition: service_healthy - # ── API ──────────────────────────────────────────────────────────────────── api: build: context: . dockerfile: apps/api/Dockerfile - restart: unless-stopped + restart: "unless-stopped" env_file: # Copy apps/api/.env.docker.example → apps/api/.env.docker and fill in # your values before running docker compose up. @@ -50,9 +30,7 @@ services: postgres: condition: service_healthy # Run migrations automatically on every startup, then start the server. - command: > - sh -c "node node_modules/.bin/prisma migrate deploy --schema - prisma/schema.prisma && node dist/server.js" + command: [ "npm", "run", "start:prod" ] healthcheck: test: [ "CMD-SHELL", "wget -qO- http://localhost:8000/api/health || exit 1" ] interval: 15s @@ -65,7 +43,7 @@ services: build: context: . dockerfile: apps/bot/Dockerfile - restart: unless-stopped + restart: "unless-stopped" env_file: # Copy apps/bot/.env.example → apps/bot/.env.docker and set BOT_API_KEY - apps/bot/.env.docker @@ -82,25 +60,39 @@ services: # Nginx forwards /api/* → api:8000, so the browser always calls its # own origin (/api/…) and never needs the internal service address. NEXT_PUBLIC_API_URL: /api - restart: unless-stopped + restart: "unless-stopped" env_file: - apps/web/.env.docker depends_on: api: condition: service_healthy + healthcheck: + test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:5173/FinTrack/ || exit 1" ] + interval: 15s + timeout: 5s + retries: 5 + start_period: 60s # ── Nginx reverse proxy ──────────────────────────────────────────────────── nginx: image: nginx:alpine - restart: unless-stopped + restart: "unless-stopped" ports: - - "80:80" + - "8080:80" volumes: - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./nginx/proxy_headers.conf:/etc/nginx/proxy_headers.conf:ro depends_on: - - web - - api + web: + condition: service_healthy + api: + condition: service_healthy + healthcheck: + test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1/FinTrack/ || exit 1" ] + interval: 20s + timeout: 5s + retries: 3 + start_period: 10s volumes: postgres_data: - pgadmin_data: diff --git a/dx b/dx new file mode 100644 index 0000000..d273625 --- /dev/null +++ b/dx @@ -0,0 +1,256 @@ +#!/usr/bin/env bash +# ================================================================ +# dx — Docker eXecutioner CLI +# Works with any Docker Compose project. +# +# First time: +# sh dx setup # copy .env.example files to .env +# +# Daily dev: +# sh dx dev # start all containers +# sh dx logs api # watch API logs +# sh dx logs web # watch Next.js logs +# sh dx shell api # shell into API container +# sh dx shell postgres psql -U user # open psql session +# sh dx run api:prisma:migrate:dev # run a Prisma migration +# sh dx run api:prisma:generate # regenerate Prisma client +# sh dx shell api npm run prisma:studio # open Prisma Studio +# sh dx run test # run all tests +# sh dx run test:api # run API tests +# sh dx run check-types # run TS type checks +# sh dx run lint # lint the whole monorepo +# sh dx restart api # restart API after .env change +# sh dx down # stop and remove containers +# +# Deploy (prod): +# sh dx pbuild # build all images +# sh dx prod # start prod stack +# sh dx plogs api # tail prod API logs +# sh dx pshell api # shell into prod API +# sh dx pshell postgres psql -U user # psql into prod database +# sh dx prestart api # restart prod API after deploy +# sh dx ps # check container health +# +# Docker commands run on the HOST. +# Everything else is proxied through the RUNNER container via npm. +# Run sh dx help for the full command reference. +# ================================================================ + +set -euo pipefail + +# ── Configuration (override via env vars) ──────────────────────── +DEV_COMPOSE="${DX_DEV_COMPOSE:-compose.dev.yaml}" +PROD_COMPOSE="${DX_PROD_COMPOSE:-compose.yaml}" +RUNNER_SERVICE="${DX_RUNNER:-runner}" +RUNNER_PROFILE="${DX_RUNNER_PROFILE:-tools}" + +# ── Helpers ─────────────────────────────────────────────────────── +log() { printf '\033[0;36m▸\033[0m %s\n' "$*"; } +warn() { printf '\033[0;33m⚠\033[0m %s\n' "$*" >&2; } +die() { printf '\033[0;31m✖\033[0m %s\n' "$*" >&2; exit 1; } + +confirm() { + local msg="${1:-Are you sure?}" + printf "\033[0;33m⚠\033[0m %s [y/N] " "$msg" + read -r response + case "$response" in + [yY][eE][sS]|[yY]) return 0 ;; + *) log "Action cancelled."; exit 0 ;; + esac +} + +require_cmd() { + command -v "$1" &>/dev/null || die "'$1' is not installed or not in PATH." +} + +require_file() { + [[ -f "$1" ]] || die "File not found: $1" +} + +dev_compose() { require_file "$DEV_COMPOSE"; docker compose -f "$DEV_COMPOSE" "$@"; } +prod_compose() { require_file "$PROD_COMPOSE"; docker compose -f "$PROD_COMPOSE" "$@"; } + +runner() { + require_file "$DEV_COMPOSE" + docker compose \ + -f "$DEV_COMPOSE" \ + --profile "$RUNNER_PROFILE" \ + run --rm "$RUNNER_SERVICE" "$@" +} + +# shell_into [cmd...] +# Tries exec first; falls back to run --rm for stopped dev containers. +shell_into() { + local compose_fn="$1" + local is_prod="$2" + local svc="$3" + shift 3 + + if [[ $# -ge 1 ]]; then + # Explicit command — exec it directly. + "$compose_fn" exec "$svc" "$@" + elif "$compose_fn" exec "$svc" sh -c 'exit 0' 2>/dev/null; then + "$compose_fn" exec "$svc" sh + elif [[ "$is_prod" == "true" ]]; then + die "Container '$svc' is not running. Start it first with: sh dx prod" + else + warn "Container '$svc' is not running — starting a one-off instance instead." + "$compose_fn" run --rm "$svc" sh + fi +} + +CMD="${1:-help}" +shift || true + +# ── Commands ───────────────────────────────────────────────────── +case "$CMD" in + + # ── Help ──────────────────────────────────────────────────────── + help|-h|--help) + cat <<'EOF' +dx — Docker eXecutioner CLI + + Commands target dev by default. Prefix any lifecycle or shell + command with [p] to run it against the production stack instead. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ENVIRONMENT OVERRIDES +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + DX_DEV_COMPOSE Dev compose file (default: compose.dev.yaml) + DX_PROD_COMPOSE Prod compose file (default: compose.yaml) + DX_RUNNER Runner service name (default: runner) + DX_RUNNER_PROFILE Runner profile name (default: tools) + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + SETUP +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + setup + Copy all *.example env files to their real counterparts. + Run once after cloning, then edit the generated files. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + LIFECYCLE +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + [p]dev [args] Start all containers in detached mode. + [p]stop [svc] Stop running containers (keeps volumes). + [p]down [args] Stop and remove containers. + [p]downv [args] Stop and remove containers + volumes. + [p]restart [svc] Restart one or all services. + [p]build [args] Build images. + [p]rebuild [args] Build images without cache. + [p]logs [svc] Follow log output (Ctrl-C to stop). + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + PROCESS STATUS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ps List containers with status and health. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + SHELL & EXEC +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + [p]shell [cmd...] Open an interactive sh or run a command. + Falls back to run --rm if not running (dev only). + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + RUNNER (proxied through the runner container) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + run