Files
jaengseung-made/docs/superpowers/plans/2026-06-02-nas-selfhost-migration.md

24 KiB

쟁승메이드 NAS 풀 self-host 전환 — 마이그레이션 계획

For agentic workers: 이 문서는 인프라 마이그레이션 계획이다(엄격한 코드 TDD 아님 — 대부분 NAS/Docker/DB 작업이라 "검증 명령"이 테스트 역할). Phase 단위로 목표·작업(파일/명령)·검증·완료조건을 정의한다. 체크박스(- [ ])로 추적한다. 상당수 단계는 NAS SSH·Supabase Dashboard·DNS·OAuth 콘솔에서 사람이 실행해야 하며, 그런 단계는 [사용자 실행]으로 표시한다.

Goal: Vercel + Supabase(클라우드) + GitHub public 로 운영 중인 쟁승메이드를 NAS 자체 호스팅 + self-host Supabase 스택 + 개인 Gitea(비공개) 로 무중단에 가깝게 이전한다.

Architecture: NAS에 별도 docker-compose 프로젝트로 self-host Supabase(Postgres+GoTrue+PostgREST+Storage+Kong) + Next.js(standalone) 컨테이너를 올리고, 기존 nginx로 SSL·리버스 프록시. 앱 코드는 supabase endpoint(URL)와 키만 교체해 Auth·RLS·storage를 보존. Vercel/Supabase 병행 운영 후 DNS 전환으로 컷오버, 문제 시 DNS 롤백.

Tech Stack: Next.js 16(standalone) · Supabase self-hosting(docker) · PostgreSQL · Kong · nginx + Let's Encrypt · Synology DDNS · Gitea · Resend(SMTP)

근거 Spec: docs/superpowers/specs/2026-06-02-nas-selfhost-migration-design.md


변수 (실행자가 본인 환경값으로 채움)

변수 의미 예시
<NAS_HOST> NAS SSH 호스트 gahusb.synology.me
<APP_DOMAIN> 서비스 도메인 jaengseung-made.com
<SUPA_DOMAIN> self-host Supabase 외부 URL supa.jaengseung-made.com 또는 <APP_DOMAIN>/supabase 경로
<NAS_SUPA_DIR> NAS 내 Supabase compose 경로 /volume1/docker/jsm-supabase
<NAS_APP_DIR> NAS 내 Next 앱 경로 /volume1/docker/jsm-web
<CLOUD_DB_URL> 현재 Supabase 클라우드 connection string Dashboard > Settings > Database

⚠️ 이 변수들은 placeholder가 아니라 환경 고유 시크릿/주소다. 각 단계에서 실제 값으로 치환해 실행한다. 키·비밀번호는 .env에만 두고 git 커밋 금지.


Phase 0 — 사전 준비 & 리소스 실측 (2026-06-02 완료)

실측 결과 (2026-06-02):

  • RAM total 17.8GB / available 14GB + swap 12GB → 넉넉. 디스크 /volume1 1.8TB 여유 → .
  • CPU Celeron 2코어, load avg 5.19/2.64/2.16 → 🔶 유일한 실질 리스크(기존 부하 높음). 차단요소 아님(빌드 로컬, 런타임만 추가). Phase 6-4에서 실측 판단, 부족 시 컨테이너 CPU limit·스케줄 조정.
  • 포트: 5432 미점유 / 8000=portainer·3000=gitea 점유 → 해결책: Postgres·Kong·Next 모두 host 비노출, 기존 nginx만 외부(docker 내부 네트워크 통신). Studio만 필요 시 임시 포트(8100 등).
  • DNS: 가비아 관리. jaengseung-made.com→Vercel(216.198.79.1), NAS 공인 IP 211.44.164.244(고정).
  • 443: 외부 테스트 결과 이미 HTTPS 200 응답(포트포워딩+인증서 동작 중). → 노출 방식 = 기존 nginx(443)에 vhost 추가로 확정. Cloudflare Tunnel 불필요(보강 옵션).
  • gitea 기존 운영 중(:3000) → Phase 5는 신규 설치 없이 기존 gitea에 비공개 레포만 생성.

목표: 착수 가능 여부를 확정하고 네트워크/도메인 사전작업을 끝낸다.

작업:

  • 0-1. [사용자 실행] NAS 여유 리소스 실측 — SSH 후:
    • CPU: top -bn1 | head -5 (현재 부하), 코어 수 nproc
    • RAM: free -m (총 18,432MB 확인, available 여유)
    • 디스크: df -h /volume1 (Supabase+Postgres 데이터+이미지 위해 최소 20GB 여유 권장)
  • 0-2. 기존 NAS 포트 점유 확인 — docker ps --format '{{.Names}} {{.Ports}}' 로 5432·8000·3000 등 충돌 여부. 충돌 시 self-host 포트 매핑을 비점유 포트로 계획.
  • 0-3. [사용자 실행] DDNS/도메인 확인: <APP_DOMAIN> DNS 관리 위치 파악, 현재 Vercel을 가리키는 A/CNAME 레코드 확인(컷오버 때 변경 대상). 공인 IP 고정/DDNS 여부 확인.
  • 0-4. [사용자 실행] 라우터 포트포워딩 가능 여부 확인(443, 필요시 80 for ACME). ISP가 80/443 차단하는지 확인.
  • 0-5. 현재 Supabase 사용 기능 인벤토리 확정: Auth(이메일+Google OAuth), DB+RLS, Storage(pack-files). app/login/page.tsx의 OAuth provider 목록 재확인.

검증: RAM available ≥ 4GB, 디스크 여유 ≥ 20GB, 포트 충돌 없음, 443 포워딩 가능.

완료 조건: 위 검증 통과. 하나라도 실패 시 해당 항목 해소 전까지 Phase 1 착수 금지(특히 0-4 실패면 전체 재검토).


Phase 1 — NAS에 self-host Supabase 스택 기동 (2026-06-06 완료)

실행 결과 (2026-06-06):

  • 경로 /volume1/docker/jsm. 최신 스택(studio 2026.06.03, gotrue v2.189, postgres 15.8) 11개 컨테이너 전부 healthy.
  • 포트: KONG_HTTP_PORT=8100, KONG_HTTPS_PORT=8543(8000=portainer 회피). curl(apikey 포함) auth/rest/storage = 200.
  • pooler(supavisor) 5432 충돌 해결: NAS에 기존 127.0.0.1:5432(로컬 Postgres) 점유 → docker-compose.yml:522- ${POSTGRES_PORT}:5432 매핑 주석 처리(6543만 노출). pooler는 앱 미사용이라 무영향. 백업 docker-compose.yml.bak.
  • 레거시 JWT_SECRET 호환 확정: gotrue가 레거시 anon/service_role 키로 정상 → 앱 무수정 연결 가능(신규 비대칭 키 불필요).
  • 첫 기동 시 db init이 Celeron에서 66초+ 걸려 healthcheck 일시 실패 → 의존 서비스 미기동 → up -d 재실행으로 해결(알려진 패턴).

목표: 빈 self-host Supabase 스택을 NAS에 띄우고 헬스체크를 통과한다(데이터는 다음 Phase).

작업:

  • 1-1. [사용자 실행] 공식 self-hosting 자산 가져오기 — 로컬 또는 NAS에서:
    git clone --depth 1 https://github.com/supabase/supabase
    cp -r supabase/docker <NAS_SUPA_DIR>
    cd <NAS_SUPA_DIR> && cp .env.example .env
    
  • 1-2. 시크릿 생성 — .env 채우기:
    • POSTGRES_PASSWORD = 강한 랜덤
    • JWT_SECRET = 40자+ 랜덤
    • ANON_KEY / SERVICE_ROLE_KEY = 위 JWT_SECRET으로 서명한 JWT (Supabase 문서의 생성기 또는 supabase/docker 안내대로 생성)
    • SITE_URL=https://<APP_DOMAIN>, API_EXTERNAL_URL=https://<SUPA_DOMAIN>, SUPABASE_PUBLIC_URL=https://<SUPA_DOMAIN>
    • DASHBOARD_USERNAME/DASHBOARD_PASSWORD(studio 보호)
  • 1-3. GoTrue(Auth) SMTP = Resend 설정 — .env:
    SMTP_HOST=smtp.resend.com
    SMTP_PORT=465
    SMTP_USER=resend
    SMTP_PASS=<RESEND_API_KEY>
    SMTP_ADMIN_EMAIL=bgg8988@gmail.com
    SMTP_SENDER_NAME=쟁승메이드
    GOTRUE_MAILER_AUTOCONFIRM=false
    GOTRUE_EXTERNAL_GOOGLE_ENABLED=true
    GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=<GOOGLE_CLIENT_ID>
    GOTRUE_EXTERNAL_GOOGLE_SECRET=<GOOGLE_CLIENT_SECRET>
    GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=https://<SUPA_DOMAIN>/auth/v1/callback
    
  • 1-4. 포트 매핑을 0-2 결과에 맞춰 조정(Kong 8000/8443, Postgres 5432 충돌 시 host측 포트 변경).
  • 1-5. [사용자 실행] 기동: docker compose up -ddocker compose ps 모든 컨테이너 healthy.
  • 1-6. 검증: 내부에서 curl -s http://localhost:<KONG_PORT>/auth/v1/health → ok, PostgREST /rest/v1/ 응답, Studio 접속.

검증: 전 컨테이너 healthy + Auth/REST 헬스 응답.

완료 조건: 빈 self-host Supabase가 NAS에서 안정 기동. (외부 노출은 Phase 6에서.)


Phase 2 — 데이터 마이그레이션 (클라우드 → NAS) (2026-06-06 완료)

실행 결과 (2026-06-06):

  • 발견: 클라우드 DB가 멀티 프로젝트 공유 — public에 쟁승 10개 외 ebay_search_history·lotto_history·wk_*(별개 앱) 존재. 박재오 결정: 쟁승메이드 관련만 이전(ebay/lotto/wk 제외, 클라우드 해지 보류).
  • 발견: PG 버전 불일치 — 클라우드 PG 17.6, NAS 기본 15.8 → pg_dump 거부. NAS db를 docker-compose.pg17.yml로 PG17(17.6.1)로 전환(.env COMPOSE_FILE=docker-compose.yml:docker-compose.pg17.yml, PG15 볼륨 down -v+rm volumes/db/data 후 재init).
  • 선별 덤프: public 10개(schema+data) + auth.users/identities·storage(data-only). PGPASSWORD로 비번 분리(URL 노출 회피).
  • 무손실 확정: NAS↔CLOUD 행수 12개 항목 완전 일치 — users4/identities5/profiles4/quotes4/payments4/orders15/products25/project_milestones7/saju_records3, contact_requests0/survey_responses0/pack_files0.
  • RLS 이전 확인: project_milestones=authenticated(anon 없음, 2026-06-01 보안수정 반영), quotes=authenticated.
  • storage 0 → buckets/objects 없음 = pack 실파일은 storage 미사용(packs-lab) → 2-3 실파일 이전 스킵.
  • 후속 메모: subscriptions 테이블이 클라우드에 미존재(앱 /api/subscription이 참조하나 미배포 상태) → 구독 기능 활성화 시 별도 생성 필요. 비번 Rk..8!은 채팅 노출됐으므로 클라우드 해지 시 자연 폐기 또는 사전 재설정.

목표: 클라우드 Supabase의 데이터·인증·스토리지를 NAS Postgres로 무손실 이전.

작업:

  • 2-1. [사용자 실행] 클라우드 전체 덤프 — 로컬에서:
    pg_dump "<CLOUD_DB_URL>" --no-owner --no-privileges -Fc -f jsm-cloud.dump
    
    (auth·storage·public 스키마 포함. RLS 정책은 덤프에 포함됨.)
  • 2-2. NAS Postgres로 복원:
    # 덤프를 NAS로 전송 후, NAS에서:
    docker compose exec -T db pg_restore --no-owner --no-privileges -d postgres < jsm-cloud.dump
    
    • 충돌(이미 존재하는 supabase 내부 객체)은 무시 가능 — public/auth.users/storage 데이터가 들어왔는지가 핵심.
  • 2-3. Storage 객체(pack-files 실파일) 이전: 클라우드 Storage 버킷 다운로드 → NAS storage 볼륨에 업로드(또는 storage-api import). 버킷명·경로 보존.
  • 2-4. 시퀀스/식별자 정합성 확인.
  • 2-5. 검증(행수 대조) — 주요 테이블 each:
    -- 클라우드와 NAS 양쪽에서 실행해 비교
    select 'profiles' t, count(*) from profiles
    union all select 'quotes', count(*) from quotes
    union all select 'project_milestones', count(*) from project_milestones
    union all select 'subscriptions', count(*) from subscriptions
    union all select 'contact_requests', count(*) from contact_requests
    union all select 'orders', count(*) from orders
    union all select 'payments', count(*) from payments
    union all select 'saju_records', count(*) from saju_records;
    select count(*) from auth.users;  -- 사용자 수 일치 확인
    
  • 2-6. RLS 정책 이전 확인:
    select schemaname, tablename, policyname, roles, cmd
    from pg_policies where schemaname='public' order by tablename;
    
    → 클라우드와 동일 정책 세트인지(특히 quotes RLS, project_milestones에 anon 정책 없어야 함 — 2026-06-01 수정 반영).

검증: 전 테이블 행수 일치 + auth.users 수 일치 + RLS 정책 동일.

완료 조건: NAS Postgres가 클라우드 데이터·인증·정책의 완전 사본을 보유.


Phase 3 — Next.js self-host 빌드 준비

목표: 앱을 NAS에서 next start로 구동 가능한 standalone 컨테이너로 만든다(코드 거의 무수정).

Files:

  • Modify: next.config.ts (output standalone, maxDuration 무관화)
  • Modify: app/api/saju/analyze/route.ts:11 (maxDuration 제거)
  • Create: Dockerfile
  • Create: .dockerignore

작업:

  • 3-1. next.config.ts에 standalone 출력 추가:
    const nextConfig: NextConfig = {
      output: 'standalone',
      // ...기존 headers/redirects 유지
    };
    
  • 3-2. maxDuration 제거 하지 않음export const maxDuration = 60Vercel 전용 메타라 self-host(next start)에선 무시된다. 컷오버 전까지 Vercel 운영(saju 60초)을 깨지 않으려면 그대로 유지. (양쪽 호환)
  • 3-3. Dockerfile 생성(멀티스테이지, standalone):
    FROM node:20-alpine AS deps
    WORKDIR /app
    COPY package.json package-lock.json ./
    RUN npm ci
    FROM node:20-alpine AS builder
    WORKDIR /app
    COPY --from=deps /app/node_modules ./node_modules
    COPY . .
    ARG NEXT_PUBLIC_SUPABASE_URL
    ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
    ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
    ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
    RUN npm run build
    FROM node:20-alpine AS runner
    WORKDIR /app
    ENV NODE_ENV=production
    COPY --from=builder /app/.next/standalone ./
    COPY --from=builder /app/.next/static ./.next/static
    COPY --from=builder /app/public ./public
    EXPOSE 3000
    CMD ["node", "server.js"]
    

    NEXT_PUBLIC_*는 빌드타임에 인라인되므로 build-arg로 주입.

  • 3-4. .dockerignore: node_modules, .next, .git, .env*, docs.
  • 3-5. [로컬 빌드] 로컬에서 standalone 빌드 검증: npm run build.next/standalone/server.js 생성 확인.

검증: 로컬 npm run build 성공 + standalone 산출물 생성.

완료 조건: 앱이 컨테이너로 패키징 가능. (env 전환은 Phase 4.)


Phase 4 — 코드 env 전환 + 로컬 통합 테스트 (2026-06-06 완료)

실행 결과 (2026-06-06):

  • 로컬 .env.local을 NAS Supabase로 전환(URL/ANON/SERVICE) → LAN(192.168.45.54:8100) 및 도메인(https://supa.jaengseung-made.com) 양쪽에서 apikey 200·RLS([]) 확인.
  • 전 페이지(home/login/mypage/work/music/packages/saju/freelance) 200, Supabase 연결 에러 0.
  • Google OAuth E2E 성공: 브라우저 로그인 → supa 콜백 → 세션 → mypage 본인 데이터(RLS) 확인. 이메일/비번 계정은 없어 OAuth로만 검증.
  • 검증 후 .env.local은 클라우드로 복구(로컬 개발 정상화). 백업 .env.local.cloud.bak.

목표: 앱이 NAS self-host Supabase를 바라보게 하고, 로컬에서 핵심 흐름을 검증.

작업:

  • 4-1. .env.production(또는 배포용 env) 작성 — git 커밋 금지:
    NEXT_PUBLIC_SUPABASE_URL=https://<SUPA_DOMAIN>
    NEXT_PUBLIC_SUPABASE_ANON_KEY=<self-host ANON_KEY (Phase 1-2)>
    SUPABASE_SERVICE_ROLE_KEY=<self-host SERVICE_ROLE_KEY>
    RESEND_API_KEY=...  PORTONE_*=...  GEMINI_API_KEY=...   # 기존 외부 키 그대로
    
  • 4-2. lib/supabase/{client,server,admin}.ts가 위 env만 참조하는지 재확인(하드코딩 URL 없음 — 이미 env 기반).
  • 4-3. app/auth/callback/route.ts 및 OAuth redirect가 <APP_DOMAIN> 기준인지 확인. Google Cloud Console에서 승인 redirect URI에 https://<SUPA_DOMAIN>/auth/v1/callback + https://<APP_DOMAIN>/auth/callback 추가 [사용자 실행].
  • 4-4. [로컬 통합 테스트] 로컬 앱을 NAS Supabase(임시로 외부 접근 허용 또는 VPN/내부망)로 띄워 검증:
    • 이메일 회원가입 → Resend 메일 수신 → 확인 → 로그인
    • Google OAuth 로그인
    • 마이페이지 진입(auth.getUser 동작), 프로젝트/견적 조회(RLS user_id 필터)
    • pack-files 서명 URL 다운로드(/api/packs/sign-link)
    • 결제 테스트(/payment/test) → orders/payments/subscriptions 기록
    • 사주 분석(/api/saju/analyze, Gemini) 동작 + 저장
  • 4-5. 발견된 endpoint/쿠키 도메인 문제 수정(필요 시 supabase-js options, 쿠키 domain).

검증: 위 6개 흐름 전부 NAS Supabase에서 정상.

완료 조건: 앱이 self-host 백엔드로 핵심 기능 end-to-end 동작.


Phase 5 — Gitea 이전 + 별도 배포 파이프라인 (2026-06-06 완료)

실행 결과 (2026-06-06):

  • 코드를 기존 NAS Gitea(gitea.gahusb.synology.me/gahusb/jaengseung-made)로 이전(옛 main은 backup-old-main 보존). README 갱신.
  • 배포 = 로컬 빌드 → Gitea Container Registry → NAS pull: 로컬 docker build(supa URL + 새 ANON build-arg) → gitea.gahusb.synology.me/gahusb/jsm-web:latest push → NAS pull + compose up.
  • 빌드 이슈 해결: /api/survey 등 모듈 레벨 new Resend()가 docker 빌드(env 없음)에서 throw → Dockerfile 빌드타임 더미 RESEND_API_KEY로 통과.
  • 🚨 데모 키 발견·교체: NAS supabase가 supabase-demo 데모 키(JWT_SECRET/ANON/SERVICE/VAULT) 사용 중이었음 → 실제 키 생성(crypto)·교체, 데모 키 차단(401) 확인. (POSTGRES_PASSWORD는 데모 잔존 — 6543 LAN 한정, 컷오버 시 DB 재구축으로 교체)
  • jsm-web 컨테이너: env_file 주입 정상, 컨테이너→supa hairpin 200(공유기 지원), app.jaengseung-made.com(테스트 서브도메인, DSM 역프록시→13000) 외부 SSL 200 + 브라우저 OAuth·데이터 검증 통과.

목표: 코드를 개인 Gitea(비공개)로 옮기고, 기존 webpage-deployer분리된 전용 배포 방식을 구축한다.

결정 포인트 (5-1에서 택1):

  • (권장) 로컬 빌드 → 이미지 전송 → NAS compose: Celeron 빌드 금지 규칙에 부합. 로컬 docker builddocker save | ssh nas docker load(또는 사설 레지스트리 push/pull) → NAS에서 docker compose up -d 재기동. 가장 단순·통제 명확.
  • (대안) Gitea Actions 러너: 러너를 로컬/별도 머신(NAS 아님)에 두고 push 시 빌드→전송. 자동화↑ 설정↑.

작업:

  • 5-1. 배포 방식 확정(위 택1 — 권장: 로컬 빌드→이미지 전송).
  • 5-2. [사용자 실행] 기존 NAS gitea(:3000)에 비공개 레포 생성, 현재 레포를 미러 push:
    git remote add gitea https://<GITEA_HOST>/<user>/jaengseung-made.git
    git push gitea --all && git push gitea --tags
    
  • 5-3. NAS에 <NAS_APP_DIR>/docker-compose.yml 작성 — next-app 서비스(이미지 + env_file + Supabase 스택 네트워크 연결).
  • 5-4. 배포 스크립트 작성(로컬) scripts/deploy-nas.(sh|ps1):
    docker build --build-arg NEXT_PUBLIC_SUPABASE_URL=... --build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY=... -t jsm-web:latest .
    docker save jsm-web:latest | ssh <NAS_HOST> "docker load"
    ssh <NAS_HOST> "cd <NAS_APP_DIR> && docker compose up -d"
    
  • 5-5. 첫 배포 실행 → NAS에서 next-app 컨테이너 healthy, 내부 curl localhost:3000 200.
  • 5-6. (선택) GitHub public 레포 처리: 아카이브/비공개 전환 또는 유지(미러). 결정 기록.

검증: Gitea push → 배포 스크립트 → NAS 앱 컨테이너 구동 성공.

완료 조건: Gitea 단일 소스 + 재현 가능한 배포 파이프라인.


Phase 6 — 도메인·SSL·nginx + 병행→컷오버

부분 완료 (2026-06-06) — supa 백엔드 노출 + OAuth:

  • 도메인 DNS는 가비아 등록 + Cloudflare 네임서버 위임 구조 확인 → supa 레코드는 Cloudflare DNS에 추가(A supa→211.44.164.244, DNS only).
  • 외부 노출은 Synology DSM 역방향 프록시가 443 관리 → supa.jaengseung-made.com(443) → localhost:8100(kong) + DSM Let's Encrypt 인증서. (cloudflared도 NAS에 존재 — 향후 Tunnel 대안)
  • Google OAuth: docker-compose.yml GOTRUE_EXTERNAL_GOOGLE 주석 해제 + .env(GOOGLE_ENABLED/CLIENT_ID/SECRET), redirect=https://supa.jaengseung-made.com/auth/v1/callback, Google Console redirect URI 등록. authorize?provider=google → 302 accounts.google.com 확인.
  • 남은 것(앱 컷오버): Phase 5(앱 NAS 배포) 후 jaengseung-made.com을 Vercel→NAS Next로 전환 + 앱 OAuth redirect를 도메인으로 + ADDITIONAL_REDIRECT_URLS 정리(현재 localhost 테스트분 포함).

목표: 외부 트래픽을 SSL로 받고, 검증 후 DNS를 NAS로 전환한다.

노출 방식 확정(Phase 0): 443이 이미 열려 있고 기존 nginx가 HTTPS 운영 중 → 기존 nginx에 vhost 추가. 신규 포트포워딩 불필요. Cloudflare Tunnel 미사용.

작업:

  • 6-1. [사용자 실행] 기존 nginx(443 운영 중)에 vhost 추가:
    • <APP_DOMAIN> → next-app:3000 (docker 내부 네트워크, host 비노출)
    • <SUPA_DOMAIN>(또는 /auth /rest /storage 경로) → kong (docker 내부)
  • 6-2. [사용자 실행] 기존 인증서 발급 방식 그대로 <APP_DOMAIN>·<SUPA_DOMAIN> 인증서 추가(현 nginx가 이미 Let's Encrypt 또는 Synology 인증서 사용 중 — 동일 절차 차용). 443은 이미 열려 있어 신규 포워딩 불필요.
  • 6-3. [사용자 실행] 임시 호스트(hosts 파일 or 스테이징 서브도메인)로 NAS 스택을 운영 DNS 변경 전에 외부망에서 검증 — Phase 4의 6개 흐름 재실행(이번엔 실제 도메인/SSL/쿠키).
  • 6-4. 병행 운영: Vercel/Supabase는 그대로 둔 채 NAS 안정성 24~48h 관찰(로그·메모리·CPU).
  • 6-5. [사용자 실행] 데이터 최종 재동기(컷오버 직전 클라우드 증분 — 컷오버 윈도 동안 신규 가입/주문 최소화 또는 점검 공지). 2-1~2-5 재실행(델타).
  • 6-6. [사용자 실행] DNS 컷오버: <APP_DOMAIN> A/CNAME → NAS 공인 IP/DDNS. TTL 낮춰두고 전환.
  • 6-7. 컷오버 후 검증: 실제 도메인에서 로그인·결제·사주·pack 다운로드 재확인. GA/로그 정상.

검증: 실제 도메인+SSL에서 전 흐름 정상, 인증서 유효, 응답시간 허용 범위.

완료 조건: 운영 트래픽이 NAS를 향하고 핵심 기능 정상. 문제 시 6-6 DNS 롤백.


Phase 7 — 백업·운영·클라우드 해지

목표: 운영 지속성을 확보하고 클라우드 자원을 정리한다.

작업:

  • 7-1. Postgres 일일 백업 cron(pg_dump → 별도 볼륨/외부 보관) + 복구 리허설 1회.
  • 7-2. Storage 백업(rsync 등) 주기 설정.
  • 7-3. 헬스체크/모니터링: 컨테이너 healthcheck + 다운 시 알림(기존 agent-office 텔레그램 재활용 가능).
  • 7-4. 인증서 자동 갱신 동작 확인(갱신 리허설 or 만료 알림).
  • 7-5. self-host Supabase 업그레이드/롤백 절차 문서화(docs/).
  • 7-6. 안정 운영 1~2주 확인 후 [사용자 실행] Vercel 프로젝트·Supabase 클라우드 자원 해지(데이터 최종 백업 보관 후). 해지 전 되돌릴 수 없음 — 백업 2중 확인.

검증: 백업에서 복구 리허설 성공 + 모니터링 알림 동작 + 인증서 갱신 확인.

완료 조건: 무인 운영 가능 상태(백업·모니터링·갱신) + 클라우드 의존 0.


롤백 전략

  • 컷오버 전(Phase 1~5): 운영은 계속 Vercel/Supabase. NAS 작업은 격리되어 운영 무영향. 중단해도 손실 없음.
  • 컷오버(Phase 6): DNS 전환이 유일한 전환점. 문제 시 DNS를 Vercel로 되돌리면 즉시 복구(클라우드 미해지 상태이므로). TTL을 미리 낮춰 롤백 신속화.
  • 해지 후(Phase 7-6 이후): 되돌리기 어려움 → 안정 1~2주 + 백업 2중 확인 후에만 해지.

리스크 & 대응

리스크 대응
CPU(2코어) 동시부하 한계 Phase 6-4 관찰에서 측정, 부족 시 컨테이너 CPU/메모리 limit·기존 서비스 스케줄 조정
가정 인터넷/전기 다운 가용성 다운그레이드 수용(주권 우선). UPS·모니터링 알림으로 완화
OAuth/쿠키 도메인 불일치 Phase 4-3·6-3에서 실도메인 검증
데이터 컷오버 윈도 신규 데이터 유실 6-5 델타 재동기 + 점검 공지로 윈도 최소화
self-host Supabase 버전 드리프트 7-5 업그레이드 절차 문서화, 핀 버전 고정

실행 순서 요약

Phase 0(실측) → 1(스택 기동) → 2(데이터 이전) → 3(앱 컨테이너화) → 4(env 전환·로컬 통합테스트) → 5(Gitea·배포) → 6(도메인·SSL·컷오버) → 7(백업·운영·해지). 컷오버(6-6) 전까지는 운영 무영향이라 안전하게 단계 진행 가능.