# NAS 배포 상세 가이드 (UGREEN 등 Docker 호스트) 이 문서는 **다른 PC에서 작업한 뒤 NAS에 올리는 경우**까지 포함해, 처음부터 끝까지 순서대로 따라 할 수 있도록 적었다. 요약·변수 설명은 `docs/deploy.md`를 참고하고, 여기서는 **SSH 기준으로 한 단계씩** 기술한다. 앱·문서의 **공식 버전**은 `package.json`의 `version`과 `docs/spec.md`의 `현재 버전`을 기준으로 한다. --- ## 1. 전제 조건(미리 확인) 다음이 갖춰져 있어야 한다. 기종마다 메뉴 이름은 다를 수 있다. 1. **Docker**(및 **Docker Compose** 플러그인 또는 `docker compose` 명령)가 NAS에서 동작한다. 2. **SSH**로 NAS에 접속할 수 있다. (예: Mac 터미널에서 `ssh nas`처럼 호스트 별칭을 써도 된다.) 3. NAS에 **Git**이 설치되어 있거나, Git 없이 배포하려면 12절의 대안을 본다. 원격 저장소 URL(예시, 실제 주소는 저장소 관리 화면과 동일해야 한다): - `https://git.sori.studio/zenn/todo.sori.studio.git` --- ## 2. NAS에 둘 폴더 경로 정하기 이 프로젝트는 예시로 다음 경로를 쓴다. - `/volume1/docker/projects/apps/todo` **의미:** Docker용 프로젝트를 모아 두는 `apps` 아래에 `todo` 전용 디렉터리를 둔다. 다른 경로를 써도 되지만, 이후 명령의 `cd` 경로만 본인 환경에 맞게 바꾸면 된다. --- ## 3. SSH로 NAS 접속 후 폴더로 이동 ```bash ssh ``` 접속 후: ```bash cd /volume1/docker/projects/apps ``` `apps` 목록에 `todo`가 보이는지 확인한다. ```bash ls ``` --- ## 4. Git으로 가져오기(권장: 빈 폴더에 클론) ### 4-1. `todo` 폴더가 **아직 없거나**, 비어 있게 만들 수 있는 경우 `apps`에서: ```bash git clone https://git.sori.studio/zenn/todo.sori.studio.git todo cd todo ``` 이렇게 하면 `todo` 안에 저장소 파일이 채워진다. ### 4-2. 이미 `todo` 폴더로 들어가 있는데 **안이 비어 있는** 경우 `todo` 안에서 현재 디렉터리에 클론한다. ```bash cd /volume1/docker/projects/apps/todo git clone https://git.sori.studio/zenn/todo.sori.studio.git . ``` 마지막의 `.`은 “현재 폴더에 받아라”는 뜻이다. ### 4-3. 폴더 안에 **다른 파일이 이미 있는** 경우 `git clone ... .`은 비어 있지 않으면 실패한다. 다음 중 하나로 정리한다. - 다른 곳으로 파일을 옮기거나 이름을 바꾼 뒤 빈 폴더에서 4-2를 실행한다. - 또는 상위에서 새 이름으로 클론한다. `git clone https://git.sori.studio/zenn/todo.sori.studio.git todo-app` 이후 `docker compose`는 **클론된 디렉터리 안**에서만 실행하면 된다. ### 4-4. 클론 후 한 번 확인 ```bash cd /volume1/docker/projects/apps/todo ls ``` `docker-compose.yaml`, `Dockerfile`, `package.json` 등이 보이면 된다. --- ## 5. NAS의 LAN IP 확인(중요) 웹앱은 **사용자의 브라우저**에서 PocketBase 주소로 직접 요청한다. 그래서 빌드에 넣는 주소는 **“브라우저가 NAS에 접속할 때 쓰는 주소”**여야 한다. 예시: - `docker-compose.yaml`에 적어 둔 **호스트 포트**(웹 `42881`, PocketBase `42917`)는 다른 스택과 겹치지 않게 잡은 값이다. 겹치면 해당 파일의 `ports`만 바꾼 뒤, 아래 `export`·`.env`의 포트도 같이 맞춘다. - 같은 NAS 안의 브라우저만: `http://127.0.0.1:42917`처럼 **호스트에 열린 포트**로 접근한다. **폰이나 다른 PC**에서는 `127.0.0.1` 대신 **NAS LAN IP**를 써야 한다. - 집 안에서 여러 기기: `http://192.168.x.x:42917`(API), `http://192.168.x.x:42881`(웹) 형태로 쓴다. IP 확인은 NAS 관리 화면의 네트워크 정보를 보거나, SSH에서 NAS OS에 맞는 명령으로 확인한다. (기종별로 명령이 다를 수 있다.) 아래에서는 예시로 `192.168.0.50`을 쓴다. **본인 NAS IP로 바꿔야 한다.** --- ## 6. 환경 변수 설정 후 Compose 실행 `docker compose`는 프로젝트 루트에 **`.env` 파일이 있으면 자동으로 읽는다.** (Git에 올리지 말 것. `.gitignore`에 포함됨.) **`.env.example`은 읽지 않는다.** 적용하려면 `cp .env.example .env` 후 `.env`만 수정한다. ### 6-0. 운영 도메인을 쓰는 경우(예: todo.sori.studio) `/volume1/docker/projects/apps/todo/.env` 예시: ```bash VITE_PUBLIC_APP_URL=https://todo.sori.studio VITE_POCKETBASE_URL=https://api.todo.sori.studio ``` 그다음: ```bash cd /volume1/docker/projects/apps/todo docker compose up -d --build ``` 리버스 프록시에서 **`todo.sori.studio` → 웹**, **`api.todo.sori.studio` → PocketBase** 로 각각 연결해야 한다. (경로·TLS는 사용 중인 NAS/프록시 제품 문서를 따른다.) ### 6-1. LAN IP만 쓰는 경우 프로젝트 루트에서 `export`로 한 세션에만 줄 수도 있다. ```bash cd /volume1/docker/projects/apps/todo export VITE_PUBLIC_APP_URL="http://192.168.0.50:42881" export VITE_POCKETBASE_URL="http://192.168.0.50:42917" docker compose up -d --build ``` - 첫 실행은 이미지 다운로드·빌드로 **시간이 걸릴 수 있다.** - 백그라운드 실행은 `-d` 때문이다. ### 6-2. 잘 떴는지 확인 ```bash docker compose ps ``` `pocketbase-todo`, `todo-web`(컨테이너 이름·`docker-compose.yaml`의 `container_name`)이 `running`에 가깝게 보이면 된다. ### 6-3. 로그가 궁금할 때 ```bash docker compose logs -f --tail=100 ``` --- ## 7. 브라우저로 접속해 보기 예시 IP 기준: - **할 일 웹앱:** `http://192.168.0.50:42881` - **PocketBase 관리자 UI:** `http://192.168.0.50:42917/_/` 접속이 안 되면: - NAS 방화벽 또는 보안 앱에서 **42881, 42917** 포트가 막혀 있지 않은지 확인한다. - `docker compose ps`로 컨테이너가 떠 있는지 다시 본다. --- ## 8. PocketBase 최초 설정(한 번) 1. 브라우저에서 `http://:42917/_/` 를 연다. (호스트 포트를 바꿨다면 그 번호로 연다.) 2. 관리자 계정(이메일·비밀번호)을 만들고 마법사를 끝낸다. 3. **컬렉션 `todos` 생성** - 필드 `title`: 타입 **Text** - 필드 `done`: 타입 **Bool** 4. **API 규칙** 처음에는 테스트로 느슨하게 두었다가, 나중에 로그인 기반으로 조이는 것을 권장한다. (운영 정책에 맞게 조정.) 5. **설정 → CORS** 웹앱 출처를 허용 목록에 넣는다. 예: `http://192.168.0.50:42881` 또는 `https://todo.sori.studio` 포트·프로토콜·호스트가 **실제로 주소창에 쓰는 것과 한 글자라도 다르면** 브라우저가 차단한다. 이후 `http://:42881`에서 목록·추가·완료 토글이 동작하는지 본다. --- ## 9. 왜 `export`로 주소를 줄이는가 `VITE_POCKETBASE_URL`은 **Docker 이미지를 빌드할 때** 프런트 번들에 박힌다. NAS IP나 도메인이 바뀌면, **다시 빌드**해야 한다. ```bash cd /volume1/docker/projects/apps/todo export VITE_PUBLIC_APP_URL="http://새로운_주소:42881" export VITE_POCKETBASE_URL="http://새로운_주소:42917" docker compose up -d --build ``` --- ## 10. 코드를 최신으로 갱신할 때(이미 클론해 둔 경우) **주의:** `apps` 폴더 자체(`/volume1/docker/projects/apps`)에는 `.git`이 없다. 여기서 `git pull`을 하면 `fatal: not a git repository`가 난다. **항상 클론해 둔 프로젝트 디렉터리 안으로 들어간 뒤** Git 명령을 실행한다. `git pull` 한 줄만으로는 **원격에 없는 브랜치에 붙어 있거나**, **로컬 수정이 있어 병합이 막히거나**, **처음 한 번도 `fetch`를 안 한 상태**면 기대한 대로 안 받아질 수 있다. 아래 순서를 권장한다. ```bash cd /volume1/docker/projects/apps/todo ls -a .git ``` `.git`이 보이면 이 경로가 저장소 루트가 맞다. 없다면 상위 `apps`에 있지 않은지, 폴더 이름이 `todo`가 맞는지 확인한다. ```bash cd /volume1/docker/projects/apps/todo # 1) 작업 트리 확인(로컬에서 고친 파일이 있으면 pull이 거부되거나 충돌한다) git status # 2) 원격 정보를 가져온 뒤, 추적 브랜치(보통 main)를 최신으로 맞춘다 git fetch origin git checkout main git pull --ff-only origin main ``` - `git pull`만 쓰고 싶다면 **이미 `main`을 추적 중**이어야 한다. 첫 클론 후 한 번도 브랜치를 안 바꿨다면 `git branch -vv`로 `origin/main`을 따라가는지 본다. - **로컬에서 수정한 파일이 있으면** `git pull`이 실패한다. NAS에서 직접 고친 내용을 버려도 된다면 `git restore .` 등으로 되돌린 뒤 다시 2)를 실행한다. 유지해야 하면 `git stash` 후 pull하고 `git stash pop`(충돌 가능)을 쓴다. - **비공개 저장소**면 NAS에 SSH 키·자격 증명(credential helper)이 등록돼 있어야 `fetch`/`pull`이 된다. 이후 **이미지·번들**을 갱신한다. ```bash cd /volume1/docker/projects/apps/todo # 3) .env로 주소를 두었다면 export는 생략해도 된다. 없으면 6-1과 같이 export 한다. export VITE_PUBLIC_APP_URL="http://192.168.0.50:42881" export VITE_POCKETBASE_URL="http://192.168.0.50:42917" # 4) compose에서 image로 받는 서비스(PocketBase 등) 최신화가 필요하면(선택) docker compose pull # 5) 웹 이미지는 저장소 코드로 다시 빌드해야 반영된다 docker compose up -d --build ``` `VITE_*` 값이 이전과 같아도, 프런트나 `Dockerfile`이 바뀌었으면 **`--build`로 다시 빌드**하는 편이 안전하다. --- ## 11. HTTPS와 PWA(나중에 해도 됨) 집 안 HTTP만으로도 개발·가족용으로는 쓸 수 있다. 다만 **PWA 설치·서비스 워커**는 브라우저·환경에 따라 HTTPS를 요구하는 경우가 많다. 공유기 뒤에서 도메인을 달거나, NAS 리버스 프록시로 **TLS 종료**하는 구성을 최종 목표로 두면 좋다. (구체 도메인·인증서 발급은 NAS/OS마다 다르다.) ### 11-1. `ERR_SSL_VERSION_OR_CIPHER_MISMATCH`(api 서브도메인) PocketBase 컨테이너는 **기본적으로 HTTPS(TLS)를 제공하지 않는다.** `https://api.todo.sori.studio`는 **리버스 프록시가 443에서 TLS를 종료**하고, 뒤쪽은 `http://:42917`처럼 **평문 HTTP**로 PocketBase에 넘기는 구조가 일반적이다. 이 오류는 **브라우저↔443 구간**에서 TLS 협상이 깨질 때 난다. 앱·Compose 코드 문제라기보다 **프록시·인증서·포트** 쪽을 본다. 1. **PocketBase 자체 확인** 브라우저에서 `http://:42917/_/`(호스트 포트는 `docker-compose.yaml` 기준)로 관리자가 열리면 PB는 정상이다. 2. **DNS** `api.todo.sori.studio`가 **지금 TLS를 설정한 그 NAS(또는 프록시)** IP를 가리키는지 확인한다. 3. **리버스 프록시 443 설정** - `api`용 서버 블록에 **유효한 인증서**(Let’s Encrypt 등)가 붙어 있는지, 만료·체인 누락이 없는지. - `proxy_pass` 등 **업스트림은 `http://127.0.0.1:42917`** 처럼 PB의 **HTTP** 포트로 보내는지(HTTPS로 백엔드에 붙이면 PB는 응답하지 않는다). - 443에서 다른 서비스의 기본 인증서·잘못된 가상 호스트가 응답하고 있지 않은지. 4. **Cloudflare 등 CDN을 쓰는 경우** SSL 모드가 **Full(strict)** 인데 오리진에 맞는 인증서가 없으면 비슷한 증상이 날 수 있다. 오리진은 HTTP만 둘 거면 **Flexible**(보안상 트레이드오프 있음) 또는 오리진에 정식 TLS를 올리고 **Full**에 맞추는 식으로 정책을 맞춘다. 5. **터미널 확인(선택)** `curl -vI https://api.todo.sori.studio` 로 응답 헤더·인증서 이름이 기대와 같은지 본다. ### 11-2. Cloudflare Tunnel + NPM, SSL 모드 **Full** **`Full`의 의미(오렌지 구름·일반 프록시 기준):** 사용자↔Cloudflare는 HTTPS이고, Cloudflare↔**오리진(집의 서버)** 도 **HTTPS**로 연결하려 한다. 그래서 오리진 쪽(예: NPM이 443에서 받는 호스트)에는 **TLS가 켜져 있고**, Cloudflare가 신뢰할 수 있는지는 모드에 따라 다르다(`Full`은 자체서명도 허용, **`Full (strict)`** 는 정식 체인 필요). **PocketBase는 HTTP만 제공**하므로, Cloudflare가 최종적으로 붙는 대상은 “PB 컨테이너”가 아니라 **그 앞의 NPM(또는 터널)** 이어야 한다. NPM이 `api.todo.sori.studio`에 대해 443에서 인증서를 내고, **내부 업스트림만** `http://192.168.50.146:42917` 같은 **HTTP**로 넘기면 된다. **Cloudflare Tunnel을 같이 쓰는 경우** 흔한 오해: - 터널의 **Public Hostname** 서비스 URL은 PocketBase에 맞게 **`http://` + LAN IP + 포트**(예: `http://192.168.50.146:42917`)로 두는 경우가 많다. 여기를 **`https://`…로 잘못 두면** PB는 TLS를 못 하므로 협상 오류로 이어질 수 있다. - **Tunnel로 끝내는 호스트**와 **NPM으로만 노출하는 호스트**를 같은 서브도메인에 이중으로 겹치면 경로가 꼬이기 쉽다. `api` 한 줄은 **터널만** 또는 **NPM만** 중 하나가 실제로 받도록 정리하는 것이 좋다. - 대시보드 **SSL/TLS → 개요**의 `Full`은, **DNS가 터널(CNAME)만 타고 오렌지 프록시를 안 쓰는 구성**과는 동작 맥락이 다를 수 있다. 문제가 나면 Zero Trust에서 해당 **Public Hostname**의 스킴·포트·대상 IP를 다시 확인한다. --- ## 12. NAS에 Git이 없을 때 - Mac/PC에서 클론한 뒤 `rsync`나 압축(zip)으로 `todo` 폴더 전체를 NAS에 올리고, NAS에서는 `docker compose up -d --build`만 실행한다. - 이 경우에도 **6절의 `export VITE_POCKETBASE_URL`**은 동일하게 필요하다. --- ## 13. 자주 막히는 지점 | 증상 | 점검 | | --- | --- | | 웹은 뜨는데 데이터가 안 됨 | PocketBase `todos` 컬렉션·필드명, API 규칙, CORS에 웹 출처(`http://…:42881` 또는 `https://todo.sori.studio`)가 들어갔는지 | | 폰에서만 안 됨 | `VITE_POCKETBASE_URL`이 `127.0.0.1`이 아닌지, 폰이 같은 Wi‑Fi인지, NAS IP가 맞는지 | | 빌드 후에도 주소가 이상함 | `docker compose build --no-cache` 후 다시 `up`, 또는 `export`를 잊지 않았는지 | | ARM NAS에서 이미지 오류 | 사용 중인 이미지가 해당 CPU 아키텍처를 지원하는지 로그로 확인한다. | | 컨테이너 이름 충돌 | `pocketbase-todo`·`todo-web`이 이미 있으면 `docker-compose.yaml`의 `container_name`을 바꾼다. | | `fatal: not a git repository` | `apps`가 아니라 **`cd todo`(클론한 폴더)** 안에서 실행했는지 확인한다. | | `git pull`이 안 되거나 안 받아짐 | 10절 순서대로 `git status`·`fetch`·`checkout main`·`pull --ff-only`, 로컬 수정·비공개 저장소 자격 확인 | | `ERR_SSL_VERSION_OR_CIPHER_MISMATCH` (`https://api…`) | 11-1절. 프록시 443·인증서·`proxy_pass`가 PB **HTTP** 포트로 가는지 확인 | | PocketBase `unable to open database file (14)` | `./pb_data` 쓰기 권한. `docker-compose.yaml`에서 `user: "1000:10"`을 켠 상태면 호스트에서 `sudo chown -R 1000:10 pb_data`. 또는 `user` 줄을 주석으로 둔 채 기동해 DB 생성 후 `chown`하고 `user` 재적용. | --- ## 14. 한 페이지로 순서만 보기 1. SSH 접속 2. `cd /volume1/docker/projects/apps` 3. `git clone … todo` **또는** `cd todo` 후 `git clone … .` 4. NAS LAN IP 확인 5. LAN만 쓸 때: `export VITE_PUBLIC_APP_URL="http://:42881"` 및 `export VITE_POCKETBASE_URL="http://:42917"` (또는 6-0과 같이 `.env`에 기록) 6. `docker compose up -d --build` 7. 브라우저: `:42881` 앱, `:42917/_/` 관리자 8. `todos` 컬렉션·CORS·API 규칙 설정 이후 작업은 **`cd …/apps/todo`** → `git fetch` → `git pull --ff-only origin main`(또는 추적 브랜치) → 필요 시 `export` 또는 `.env` 확인 → `docker compose pull`(선택) → `docker compose up -d --build`를 반복하면 된다.