# 배포 가이드 > v1.12.5는 Google 로그인/계정 연동과 통계 콘텐츠 세션 원장을 포함한다. 운영 배포 전 `068_csp_violation_aggregates.sql`, `069_media_catalog.sql`, `070_post_import_jobs.sql`, `071_users_google_oauth.sql`, `072_analytics_content_sessions.sql`을 순서대로 적용하고 미디어 카탈로그는 reconcile 완료 후 `MEDIA_CATALOG_MODE=database` 고정 순서를 지킨다. 게시물 Export·Import·일반 업로드는 디스크·임시 디렉터리 스트리밍을 사용하므로 NAS 앱 컨테이너의 임시 저장 공간과 `public/uploads/exports`, `public/uploads/_import-jobs` 쓰기 권한을 확인한다. > 로컬 기준 v1.12.5에서 `npm run lint`, `npm test`, `npm run build` 검증을 통과했다. ## 빌드 유형 | 유형 | 명령어 | 용도 | |------|--------|------| | 개발 | `npm run dev` | 로컬 테스트, 개발 서버 | | 프로덕션 | `npm run build` | NAS 배포, 운영 서버 | | 단위 검증 | `npm run verify` | JavaScript 문법 점검 + Node 단위 테스트 + 프로덕션 빌드 | | CI 전체 검증 | `npm run verify:ci` | 문법 + 단위 + Playwright API/E2E + 프로덕션 빌드 | > `npm run dev`는 프로젝트 전용 실행 스크립트를 통해 개발 서버, Admin, Tailwind Viewer 링크만 요약 출력한다. ## v1.12.5 배포 전 검증 Playwright API 테스트도 브라우저 바이너리는 필요하지 않지만 `verify:ci`에는 Chromium E2E가 포함된다. 최초 1회 의존성과 Chromium을 설치하고 Docker daemon이 실행 중인지 확인한다. ```bash npm install npx playwright install chromium npm run test:unit npm run test:api npm run test:e2e npm run verify:ci ``` Linux CI 이미지에서 브라우저 시스템 의존성도 함께 준비해야 하면 `npx playwright install --with-deps chromium`을 사용한다. API/E2E 명령은 `docker-compose.test.yml`의 `sori_studio_test` DB와 임시 업로드 루트만 사용하고 종료 시 제거한다. 테스트 중 Export worker와 추천 사이트 파비콘 cron은 비활성화된다. ### 운영 마이그레이션과 미디어 카탈로그 전환 ```bash sh scripts/migrate-production-db.sh status sh scripts/migrate-production-db.sh migrate sh scripts/migrate-production-db.sh status ``` - `068_csp_violation_aggregates.sql`은 CSP 보고 집계 테이블을 만든다. - `069_media_catalog.sql`은 미디어 카탈로그·참조·상태 계약을 만든다. 최초 상태는 `ready=false`이므로 마이그레이션 직후 곧바로 `MEDIA_CATALOG_MODE=database`만 켠 채 운영하지 않는다. 반드시 reconcile을 먼저 끝낸다. - `070_post_import_jobs.sql`은 게시물 Import 작업/항목 상태 테이블을 만든다. - `071_users_google_oauth.sql`은 회원 Google 로그인·계정 연동용 식별자 컬럼과 고유 인덱스를 만든다. - `072_analytics_content_sessions.sql`은 콘텐츠 단위 통계 세션 원장을 만들고 실시간 세션에 현재 콘텐츠 세션 해시를 추가한다. - 배포 직후(또는 `filesystem`) 상태에서 관리자 인증 후 `POST /admin/api/media/reconcile`을 1회 실행한다. 이 API는 파일을 삭제하거나 이동하지 않고 발견 파일 upsert, 참조 백필, 사라진 파일 `missing` 표시만 수행한다. - `media_catalog_state`의 `ready=true`, 파일시스템/카탈로그 `count`와 `bytes` 일치, `staging`·`error` 행 0을 확인한다. - 확인이 끝나면 운영 env를 `MEDIA_CATALOG_MODE=database`로 고정하고 재기동한다. 개발 환경도 동일하게 `database`로 고정한다. 준비 미완료·불일치·DB 조회 오류가 발생하면 애플리케이션은 자동으로 파일시스템 목록으로 fallback한다. - 관리자 화면의 임시 전환 버튼·런타임 모드 덮어쓰기 API는 제거했다. 모드 변경은 환경 변수만 사용한다. - `npm run media:reconcile:dev`는 loopback 비운영 DB 전용 안전장치가 있으므로 개발 DB 확인에만 사용한다. 운영에서는 관리자 API를 사용한다. - 개발 DB에서는 124개·77,019,754 bytes, 개수·용량 차이 0으로 reconcile을 확인했다. 운영 볼륨은 운영 수치로 별도 확인해야 한다. ### CSP 관찰 v1.11.0은 `base-uri`, `object-src`, `frame-ancestors`만 강제한다. 광고 코드, 사용자 사이트 코드, 외부 이미지·미디어·iframe에 영향을 줄 수 있는 출처 정책은 계속 Report-Only이므로 기존 광고와 사용자 코드 실행을 차단하지 않는다. 전체 HTML 문서 `/pages/**`는 강제 CSP에서 제외하고 Report-Only 보고만 유지한다. 배포 후 `csp_violation_aggregates`의 `policy_version='compat-v1'`, `effective_directive`, `blocked_origin`, `document_path`, `user_agent_family`, `violation_count`, `last_seen_at`을 관찰한다. URL 쿼리·fragment·차단 자산 경로와 User-Agent 원문은 저장되지 않는다. 반복 위반이 광고·사이트 코드의 정상 출처인지 확인한 뒤에만 강제 정책 확대를 별도 배포한다. --- ## 로컬 개발 ### 필수 조건 - Node.js 22 LTS 권장 - npm 9+ - 개발 DB ### 실행 단계 ```bash # 프로젝트 클론 git clone https://git.sori.studio/zenn/sori.studio.git # 디렉토리 이동 cd sori.studio # 의존성 설치 npm install # 개발 환경 변수 설정 # .env.development는 Git에 올리지 않는 로컬 전용 파일 # 새로 만들 때는 .env.example을 복사한 뒤 비밀번호를 랜덤 값으로 교체 cp .env.example .env.development openssl rand -hex 32 # 로컬 DB 컨테이너를 호스트에서 접근할 때는 127.0.0.1:43081 사용 # 개발 서버 실행 (127.0.0.1:43080) npm run dev ``` ### 로컬 개발 DB 로컬 개발 DB는 Docker Compose의 `sori-studio-db` 서비스만 실행한다. ```bash # Docker daemon 시작 # Docker Desktop을 사용하면 Docker.app을 먼저 실행 # Colima를 사용하면 아래 명령 실행 colima start # 개발 DB 컨테이너 실행 ENV_FILE=.env.development docker compose --env-file .env.development up -d sori-studio-db # 개발 DB 마이그레이션 실행 npm run db:migrate:dev # DB 준비 상태 확인 docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio # 시드 데이터 확인 docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;' ``` > 지난 버전 마이그레이션을 아직 적용하지 않았다면 `062_site_settings_display_counts.sql`부터 `069_media_catalog.sql`까지 순차 적용되어야 한다. 앱 재기동 전에 운영 DB 마이그레이션을 먼저 완료한다. 일반 배포에서는 `npm run db:migrate:prod` 또는 NAS 컨테이너 내부 마이그레이션 명령이 이 순서를 자동 처리한다. ### 확인 주소 - 개발 서버: http://127.0.0.1:43080 - 관리자: http://127.0.0.1:43080/admin - Tailwind Viewer: http://127.0.0.1:43080/_tailwind/ ### 로컬 DB 확인 방법 로컬 개발 DB는 PostgreSQL이며 호스트에서는 `127.0.0.1:43081`로 접근한다. 접속 정보는 Git에 포함하지 않는 `.env.development` 값을 사용한다. | 항목 | 값 | |------|----| | Host | `127.0.0.1` | | Port | `43081` | | Database | `.env.development`의 `POSTGRES_DB` | | User | `.env.development`의 `POSTGRES_USER` | | Password | `.env.development`의 `POSTGRES_PASSWORD` | 터미널에서 바로 확인할 때는 컨테이너 내부 `psql`을 사용한다. ```bash docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;' docker exec -it sori-studio-db psql -U sori_studio -d sori_studio ``` GUI로 확인할 때는 DBeaver, TablePlus, DataGrip, CloudBeaver 같은 PostgreSQL 클라이언트에서 위 접속 정보를 입력한다. phpMyAdmin은 MySQL/MariaDB용 도구라 이 프로젝트의 PostgreSQL DB 확인 용도로는 사용하지 않는다. --- ## UGREEN NAS Docker 배포 > Dockerfile과 docker-compose 설정은 초안이며 NAS 운영 환경에서는 아직 검증 전이다. ### SSH 접속 ```bash ssh [NAS_IP] ``` ### 프로젝트 설치 ```bash # 프로젝트 디렉토리로 이동 cd /volume1/docker/projects/apps/ # 프로젝트 클론 git clone https://git.sori.studio/zenn/sori.studio.git # 디렉토리 이동 cd sori.studio # 운영 환경 변수 설정 # .env.production은 Git에 올리지 않는 운영 전용 파일 cp .env.example .env.production # .env.production 파일에 운영 DB 연결 정보, 운영 전용 랜덤 비밀번호, APP_PORT=43080, DB_PORT=43081 입력 # 운영 DB에 owner/admin이 없으면 /admin/login에서 ADMIN_EMAIL/ADMIN_PASSWORD로 최초 owner 계정이 생성됨 # MEMBER_SESSION_SECRET은 ADMIN_PASSWORD와 다른 긴 난수 문자열로 반드시 입력 # Google 로그인을 사용하려면 GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, # GOOGLE_OAUTH_REDIRECT_URI=https://sori.studio/api/auth/google/callback 입력 # AdSense 통계를 대시보드에 연동하려면 GOOGLE_ADSENSE_CLIENT_ID, GOOGLE_ADSENSE_CLIENT_SECRET, # GOOGLE_ADSENSE_REFRESH_TOKEN, GOOGLE_ADSENSE_ACCOUNT_NAME, GOOGLE_ADSENSE_CURRENCY_CODE를 추가로 입력 # Docker 네트워크 대역이 NAS 기존 컨테이너와 겹치면 DOCKER_SUBNET을 다른 사설 대역으로 변경 # Docker 내부 앱에서 PostgreSQL에 접근할 때는 sori-studio-db:5432 사용 # Docker 빌드 및 실행 docker compose --env-file .env.production up -d --build # 운영 DB 마이그레이션 상태 확인 sh scripts/migrate-production-db.sh status # schema_migrations 도입 전 운영 DB가 이미 최신이면 최초 1회 기준점 기록(실제 SQL 실행 없음) sh scripts/migrate-production-db.sh baseline # 이후 배포에서는 아직 적용되지 않은 SQL만 순서대로 실행 sh scripts/migrate-production-db.sh migrate ``` ### 운영 업데이트 (코드 반영) 이미 한 번 올려 둔 NAS에서 **새 커밋을 받아 반영**할 때는 보통 아래 순서를 따른다. 최초 설치 절차와 달리 `git clone`은 하지 않는다. ```bash # 프로젝트 루트로 이동 (경로는 NAS 환경에 맞게 조정) cd /volume1/docker/projects/apps/sori.studio # 원격 저장소 최신 코드 받기 git pull # DB 스키마 변경이 포함된 배포면 미적용 SQL만 적용 (npm 없이 실행 가능) sh scripts/migrate-production-db.sh status sh scripts/migrate-production-db.sh migrate # 앱 이미지 재빌드 후 컨테이너 재기동 docker compose --env-file .env.production up -d --build ``` | 단계 | 설명 | |------|------| | `git pull` | 애플리케이션·Dockerfile·`db/migrations` 등 Git에 있는 변경을 받는다. | | `migrate` | `db/migrations/`에 새 SQL이 있으면 운영 DB에만 적용한다. 스키마 변경이 없으면 생략해도 된다. | | `up -d --build` | Nuxt 프로덕션 빌드가 Docker 이미지 안에서 수행되므로, **NAS 호스트에 Node/npm이 없어도** 앱 코드 반영이 가능하다. | 주의: - `.env.production`은 Git에 포함하지 않는다. `git pull`로 덮어쓰이지 않는다. 값을 바꿀 때만 파일을 직접 수정한다. - `public/uploads/` 업로드 파일은 Docker 볼륨(`./public/uploads`)에 있으므로, **이미지 파일만 추가·수정한 경우** 앱 재빌드 없이도 URL로 바로 보인다. - 로컬에서 미리 확인하려면 `npm run verify` 후 NAS에서 위 명령을 실행하면 된다. 컨테이너만 재시작하고 이미지는 그대로 두려면(환경 변수만 바꾼 경우 등): ```bash docker compose --env-file .env.production up -d ``` 코드 변경 없이 `.env.production`만 수정했다면 `--build` 없이 `up -d`만으로 충분하다. ### Google 로그인 설정 Google 계정 로그인과 계정 연동을 사용하려면 Google Cloud Console의 OAuth 클라이언트에 아래 리다이렉트 URI를 등록한다. - 로컬 개발: `http://127.0.0.1:43080/api/auth/google/callback` - 운영: `https://sori.studio/api/auth/google/callback` 운영 `.env.production`에는 아래 값을 추가한다. AdSense와 같은 OAuth 클라이언트를 써도 되지만, 장기적으로는 로그인용 클라이언트를 별도로 두는 편이 관리하기 쉽다. ```bash GOOGLE_OAUTH_CLIENT_ID=... GOOGLE_OAUTH_CLIENT_SECRET=... GOOGLE_OAUTH_REDIRECT_URI=https://sori.studio/api/auth/google/callback ``` 관리자 Google 로그인은 자동으로 열리지 않는다. 먼저 기존 관리자 이메일/비밀번호로 로그인한 뒤 `/settings`에서 Google 계정을 연결해야 하며, 연결된 `owner` 또는 `admin` 계정만 `/admin/login`의 Google 로그인을 사용할 수 있다. ### AdSense refresh token 발급 AdSense Management API를 관리자 대시보드에서 조회하려면 OAuth 클라이언트 ID/시크릿 외에 1회 발급한 refresh token이 필요하다. ```bash # credentials.json 경로를 직접 지정해서 실행 GOOGLE_ADSENSE_CREDENTIALS_PATH=/path/to/credentials.json npm run adsense:token # 또는 env 파일에 GOOGLE_ADSENSE_CLIENT_ID / GOOGLE_ADSENSE_CLIENT_SECRET을 넣고 실행 ENV_FILE=.env.production npm run adsense:token ``` 스크립트가 표시하는 `http://127.0.0.1:43082/oauth2callback` 주소는 Google Cloud OAuth 클라이언트의 승인된 리다이렉트 URI에 등록되어 있어야 한다. 브라우저 승인 후 터미널에 출력되는 `GOOGLE_ADSENSE_REFRESH_TOKEN`과 `GOOGLE_ADSENSE_ACCOUNT_NAME`을 `.env.production`에 저장한다. 광고 코드의 `data-ad-client="ca-pub-..."` 값은 API 계정 이름으로 쓸 때 `ca-`를 빼고 `accounts/pub-...` 형식으로 적는다. 대시보드 표시 통화는 필요하면 `GOOGLE_ADSENSE_CURRENCY_CODE=KRW`처럼 지정한다. 이 통화 설정은 보고서 수익 지표에만 적용되며, 잔고는 Google이 지급 계정 통화로 내려주는 값을 그대로 표시한다. ### Docker 네트워크 충돌 대응 NAS에 Docker 컨테이너가 많이 실행 중이면 `could not find an available, non-overlapping IPv4 address pool` 오류가 날 수 있다. 이 프로젝트는 기본 `DOCKER_SUBNET=10.250.50.0/24`를 사용한다. 해당 대역도 NAS 내부망 또는 다른 Docker 네트워크와 겹치면 `.env.production`에서 예를 들어 아래처럼 바꾼 뒤 다시 실행한다. ```bash DOCKER_SUBNET=10.250.51.0/24 docker compose --env-file .env.production up -d --build ``` ### 포트 - 로컬 개발: 43080 - NAS Docker 외부: 43080 - 컨테이너 내부: 3000 - PostgreSQL 호스트 로컬 전용: `127.0.0.1:43081` - Docker 내부 네트워크 기본값: `10.250.50.0/24` - HTTPS: 3001 (SSL 설정 시) ### 배포 후 확인 - `docker ps`에서 `sori-studio`, `sori-studio-db`가 `Up` 상태인지 확인한다. - 공개 사이트와 `/admin` 로그인이 정상 동작하는지 확인한다. - 스키마 변경 배포였다면 `sh scripts/migrate-production-db.sh status`로 미적용 마이그레이션이 없는지 확인한다. - `/sitemap.xml`, `/robots.txt`, `/rss.xml`이 정상 응답하는지 확인한다. Google Search Console에는 `https://도메인/sitemap.xml`을 제출한다. --- ## 데이터베이스 - 로컬 개발: `.env.development`의 `DATABASE_URL` - NAS 운영: `.env.production`의 `DATABASE_URL` - 로컬 개발 예시: `postgres://sori_studio:비밀번호@127.0.0.1:43081/sori_studio` - NAS Docker 예시: `postgres://sori_studio:비밀번호@sori-studio-db:5432/sori_studio` - `DB_BIND_ADDRESS` 기본값은 `127.0.0.1`이다. 특별한 관리망 요구가 없다면 `0.0.0.0`으로 변경하지 않는다. - `.env.example`에는 실제 비밀번호나 개인 이메일을 기록하지 않음 - 개발/운영 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용 - `ADMIN_EMAIL`/`ADMIN_PASSWORD`는 운영 DB에 owner/admin이 없는 최초 관리자 생성에만 사용한다. 같은 이메일의 일반 회원이 이미 있으면 owner로 승격하고 비밀번호를 `ADMIN_PASSWORD` 기준으로 갱신한다. 첫 owner 계정이 DB에 생성된 뒤에는 관리자 로그인도 DB의 bcrypt 비밀번호를 기준으로 검증한다. - 회원 세션 서명용 `MEMBER_SESSION_SECRET`은 관리자 비밀번호와 분리된 긴 난수 문자열을 사용 - 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리 - 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음 - 운영 환경에서 `DATABASE_URL`이 없으면 샘플 콘텐츠로 대체하지 않고 서버 오류로 실패 - Docker 운영 컨테이너는 `.env.production`의 서버 환경 변수를 런타임 `process.env`에서 우선 읽는다. ### 마이그레이션 - 스키마 변경은 `db/migrations/`의 SQL 파일로 관리한다. 버전별 변경 내역은 `docs/update.md`를 참고한다. - 로컬: `npm run db:migrate:dev` - NAS 운영: `sh scripts/migrate-production-db.sh status` → `migrate` - 각 SQL 파일 실행과 `schema_migrations` 이력 기록은 같은 `psql --single-transaction`에서 처리되며 오류 시 함께 롤백된다. - `schema_migrations`가 없는 기존 운영 DB에서 `posts` 테이블이 감지되면 `migrate`는 001부터 자동 실행하지 않는다. 현재 코드 기준 최신 DB라면 최초 1회 `sh scripts/migrate-production-db.sh baseline`으로 기존 파일을 적용 완료로 기록한다. 특정 번호까지만 기록하려면 예: `sh scripts/migrate-production-db.sh baseline 031`. ### 이메일 인증(Resend, 선택) 회원가입(일반)·비밀번호 찾기에 이메일 OTP를 쓰려면 `npm run db:migrate:dev`로 `018_email_otp_challenges.sql`을 적용하고, `.env`에 다음을 설정한다. | 변수 | 설명 | |------|------| | `RESEND_API_KEY` | [Resend](https://resend.com) API 키 | | `RESEND_FROM_EMAIL` | 발신 주소(Resend에서 허용된 도메인 또는 테스트 발신자) | | `MEMBER_SESSION_SECRET` | 회원 세션 쿠키 서명용 비밀값. 운영에서는 필수이며 `ADMIN_PASSWORD`와 분리된 긴 난수 문자열을 사용한다. **OTP 해시에 쓰는 pepper로도 사용**되므로, `EMAIL_OTP_PEPPER`를 비워 두면 이 값이 OTP용 비밀 재료가 된다. | | `EMAIL_OTP_PEPPER` | **선택.** 이메일로 받은 6자리 숫자를 DB에 넣기 전 SHA256 해시할 때 섞는 **서버 전용 비밀 문자열**이다. DB가 유출돼도 pepper를 모르면 인증번호 역산·무차별 대입이 어렵다. **짧은 숫자 한두 개가 아니라**, `openssl rand -hex 32`처럼 **긴 난수 문자열(32바이트 이상 권장)**을 쓰는 것이 안전하다. 비우면 `MEMBER_SESSION_SECRET`을 pepper로 쓴다. | `RESEND_API_KEY`와 `RESEND_FROM_EMAIL`이 비어 있으면 기존처럼 이메일 OTP 없이 최초 관리자 가입·일반 가입(OTP 생략)이 동작한다. ### 게시물 Export 설정(선택) | 변수 | 설명 | |------|------| | `POST_EXPORT_MAX_FILE_SIZE_BYTES` | 게시물 Export 분할 ZIP 목표 최대 용량. 기본값은 500MB이며 관리자 설정 화면 요청값이 있으면 그 값을 우선 사용한다. | - 게시물 Import는 관리자 설정의 Import 패널에서 Export ZIP 파일을 업로드해 작업으로 실행한다. 1회 업로드 파일은 300MB 이하, Markdown 게시물은 최대 1000개까지 처리한다. 운영 배포 전 `070_post_import_jobs.sql` 적용과 `public/uploads/_import-jobs` 쓰기 권한을 확인한다. - 관리 도구: CloudBeaver 등으로 추후 연결 가능하게 설계 - NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성 - 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development`와 `--env-file .env.development`를 함께 사용 - 운영 환경 파일은 프로젝트 루트의 `.env.production`을 우선 사용한다. 없으면 `.env`를 읽고, 둘 다 없으면 실행 중인 `sori-studio-db` 컨테이너의 `POSTGRES_DB`·`POSTGRES_USER`를 사용한다. ### 통계 데이터 보관 정책 - `site_analytics_daily`, `post_analytics_daily`: 사이트 전체 방문자와 게시물별 조회수의 누적 원본이므로 자동 삭제하지 않는다. - `analytics_traffic_daily`: 유입원·디바이스·키워드 축약 집계 원본이므로 자동 삭제하지 않는다. - `analytics_daily_visitors`: 일별 중복 방문 제거용 해시만 담으므로 32일 초과 행은 통계 수집·관리자 조회 흐름에서 주기적으로 삭제한다. - `analytics_active_sessions`: 현재 접속자 목록용 임시 데이터이며 90초 초과 행은 조회·수집 시 삭제한다. `ip_address`는 현재 접속자 화면에서 익명 방문자 묶음과 보조 식별 정보로 표시한다. - 관리자 대시보드 차트는 최대 365일 범위를 조회하며, 차트 범위를 넘는 집계도 누적 통계 원본으로 보관한다. - 관리자 통계 집계 시작일은 `ANALYTICS_COLLECTED_SINCE` 환경 변수로 설정한다. 기본값은 `2026-06-01`이다. - 관리자 세션으로 공개 글·페이지를 확인하는 요청은 통계 수집에서 제외된다. 운영자가 직접 방문자처럼 검증해야 할 때는 관리자 로그아웃 또는 별도 브라우저 프로필을 사용한다. ### 개발/운영 DB 분리 검증 절차 검증 전제는 실제 비밀번호나 전체 `DATABASE_URL`을 화면 공유, 문서, 커밋 메시지에 노출하지 않는 것이다. 확인할 때는 호스트, 포트, DB 이름, 파일명만 대조한다. 1. `.env.development` 확인. ```bash # 로컬 개발 DB는 호스트 기준 127.0.0.1:43081을 사용해야 한다. # DATABASE_URL 전체 값은 공유하지 않는다. rg -n "^(DATABASE_URL|POSTGRES_DB|POSTGRES_USER|DB_PORT)=" .env.development ``` 기준: - `DATABASE_URL` 호스트가 `127.0.0.1` - `DATABASE_URL` 포트가 `43081` - `DB_PORT=43081` - 운영 NAS 호스트명, 운영 IP, 운영 DB 이름이 포함되지 않음 2. `.env.production` 확인. ```bash # 운영 파일은 Git에 올리지 않는 운영 전용 파일이다. # 값이 없으면 NAS 배포 전 작성해야 한다. test -f .env.production && rg -n "^(DATABASE_URL|POSTGRES_DB|POSTGRES_USER|APP_PORT|DB_PORT)=" .env.production ``` 기준: - NAS Docker 내부 실행 기준이면 `DATABASE_URL` 호스트가 `sori-studio-db` - NAS 외부 DB를 별도 인스턴스로 쓰는 경우에도 로컬 개발 DB(`127.0.0.1:43081`)를 가리키지 않음 - `APP_PORT=43080` - `DB_PORT=43081` - `MEMBER_SESSION_SECRET`이 비어 있지 않고 `ADMIN_PASSWORD`와 다름 - `.env.development`와 DB 비밀번호, 관리자 비밀번호가 서로 다름 3. 로컬 개발 DB 연결 확인. ```bash docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT current_database(), current_user;' ``` 기준: - `accepting connections` 표시 - `current_database`가 로컬 개발 DB 이름 - `current_user`가 로컬 개발 DB 계정 4. 로컬 개발 서버 연결 확인. ```bash npm run dev ``` 기준: - 출력 주소가 `http://127.0.0.1:43080` - 관리자 API 요청에서 `127.0.0.1:43081` 연결 오류가 발생하지 않음 5. 커밋 전 민감 정보 확인. ```bash git status --short git diff -- . ':!package-lock.json' ``` 기준: - `.env.development`, `.env.production`이 변경 목록에 포함되지 않음 - 문서와 코드 diff에 실제 DB 비밀번호, 관리자 비밀번호, 운영 접속 주소가 포함되지 않음 ### 컨테이너가 `Restarting`일 때 `Error response from daemon: Container … is restarting, wait until the container is running`은 **프로세스가 곧바로 종료**되어 `restart: unless-stopped`가 반복 시도하는 상태다. 원인은 로그에 나온다. 1. **어느 서비스인지 확인** (`docker-compose.yml` 기준 이름은 `sori-studio`, `sori-studio-db`). ```bash docker ps -a --filter "name=sori-studio" ``` 2. **해당 컨테이너 로그** (가장 중요). ```bash docker logs sori-studio --tail 150 docker logs sori-studio-db --tail 150 ``` Compose로 올렸다면: ```bash docker compose --env-file .env.production logs sori-studio --tail 200 docker compose --env-file .env.production logs sori-studio-db --tail 200 ``` 3. **자주 나오는 원인** - **`sori-studio`**: `DATABASE_URL` 누락·오타, `MEMBER_SESSION_SECRET` 미설정, DB 호스트가 컨테이너 기준으로 잘못됨(예: 앱은 Docker 안인데 URL만 `127.0.0.1`로 DB를 가리킴), 애플리케이션 예외로 즉시 종료. - **`sori-studio-db`**: 이미 초기화된 볼륨과 다른 `POSTGRES_PASSWORD`로 다시 올린 경우, `docker-entrypoint-initdb.d` 마이그레이션 SQL 오류, 디스크/권한 문제. - **`sori-studio-db` 로그에 `ls: can't open '/docker-entrypoint-initdb.d/': Permission denied`**: 아래 **NAS·호스트에서 `db/migrations` 권한** 절차를 확인한다. 4. 로그를 고친 뒤에는 `docker compose --env-file .env.production up -d`로 다시 올리고, `docker ps`에서 `Up` 상태인지 확인한다. ### NAS·호스트에서 `db/migrations` 권한 `docker-compose.yml`은 `./db/migrations`를 Postgres 이미지의 `/docker-entrypoint-initdb.d`에 **읽기 전용**으로 붙인다. 공식 엔트리포인트는 이 디렉터리를 `ls`로 읽는데, NAS(UGREEN 등)나 SSH로 복사한 트리에서 **폴더·파일이 700/600만 허용**이거나 **상위 디렉터리에 실행(x) 비트가 없으면** 컨테이너 안 `postgres` 사용자가 경로를 통과하지 못해 `Permission denied`가 반복되고 DB 컨테이너가 재시작 루프에 들어갈 수 있다. 프로젝트 루트( `docker compose` 를 실행하는 디렉터리)에서 SSH로 다음을 적용한다. **비밀번호는 바꾸지 않으며**, 읽기·디렉터리 통과만 연다. ```bash cd /volume1/docker/projects/apps/sori.studio # 마이그레이션 디렉터리와 그 안 SQL: 모두 읽기, 디렉터리는 검색 가능 sudo chmod -R a+rX db/migrations # 상위 db/, 프로젝트 루트가 다른 사용자만 rwx 인 경우 통과 허용 sudo chmod a+x . db db/migrations ``` 그다음 DB 컨테이너만 재시작한다. ```bash docker compose --env-file .env.production restart sori-studio-db ``` 여전히 동일하면 프로젝트가 **SMB 공유 폴더 위**에만 있지 않은지 확인한다. Docker 데몬이 네이티브 경로(ext4 등)의 디렉터리를 마운트할 때 권한이 더 예측 가능하다. --- ## 운영 보조 명령 배포와 직접 관련 없지만 운영 DB·업로드 볼륨에 대해 **필요할 때만** 실행하는 명령이다. | 명령 | 용도 | |------|------| | `npm run images:backfill-post-thumbnails` | 로컬 Node/npm 환경에서 게시물 대표 이미지용 카드 썸네일(`thumbs/*-card.webp`) 일괄 생성 | | `npm run images:backfill-post-body-thumbnails` | 로컬 Node/npm 환경에서 게시물 본문 표시용 WebP 이미지(`thumbs/*-body-1600.webp`, `thumbs/*-body-720.webp`, `thumbs/*-body-2560.webp`) 일괄 생성 | | `npm run images:backfill-page-cover-thumbnails` | 로컬 Node/npm 환경에서 홈 Pages 진열장 커버용 WebP 이미지(`thumbs/*-page-cover.webp`) 일괄 생성 | | `sh scripts/backfill-post-thumbnails-production.sh` | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 카드 썸네일 일괄 생성 | | `sh scripts/backfill-post-body-thumbnails-production.sh` | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 본문 표시용 WebP 이미지 일괄 생성 | | `sh scripts/backfill-page-cover-thumbnails-production.sh` | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 Pages 커버 썸네일 일괄 생성 | | `npm run navigation:refresh-recommended-favicons` | 추천 사이트 파비콘 캐시를 수동으로 전체 갱신 | 본문 표시용 WebP 이미지(`thumbs/*-body-1600.webp`, `thumbs/*-body-720.webp`, `thumbs/*-body-2560.webp`)는 게시물 저장 시 생성된다. 기존 게시물의 과거 이미지는 로컬에서는 `npm run images:backfill-post-body-thumbnails`, NAS 운영에서는 `sh scripts/backfill-post-body-thumbnails-production.sh`로 일괄 생성할 수 있다. production 래퍼는 `docker compose up -d sori-studio` 후 실행 중인 앱 컨테이너 안의 Node로 백필 스크립트를 실행하므로 NAS 호스트에 npm이 없어도 된다. Pages 진열장 커버용 WebP 이미지(`thumbs/*-page-cover.webp`, 450×600)는 페이지 저장 시 생성된다. 기존 홈 노출 페이지의 과거 이미지는 로컬에서는 `npm run images:backfill-page-cover-thumbnails`, NAS 운영에서는 `sh scripts/backfill-page-cover-thumbnails-production.sh`로 일괄 생성할 수 있다. Homepage 위젯 연동은 `docs/homepage-widget.md`를 참고한다. Docker에서 Homepage가 같은 네트워크에 있으면 `http://sori-studio:3000/api/homepage-widget`을 사용한다. --- ## 업로드 파일 - 관리자 글쓰기에서 업로드한 이미지는 `/uploads/posts/YYYY/MM/` URL로 제공한다. - 로컬 개발에서는 실제 파일이 `public/uploads/posts/YYYY/MM/` 아래 저장된다. - 게시물 Export ZIP 산출물은 `public/uploads/exports/YYYY/MM/{jobId}/` 아래 생성되며, 관리자 다운로드 API를 통해 내려받는다. - 완료·실패한 게시물 Export 작업을 관리자 화면에서 삭제하면 연결된 ZIP 파일도 함께 삭제된다. - `public/uploads/`는 Git에 포함하지 않는다. - NAS 운영에서는 `docker-compose.yml`의 `./public/uploads:/app/public/uploads` 볼륨으로 업로드 파일을 유지한다. - 운영 빌드에서는 `/uploads/**` 서버 라우트가 `.output/public`이 아니라 `/app/public/uploads` 볼륨의 실제 파일을 직접 제공한다. - `/uploads/**` 응답은 `ETag`와 `Last-Modified`를 제공한다. 고유 URL로 저장되는 원본·시스템·회원 썸네일 파일은 1년 immutable 캐시를 사용하고, 같은 URL로 재생성될 수 있는 게시물 `thumbs` 파생 파일은 1시간 캐시와 `stale-while-revalidate`를 사용한다. - `MAX_FILE_SIZE`, `MAX_VIDEO_FILE_SIZE`, `MAX_AUDIO_FILE_SIZE`, `MAX_DOCUMENT_FILE_SIZE` 환경 변수로 관리자 미디어 업로드 파일별 최대 크기를 제한한다. 1회 업로드 최대 파일 수와 전체 용량은 관리자 Settings > POST 설정 > 업로드 제한에서 조절한다. 리버스 프록시(Nginx 등)를 쓰면 `client_max_body_size`가 앱 한도보다 작지 않은지 함께 확인한다. - 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다. ## 사용자 액션 필요 항목 - NAS SSH 접속 주소 확인. - NAS 프로젝트 루트 경로 확정. - 운영 DB 이름, 계정, 권한 확정. - 운영 업로드 볼륨 경로 확정. - 도메인 `sori.studio`의 NAS 연결 방식 확정.