이 블로그는 소스가 공개돼 있었다. github.io 레포에 astro 소스가 그대로 들어 있어 누구나 src/를 열어 볼 수 있었다. 정적 블로그는 어차피 빌드된 HTML을 모두에게 서빙하니 코드가 공개돼도 큰일은 아니다. 그런데 신경 쓰이는 것이 둘 있었다. 미발행 초안과 커밋 히스토리다. published: false로 묻어 둔 글과 .hide/ 폴더에 제목만 잡아 둔 글이 전부 public 레포에 남아 있었다.
그래서 소스는 private로 감추고, 사이트는 지금처럼 public으로 무료 유지하고 싶었다. 문제는 이 둘이 GitHub Pages에서 그냥은 양립하지 않는다는 점이다.
이 글은 그 둘을 쪼개 붙인 기록이며, 다루는 내용은 다음과 같다.
- 감춰서 실익이 있는 것과, 어차피 공개인 것의 구분
- private 레포에서 Pages를 서빙할 수 없다는 제약과 그 우회
- private 소스 → 빌드 → public 배포로 이어지는 구조
- 크로스 레포 배포에 필요한 조각들
- 겪은 함정 세 가지
감추면 실익이 있는 것, 이미 공개된 것
먼저 계산부터 하고 넘어가는 게 좋다. 정적 사이트에서 “소스를 감춘다”의 실익은 생각보다 좁다. 빌드된 HTML·CSS·JS는 어차피 모든 방문자에게 그대로 내려간다. private로 실제로 가려지는 것은 다음 정도다.
- 마크다운 원본과 미발행 초안 (
published: false,.hide/) astro.config·스키마 같은 설정- 커밋 히스토리와 코드 주석
내 경우 실질적인 동기는 초안이었다. 아이디어만 적어 둔 글이 public 히스토리에 남는 게 싫었다. 코드 자체를 숨기는 건 부차적이었고, 어차피 빌드 결과물로 대부분 드러난다. 이 계산을 먼저 해 두면 무엇을 위해 복잡도를 늘리는지가 분명해진다.
private 레포에서 Pages를 못 서빙한다는 제약
가장 단순한 해법은 레포를 private로 바꾸고 Pages를 그대로 쓰는 것이다. 그런데 이게 막힌다. GitHub Pages를 private 레포에서 서빙하려면 GitHub Pro나 Advanced Security가 필요하다. 무료 플랜에서 Pages는 public 레포에서만 동작한다.
그래서 방향을 뒤집었다. 소스는 private, 서빙은 public — 두 레포로 나누되 Pages는 계속 public 레포가 담당하게 하는 것이다. 이러면 유료 요소 없이 무료 플랜을 유지한다.
두 레포로 나눈 구조
[private: seotory/blog] [public: seotory.github.io]
astro 소스 + 히스토리 CI 빌드 빌드 결과물(dist)만
└─ .github/workflows/deploy.yml ───push───▶ └─ index.html, _astro/, CNAME ...
│ Pages가 서빙
▼
blog.seotory.com
- private 레포가 진짜 작업 공간이다. 글도 여기서 쓰고 커밋 히스토리도 여기 쌓인다.
- push하면 CI가
astro build로dist/를 만들고, 그 결과물을 public 레포로 push한다. - public 레포는 빌드 결과물만 갖는다. 소스는 없다. Pages는 여기서 서빙하므로 public이라 무료다.
즉 public 레포는 소스의 사본이 아니라 배포 산출물의 저장소가 된다.
크로스 레포 배포의 조각들
private CI가 다른 레포로 push하는 건 기본 설정만으로는 안 된다. 필요한 조각이 둘 있다.
배포 키가 필요한 이유
워크플로가 기본으로 받는 GITHUB_TOKEN은 자기 레포에만 권한이 있다. 다른 레포로 push하려면 별도 자격증명이 필요하고, SSH 배포 키를 쓰면 깔끔하다.
- 키쌍을 만들어 공개키를 public 레포의 Deploy key(쓰기 허용)로 등록한다.
- 개인키를 private 레포의 Actions secret(
DEPLOY_KEY)으로 등록한다.
배포 워크플로
peaceiris/actions-gh-pages가 이 크로스 레포 push를 지원한다.
- uses: peaceiris/actions-gh-pages@v4
with:
deploy_key: ${{ secrets.DEPLOY_KEY }}
external_repository: seotory/seotory.github.io
publish_branch: master
publish_dir: ./dist
force_orphan: true
external_repository가 대상 레포, deploy_key가 위에서 만든 개인키다. force_orphan: true는 push할 때마다 대상 브랜치를 단일 커밋으로 덮어쓴다. 덕분에 public 레포에 빌드 결과물이 쌓이지 않고 소스도 절대 남지 않는다. 처음 돌릴 때는 이 옵션이 기존 public 레포의 소스 히스토리까지 한 번에 갈아엎어 준다.
세 가지 함정
브랜치 서빙에서 Jekyll이 삼키는 _astro 폴더
Pages 소스를 “Deploy from a branch”로 바꾸면 Pages는 그 브랜치를 Jekyll로 한 번 처리한다. 그런데 Jekyll은 밑줄(_)로 시작하는 폴더를 빌드에서 제외한다. astro의 정적 자산은 _astro/에 들어 있으므로, 그대로 두면 CSS·JS가 전부 404가 된다.
해결은 출력에 빈 .nojekyll 파일을 하나 넣는 것이다. 이 파일이 있으면 Pages가 Jekyll 처리를 건너뛰고 파일을 그대로 서빙한다. peaceiris/actions-gh-pages는 이 파일을 자동으로 넣어 주지만, 손으로 배포한다면 직접 넣어야 한다.
private 레포에서 매번 실패하는 CodeQL
public 레포에서 쓰던 CodeQL 워크플로를 그대로 들고 오면 private 레포에서 매번 실패한다. 분석은 되는데 마지막 업로드에서 “code scanning is not enabled”로 막힌다. 코드 스캐닝은 public 레포에선 무료지만, private 레포에선 Advanced Security가 있어야 켜진다.
배포와는 무관한 실패지만 빨간 표시가 매 커밋에 붙는다. 정적 블로그에 코드 스캐닝은 실익이 없으니 워크플로를 지웠다.
force_orphan이 회수하지 못하는 과거
솔직한 한계 하나. force_orphan은 앞으로의 소스와 초안을 확실히 감춘다. 그런데 이미 public이던 시절의 코드는 완전히 회수되지 않는다. 누군가 이미 clone·fork했거나, 강제 푸시 뒤에도 한동안 SHA로 접근 가능하거나, 검색엔진·아카이브에 캐시된 것까지는 못 지운다. 흔적까지 지우려면 public 레포를 삭제하고 새로 만드는 수밖에 없다. 개인 블로그라 대개 force_orphan으로 충분하지만, 이건 알고 선택해야 한다.
정리
최종 구조를 요약하면 이렇다.
private (seotory/blog) |
public (seotory.github.io) |
|
|---|---|---|
| 내용 | astro 소스 + 히스토리 + CI | 빌드 결과물만, 단일 커밋 |
| 역할 | 작업·빌드 | Pages 서빙 (무료) |
| 자격증명 | DEPLOY_KEY (개인키) |
Deploy key (공개키, 쓰기) |
이번 분리에서 남는 것은 다음과 같다.
- 정적 사이트에서 감출 수 있는 건 소스·초안·설정·히스토리뿐이다. 빌드 결과물은 어차피 공개다. 무엇을 위해 복잡도를 늘리는지부터 계산한다.
- private 레포에서 무료로 Pages를 서빙할 수는 없다. 대신 소스는 private, 서빙은 public으로 나누면 무료를 유지한다.
- 크로스 레포 push는
GITHUB_TOKEN으로 안 된다. 배포 키가 필요하다. - 브랜치 서빙에는
.nojekyll이 필수다. 없으면_astro/가 통째로 사라진다. force_orphan은 미래를 감출 뿐, 이미 공개된 과거는 못 지운다.
소스를 감추는 일은 생각보다 확실하지 않고, 대신 초안을 안전하게 쌓아 둘 공간을 얻는 게 이 구조의 실익이다. 그 목적이 분명하다면 무료 플랜에서도 충분히 만들 수 있다.