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:
27
.env.docker.example
Executable file
27
.env.docker.example
Executable file
@@ -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=
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
.env.docker
|
||||
._*
|
||||
|
||||
/generated/prisma
|
||||
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);
|
||||
|
||||
69
docker-compose.yml
Executable file
69
docker-compose.yml
Executable file
@@ -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:
|
||||
27
scripts/docker-up.sh
Executable file
27
scripts/docker-up.sh
Executable file
@@ -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
|
||||
Reference in New Issue
Block a user