GitHub + Cloudflare Pages + R2로 서버 없는 Hugo 블로그 만들기

들어가며

서버를 한 대도 띄우지 않고, GitHub 저장소와 Cloudflare만으로 글쓰기 화면까지 갖춘 Hugo 블로그를 만들었습니다. 글은 GitHub에 저장되고, Cloudflare Pages가 빌드와 호스팅을 맡고, 이미지는 R2 버킷에 올라갑니다.

이전에 같은 블로그를 서버의 Docker(Gitea + nginx + 업로더)로 만들었는데, 이번 방식과 비교하면 이렇습니다.

서버 + GiteaGitHub + Cloudflare (이번 글)
서버컨테이너 4개 상시 실행없음
글 저장소직접 운영하는 GiteaGitHub 비공개 저장소
빌드서버에서 30초마다 확인push할 때마다 Pages가 빌드
로그인Gitea가 직접 처리Pages Functions가 GitHub 로그인 중계
이미지 업로드Python 컨테이너가 R2 API로 업로드Pages Functions가 R2 바인딩으로 저장 (API 키 불필요)
백업직접 해야 함GitHub와 R2가 보관

관리할 서버가 없다는 게 가장 큰 장점이고, 애드센스도 호스팅 위치와 상관없이 똑같이 붙일 수 있습니다. 아래에서는 도메인을 example.com으로 바꿔서 설명합니다.

전체 구조

image 1

글쓴이는 /admin에서 Pages Functions를 거쳐 GitHub로 로그인하고, 글은 브라우저가 GitHub 저장소에 직접 커밋합니다. 커밋이 생길 때마다 Pages가 Hugo로 다시 빌드하고, 이미지는 Functions가 R2 바인딩으로 바로 저장합니다. 방문자는 페이지를 Pages에서, 이미지를 R2에서 받습니다.

준비물

항목내용
GitHub 계정비공개 저장소 하나(이 글에서는 username/blog). 파일 업로드는 GitHub Desktop이나 git으로 합니다.
도메인Cloudflare에서 DNS를 관리하는 example.com. 블로그는 log.example.com, 이미지는 img.example.com을 씁니다.
R2 버킷이미지 전용 버킷 하나(예: blog-images)와 그 버킷에 연결한 커스텀 도메인 img.example.com

R2 버킷은 다른 사이트(예: WordPress)와 따로 만드는 것을 권합니다. Pages에 R2 바인딩을 주면 그 버킷 전체에 쓰기와 삭제 권한이 생기기 때문에, 분리해두면 다른 사이트 이미지에 닿을 여지가 없습니다. 비용은 계정 단위로 과금되니 버킷을 나눠도 같습니다.

버킷을 만든 뒤 Settings → Custom Domains에서 img.example.com을 연결하고, 상태가 Active인지 확인합니다. r2.dev 공개 주소는 켜지 않아도 됩니다.

저장소 파일 만들기

아래 스크립트를 git이 있는 곳(내 PC의 Git Bash, macOS 터미널, 리눅스 서버 어디든)에서 실행하면 ~/blog-site에 저장소에 올릴 파일이 모두 만들어집니다. 도메인은 맨 위의 SITE와, 업로드 위젯의 pattern 한 줄에 있는 img\.example\.com만 바꾸면 됩니다.

만들어지는 파일은 이렇습니다.

경로역할
hugo.toml사이트 제목, 메뉴, 테마 설정
themes/PaperMod/테마. 서브모듈이 아니라 파일 그대로 포함해서 빌드가 단순합니다.
content/posts/글. CMS에서 쓴 글도 여기에 마크다운으로 저장됩니다.
static/admin/config.ymlDecap CMS 설정 (GitHub 저장소, 로그인 경로, 입력 항목)
static/admin/index.htmlCMS 화면 + R2 이미지 업로드 위젯 (브라우저에서 webp 변환)
functions/api/auth.jsCMS 로그인 시작 → GitHub 승인 화면으로 이동
functions/api/callback.jsGitHub가 준 code를 토큰으로 바꿔 CMS 창에 전달
functions/api/r2-upload.js로그인한 사용자 확인 후 이미지를 R2에 저장하고 주소 반환
#!/bin/bash
# log.example.com — GitHub 저장소 + Cloudflare Pages 구성 (서버 컨테이너 없음)
#   GitHub          글 저장소 (Decap CMS가 커밋)
#   Cloudflare Pages  push마다 Hugo 빌드 + 호스팅 + Functions(/api/*)
#     /api/auth, /api/callback   Decap CMS GitHub 로그인 중계
#     /api/r2-upload             이미지 → R2 버킷(바인딩) 저장, img.example.com 주소 반환
#   이미지는 브라우저에서 webp(긴 변 2000px)로 변환한 뒤 올립니다.
set -euo pipefail

SITE=log.example.com
IMG=img.example.com
DIR=~/blog-site

[ -e "$DIR" ] && { echo "$DIR 가 이미 있습니다. 중단합니다."; exit 1; }
command -v git >/dev/null || { echo "git 이 필요합니다."; exit 1; }

read -rp "GitHub 아이디: " GH_USER
read -rp "저장소 이름 [blog]: " REPO; REPO=${REPO:-blog}

mkdir -p "$DIR"/{content/posts,static/admin,functions/api,themes}
cd "$DIR"

echo "== 테마 =="
git clone -q --depth 1 https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
rm -rf themes/PaperMod/.git

cat > .gitignore <<'EOF'
public/
resources/
.hugo_build.lock
node_modules/
EOF

cat > hugo.toml <<EOF
baseURL = "https://$SITE/"
languageCode = "ko-kr"
timeZone = "Asia/Seoul"
title = "My log"
theme = "PaperMod"

[params]
  DateFormat = "2006-01-02"

  [params.homeInfoParams]
    Title = "나의 기록"
    Content = "짧게 남기는 기록"

[[menu.main]]
  name = "Daily"
  url = "/categories/daily/"
  weight = 10

[[menu.main]]
  name = "Dev"
  url = "/categories/dev/"
  weight = 20

[[menu.main]]
  name = "Review"
  url = "/categories/review/"
  weight = 30
EOF

cat > content/posts/$(date +%F)-hello.md <<EOF
---
title: "첫 글"
date: $(date +%F)
draft: false
categories: ["Daily"]
---

$SITE을 시작합니다.
EOF

echo "== CMS (/admin) =="
cat > static/admin/config.yml <<EOF
backend:
  name: github
  repo: $GH_USER/$REPO
  branch: main
  base_url: https://$SITE
  auth_endpoint: api/auth

media_folder: static/images
public_folder: /images
locale: ko

collections:
  - name: posts
    label: 글
    folder: content/posts
    create: true
    slug: "{{year}}-{{month}}-{{day}}-{{slug}}"
    fields:
      - { label: 제목, name: title, widget: string }
      - { label: 날짜, name: date, widget: datetime, date_format: "YYYY-MM-DD", time_format: false, format: "YYYY-MM-DD" }
      - { label: 초안, name: draft, widget: boolean, default: false }
      - { label: 태그, name: tags, widget: list, required: false }
      - { label: 카테고리, name: categories, widget: select, multiple: true, required: false, options: ["Daily", "Dev", "Review"] }
      - { label: 대표 이미지, name: cover, widget: object, required: false, collapsed: true, fields: [ { label: 이미지, name: image, widget: r2, required: false } ] }
      - { label: 본문, name: body, widget: markdown }
EOF

cat > static/admin/index.html <<'EOF'
<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <meta name="robots" content="noindex" />
  <title>글쓰기 · My log</title>
</head>
<body>
  <script src="https://unpkg.com/decap-cms@^3.0.0/dist/decap-cms.js"></script>
  <script>
    function ghToken() {
      try { return JSON.parse(localStorage.getItem('decap-cms-user')).token; } catch (e) { return null; }
    }
    // jpg/png → webp, 긴 변 2000px. 그 밖의 형식이나 변환 실패 시 원본 그대로.
    function toWebp(file) {
      return new Promise(function (resolve) {
        if (!/^image\/(jpeg|png)$/.test(file.type)) return resolve({ blob: file, name: file.name });
        var url = URL.createObjectURL(file), img = new Image();
        img.onload = function () {
          var max = 2000, w = img.naturalWidth, h = img.naturalHeight;
          if (Math.max(w, h) > max) { var s = max / Math.max(w, h); w = Math.round(w * s); h = Math.round(h * s); }
          var c = document.createElement('canvas'); c.width = w; c.height = h;
          c.getContext('2d').drawImage(img, 0, 0, w, h);
          URL.revokeObjectURL(url);
          c.toBlob(function (b) {
            if (!b || b.type !== 'image/webp') return resolve({ blob: file, name: file.name });
            resolve({ blob: b, name: file.name.replace(/\.[^.]+$/, '') + '.webp' });
          }, 'image/webp', 0.8);
        };
        img.onerror = function () { URL.revokeObjectURL(url); resolve({ blob: file, name: file.name }); };
        img.src = url;
      });
    }
    var R2Control = createClass({
      getInitialState: function () { return { busy: false, err: '' }; },
      upload: function (e) {
        var f = e.target.files[0], self = this;
        if (!f) return;
        self.setState({ busy: true, err: '' });
        toWebp(f).then(function (out) {
          return fetch('/api/r2-upload', {
            method: 'POST',
            headers: { 'Authorization': 'token ' + ghToken(), 'X-Filename': encodeURIComponent(out.name), 'Content-Type': out.blob.type || 'application/octet-stream' },
            body: out.blob
          });
        }).then(function (r) {
          return r.json().then(function (j) { if (!r.ok) throw new Error(j.error || r.status); return j; });
        }).then(function (j) {
          self.props.onChange(j.url); self.setState({ busy: false });
        }).catch(function (err) {
          self.setState({ busy: false, err: '업로드 실패: ' + err.message });
        });
      },
      render: function () {
        var v = this.props.value || '', self = this;
        return h('div', { className: this.props.classNameWrapper },
          h('input', { type: 'file', accept: 'image/*', onChange: this.upload, disabled: this.state.busy }),
          this.state.busy ? h('span', null, ' R2에 올리는 중…') : null,
          h('input', {
            type: 'text', value: v, placeholder: '또는 이미지 주소 붙여넣기',
            style: { width: '100%', marginTop: '8px' },
            onChange: function (e) { self.props.onChange(e.target.value.trim()); }
          }),
          v ? h('img', { src: v, style: { maxWidth: '240px', display: 'block', marginTop: '8px' } }) : null,
          this.state.err ? h('div', { style: { color: '#c00', marginTop: '6px' } }, this.state.err) : null
        );
      }
    });
    CMS.registerWidget('r2', R2Control);
    CMS.registerEditorComponent({
      id: 'r2-image',
      label: '이미지 업로드',
      fields: [
        { name: 'url', label: '이미지', widget: 'r2' },
        { name: 'alt', label: '이미지 설명', widget: 'string', required: false }
      ],
      pattern: /^!\[([^\]]*)\]\((https:\/\/img\.example\.com\/[^)]*)\)$/,
      fromBlock: function (m) { return { alt: m[1], url: m[2] }; },
      toBlock: function (d) { return '![' + (d.alt || '') + '](' + (d.url || '') + ')'; },
      toPreview: function (d) { return '<img src="' + (d.url || '') + '" alt="' + (d.alt || '') + '" style="max-width:100%">'; }
    });
  </script>
</body>
</html>
EOF

echo "== Pages Functions (/api) =="
cat > functions/api/auth.js <<'EOF'
// Decap CMS 로그인 시작: GitHub 승인 화면으로 보냅니다.
export async function onRequestGet({ request, env }) {
  const origin = new URL(request.url).origin;
  const state = crypto.randomUUID();
  const to = new URL("https://github.com/login/oauth/authorize");
  to.searchParams.set("client_id", env.GITHUB_CLIENT_ID);
  to.searchParams.set("redirect_uri", `${origin}/api/callback`);
  to.searchParams.set("scope", env.GITHUB_SCOPE || "repo");
  to.searchParams.set("state", state);
  return new Response(null, {
    status: 302,
    headers: {
      Location: to.toString(),
      "Set-Cookie": `oauth_state=${state}; Path=/api; HttpOnly; Secure; SameSite=Lax; Max-Age=600`,
    },
  });
}
EOF

cat > functions/api/callback.js <<'EOF'
// GitHub가 돌려준 code를 토큰으로 바꾸고, Decap CMS 창에 전달합니다.
export async function onRequestGet({ request, env }) {
  const url = new URL(request.url);
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");
  const cookie = (request.headers.get("Cookie") || "").match(/(?:^|;\s*)oauth_state=([^;]+)/);

  let status = "error", content = { error: "로그인 요청이 올바르지 않습니다." };
  if (code && state && cookie && cookie[1] === state) {
    const r = await fetch("https://github.com/login/oauth/access_token", {
      method: "POST",
      headers: { "Content-Type": "application/json", Accept: "application/json", "User-Agent": "decap-auth" },
      body: JSON.stringify({ client_id: env.GITHUB_CLIENT_ID, client_secret: env.GITHUB_CLIENT_SECRET, code }),
    });
    const j = await r.json().catch(() => ({}));
    if (j.access_token) { status = "success"; content = { token: j.access_token, provider: "github" }; }
    else content = { error: j.error_description || j.error || "토큰 발급 실패" };
  }

  const msg = JSON.stringify(`authorization:github:${status}:${JSON.stringify(content)}`);
  const html = `<!doctype html><meta charset="utf-8"><body><script>
(function () {
  var origin = ${JSON.stringify(url.origin)};
  window.addEventListener("message", function (e) {
    if (e.origin !== origin) return;
    window.opener.postMessage(${msg}, origin);
  }, false);
  window.opener.postMessage("authorizing:github", origin);
})();
</script></body>`;
  return new Response(html, {
    headers: {
      "Content-Type": "text/html; charset=utf-8",
      "Set-Cookie": "oauth_state=; Path=/api; HttpOnly; Secure; SameSite=Lax; Max-Age=0",
    },
  });
}
EOF

cat > functions/api/r2-upload.js <<'EOF'
// CMS에서 올린 이미지를 R2(바인딩 IMAGES)에 저장하고 공개 주소를 돌려줍니다.
const TYPES = { ".webp": "image/webp", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
                ".png": "image/png", ".gif": "image/gif", ".avif": "image/avif" };

const json = (status, obj) => new Response(JSON.stringify(obj), {
  status, headers: { "Content-Type": "application/json; charset=utf-8" },
});

export async function onRequestPost({ request, env }) {
  const auth = request.headers.get("Authorization") || "";
  if (!auth.startsWith("token ") || auth.length < 10) return json(403, { error: "로그인이 필요합니다" });
  const u = await fetch("https://api.github.com/user", {
    headers: { Authorization: "Bearer " + auth.slice(6), Accept: "application/vnd.github+json", "User-Agent": "r2-uploader" },
  });
  const login = u.ok ? ((await u.json()).login || "") : "";
  if (!login || login.toLowerCase() !== String(env.ALLOWED_USER || "").toLowerCase()) {
    return json(403, { error: "로그인이 필요합니다" });
  }

  const name = decodeURIComponent(request.headers.get("X-Filename") || "image.webp");
  const dot = name.lastIndexOf(".");
  const ext = (dot >= 0 ? name.slice(dot) : "").toLowerCase();
  if (!TYPES[ext]) return json(415, { error: "이미지 파일만 올릴 수 있습니다" });

  const max = Number(env.MAX_MB || 20) * 1024 * 1024;
  const body = await request.arrayBuffer();
  if (!body.byteLength || body.byteLength > max) return json(413, { error: "파일이 없거나 너무 큽니다" });

  const stem = (dot >= 0 ? name.slice(0, dot) : name).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "").slice(0, 50) || "image";
  const t = new Date(Date.now() + 9 * 3600 * 1000); // KST
  const p = (n) => String(n).padStart(2, "0");
  const rand = crypto.getRandomValues(new Uint8Array(2)).reduce((s, b) => s + b.toString(16).padStart(2, "0"), "");
  const prefix = String(env.KEY_PREFIX || "log").replace(/^\/+|\/+$/g, "");
  const key = `${prefix}/${t.getUTCFullYear()}/${p(t.getUTCMonth() + 1)}/` +
              `${p(t.getUTCDate())}${p(t.getUTCHours())}${p(t.getUTCMinutes())}${p(t.getUTCSeconds())}-${rand}-${stem}${ext}`;

  await env.IMAGES.put(key, body, {
    httpMetadata: { contentType: TYPES[ext], cacheControl: "public, max-age=31536000, immutable" },
  });
  return json(200, { url: `${String(env.PUBLIC_BASE).replace(/\/+$/, "")}/${key}`, key });
}
EOF

git init -q -b main
git add -A
git commit -q -m "init"
git remote add origin "https://github.com/$GH_USER/$REPO.git"

echo
echo "완료: $DIR"
echo "다음: GitHub에 빈 저장소 $GH_USER/$REPO 를 만든 뒤 'cd $DIR && git push -u origin main'"

스크립트를 쓰기 어려운 환경(예: Windows에서 GitHub Desktop만 쓰는 경우)이라면 다른 곳에서 한 번 실행해 만든 blog-site 폴더의 내용을 그대로 저장소 폴더에 복사해도 됩니다. 이때 .git 폴더는 빼고 복사하세요.

GitHub 설정

1. 비공개 저장소 만들기

github.com → + → New repository에서 이름은 blog, Private을 선택하고 README·.gitignore·license는 모두 체크하지 않습니다. 비공개를 권하는 이유는 CMS에서 “초안”으로 저장한 글도 저장소에는 파일로 들어가기 때문입니다. 공개 저장소면 발행 전 글을 누구나 읽을 수 있습니다.

2. 파일 올리기: 둘 중 편한 방법으로 올립니다.

  • GitHub Desktop: 저장소를 clone한 폴더에 blog-site의 내용을 복사하고 Commit to main → Push origin을 누릅니다. 로그인은 Desktop이 처리하니 토큰이 필요 없습니다.
  • git 명령: cd ~/blog-site && git push -u origin main. 비밀번호 대신 토큰을 입력해야 하니, Settings → Developer settings → Personal access tokens → Fine-grained tokens에서 저장소 하나에 Contents: Read and write 권한만 준 토큰을 7~30일 기한으로 만들어 씁니다. 처음 한 번만 쓰니 push 후 지워도 됩니다.

저장소에 content, functions, static, themes 폴더와 hugo.toml이 보이면 성공입니다.

3. CMS 로그인용 OAuth 앱

Settings → Developer settings → OAuth Apps → New OAuth App에서 만듭니다.

항목값
Homepage URLhttps://log.example.com
Authorization callback URLhttps://log.example.com/api/callback

만든 뒤 Client ID를 복사하고, Generate a new client secret으로 Client secret을 만들어 복사해둡니다. Secret은 화면을 벗어나면 다시 볼 수 없습니다.

Cloudflare 설정

1. Pages 프로젝트 만들기

Workers & Pages → 애플리케이션 생성에서 Pages 탭(또는 화면 아래의 “Pages를 배포하려고 하시나요? 시작하기”)을 골라 Git 저장소를 연결합니다. 기본 화면은 Workers 생성 화면이라 “배포 명령 npx wrangler deploy” 칸이 보이는데, 그쪽으로 만들면 functions/ 폴더가 동작하지 않으니 주의하세요.

항목값
프레임워크 미리 설정Hugo
빌드 명령hugo --minify
빌드 출력 디렉터리public
루트 디렉터리비워둠
환경 변수HUGO_VERSION = 0.165.0

저장 및 배포를 누르면 blog-xxx.pages.dev 주소에 첫 글이 뜹니다.

2. 변수 (프로젝트 → 설정 → 변수 및 비밀, 프로덕션)

유형이름값
텍스트GITHUB_CLIENT_IDOAuth 앱의 Client ID
비밀GITHUB_CLIENT_SECRETOAuth 앱의 Client secret
텍스트ALLOWED_USER이미지를 올릴 수 있는 GitHub 아이디
텍스트PUBLIC_BASEhttps://img.example.com

선택 변수로 KEY_PREFIX(R2 안의 폴더, 기본 log), MAX_MB(업로드 한도, 기본 20), GITHUB_SCOPE(공개 저장소라면 public_repo)가 있습니다.

3. R2 바인딩 (설정 → 바인딩 → 추가 → R2 버킷)

변수 이름은 IMAGES, 버킷은 이미지 전용으로 만든 버킷을 고릅니다. 바인딩 덕분에 R2 API 키를 따로 만들 필요가 없습니다.

4. 다시 배포

변수와 바인딩은 다음 배포부터 적용됩니다. 배포 탭에서 최신 배포의 ... → 배포 다시 시도를 누릅니다.

5. 사용자 지정 도메인

프로젝트 → 사용자 지정 도메인에서 log.example.com을 추가합니다. DNS가 Cloudflare에 있으면 CNAME 레코드가 자동으로 생기고, 몇 분 뒤 활성으로 바뀝니다.

글쓰기와 반영 시간

https://log.example.com/admin에서 GitHub로 로그인한 뒤 글을 쓰고 게시하면, GitHub에 커밋되고 Pages가 다시 빌드해서 1~2분 뒤 사이트에 반영됩니다. Hugo 빌드 자체는 몇 초면 끝나고, 시간은 대부분 빌드 환경을 준비하는 데 걸립니다. 진행 상황은 Pages 프로젝트의 배포 탭에서 볼 수 있고, 빌드가 실패하면 사이트는 직전 버전 그대로 유지됩니다.

이미지는 본문 편집기의 + 메뉴에서 이미지 업로드를 골라 올립니다. 브라우저에서 webp로 바뀐 뒤 R2에 올라가고, ![설명](https://img.example.com/log/2026/11/...webp)이 본문에 들어갑니다. 이미지는 빌드를 기다리지 않고 바로 보입니다.

제목 바꾸기

hugo.toml의 title이 브라우저 탭과 사이트 왼쪽 위에 나오는 이름입니다. 홈 화면의 큰 문구는 [params.homeInfoParams]의 Title과 Content입니다.

title = "My log"

카테고리 바꾸기

스크립트는 Daily, Dev, Review 세 카테고리로 만듭니다. 카테고리를 바꾸거나 추가할 때는 두 파일에서 이름을 같게 맞춰야 메뉴 링크가 동작합니다. Hugo는 주소를 소문자로 만들기 때문에 Daily 카테고리의 주소는 /categories/daily/입니다.

# static/admin/config.yml
      - { label: 카테고리, name: categories, widget: select, multiple: true, required: false, options: ["Daily", "Dev", "Review"] }
# hugo.toml
[[menu.main]]
  name = "Daily"
  url = "/categories/daily/"
  weight = 10

메뉴의 name은 표시용이라 name = "일상", url = "/categories/daily/"처럼 주소는 영어, 메뉴는 한글로 써도 됩니다. 이미 쓴 글의 categories: 값도 같이 바꿔야 예전 카테고리 페이지가 남지 않습니다.

애드센스

호스팅이 Cloudflare여도 애드센스 조건은 같습니다. PaperMod에서는 layouts/partials/extend_head.html에 광고 스크립트를 넣으면 모든 페이지에 적용되고, ads.txt는 루트 도메인(example.com/ads.txt)에 있어야 합니다.

설치하며 막혔던 곳

증상원인과 해결
프로젝트 생성 화면에 “배포 명령 npx wrangler deploy“가 있음Pages가 아니라 Workers 생성 화면입니다. 뒤로 가서 Pages 탭으로 만들어야 functions/가 동작합니다.
/admin에서 “Failed to load config.yml (522)”log 서브도메인이 Pages가 아닌 서버 IP를 가리키는 경우입니다. 예전에 만든 A 레코드를 지우고, 사용자 지정 도메인을 다시 추가해 CNAME(blog-xxx.pages.dev)이 생기게 합니다.
pages.dev 주소에서 로그인이 안 됨정상입니다. OAuth 콜백 주소가 log.example.com이라 그 도메인에서만 로그인됩니다.
저장소에 스크립트(.sh) 파일만 올라감스크립트는 파일을 만드는 도구라 실행해야 합니다. 실행 결과를 올리고, 저장소에 이미 커밋이 있으면 처음 한 번만 git push --force로 덮어씁니다.
저장소 이름을 blog가 아닌 다른 이름으로 만듦static/admin/config.yml의 repo:와 git remote 주소를 실제 저장소 이름으로 바꿉니다.
업로드 실패: 로그인이 필요합니다Pages 변수 ALLOWED_USER가 CMS에 로그인한 GitHub 아이디와 같은지 확인합니다.
변수나 바인딩을 넣었는데 동작 안 함다음 배포부터 적용됩니다. 배포 탭에서 배포 다시 시도를 누릅니다.
반영된 글이 안 보임배포 탭에서 성공인지 확인하고, 성공이면 Ctrl+F5로 새로고침합니다.

글이 하나도 안 보일 때 (시간대)

빌드는 성공했는데 홈에 글이 없다면 시간대 문제입니다. Cloudflare는 UTC로 빌드하기 때문에, 날짜만 적힌 글(date: 2026-11-09)을 UTC 0시, 즉 한국시간 오전 9시에 발행될 미래 글로 보고 숨깁니다. 한국시간 오전 0~9시 사이에 쓴 글이 사라지는 이유가 이것입니다.

hugo.toml에 아래 한 줄을 넣으면 날짜를 한국시간 기준으로 읽어서 해결됩니다. 위 스크립트에는 이미 들어 있습니다.

timeZone = "Asia/Seoul"

처음 저장소를 만들 때 쓴 --force는 그 한 번만 씁니다. CMS로 글을 쓰기 시작한 뒤에 쓰면 그동안 쓴 글이 지워질 수 있습니다.

  이 사이트는 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

댓글 남기기