독자 도메인 + Google Sites(Google Apps 시대의)로 운영하던 이 사이트는 오래전에 Google Sites가 사용 불가가 되어 https://www.ixam.net 라는 URL을 방치하고 있었는데, 슬슬 어떻게든 활용하고 싶어 리뉴얼을 결심한 것이 계기다. 어차피 할 거면 서버리스에, Google Sites처럼 서비스 종료에 끌려가지 않는 형태로 만들고 싶어서 ChatGPT와 함께 시도한 결과물이다.

솔직히 ChatGPT만으로 금세 끝나겠지 했는데 의외로 만만치 않았다. 기본 요건을 빠뜨리거나 트러블슈팅이 뱅뱅 도는 일이 잦았고, 결국에는 내가 검색한 GitHub 리포지터리(https://github.com/patrickgrey/website/) 등을 참고하면서 공동 작업으로 겨우 완성했다.

기술력은 있지만 착각이나 요건 인지 오류를 자주 일으키는 부하를 수정해 가며 완성으로 이끄는 느낌이 강했고, 그런 수준까지 올라온 AI가 대단하다고도 느꼈다. 그리고 내용이 복잡해질수록 점점 짜증을 돋우는 수준의 바보 짓을 발휘한다. 이것도 어떤 의미에서는 리얼일지도?

지금으로서는 길게 이어지는 대화로 전제를 모두 이해시키려 하기보다는, 어느 정도 이야기가 꼬여 오면 사람이 정리해서 새 스레드에서 다시 지시하는 편이 쓸 만한 모양이다. 그래도 나 혼자였다면 조사·작업·학습량과 끈기 면에서 분명히 못했을 테니, 생성 AI의 생산성은 정말 대단하다.


목표

  • https://www.example.com/ 에서 블로그를 표시할 수 있다.
  • https://www.example.com/admin/ 에서 DecapCMS 로 로그인해 글을 작성할 수 있다.
  • 작성한 글은 GitHub 에 커밋되고, 자동으로 Cloudflare 에 배포된다.
  • 참고로 현재 시점에서 추가 러닝 비용은 0 (도메인 등록 비용은 원래 발생한다).

0. 전제와 폴더 구성

0-1. 전제

  • 도메인(example.com)은 이미 보유하고 있다고 가정한다.
  • OS 는 Windows / macOS 어느 쪽이든 가능하며, 명령 예시는 둘 다 적는다.
  • 사용할 서비스(무료 플랜으로 충분)

0-2. 리포지터리의 주요 구성(예)

hugo.toml
content/
  blog/
  posts/
data/
functions/
  api/
    auth.js
    callback.js
layouts/
  _default/
    baseof.html
    index.html
    list.html
    single.html
static/
  _headers
  admin/
    config.yml
    index.html
  css/
    main.css

이 구성은 다음과 같은 역할을 갖는다.

  • hugo.toml … 사이트 전체 설정(baseURL 은 본番 URL 로 교체)
  • functions/api/*.js … Cloudflare Pages Functions(GitHub OAuth용 /api/auth/api/callback)
  • layouts/_default/*.html … Hugo 템플릿
  • static/admin/DecapCMS 화면과 설정
  • static/css/main.css … 스타일(CSS)
  • static/_headers … Cloudflare Pages HTTP 헤더 설정(선택)

※ 이 매뉴얼에서는 **환경에 따라 교체가 필요한 부분을 “교체 체크”**라고 명시한다.


1. 사전 준비

1-1. GitHub 계정 만들기

  • 브라우저에서 GitHub 에 접속해 가입한다.

1-2. Cloudflare 계정 만들기

  • 브라우저에서 Cloudflare 에 접속해 가입한다.

1-3. Git 과 Hugo 설치

  • Windows(PowerShell)

    PS C:\Users\alice> winget install Git.Git
    PS C:\Users\alice> winget install Hugo.Hugo.Extended
    
  • macOS(Terminal)

    mac:~ dev$ brew install git
    mac:~ dev$ brew install hugo
    

2. 본 절차(배포까지)

2-1. 로컬에서 리포지터리 준비

  1. GitHub 에서 빈 리포지터리를 만든다.
    • 화면: GitHub > New repository
    • 이름 예: my-hugo-blog
  2. 로컬에 클론한다.
    • Windows

      PS C:\work> git clone https://github.com/<YOUR_GH_USERNAME>/my-hugo-blog.git
      PS C:\work> cd .\my-hugo-blog
      
    • macOS

      mac:~/work dev$ git clone https://github.com/<YOUR_GH_USERNAME>/my-hugo-blog.git
      mac:~/work dev$ cd my-hugo-blog
      
  3. 앞서 소개한 폴더·파일 일식을 리포지터리 루트에 배치한다.

교체 체크(반드시 수정)

  • hugo.toml

    baseURL = "https://www.example.com"   # ← 본番 URL 로 교체(예: https://www.example.com)
    languageCode = "ja-jp"
    title = "Example Blog"
    publishDir = "public"
    
    [permalinks]
      blog = "/blog/:year/:month/:slug/"
    
  • static/admin/config.yml

    backend:
      name: github
      repo: <YOUR_GH_USERNAME>/my-hugo-blog   # ← 자신의 GitHub 리포지터리로 교체
      branch: master                          # ← 리포지터리 기본 브랜치명(main 또는 master)
      base_url: https://www.example.com       # ← 본番 URL 로 교체
      auth_endpoint: /api/auth                # ← Functions 엔드포인트(고정)
    media_folder: static/uploads
    public_folder: /uploads
    

참고: functions/api/auth.jsfunctions/api/callback.js 는 수정 없이 사용 가능하다. url.origin 을 사용하므로 환경 의존 하드코딩이 없다.

  1. 최초 커밋 및 푸시
    • Windows

      PS C:\work\my-hugo-blog> git add -A
      PS C:\work\my-hugo-blog> git commit -m "Initial commit: Hugo + [DecapCMS](https://decapcms.org/) + CF Pages"
      PS C:\work\my-hugo-blog> git push -u origin master
      
    • macOS

      mac:~/work/my-hugo-blog dev$ git add -A
      mac:~/work/my-hugo-blog dev$ git commit -m "Initial commit: Hugo + [DecapCMS](https://decapcms.org/) + CF Pages"
      mac:~/work/my-hugo-blog dev$ git push -u origin master
      

2-2. GitHub OAuth App 만들기(DecapCMS 로그인용)

  1. 화면: GitHub > Settings > Developer settings > OAuth Apps > New OAuth App
  2. 입력:
    • Application name: [DecapCMS](https://decapcms.org/) for my-hugo-blog
    • Homepage URL: https://www.example.com
    • Authorization callback URL: https://www.example.com/api/callback중요
  3. 생성 후 표시되는 정보:
    • Client ID
    • Client Secret(새로 발급해 기록)

이 값들은 Cloudflare Pages 의 환경 변수에 설정한다.


2-3. Cloudflare Pages 프로젝트 생성

  1. 화면: Cloudflare 대시보드 > Pages > Create a project > Connect to Git
  2. GitHub 와 연결해 리포지터리 my-hugo-blog 선택
  3. 빌드 설정:
    • Framework preset: None(또는 Cloudflare 자동 감지)
    • Build command: hugo
    • Build output directory: public
    • 환경 변수:
      • HUGO_VERSION = 0.128.0 (예. 로컬 Hugo 버전과 맞추면 안전)
      • GITHUB_CLIENT_ID = (이전 섹션에서 확보)
      • GITHUB_CLIENT_SECRET = (이전 섹션에서 확보)
  4. Save and Deploy 를 눌러 최초 배포를 기다린다.
    • 성공하면 *.pages.dev 형태의 프리뷰 URL 이 발급된다.

2-4. 독자 도메인 www.example.com 연결

  1. 화면: Cloudflare > Pages >(대상 프로젝트)> Custom domains > Set up a custom domain
  2. www.example.com 을 입력해 추가한다.
  3. Cloudflare 로 도메인을 이전하지 않은 경우:
    • Cloudflare 화면에 표시되는 DNS 레코드 정보(예: CNAME www -> <project>.pages.dev)를 적어 둔다.
    • 도메인을 관리 중인 레지스트라 화면에서 해당 레코드를 표시된 개념에 맞춰 설정한다(세부 절차는 레지스트라마다 다름).
  4. 반영 후 https://www.example.com/ 로 접속해 사이트가 열리는지 확인한다.

팁: Cloudflare 에 도메인을 추가한 상태라면 버튼 한 번으로 필요한 DNS 가 자동 설정된다.


2-5. DecapCMS 관리 화면에 로그인

  1. 브라우저에서 https://www.example.com/admin/ 을 연다.
  2. “Login with GitHub” 버튼을 눌러 GitHub OAuth 를 승인한다.
  3. 처음에는 GitHub 에서 “이 앱을 승인할까요?”라고 묻기 때문에 Authorize 를 누른다.

로그인에 실패하면 OAuth Callback URL 과 Cloudflare 환경 변수(GITHUB_CLIENT_ID/SECRET)를 다시 확인한다.


3. 운영 절차(기사 작성·로컬 동기화·템플릿 편집)

3-1. CMS 에서 기사 작성

  1. 화면: https://www.example.com/admin/
  2. 왼쪽 내비게이션에서 BlogNew blog 선택
  3. Title, Publish Date, Description, Body 입력
  4. Publish 를 누르면 GitHub 에 자동 커밋 → Cloudflare 가 자동 배포

생성된 Markdown 은 content/blog/ 에 저장된다.

3-2. 로컬 리포지터리를 최신 상태로 유지(CMS 게시 후)

  • Windows

    PS C:\work\my-hugo-blog> git pull origin master
    
  • macOS

    mac:~/work/my-hugo-blog dev$ git pull origin master
    

이렇게 로컬에 최신 글 파일을 내려받고 나서 템플릿이나 CSS 를 안전하게 수정한다.

3-3. 로컬에서 디자인·템플릿 수정

  1. 로컬 개발 서버를 기동한다.
    • Windows

      PS C:\work\my-hugo-blog> hugo server -D
      
    • macOS

      mac:~/work/my-hugo-blog dev$ hugo server -D
      
    • 브라우저에서 http://localhost:1313/ 확인

  2. 수정 포인트 예시
    • layouts/_default/baseof.html<head> 및 헤더/푸터
    • layouts/_default/index.html … 톱 페이지 최신 글 목록
    • layouts/_default/single.html … 본문 템플릿
    • static/css/main.css … 색상, 폰트, 여백 등
  3. 변경을 커밋 후 푸시한다.
    • Windows

      PS C:\work\my-hugo-blog> git add -A
      PS C:\work\my-hugo-blog> git commit -m "Update theme/layouts"
      PS C:\work\my-hugo-blog> git push
      
    • macOS

      mac:~/work/my-hugo-blog dev$ git add -A
      mac:~/work/my-hugo-blog dev$ git commit -m "Update theme/layouts"
      mac:~/work/my-hugo-blog dev$ git push
      
    • 수십 초~수분 후 Cloudflare 가 자동 배포

3-4. 브랜치 운용 팁

  • 기본 브랜치(main 또는 master)를 DecapCMSconfig.yml 과 Cloudflare 설정에서 일치시킨다.
  • 프리뷰가 필요하면 GitHub 에 Pull Request 를 만들면 Cloudflare Pages 가 Preview 환경을 자동 발행한다.

4. 편리한 보충(선택)

4-1. static/_headers 예시(관리 화면 캐시 금지)

/admin/*
  Cache-Control: no-store

4-2. robots 와 사이트맵(필요 시)

  • static/robots.txt 를 준비해 크롤러 제어
  • hugo.tomloutputs 를 확장해 RSS 나 sitemap 출력을 추가할 수 있다.

5. 자주 막히는 지점과 대처

  • CMS 로그인 화면이 무한 로딩
    → GitHub OAuth 의 Callback URLhttps://www.example.com/api/callback 으로 설정되어 있는지, Cloudflare 환경 변수 GITHUB_CLIENT_ID/SECRET 이 정확한지 확인한다.
  • 톱 페이지는 열리는데 기사 페이지가 404
    hugo.tomlbaseURL 이 실제 도메인과 일치하는지 확인하고, Cloudflare 빌드 로그에서 public/ 이 생성되었는지 확인한다.
  • /admin 이 새하얗게 뜬다
    static/admin/index.htmlDecapCMS 로드가 브라우저 확장 등에 의해 차단되지 않았는지 확인하고, 확장을 끄고 새로고침한다.

6. 교체·설정 체크 항목 목록(재정리)

파일/설정 교체·설정 내용(예)
hugo.toml baseURL https://www.example.com
static/admin/config.yml repo <YOUR_GH_USERNAME>/my-hugo-blog
static/admin/config.yml branch 실제에 맞춰 main 또는 master
static/admin/config.yml base_url https://www.example.com
Cloudflare Pages HUGO_VERSION 예: 0.128.0
Cloudflare Pages GITHUB_CLIENT_ID GitHub OAuth App 의 Client ID
Cloudflare Pages GITHUB_CLIENT_SECRET GitHub OAuth App 의 Client Secret
GitHub OAuth App Callback URL https://www.example.com/api/callback
Cloudflare Pages Custom domain www.example.com 을 추가

7. 부록: 파일 샘플(필요한 부분만 교체하여 사용)

7-1. functions/api/auth.js

// Cloudflare Pages Functions(/api/auth)
export async function onRequest(context) {
  const { request, env } = context;
  const client_id = env.GITHUB_CLIENT_ID;
  try {
    const url = new URL(request.url);
    const redirectUrl = new URL('https://github.com/login/oauth/authorize');
    redirectUrl.searchParams.set('client_id', client_id);
    redirectUrl.searchParams.set('redirect_uri', `${url.origin}/api/callback`);
    redirectUrl.searchParams.set('scope', 'repo user');
    redirectUrl.searchParams.set('state', crypto.getRandomValues(new Uint8Array(12)).join(''));
    return Response.redirect(redirectUrl.href, 302);
  } catch (err) {
    return new Response(String(err?.message || err), { status: 500 });
  }
}

7-2. functions/api/callback.js

function renderBody(status, content) {
  const html = `
  <script>
    const receiveMessage = (message) => {
      window.opener.postMessage(
        'authorization:github:${status}:${JSON.stringify(content)}',
        message.origin
      );
      window.removeEventListener("message", receiveMessage, false);
    }
    window.addEventListener("message", receiveMessage, false);
    window.opener.postMessage("authorizing:github", "*");
  </script>`;
  return new Blob([html]);
}

export async function onRequest(context) {
  const { request, env } = context;
  const client_id = env.GITHUB_CLIENT_ID;
  const client_secret = env.GITHUB_CLIENT_SECRET;
  try {
    const url = new URL(request.url);
    const code = url.searchParams.get('code');
    const response = await fetch('https://github.com/login/oauth/access_token', {
      method: 'POST',
      headers: { 'content-type': 'application/json', 'user-agent': 'cf-pages-oauth', 'accept': 'application/json' },
      body: JSON.stringify({ client_id, client_secret, code }),
    });
    const result = await response.json();
    if (result.error) {
      return new Response(renderBody('error', result), { headers: { 'content-type': 'text/html;charset=UTF-8' }, status: 401 });
    }
    const token = result.access_token;
    const provider = 'github';
    return new Response(renderBody('success', { token, provider }), { headers: { 'content-type': 'text/html;charset=UTF-8' }, status: 200 });
  } catch (error) {
    return new Response(error.message, { headers: { 'content-type': 'text/html;charset=UTF-8' }, status: 500 });
  }
}

7-3. static/admin/config.yml

backend:
  name: github
  repo: <YOUR_GH_USERNAME>/my-hugo-blog
  branch: main
  base_url: https://www.example.com
  auth_endpoint: /api/auth

media_folder: static/uploads
public_folder: /uploads

collections:
  - name: 'blog'
    label: 'Blog'
    folder: 'content/blog'
    create: true
    slug: '{{slug}}'
    editor:
      preview: false
    fields:
      - { label: 'Title', name: 'title', widget: 'string' }
      - { label: 'Publish Date', name: 'date', widget: 'datetime' }
      - { label: 'Description', name: 'description', widget: 'string' }
      - { label: 'Body', name: 'body', widget: 'markdown' }


9. SEO & 퍼포먼스 최적화 포인트

블로그를 구축한 뒤 아래 작업을 더하면 검색 순위와 표시 속도를 더 개선할 수 있다.

  1. 사이트맵과 robots.txt 설정
    • Hugo 가 자동 생성하는 public/sitemap.xml 을 Google Search Console 에 등록한다.
    • static/robots.txt 에 크롤러 제어 규칙을 추가한다.
  2. OGP(Open Graph Protocol)와 Twitter 카드 설정
    • layouts/_default/baseof.html<meta property="og:title">, <meta property="og:image"> 등을 추가한다.
    • SNS 공유 시 매력적으로 보이도록 한다.
  3. 이미지를 WebP 로 변환
    • resources/_gen/images/ 를 활용해 Hugo 파이프라인에서 자동 WebP 변환을 적용한다.
    • 페이지 로딩 속도를 높인다.
  4. 카테고리·태그 기능 추가
    • content/blog/ 의 Front Matter 에 categories, tags 를 추가하고 템플릿에서 태그 클라우드를 표시한다.
  5. 구조화 데이터 도입
    • JSON-LD 형식으로 기사 정보를 검색 엔진에 명시해 리치 리절트를 노린다.

8. 정리

  • GitHub 에 리포지터리를 만들고 Cloudflare Pages 와 연결한 뒤, DecapCMS 용 GitHub OAuth 를 설정하고 독자 도메인을 붙이면 완성된다.
  • 글은 /admin/ 에서 작성 → GitHub 에 커밋 → Cloudflare 가 자동 배포한다.
  • 템플릿과 디자인은 로컬에서 hugo server 로 확인하면서 수정하고 Git 으로 반영한다.

이로써 Hugo + GitHub + Decap CMS + Cloudflare 를 활용한 서버리스 블로그 환경을 구축·운영할 수 있다.

이렇게 해서 유지비가 매우 가벼운 사이트는 마련했지만, 사이트 내 검색, 카테고리, 태그·태그 클라우드 등 기존 블로그와 비교하면 아쉬운 부분이 많은 것은 어쩔 수 없는가 싶다. 앞으로도 ChatGPT 와 투톱으로 조금씩 확장해 가고 싶다.