commit e293f2cb87e58b2d256905866195c60a3cc932a3 Author: 0310700 Date: Sun Jun 14 16:20:37 2026 +0900 init: Node starter (podman dev + Coolify deploy, Claude agent 골격) Co-Authored-By: Claude Opus 4.8 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4a53217 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +npm-debug.log +.env +.env.* +.git +.gitignore +Dockerfile +compose.yaml +README.md +CLAUDE.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..087ffa6 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# .env.example — 복사해서 .env 로 쓰세요: cp .env.example .env +# 주의: .env 는 git에 올리지 마세요(.gitignore에 포함됨). + +# --- DB --- +# 워크스페이스에 DATABASE_URL 이 이미 export 되어 있으면 비워둬도 됨(자동 사용). +# DATABASE_URL=postgresql://emp_<사번>:@coolify-db.aidev.svc.cluster.local:5432/appdb?search_path=emp_<사번> + +# --- MinIO / S3 --- +# endpoint 는 콘솔(minioc)이 아니라 API 호스트(minio)입니다. +S3_ENDPOINT=https://minio.bokdev.in +S3_REGION=us-east-1 +S3_BUCKET=coolify-user-data +S3_ACCESS_KEY_ID=REPLACE_WITH_YOUR_MINIO_ACCESS_KEY +S3_SECRET_ACCESS_KEY=REPLACE_WITH_YOUR_MINIO_SECRET_KEY + +# --- 앱 --- +PORT=3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43e5063 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.env +.env.* +!.env.example +npm-debug.log* +dist/ +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..181ad1e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,42 @@ +# CLAUDE.md — 이 프로젝트에서 Claude Code가 따라야 할 규약 + +너는 사내 개발 워크스페이스에서 동작하는 코딩 에이전트다. 이 프로젝트는 **Node(ESM) 앱**이고, +**로컬 개발/테스트는 podman**, **배포는 Coolify(Gitea push → Dockerfile 자동빌드)** 로 한다. + +## 절대 규칙 +- **비밀값을 코드/커밋에 넣지 마라.** DB·MinIO 자격증명은 항상 환경변수(`.env`, 워크스페이스 env)에서만 읽는다. + `.env` 는 절대 커밋하지 않는다(`.gitignore`에 있음). 새 비밀 키가 필요하면 `.env.example` 에 placeholder만 추가. +- 컨테이너는 `docker` 명령으로 실행해도 되지만 **실제 런타임은 rootless podman**이다(`docker`는 podman 별칭). +- 운영 배포를 직접 하지 마라. 배포는 **git push → Coolify**가 담당한다. 너는 코드/Dockerfile만 책임진다. + +## 외부 연결 (이미 워크스페이스 env로 주입됨) +- **Postgres**: `DATABASE_URL` (직원 전용 schema로 격리, `search_path` 고정). 코드는 `src/db.js`를 통해 접근. +- **MinIO(S3)**: API endpoint `https://minio.bokdev.in` (콘솔 `minioc`가 아님), 버킷 `coolify-user-data`. + 키는 직원이 `.env`의 `S3_ACCESS_KEY_ID`/`S3_SECRET_ACCESS_KEY`에 채운다. 코드는 `src/s3.js`를 통해 접근. +- **LiteLLM**: AI 호출은 `~/.config/litellm/key` 의 virtual key를 쓰는 사내 gateway(`https://litellm.bok.or.kr`)를 경유. + +## 로컬 개발 워크플로 (podman) +```bash +npm install +cp .env.example .env # 그리고 MinIO 키 등 채우기 (DB는 자동주입이면 비워둬도 됨) +npm run dev # node --watch 로 핫리로드, http://localhost:3000 + +# 컨테이너로 동일하게 돌려보기 +podman build -t starter . +podman run --rm -p 3000:3000 --env-file .env \ + -e DATABASE_URL="$DATABASE_URL" starter +# 또는 +podman compose up --build +``` +연결 확인: `npm run db:check`, `npm run minio:check`, 또는 `/db` `/s3` 엔드포인트. + +## 배포 워크플로 (Coolify) +1. Gitea repo에 push (`main` 등 배포 브랜치). +2. Coolify가 이 **Dockerfile**로 자동 빌드/배포한다(로컬 podman 빌드와 동일 Dockerfile). +3. Coolify 쪽 Environment Variables 에 `DATABASE_URL`, `S3_*` 를 채운다(로컬 `.env`와 같은 키). +4. 포트는 컨테이너 `3000`. Coolify에서 도메인/포트 매핑. + +## 코드 규약 +- ESM(`import`/`export`), Node 22+. 외부 연결정보는 반드시 `src/config.js` 한 곳을 거친다. +- 새 외부 의존성을 추가하면 `package.json`에 반영하고 `npm install`로 lock 갱신. +- 변경 후에는 `npm run db:check` / `npm run minio:check`로 연결이 살아있는지 스스로 확인하라. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bf193dd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# 로컬(podman)과 Coolify 자동빌드가 공유하는 단일 Dockerfile. +# - 로컬: podman build -t starter . && podman run --rm -p 3000:3000 --env-file .env starter +# - 배포: Gitea push → Coolify가 이 Dockerfile로 빌드/배포 +FROM node:22-alpine + +WORKDIR /app + +# 의존성 레이어 캐시 +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev || npm install --omit=dev + +# 앱 소스 +COPY . . + +ENV NODE_ENV=production \ + PORT=3000 +EXPOSE 3000 + +# 비루트 실행 +USER node + +CMD ["node", "src/server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..91dabe4 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# starter — Node 앱 스타터 (podman 개발 + Coolify 배포) + +사내 워크스페이스용 Node(ESM) 스타터. DB·MinIO 연결, podman 로컬개발, Coolify 자동배포가 미리 잡혀 있다. + +## 빠른 시작 +```bash +npm install +cp .env.example .env # MinIO 키 채우기 (DB는 워크스페이스가 자동주입) +npm run dev # http://localhost:3000 +``` +- `GET /healthz` — 헬스체크 +- `GET /db` — Postgres 연결 확인 (직원 전용 schema) +- `GET /s3` — MinIO 버킷 연결 확인 + +## 컨테이너로 실행 (podman) +```bash +podman build -t starter . +podman run --rm -p 3000:3000 --env-file .env -e DATABASE_URL="$DATABASE_URL" starter +# 또는: podman compose up --build +``` + +## 배포 (Coolify) +Gitea에 push → Coolify가 `Dockerfile`로 자동 빌드/배포. Coolify Env에 `DATABASE_URL`, `S3_*` 입력. + +## 연결정보 +- **DB**: 워크스페이스 env `DATABASE_URL` 자동주입 (본인 schema 격리) +- **MinIO(S3)**: endpoint `https://minio.bokdev.in`, bucket `coolify-user-data`, 키는 `.env`에 직접 입력 +- **AI**: `~/.config/litellm/key` 에 LiteLLM virtual key 입력 → Claude Code/Codex/Gemini 공용 + +자세한 작업 규약은 `CLAUDE.md` 참고. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..60b8c31 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,17 @@ +# 로컬 개발용 (podman compose 또는 podman play). 운영 배포는 Coolify가 담당. +# podman compose up --build +# DB/MinIO 접속정보는 워크스페이스 env + .env 에서 주입됨. +services: + app: + build: . + ports: + - "3000:3000" + env_file: + - .env + environment: + # 워크스페이스에 자동 주입된 값을 컨테이너로 전달 + DATABASE_URL: ${DATABASE_URL} + S3_ENDPOINT: ${S3_ENDPOINT:-https://minio.bokdev.in} + S3_BUCKET: ${S3_BUCKET:-coolify-user-data} + S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID} + S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c09836e --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "starter", + "version": "0.1.0", + "description": "사내 Node 스타터 — podman 로컬개발 + Coolify(Dockerfile) 배포", + "type": "module", + "main": "src/server.js", + "scripts": { + "dev": "node --watch src/server.js", + "start": "node src/server.js", + "db:check": "node scripts/check-db.js", + "minio:check": "node scripts/check-minio.js" + }, + "engines": { + "node": ">=22" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.700.0", + "express": "^4.21.2", + "pg": "^8.13.1" + } +} diff --git a/scripts/check-db.js b/scripts/check-db.js new file mode 100644 index 0000000..1ad376d --- /dev/null +++ b/scripts/check-db.js @@ -0,0 +1,11 @@ +// DB 연결 빠른 확인: `npm run db:check` +import { pingDb, pool } from "../src/db.js"; + +try { + console.log("DB OK:", await pingDb()); +} catch (e) { + console.error("DB FAIL:", e.message); + process.exitCode = 1; +} finally { + await pool.end(); +} diff --git a/scripts/check-minio.js b/scripts/check-minio.js new file mode 100644 index 0000000..d809fb4 --- /dev/null +++ b/scripts/check-minio.js @@ -0,0 +1,10 @@ +// MinIO(S3) 연결 빠른 확인: `npm run minio:check` +// 먼저 ~/.config 또는 .env 에서 S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY 를 채워야 함. +import { pingS3 } from "../src/s3.js"; + +try { + console.log("S3 OK:", await pingS3()); +} catch (e) { + console.error("S3 FAIL:", e.message); + process.exitCode = 1; +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..e352b7c --- /dev/null +++ b/src/config.js @@ -0,0 +1,25 @@ +// 환경설정 — 모든 외부 연결정보는 환경변수에서만 읽는다(코드에 비밀값 금지). +// +// DB: 워크스페이스가 DATABASE_URL / PG* 를 자동 주입함(직원 전용 schema로 격리). +// MinIO(S3): 사내 오브젝트 스토리지. endpoint는 API 호스트(minio.bokdev.in), +// 콘솔(minioc.bokdev.in)이 아님에 주의. +// +// Coolify 배포 시에는 Coolify의 Environment Variables 화면에서 같은 키들을 채운다. + +export const config = { + port: parseInt(process.env.PORT || "3000", 10), + + // Postgres — 워크스페이스 자동주입. Coolify에선 본인 DB 접속정보를 넣을 것. + databaseUrl: process.env.DATABASE_URL || "", + + // MinIO / S3 호환 + s3: { + endpoint: process.env.S3_ENDPOINT || "https://minio.bokdev.in", + region: process.env.S3_REGION || "us-east-1", + bucket: process.env.S3_BUCKET || "coolify-user-data", + accessKeyId: process.env.S3_ACCESS_KEY_ID || "", + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || "", + // MinIO는 path-style 필요 + forcePathStyle: true, + }, +}; diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..0c5aec4 --- /dev/null +++ b/src/db.js @@ -0,0 +1,15 @@ +// Postgres 연결 풀. DATABASE_URL 한 줄로 접속(직원 전용 schema가 search_path에 고정됨). +import pg from "pg"; +import { config } from "./config.js"; + +export const pool = new pg.Pool({ + connectionString: config.databaseUrl, + max: 5, +}); + +export async function pingDb() { + const { rows } = await pool.query( + "select current_user as user, current_schema as schema, now() as now" + ); + return rows[0]; +} diff --git a/src/s3.js b/src/s3.js new file mode 100644 index 0000000..6d99737 --- /dev/null +++ b/src/s3.js @@ -0,0 +1,24 @@ +// MinIO(S3 호환) 클라이언트. 자격증명은 env에서만 읽는다. +import { S3Client, ListObjectsV2Command, HeadBucketCommand } from "@aws-sdk/client-s3"; +import { config } from "./config.js"; + +export const s3 = new S3Client({ + endpoint: config.s3.endpoint, + region: config.s3.region, + forcePathStyle: config.s3.forcePathStyle, + credentials: { + accessKeyId: config.s3.accessKeyId, + secretAccessKey: config.s3.secretAccessKey, + }, +}); + +export async function pingS3() { + await s3.send(new HeadBucketCommand({ Bucket: config.s3.bucket })); + const out = await s3.send( + new ListObjectsV2Command({ Bucket: config.s3.bucket, MaxKeys: 5 }) + ); + return { + bucket: config.s3.bucket, + sampleKeys: (out.Contents || []).map((o) => o.Key), + }; +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..72b394d --- /dev/null +++ b/src/server.js @@ -0,0 +1,33 @@ +// 최소 Express 서버 — /healthz, /db, /s3 로 연결 확인. +import express from "express"; +import { config } from "./config.js"; +import { pingDb } from "./db.js"; +import { pingS3 } from "./s3.js"; + +const app = express(); + +app.get("/healthz", (_req, res) => res.json({ ok: true })); + +app.get("/db", async (_req, res) => { + try { + res.json({ ok: true, ...(await pingDb()) }); + } catch (e) { + res.status(500).json({ ok: false, error: String(e.message || e) }); + } +}); + +app.get("/s3", async (_req, res) => { + try { + res.json({ ok: true, ...(await pingS3()) }); + } catch (e) { + res.status(500).json({ ok: false, error: String(e.message || e) }); + } +}); + +app.get("/", (_req, res) => + res.json({ name: "starter", endpoints: ["/healthz", "/db", "/s3"] }) +); + +app.listen(config.port, "0.0.0.0", () => { + console.log(`starter listening on :${config.port}`); +});