블로그 만들기
개요
목적
- 공부한 내용 및 일상을 기록하는 블로그를 구축
- Git Repository 생성 및 초기 설정, 개발, 배포까지의 전 과정을 경험
기간
2025. 10. 30. ~ (진행 중)
구조
- Backend(Ghost): 글 작성 에디터 사용 및 콘텐츠 관리
- Frontend(Astro): Ghost Content API를 통해 포스트 데이터를 받아 정적 페이지로 렌더링
- Vercel: Astro 정적 파일 배포 및 SEO 최적화
- Oracle: Ghost 서버 호스팅 및 데이터 저장
정보
도메인: shinem.dev (cloudflare에서 구매, $12.20/연)
구축 목표
- 편리한 에디터를 활용해 글을 작성할 수 있는 환경 구성
- SEO 최적화 및 맞춤형 디자인이 가능한 블로그 구축
- 무료 배포 플랫폼을 활용해 도메인 외 비용 최소화
환경
개발 환경
- OS: macOS (Apple Silicon)
- Terminal: zsh
- IDE: VSCode
- 소스 코드 관리: Git + Github
- 패키지 관리: pnpm
- Node 버전 관리: nvm
- DNS 관리 서버: cloudflare
기술 스택
Frontend(Head)
- Astro
Backend(Headless CMS)
- Ghost
- 배포: Oracle Instance (Free Tier)
시스템 구조 (보완 및 데이터 흐름 추가 필요)
Headless CMS 기반 Static Site Architecture
Ghost에서 블로그를 작성하고, Astro에서 API로 콘텐츠를 받아와 정적 페이지 생성(SSG) 및 저장 후 배포
-
Git Repository
- Frontend: molla11/shinem-head
- Astro를 이용한 블로그 배포
- Backend: molla11/shinem-cms
- Ghost를 이용한 블로그 작성
두 저장소는 API를 통해 연결됨
- Frontend: molla11/shinem-head
이후 추가할 내용
- 시스템 구조 및 데이터 흐름
- 배포 파이프라인
- SEO / 디자인 / 확장 계획
- 보안 및 백업
- 향후 계획
Node 버전 관리
개요
사용 목적: 다른 프로젝트들에서 Node v24를 사용하고 있었는데, Ghost에서는 v22만을 지원해 두 버전의 Node를 하나의 macOS에서 동시에 사용해야 함.
사용 환경: macOS (Apple Silicon, zsh)
설치
- nvm 설치:
brew install nvm(homebrew 설치 필요) ~/.nvm디렉토리 생성:mkdir ~/.nvm(nvm(Node Version Manager)의 설정·캐시·Node 버전들이 저장될 폴더).zshrc(사용하는 터미널 설정 파일)에 아래 내용 추가:
export NVM_DIR="$HOME/.nvm"
[ -s "/opt/homebrew/opt/nvm/nvm.sh" ] && . "/opt/homebrew/opt/nvm/nvm.sh"
~/.zshrc변경 사항 적용:source ~/.zshrc- 필요한 버전의 Node.js 모두 설치:
nvm install 22nvm install 24등
버전 전환 방법
-
명령어로 전환:
nvm use 22,nvm use 24와 같이 CLI에서 직접 전환 -
프로젝트별 자동 전환: 각 프로젝트 루트에
.nvmrc파일을 추가 후 버전 명시e.g.
.../projectA/.nvmrc→24,.../projectB/.nvmrc→22위 설정 이후 프로젝트에서
nvm use를 사용하면.nvmrc파일에 따라 Node 버전이 변경됨-
nvm use까지 완전 자동화: 아래 내용을./zshrc에 추가autoload -U add-zsh-hook load-nvmrc() { local nvmrc_path="$(nvm_find_nvmrc)" if [ -n "$nvmrc_path" ]; then local nvmrc_node_version=$(cat "$nvmrc_path") nvm use --silent "$nvmrc_node_version" >/dev/null fi } add-zsh-hook chpwd load-nvmrc load-nvmrc
-
이제 프로젝트별로 다양한 Node 버전을 편리하게 사용할 수 있다!
다른 방안
- mise: nvm과 유사하게 Node 버전을 관리할 수 있으며, Python, Java 등 다양한 언어의 버전도 함께 관리 가능
Git Repository 구성
- shinem-head: Astro 기반의 블로그 Head
shinem-cms: Ghost 기반의 블로그 CMS
두 개의 템플릿을 사용해 Astro 설치 및 사용
개요
Astro를 사용한 블로그 Head를 구축
기존에는 Template을 1개만 사용할 수 있지만, Template (blog, statlight)를 모두 활용한 웹 페이지 구축
개발 환경
패키지 관리: pnpm
Integration: react, typescript, tailwind, mdx, seo, sitemap, rss, partytown, vercel
Template: minimal로 create하고, blog 및 starlight 추가
개발 과정
-
Astro로 프로젝트 초기화: 빈 프로젝트에 진입 후, 터미널에서 아래 명령 실행
pnpm create astro@latest . --template minimal- —template minimal: 최소한의 템플릿 설치(추후 커스터마이징을 위함)
-
Astro Integration 추가: 아래 명령 실행
pnpm astro add react mdx sitemap partytown vercel- react/typescript: React 컴포넌트 사용을 위함
- 추가 예정
-
Astro Utility 추가
pnpm add @astrojs/rss astro-seo- 추가 예정
-
VSCode Extension 추가 (Astro)
- Extensions 단축키: (Ctrl + Shift + x / Cmd + Shift + x)
-
blog 템플릿 추가
-
blog Example 로드: 아래 명령 실행
pnpm dlx degit withastro/astro/examples/blog temppnpm dlx: 일회성 명령 실행 (≒ npx)degit: git 기록은 제외하고 복사- 이후 인자들: 첫 번째 인자의 내용을 두 번째 인자 하위(
프로젝트 루트/temp)에 복사
(optional)
.gitignore에temp/추가 -
temp/public,temp/src내용을 현재 프로젝트에 병합: 아래 명령 실행*
rsync명령은 macOS가 지원rsync -av --progress temp/public .: temp/public 내용을 프로젝트로 복사(병합)rsync -av --progress temp/src .: temp/src 내용을 프로젝트로 복사(병합) -
astro.config.mjs파일의defineConfig인자 수정:-
site속성 추가(배포 시 사용할 주소, 추후 변경 가능. e.g.site: “https://shinem.dev”) -
현재
defineConfig호출 관련부:export default defineConfig({ site: "https://shinem.dev", integrations: [react(), mdx(), sitemap(), partytown()], adapter: vercel() });
-
-
pnpm dev명령으로 정상 동작 확인 -
temp/삭제 (rm -rf temp)
-
-
Starlight 템플릿 추가
-
Starlight 라이브러리 추가: 아래 명령 실행
pnpm astro add starlight -
Starlight Example: basics 로드: 아래 명령 실행
pnpm dlx degit withastro/starlight/examples/basics temp -
temp/src내용을 프로젝트에 병합: 아래 명령 실행*
rsync명령은 macOS가 지원rsync -av --progress --exclude "content.config.ts" temp/src .: temp/src 내용을 프로젝트로 복사(병합)--exclude "content.config.ts":content.config.ts는 덮어쓰기 대상에서 제외
content.config.ts병합
기존의
src/content.config.ts와temp/src/content.config.ts의 내용을 아래와 같이 병합-
import 부분 병합:
defineCollection은 중복 import 중이므로temp/의 importdefineCollection부분 제거 후 나머지를 병합 -
export 병합:
collections객체에서blog,docs속성을 모두 내보낼 수 있게 병합 -
현재
collections선언 관련부:export const collections = { blog, docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), };
-
astro.config.ts병합기존의
astro.config.ts에temp/astro.config.ts내용을 병합-
temp/astro.config.ts의starlight()설정을astro.config.ts의starlight()자리로 복사 -
현재
defineConfig호출 관련부:export default defineConfig({ site: "https://shinem.dev", integrations: [ react(), mdx(), sitemap(), partytown(), starlight({ title: 'My Docs', social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/withastro/starlight' }], sidebar: [ { label: 'Guides', items: [ // Each item here is one entry in the navigation menu. { label: 'Example Guide', slug: 'guides/example' }, ], }, { label: 'Reference', autogenerate: { directory: 'reference' }, }, ], }), ], adapter: vercel(), });
-
-
Starlight의 루트를
/docs로 라우팅Starlight 자체 루트 변경 기능: 현재(25. 11. 3.) 지원하지 않음
혹시 관련 업데이트가 되었다면 알려주시면 감사하겠습니다…
해결 방안: 직접 하위 경로로 이동 및 링킹 변경
- 현재
…/docs내부의 폴더/파일을…/docs/docs로 이동
-
디렉토리 구조
src/ ├─ content/ │ └─ docs/ │ ├─ docs/ │ │ ├─ index.mdX │ │ └─ guides/ │ │ │ └─ example.md │ │ └─ reference/ │ │ └─ index.md
astro.config.mjs의starlight()설정 - sidebar 속성의slug/directory변경:docs/하위로 변경 (e.g.a/b/c→docs/a/b/c)
-
intergrations 일부:
starlight({ title: 'My Docs',social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/withastro/starlight' }], sidebar: [ { label: 'Guides', items: [ // Each item here is one entry in the navigation menu. { label: 'Example Guide', slug: 'docs/guides/example' }, ], }, { label: 'Reference', autogenerate: { directory: 'docs/reference' }, }, ], }),
- (optional) 내부 문서 링크들의 위치 변경
src/content/docs/docs/index.mdx파일의 이미지(houston.webp) 경로 변경 (../를 앞에 추가)- hero - actions - 첫 번째 link를
docs/하위로 변경
- 현재
-
pnpm dev명령으로 정상 동작 확인 -
temp/삭제 (rm -rf temp)
-
Ghost CMS 구축
개요
Fly.io를 활용해 무료 CMS 서버 구축
Shinem의 글 작성/관리 플랫폼 및 백엔드로 활용 예정
개발 환경
패키지 관리: pnpm
배포 환경: Oracle Cloud Free Tier
개발 과정
- Fly.io 활용 방법 (취소됨)
- Fly.io 배포 환경 구축
- CLI 클라이언트 설치:
brew install flyctl - Fly.io 로그인:
flyctl auth login→ 브라우저에서 로그인 (Sign with Github 추천)
- CLI 클라이언트 설치:
- Ghost 프로젝트 생성
- Ghost의 최신 Docker 이미지로 프로젝트 생성:
flyctl launch --image=ghost:latest --no-deploy→ 설정을 변경하겠냐는 질문에 N 입력 (변경하지 않음) - 데이터베이스 사용을 위해 Fly.io의 디스크 저장소 3GB 볼륨을 생성:
flyctl volumes create data -s 3
- Ghost의 최신 Docker 이미지로 프로젝트 생성:
- Fly.io 배포 환경 구축
- Node 프로젝트 초기화 (취소됨)
-
Node 프로젝트 초기화:
pnpm init -
Ghost CLI 도구 설치:
pnpm install ghost-cli@latest -gERR_PNPM_NO_GLOBAL_BIN_DIR오류 발생 시:pnpm setup명령으로 환경 변수 및 터미널 경로 추가 →source ~/.zshrc명령 실행해 변경 적용 → 다시 설치 명령 실행
-
-
Oracle 계정 생성: 정보 입력 및 결제 수단 등록(Free Tier를 사용하면 결제되지 않음)
Region을 한국 춘천(Chuncheon)으로 설정(25. 11. 04. 기준)
이메일 인증 후 약 5~10분 대기 필요
-
Compartment 생성: Identity → Compartments에서 Create Compartment 생성
e.g. name: weblog-infra, description: Ghost blog server
-
Instance 생성: Instances에서 Create Instance
Basic Information
name: shinem-cms
Create in compartment: weblog-infra
Placement: AD 1 (Chuncheon)
Image & Shape
- ARM
- Image: Ubuntu - Canonical Ubuntu 22.04 Minimal aarch64
ARM Shape에서 실행한다면 aarch64가 붙은 Image를 선택해야 함
필자는 ARM Shape 메모리가 더 커서 이를 시도했다가, Chuncheon Region의 Out of capacity 오류로 인스턴스 생성을 실패했다. 다른 방안으로 아래의 Others를 참고. 운이 좋다면 가능할 수도 - Shape: Ampere(Arm-based processor): VM.Standard.A1.Flex (OGPU: 1, Memory: 6GB)
(Always Free-eligible 태그가 붙은 Shape을 사용해야 한다!)
- Image: Ubuntu - Canonical Ubuntu 22.04 Minimal aarch64
- Others
- Image: Ubuntu - Canonical Ubuntu 22.04 Minimal
- Shape: Specialty and previous generation: VM.Standard.E2.1.Micro (OGPU: 1, Memory: 1GB)
(Always Free-eligible 태그가 붙은 Shape을 사용해야 한다!)
Security
Shielded Instance: On (optional)
Networking
VNIC name: shinem-cms-vnic
Primary network
Primary network: Create new vcn
New virtual cloud network name: vcn-shinem-cms
Create in compartment: weblog-infra
Subnet
Create new public subnet: check
New subnet name: subnet-shinem
Create in compartment: weblog-infra
CIDR block: 10.0.0.0/24
Add SSH Keys: Generate a key pair for me
Download private key, public key
~/.ssh/에 보관 추천private key (
.key파일)권한 변경: 소유자만 읽기/쓰기 가능:chmod 600 ~/.ssh/ssh-key-2025-11-03.key- private key를 분실할 경우 해당 인스턴스에 접속 불가
Boot volume
Specify a custom boot volume size and performance setting: Off (기본 46.6GB 사용, optional)
Use in-transit encryption: On
Encrypt this volume with a key that you manage: Off (optional)
Block volumes: 추가 안 함.
- ARM
-
Instance 생성: Create 클릭
만약 Out of capacity for shape VM.Standard.A1.Flex 등의 용량 부족 오류가 있다면: 다른 Image/Shape (3. - Basic Informations - Image & Shape - Others (not ARM))으로 시도
-
Public subnet 연결: Instances → Compartment 선택 → shinem-cms → Networking → Connect public subnet to internet (Connect 버튼 클릭) → Create → Close → Public IP 주소 할당 완료
-
Public IP 확인: Instance shinem-cms → Detail → Public IP Address (외부 노출 자제)
-
SSH 연결: 터미널에서 아래 명령 실행
ssh -i ~/.ssh/ssh_key_name.key ubuntu@Public_IP→ yes 입력 → 접속 완료 (프롬프트가 ubuntu@shinem-cms로 변경됨) -
Ghost CMS 설치
-
시스템 업데이트 & 도구 설치: 아래 명령 실행
시스템 업데이트:
sudo apt update && sudo apt upgrade -y- 최신 커널로 변경하라는 안내가 있다면(Newer kernel available …): q / None 등을 사용해 나감 →
sudo reboot→ 잠시 후 다시 SSH 연결
도구 설치:
sudo apt install -y curl unzip vim ufw python3 python3-setuptools build-essential(오래 걸림) - 최신 커널로 변경하라는 안내가 있다면(Newer kernel available …): q / None 등을 사용해 나감 →
-
swap 메모리 생성: RAM이 너무 작아 2GB의 추가 메모리 생성
(만약 aarch64 (RAM 6GB)라면 건너뛰어도 된다.)
sudo fallocate -l 2G /swapfile: 2GB의 빈 파일 생성sudo chmod 600 /swapfile: root만 접근할 수 있게 변경sudo mkswap /swapfile:/swapfile을 스왑 영역으로 초기화sudo swapon /swapfile:/swapfile을 활성화echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab:/etc/fstab(부팅 시의 마운트 설정 파일)에/swapfile non swap sw 0 0을 추가: 부팅할 때/swapfile을 swap으로 자동 마운트결과 확인:
free -h -
Node 22 & Ghost-CLI 설치
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -: Node 설치용 스크립트 다운로드 및 저장소 등록sudo apt install -y nodejs: Node 설치sudo npm install -g ghost-cli: Ghost-CLI 설치 (오래 걸림)설치 확인:
ghost version -
Ghost 설치:
sudo mkdir -p /var/www/ghost: Ghost 표준 디렉토리 (/var/www/ghost) 생성sudo chown ubuntu:ubuntu /var/www/ghost: 소유자를 ubuntu로 설정cd /var/www/ghost: 해당 디렉토리로 이동ghost install local: Ghost 설치 (오래 걸림)→ 자동으로
ghost start명령이 실행되어,localhost:2368에서 Ghost가 실행 중이라는 문구가 나오면 성공이다!성공 여부 확인 명령:
ghost ls-
ghost start명령은 Ghost가 설치된 위치(/var/www/ghost등)에서 실행해야 한다! -
실패 기록
나는 pnpm을 사용하려고 했다가 sqlite3를 사용하는 부분에서 충돌이 있어 다시 npm으로 시도한다. npm으로 설치하는 것을 추천한다.
실패 원인: npm 대신 pnpm을 사용하려고
npm i -g pnpm을 사용해 pnpm을 설치하고, 나머지 installing을 pnpm으로 진행함.-
설치 로그 확인 필요: Starting Ghost 실패:cd versions/6.6.0(25. 11. 04. 기준) →pnpm add sqlite3→cd /var/www/ghost→ghost start -
여전히 같은 문제가 발생한다면:sudo apt update→sudo apt install -y python3 python3-setuptools build-essential(약 10~20분 소요) →versions/6.6.0/으로 이동 → 아래 명령 실행~~pnpm remove sqlite3: sqlite3 제거~~~~pnpm add sqlite3: sqlite3 다시 추가/빌드~~ → 실패
얻은 교훈: 항상 pnpm이 좋은 것이 아니다. npm을 쓰라고 하면 npm을 사용해야겠다. 그런데 다시 추가/빌드하는 부분만 npm을 사용해도 되는가? (pnpm과 npm을 섞어 써도 되는가?)에 대한 궁금증은 있다.
-
-
-
-
DNS 설정: Cloudflare에서 DNS 추가
Type: A
Name: cms (서브도메인 설정: cms라면 cms.shinem.dev와 같이 접속)
Content: Public IP (도메인 확인 방법: ssh 연결에서
curl ifconfig.meProxy status: DNS only (인증서 발급을 위해 임시 Off, 발급 후 다시 켤 것을 추천)
TTL: 1 hr
-
Security Rules 변경
Oracle VCN → vcn-shinem-dns → Security → Network Security Groups → ig-quick-action-NSG → Security Rules → Add rules
- Add Ingress Rule: (HTTP 통신을 위함)
- Stateless: Off
- Source Type: CIDR
- Source CIDR: 0.0.0.0/0
- IP Protocol: TCP
- Source Port Range: All
- Destination Port Range: 80
- Description: Allows HTTP traffic (Let's Encrypt challenge & redirect)
- Add Ingress Rule: (HTTPS 통신을 위함)
- Stateless: Off
- Source Type: CIDR
- Source CIDR: 0.0.0.0/0
- IP Protocol: TCP
- Source Port Range: All
- Destination Port Range: 443
- Description: Allows HTTPS traffic
-
nginx 설치: 아래 명령 실행
sudo apt install -y nginx sudo systemctl enable nginx sudo systemctl start nginx새 파일 생성:
sudo vim /etc/nginx/sites-available/ghost.conf -
방화벽 설정 (서버 내부, VCN)
- 서버 내부 설정: ufw 대신 iptables 사용
Ghost가 사용하는 포트 Open:sudo ufw allow 2368SSH를 위한 포트 Open:sudo ufw allow 22(22번 포트가 안 열리면 방화벽 활성화 시 ssh 사용 불가)HTTP/HTTPS를 위한 포트 Open:sudo ufw allow 80,sudo ufw allow 443방화벽 활성화 및 설정 적용:sudo ufw enable→sudo ufw reload설정 확인:sudo ufw status→ 2368, 22번 포트의 Open 확인
-
ufw 비활성화
sudo systemctl stop ufwsudo systemctl disable ufw -
iptables 설정: 아래 명령 실행
sudo iptables -I INPUT 5 -i ens3 -p tcp --dport 80 -m state --state NEW,ESTABLISHED -j ACCEPT: HTTP(80) 설정sudo iptables -I INPUT 5 -i ens3 -p tcp --dport 443 -m state --state NEW,ESTABLISHED -j ACCEPT: HTTPS(443) 설정sudo netfilter-persistent save: iptables 규칙을 영구적으로 저장
VCN 설정
Oracle Cloud Console → Networking → VCN → VCN 선택 (vcn-shinem-cms) → Security → Default Security List for vcn-shinem-cms → Security rules:
- Add Ingress Rule: (HTTP 통신을 위함)
- Stateless: Off
- Source Type: CIDR
- Source CIDR: 0.0.0.0/0
- IP Protocol: TCP
- Source Port Range: All
- Destination Port Range: 80
- Description: Allows HTTP traffic (Let's Encrypt challenge & redirect)
- Add Ingress Rule: (HTTPS 통신을 위함)
- Stateless: Off
- Source Type: CIDR
- Source CIDR: 0.0.0.0/0
- IP Protocol: TCP
- Source Port Range: All
- Destination Port Range: 443
- Description: Allows HTTPS traffic
- 서버 내부 설정: ufw 대신 iptables 사용
-
nginx 설정
-
ghost.conf파일 설정:sudo vi /etc/nginx/sites-available/ghost.conf아래 내용 입력 후 저장 (입력 모드:
i, 저장: 명령 모드에서:wq)server { listen 80; listen [::]:80;server_name cms.shinem.dev; location / { proxy_pass http://127.0.0.1:2368; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location ~ /.well-known/acme-challenge { root /var/www/ghost/system/nginx-root/; allow all; }}
-
심볼릭 링크 생성:
sudo ln -sf /etc/nginx/sites-available/ghost.conf /etc/nginx/sites-enabled/ghost.conf -
설정 확인:
sudo nginx -t→ 마지막 문구가...nginx.conf test is successful라면 성공 -
nginx 재시작:
sudo systemctl restart nginx
-
-
HTTPS 인증서 발급: 아래 명령 실행
sudo apt install -y certbot python3-certbot-nginx: 인증서 발급에 필요한 패키지 설치sudo certbot --nginx -d [cms.shinem.dev](http://cms.shinem.dev): SSL 인증서 발급, Nginx 설정 수정, 80 → 443 리다이렉트, 자동 갱신(cron)
→ 이메일 입력 → ToS 동의 → 이메일로 소식 받기: 비동의 추천 -
Ghost 설정
/var/www/ghost/config.development.json을 에디터로 열어"url"을 사용 중인 url로 수정한다.-
config.development.json예시{ "url": "https://cms.shinem.dev", "server": { ...
-
-
Cloudflare 설정 (optional)
Cloudflare → DNS → Records → Proxy status: Proxied 선택
완성! 이제
https://cms.shinem.dev에서 Ghost에 접속할 수 있다. Admin 설정은 https://cms.shinem.dev/ghost 에서 하면 된다. 필자는 설정에서 페이지 잠금을 걸어 두었다.- 배포 시:
ghost start --production명령을 사용
- Add Ingress Rule: (HTTP 통신을 위함)
Astro Blog 개발
Ghost API 연동
-
Ghost Integration 추가: Ghost Admin 설정 → Advanced → Add custom integration
Content API key를 복사해 둘 것