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
This commit is contained in:
11
apps/api/.dockerignore
Executable file
11
apps/api/.dockerignore
Executable file
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
._*
|
||||
coverage
|
||||
.tsbuildinfo
|
||||
35
apps/api/Dockerfile
Executable file
35
apps/api/Dockerfile
Executable file
@@ -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"]
|
||||
@@ -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 {}
|
||||
|
||||
10
apps/web/.dockerignore
Executable file
10
apps/web/.dockerignore
Executable file
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
._*
|
||||
.tsbuildinfo
|
||||
18
apps/web/Dockerfile
Executable file
18
apps/web/Dockerfile
Executable file
@@ -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;"]
|
||||
30
apps/web/nginx.conf
Executable file
30
apps/web/nginx.conf
Executable file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<Wallet[]>([]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user