From d85b813701bf53f6fe0afba28c36ad23003bf963 Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Wed, 17 Jun 2026 22:35:58 +0700 Subject: [PATCH] feat: dockerize full stack (web + api + postgres) - Multi-stage Dockerfiles for API (NestJS, prisma migrate on start) and web (Vite + nginx reverse proxy) - docker-compose.yml orchestrating db/api/web with healthcheck and persistent volumes - nginx proxies /api and /avatars to API; web built with relative API URL - scripts/docker-up.sh: ExFAT-safe wrapper that strips macOS AppleDouble (._*) sidecars before build - Conditionally register GoogleStrategy only when GOOGLE_CLIENT_ID is set - Fix unused-variable TS errors blocking production build --- .env.docker.example | 27 ++++++++ .gitignore | 1 + apps/api/.dockerignore | 11 +++ apps/api/Dockerfile | 35 ++++++++++ apps/api/src/auth/auth.module.ts | 7 +- apps/web/.dockerignore | 10 +++ apps/web/Dockerfile | 18 +++++ apps/web/nginx.conf | 30 ++++++++ .../components/pages/goals/AddMoneyDialog.tsx | 2 +- .../components/pages/wallets/WalletCard.tsx | 15 +--- docker-compose.yml | 69 +++++++++++++++++++ scripts/docker-up.sh | 27 ++++++++ 12 files changed, 236 insertions(+), 16 deletions(-) create mode 100755 .env.docker.example create mode 100755 apps/api/.dockerignore create mode 100755 apps/api/Dockerfile create mode 100755 apps/web/.dockerignore create mode 100755 apps/web/Dockerfile create mode 100755 apps/web/nginx.conf create mode 100755 docker-compose.yml create mode 100755 scripts/docker-up.sh diff --git a/.env.docker.example b/.env.docker.example new file mode 100755 index 0000000..e91c91f --- /dev/null +++ b/.env.docker.example @@ -0,0 +1,27 @@ +# Copy to .env.docker and edit before running: +# cp .env.docker.example .env.docker +# docker compose --env-file .env.docker up -d --build + +# ---- Postgres ---- +POSTGRES_USER=tabungin +POSTGRES_PASSWORD=change-me-strong-password +POSTGRES_DB=tabungin + +# ---- API ---- +# Generate a long random string, e.g.: openssl rand -base64 48 +JWT_SECRET=replace-with-a-long-random-secret + +# Public URL where the web UI is served (used for CORS + OAuth callbacks) +WEB_APP_URL=http://localhost:8080 + +# Host ports (change if something else is using these) +WEB_PORT=8080 +API_PORT=3001 + +# ---- Optional integrations ---- +EXCHANGE_RATE_URL=https://api.exchangerate-api.com/v4/latest/IDR +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_CALLBACK_URL=http://localhost:8080/api/auth/google/callback +OTP_SEND_WEBHOOK_URL= +OTP_SEND_WEBHOOK_URL_TEST= diff --git a/.gitignore b/.gitignore index 98350cf..c1e4c36 100755 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules # Keep environment variables out of version control .env +.env.docker ._* /generated/prisma \ No newline at end of file diff --git a/apps/api/.dockerignore b/apps/api/.dockerignore new file mode 100755 index 0000000..55921f4 --- /dev/null +++ b/apps/api/.dockerignore @@ -0,0 +1,11 @@ +node_modules +dist +.git +.gitignore +.env +.env.local +*.log +.DS_Store +._* +coverage +.tsbuildinfo diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100755 index 0000000..b9aba07 --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 + +# ---- Production dependencies (includes prisma CLI for migrations) ---- +FROM node:20-alpine AS deps +WORKDIR /app +RUN apk add --no-cache python3 make g++ openssl +COPY package*.json ./ +COPY prisma ./prisma +RUN npm ci --omit=dev \ + && npm install prisma@^6.14.0 \ + && npx prisma generate + +# ---- Build the NestJS app ---- +FROM node:20-alpine AS builder +WORKDIR /app +RUN apk add --no-cache python3 make g++ openssl +COPY package*.json ./ +COPY prisma ./prisma +RUN npm ci +COPY . . +RUN npm run build + +# ---- Runtime image ---- +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +RUN apk add --no-cache openssl wget +COPY --from=deps /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/public ./public +COPY --from=builder /app/prisma ./prisma +COPY package*.json ./ +EXPOSE 3001 +# Apply migrations on every start (idempotent), then launch the API +CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main"] diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 6af4f30..b532022 100755 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -19,7 +19,12 @@ import { OtpModule } from '../otp/otp.module'; }), ], controllers: [AuthController], - providers: [AuthService, JwtStrategy, GoogleStrategy], + providers: [ + AuthService, + JwtStrategy, + // Only register Google OAuth when credentials are provided + ...(process.env.GOOGLE_CLIENT_ID ? [GoogleStrategy] : []), + ], exports: [AuthService, JwtModule], }) export class AuthModule {} diff --git a/apps/web/.dockerignore b/apps/web/.dockerignore new file mode 100755 index 0000000..ff92eec --- /dev/null +++ b/apps/web/.dockerignore @@ -0,0 +1,10 @@ +node_modules +dist +.git +.gitignore +.env +.env.local +*.log +.DS_Store +._* +.tsbuildinfo diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100755 index 0000000..d8ebe13 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,18 @@ +# syntax=docker/dockerfile:1 + +# ---- Build the Vite/React app ---- +FROM node:20-alpine AS builder +WORKDIR /app +ARG VITE_API_URL= +ENV VITE_API_URL=$VITE_API_URL +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# ---- Serve static build with nginx + reverse proxy to API ---- +FROM nginx:alpine AS runner +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf new file mode 100755 index 0000000..fef7dcb --- /dev/null +++ b/apps/web/nginx.conf @@ -0,0 +1,30 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Client upload body size (avatars, etc.) + client_max_body_size 10m; + + # Proxy API calls to the api service (keeps /api prefix) + location /api/ { + proxy_pass http://api:3001; + 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 static uploads (avatars served by the API from /public) + location /avatars/ { + proxy_pass http://api:3001; + proxy_set_header Host $host; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/apps/web/src/components/pages/goals/AddMoneyDialog.tsx b/apps/web/src/components/pages/goals/AddMoneyDialog.tsx index 5f0dacd..20e45c9 100755 --- a/apps/web/src/components/pages/goals/AddMoneyDialog.tsx +++ b/apps/web/src/components/pages/goals/AddMoneyDialog.tsx @@ -50,7 +50,7 @@ interface AddMoneyDialogProps { onSuccess: () => void; } -export function AddMoneyDialog({ open, onOpenChange, goalId, goalCurrency, onSuccess }: AddMoneyDialogProps) { +export function AddMoneyDialog({ open, onOpenChange, goalId, onSuccess }: AddMoneyDialogProps) { const { t } = useLanguage(); const [loading, setLoading] = useState(false); const [wallets, setWallets] = useState([]); diff --git a/apps/web/src/components/pages/wallets/WalletCard.tsx b/apps/web/src/components/pages/wallets/WalletCard.tsx index 6d2742b..9982983 100755 --- a/apps/web/src/components/pages/wallets/WalletCard.tsx +++ b/apps/web/src/components/pages/wallets/WalletCard.tsx @@ -69,19 +69,6 @@ export function WalletCard({ wallet, balance, onEdit, onDelete, onClick }: Walle loadRates(); }, []); - // Format large numbers with suffix - const formatCompactNumber = (num: number): string => { - const absNum = Math.abs(num); - if (absNum >= 1_000_000_000) { - return `${(num / 1_000_000_000).toFixed(1)} ${t.numberFormat.billion}`; - } else if (absNum >= 1_000_000) { - return `${(num / 1_000_000).toFixed(1)} ${t.numberFormat.million}`; - } else if (absNum >= 1_000) { - return `${(num / 1_000).toFixed(1)} ${t.numberFormat.thousand}`; - } - return num.toLocaleString('id-ID'); - }; - // Fetch goal allocations for this wallet useEffect(() => { const fetchAllocations = async () => { @@ -128,7 +115,7 @@ export function WalletCard({ wallet, balance, onEdit, onDelete, onClick }: Walle } }, [wallet.id, balance.reservedBalance, balance.totalBalance, exchangeRates]); - const formatBalance = (amount: number, isReserved = false) => { + const formatBalance = (amount: number, _isReserved = false) => { if (wallet.kind === 'asset' && wallet.unit) { // For assets: amount is already in units, convert to IDR for secondary display const idrValue = amount * (balance.pricePerUnit || 0); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100755 index 0000000..8bb75f4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,69 @@ +# Tabungin - full stack (web + api + postgres) in one command: +# cp .env.docker.example .env.docker (then edit secrets) +# docker compose --env-file .env.docker up -d --build +# +# Web (UI): http://localhost:${WEB_PORT:-8080} +# API (Swagger): http://localhost:${API_PORT:-3001}/api +# Postgres: internal only (not published by default) + +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-tabungin} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tabungin} + POSTGRES_DB: ${POSTGRES_DB:-tabungin} + volumes: + - db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tabungin} -d ${POSTGRES_DB:-tabungin}"] + interval: 5s + timeout: 5s + retries: 10 + # Uncomment to access Postgres from the host: + # ports: + # - "5432:5432" + + api: + build: + context: ./apps/api + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 3001 + DATABASE_URL: postgresql://${POSTGRES_USER:-tabungin}:${POSTGRES_PASSWORD:-tabungin}@db:5432/${POSTGRES_DB:-tabungin}?schema=public + DATABASE_URL_SHADOW: postgresql://${POSTGRES_USER:-tabungin}:${POSTGRES_PASSWORD:-tabungin}@db:5432/${POSTGRES_DB:-tabungin}_shadow?schema=public + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} + WEB_APP_URL: ${WEB_APP_URL:-http://localhost:8080} + # Optional integrations (leave blank to disable) + EXCHANGE_RATE_URL: ${EXCHANGE_RATE_URL:-} + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} + GOOGLE_CALLBACK_URL: ${GOOGLE_CALLBACK_URL:-http://localhost:8080/api/auth/google/callback} + OTP_SEND_WEBHOOK_URL: ${OTP_SEND_WEBHOOK_URL:-} + OTP_SEND_WEBHOOK_URL_TEST: ${OTP_SEND_WEBHOOK_URL_TEST:-} + volumes: + # Persist uploaded avatars across container rebuilds + - api_uploads:/app/public + ports: + - "${API_PORT:-3001}:3001" + + web: + build: + context: ./apps/web + args: + # Empty => requests are same-origin relative; nginx proxies /api & /avatars + VITE_API_URL: "" + restart: unless-stopped + depends_on: + - api + ports: + - "${WEB_PORT:-8080}:80" + +volumes: + db_data: + api_uploads: diff --git a/scripts/docker-up.sh b/scripts/docker-up.sh new file mode 100755 index 0000000..21f2bbe --- /dev/null +++ b/scripts/docker-up.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# ExFAT-safe Docker build wrapper. +# +# On ExFAT volumes macOS writes "._*" AppleDouble sidecar files for any file +# with extended attributes. Docker BuildKit reads xattrs while walking the +# build context and aborts with "operation not permitted" on these sidecars, +# even though they are in .dockerignore. +# +# This script wipes the sidecars in the build contexts right before building +# so the context walk succeeds. Source on the host is otherwise untouched. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +ENV_FILE="${ENV_FILE:-.env.docker}" + +echo "==> Cleaning macOS AppleDouble (._*) files from build contexts..." +find apps/api apps/web \( -name "._*" -o -name ".DS_Store" \) -delete 2>/dev/null || true + +echo "==> Building and starting containers (env-file: $ENV_FILE)..." +if [ -f "$ENV_FILE" ]; then + exec docker compose --env-file "$ENV_FILE" up -d --build +else + echo " $ENV_FILE not found; running without --env-file." + exec docker compose up -d --build +fi