Hugo로 블로그 만들기

들어가며

스크립트 하나로 log.example.com에 글쓰기 화면까지 갖춘 Hugo 블로그를 띄우고, 이미지는 Cloudflare R2(img.example.com)로 올리는 구성을 정리했습니다. 이미 NPG(Nginx Proxy Guard)로 WordPress를 돌리는 서버에 기존 컨테이너를 건드리지 않고 얹는 것을 기준으로 했습니다.

이 구성을 고른 이유는 세 가지입니다.

  • 서버 부담이 거의 없습니다. 방문자에게는 빌드된 HTML만 나가고, 이미지는 R2가 제공합니다.
  • 브라우저에서 글을 씁니다. Decap CMS(/admin)에서 글을 쓰고 저장하면 Gitea 저장소에 커밋되고, 30초 안에 자동으로 다시 빌드됩니다.
  • 단독으로 돌아갑니다. Git 저장소(Gitea)까지 같은 스택 안에 있어서 다른 서버나 외부 Git 서비스에 의존하지 않습니다.

글쓰기 화면에서 이미지를 고르면 webp로 변환되고 긴 변이 2000px 이하로 줄어든 뒤 R2에 올라가며, 본문에는 https://img.example.com/log/2026/11/...webp 주소가 들어갑니다.

image

준비물

스크립트를 돌리기 전에 아래 네 가지를 갖춰두면 됩니다.

항목내용
서버Docker와 Docker Compose가 설치된 Linux 서버. git, curl, python3가 있어야 합니다. 127.0.0.1의 8094, 8095 포트가 비어 있어야 합니다.
리버스 프록시NPG(Nginx Proxy Guard)나 Nginx Proxy Manager처럼 도메인을 내부 포트로 연결하고 SSL을 붙여주는 프록시
도메인Cloudflare에서 관리하는 example.com. 서브도메인 log.example.com(블로그), git.example.com(Gitea)을 씁니다.
R2이미지용 버킷 하나와 공개 주소(img.example.com처럼 커스텀 도메인 연결), 그 버킷에 Object Read & Write 권한이 있는 R2 API 토큰(Access Key ID, Secret Access Key), 계정 ID

R2 API 토큰은 Cloudflare 대시보드의 R2 → API 토큰 관리에서 만들고, 권한 범위를 해당 버킷 하나로 좁혀두는 것이 좋습니다. 키 값은 스크립트를 실행할 때 직접 입력하며, 서버의 ~/log-stack/.env.r2(권한 600)에만 저장됩니다.

설치 스크립트

아래 스크립트를 서버에 setup-log.sh로 저장하고 bash setup-log.sh로 실행합니다. 도메인은 맨 위의 SITE, GITHOST와 이미지 주소 기본값(img.example.com, 업로드 위젯의 pattern 한 곳 포함)만 자기 것으로 바꾸면 됩니다.

#!/bin/bash
# log.example.com — 단독 구성 (NPG 등 리버스 프록시 뒤)
#   web        nginx  127.0.0.1:8094  → 정적 사이트 + /api/r2-upload
#   gitea             127.0.0.1:8095  → git.example.com (글 저장소 + CMS 로그인)
#   builder    Gitea 변경을 30초마다 확인해 Hugo로 빌드
#   r2-uploader  CMS 이미지 → webp 변환 → R2 (img.example.com/log/...)
# 기존 컨테이너·설정은 건드리지 않습니다. ~/log-stack, ~/log-src 만 새로 만듭니다.
set -euo pipefail

STACK=~/log-stack
SRC=~/log-src
SITE=log.example.com
GITHOST=git.example.com
REPO=log

[ -e "$STACK" ] && { echo "$STACK 가 이미 있습니다. 중단합니다."; exit 1; }
[ -e "$SRC" ] && { echo "$SRC 가 이미 있습니다. 중단합니다."; exit 1; }
if ss -ltn | grep -qE '127\.0\.0\.1:(8094|8095)\b|\*:(8094|8095)\b|:::(8094|8095)\b'; then
  echo "8094 또는 8095 포트가 이미 사용 중입니다. 중단합니다."; exit 1
fi
for c in git curl python3; do command -v $c >/dev/null || { echo "$c 가 필요합니다."; exit 1; }; done
DOCKER="docker"; docker info >/dev/null 2>&1 || DOCKER="sudo docker"

echo "== 설정값 입력 =="
read -rp "Gitea 관리자 아이디 [admin]: " GUSER; GUSER=${GUSER:-admin}
read -rp "Gitea 이메일: " GMAIL
while :; do read -rsp "Gitea 비밀번호 (8자 이상): " GPASS; echo; [ ${#GPASS} -ge 8 ] && break; echo "8자 이상으로 입력하세요."; done
read -rp "R2 계정 ID (Account ID): " R2_ACCOUNT_ID
read -rp "R2 Access Key ID: " R2_ACCESS_KEY_ID
read -rsp "R2 Secret Access Key: " R2_SECRET_ACCESS_KEY; echo
read -rp "R2 버킷 이름 (이미지 공개 주소가 연결된 버킷): " R2_BUCKET
read -rp "이미지 공개 주소 [https://img.example.com]: " PUBLIC_BASE; PUBLIC_BASE=${PUBLIC_BASE:-https://img.example.com}

mkdir -p "$STACK"/{web,builder,r2-uploader,data/public}
cd "$STACK"

echo "== 1. 설정 파일 =="
umask 077
cat > .env.r2 <<EOF
R2_ACCOUNT_ID=$R2_ACCOUNT_ID
R2_ACCESS_KEY_ID=$R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY=$R2_SECRET_ACCESS_KEY
R2_BUCKET=$R2_BUCKET
PUBLIC_BASE=$PUBLIC_BASE
KEY_PREFIX=log
ALLOWED_USER=$GUSER
EOF
umask 022

cat > docker-compose.yml <<EOF
name: log-stack

services:
  gitea:
    image: gitea/gitea:1
    restart: unless-stopped
    mem_limit: 300m
    environment:
      USER_UID: "1000"
      USER_GID: "1000"
      GITEA__database__DB_TYPE: sqlite3
      GITEA__server__DOMAIN: $GITHOST
      GITEA__server__ROOT_URL: https://$GITHOST/
      GITEA__server__HTTP_PORT: "3000"
      GITEA__server__DISABLE_SSH: "true"
      GITEA__security__INSTALL_LOCK: "true"
      GITEA__service__DISABLE_REGISTRATION: "true"
      GITEA__repository__ENABLE_PUSH_CREATE_USER: "true"
      GITEA__cors__ENABLED: "true"
      GITEA__cors__ALLOW_DOMAIN: https://$SITE
      GITEA__cors__HEADERS: Content-Type,User-Agent,Authorization
    volumes:
      - ./data/gitea:/data
    ports:
      - "127.0.0.1:8095:3000"

  web:
    image: nginx:stable-alpine
    restart: unless-stopped
    volumes:
      - ./web/default.conf:/etc/nginx/conf.d/default.conf:ro
      - ./data/public:/srv/site:ro
    ports:
      - "127.0.0.1:8094:80"
    depends_on:
      - r2-uploader

  builder:
    build: ./builder
    restart: unless-stopped
    mem_limit: 200m
    env_file: .env.log
    environment:
      REPO: $REPO
    volumes:
      - ./data/public:/public
    depends_on:
      - gitea

  r2-uploader:
    build: ./r2-uploader
    restart: unless-stopped
    mem_limit: 256m
    env_file: .env.r2
    environment:
      TZ: KST-9
    depends_on:
      - gitea
EOF

cat > web/default.conf <<'EOF'
server {
    listen 80;
    server_name _;
    root /srv/site;
    index index.html;
    client_max_body_size 25m;
    gzip on;
    gzip_types text/css application/javascript application/json image/svg+xml application/xml;

    location = /api/r2-upload {
        proxy_pass http://r2-uploader:8000;
        proxy_read_timeout 120s;
    }

    location / {
        try_files $uri $uri/ =404;
    }

    error_page 404 /404.html;
}
EOF

cat > builder/Dockerfile <<'EOF'
FROM alpine:latest
RUN apk add --no-cache git hugo
COPY build.sh /build.sh
CMD ["sh", "/build.sh"]
EOF

cat > builder/build.sh <<'EOF'
#!/bin/sh
REPO_URL="http://${GITEA_USER}:${GITEA_TOKEN}@gitea:3000/${GITEA_USER}/${REPO}.git"
SRC=/work/src
NEED=0
while true; do
  if [ ! -d "$SRC/.git" ]; then
    git clone -q "$REPO_URL" "$SRC" && NEED=1
  else
    cd "$SRC" && git fetch -q origin
    if [ "$(git rev-parse HEAD)" != "$(git rev-parse '@{u}')" ]; then
      git reset -q --hard '@{u}' && NEED=1
    fi
  fi
  if [ "$NEED" = 1 ]; then
    cd "$SRC" && hugo --minify --cleanDestinationDir -d /public && echo "built $(date)"
    NEED=0
  fi
  sleep 30
done
EOF

cat > r2-uploader/Dockerfile <<'EOF'
FROM python:3.12-alpine
RUN pip install --no-cache-dir pillow
COPY app.py /app/app.py
CMD ["python", "-u", "/app/app.py"]
EOF

cat > r2-uploader/app.py <<'EOF'
import hashlib, hmac, io, json, os, re, secrets, time, urllib.error, urllib.parse, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

try:
    from PIL import Image, ImageOps
except ImportError:
    Image = None

ACCOUNT = os.environ["R2_ACCOUNT_ID"]
AK = os.environ["R2_ACCESS_KEY_ID"]
SK = os.environ["R2_SECRET_ACCESS_KEY"]
BUCKET = os.environ["R2_BUCKET"]
PUBLIC = os.environ["PUBLIC_BASE"].rstrip("/")
PREFIX = os.environ.get("KEY_PREFIX", "log").strip("/")
ALLOWED = os.environ["ALLOWED_USER"]
GITEA = os.environ.get("GITEA_API", "http://gitea:3000/api/v1")
MAX = int(os.environ.get("MAX_MB", "20")) * 1024 * 1024
MAX_W = int(os.environ.get("MAX_WIDTH", "2000"))
QUALITY = int(os.environ.get("WEBP_QUALITY", "80"))
HOST = f"{ACCOUNT}.r2.cloudflarestorage.com"
CT = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
      ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif"}


def gitea_login(token):
    try:
        req = urllib.request.Request(GITEA + "/user", headers={"Authorization": "token " + token})
        with urllib.request.urlopen(req, timeout=15) as r:
            return json.loads(r.read()).get("login")
    except Exception:
        return None


def to_webp(data, ext):
    # jpg/png -> webp (긴 변은 MAX_WIDTH로 축소). gif/webp/avif는 그대로.
    if Image is None or ext in (".gif", ".webp", ".avif"):
        return data, ext
    try:
        im = ImageOps.exif_transpose(Image.open(io.BytesIO(data)))
        if im.width > MAX_W:
            im.thumbnail((MAX_W, MAX_W * 10))
        if im.mode not in ("RGB", "RGBA"):
            im = im.convert("RGBA")
        buf = io.BytesIO()
        im.save(buf, "WEBP", quality=QUALITY, method=4)
        return buf.getvalue(), ".webp"
    except Exception as e:
        print("webp 변환 실패, 원본으로 올립니다:", e, flush=True)
        return data, ext


def _h(key, msg):
    return hmac.new(key, msg.encode(), hashlib.sha256).digest()


def r2_put(key, data, ctype):
    # S3 호환 API PUT (AWS SigV4, region=auto)
    uri = "/" + BUCKET + "/" + urllib.parse.quote(key, safe="/-_.~")
    amz = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
    day = amz[:8]
    ph = hashlib.sha256(data).hexdigest()
    hdrs = {
        "cache-control": "public, max-age=31536000, immutable",
        "content-type": ctype,
        "host": HOST,
        "x-amz-content-sha256": ph,
        "x-amz-date": amz,
    }
    signed = ";".join(sorted(hdrs))
    canon = "\n".join(["PUT", uri, "", "".join(f"{k}:{hdrs[k]}\n" for k in sorted(hdrs)), signed, ph])
    scope = f"{day}/auto/s3/aws4_request"
    sts = "\n".join(["AWS4-HMAC-SHA256", amz, scope, hashlib.sha256(canon.encode()).hexdigest()])
    k = _h(_h(_h(_h(("AWS4" + SK).encode(), day), "auto"), "s3"), "aws4_request")
    sig = hmac.new(k, sts.encode(), hashlib.sha256).hexdigest()
    hdrs["authorization"] = f"AWS4-HMAC-SHA256 Credential={AK}/{scope}, SignedHeaders={signed}, Signature={sig}"
    del hdrs["host"]
    req = urllib.request.Request("https://" + HOST + uri, data=data, method="PUT", headers=hdrs)
    with urllib.request.urlopen(req, timeout=60) as r:
        r.read()


class Handler(BaseHTTPRequestHandler):
    def reply(self, code, obj):
        b = json.dumps(obj, ensure_ascii=False).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(b)))
        self.end_headers()
        self.wfile.write(b)

    def do_POST(self):
        if self.path != "/api/r2-upload":
            return self.reply(404, {"error": "not found"})
        auth = self.headers.get("Authorization", "")
        if not auth.startswith("token ") or gitea_login(auth[6:]) != ALLOWED:
            return self.reply(403, {"error": "로그인이 필요합니다"})
        n = int(self.headers.get("Content-Length") or 0)
        if n <= 0 or n > MAX:
            return self.reply(413, {"error": "파일이 없거나 너무 큽니다"})
        name = urllib.parse.unquote(self.headers.get("X-Filename", "image.jpg"))
        stem, ext = os.path.splitext(name)
        ext = ext.lower()
        if ext not in CT:
            return self.reply(415, {"error": "이미지 파일만 올릴 수 있습니다"})
        data, ext = to_webp(self.rfile.read(n), ext)
        stem = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip("-.")[:50] or "image"
        key = f"{PREFIX}/{time.strftime('%Y/%m')}/{time.strftime('%d%H%M%S')}-{secrets.token_hex(2)}-{stem}{ext}"
        try:
            r2_put(key, data, CT[ext])
        except urllib.error.HTTPError as e:
            return self.reply(502, {"error": f"R2 {e.code}: " + e.read().decode(errors="replace")[:300]})
        except Exception as e:
            return self.reply(502, {"error": str(e)[:300]})
        self.reply(200, {"url": f"{PUBLIC}/{key}", "key": key})

    def log_message(self, fmt, *args):
        print("%s - %s" % (self.address_string(), fmt % args), flush=True)


if __name__ == "__main__":
    print("r2-uploader 시작 (webp 변환:", "켜짐" if Image else "꺼짐", ")", flush=True)
    ThreadingHTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
EOF

echo "== 2. Gitea 시작 =="
touch .env.log && chmod 600 .env.log
$DOCKER compose up -d gitea
printf "Gitea 준비 대기"
for i in $(seq 60); do
  curl -fs http://127.0.0.1:8095/api/healthz >/dev/null 2>&1 && break
  printf "."; sleep 2
done
echo
curl -fs http://127.0.0.1:8095/api/healthz >/dev/null || { echo "Gitea가 뜨지 않았습니다: $DOCKER compose logs gitea"; exit 1; }

echo "== 3. 관리자 계정, 토큰, CMS 로그인 앱 =="
GX="$DOCKER compose exec -T -u git gitea gitea"
$GX admin user create --admin --username "$GUSER" --password "$GPASS" --email "$GMAIL" --must-change-password=false
SETUP_TOKEN=$($GX admin user generate-access-token --username "$GUSER" --token-name setup --scopes "write:repository,write:user" --raw | tr -d '\r\n ')
BUILD_TOKEN=$($GX admin user generate-access-token --username "$GUSER" --token-name builder --scopes "read:repository" --raw | tr -d '\r\n ')
[ -n "$SETUP_TOKEN" ] && [ -n "$BUILD_TOKEN" ] || { echo "토큰 생성 실패"; exit 1; }
printf 'GITEA_USER=%s\nGITEA_TOKEN=%s\n' "$GUSER" "$BUILD_TOKEN" > .env.log

APP_ID=$(curl -fsS -X POST http://127.0.0.1:8095/api/v1/user/applications/oauth2 \
  -H "Authorization: token $SETUP_TOKEN" -H "Content-Type: application/json" \
  -d "{\"name\":\"decap-cms\",\"redirect_uris\":[\"https://$SITE/admin/\"],\"confidential_client\":false}" \
  | python3 -c 'import sys,json; print(json.load(sys.stdin)["client_id"])')
echo "CMS 앱 ID: $APP_ID"

echo "== 4. Hugo 소스 ~/log-src =="
mkdir -p "$SRC"/content/posts "$SRC"/static/admin "$SRC"/themes
cd "$SRC"
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
EOF

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

[params]

DateFormat = “2006-01-02”

[params.homeInfoParams]

Title = “나의 기록” Content = “짧게 남기는 기록” [[menu.main]] name = “일상” url = “/categories/일상/” weight = 10 [[menu.main]] name = “개발” url = “/categories/개발/” weight = 20 [[menu.main]] name = “리뷰” url = “/categories/리뷰/” weight = 30 EOF cat > static/admin/config.yml <<EOF backend: name: gitea repo: $GUSER/$REPO branch: main base_url: https://$GITHOST api_root: https://$GITHOST/api/v1 app_id: $APP_ID 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: [“일상”, “개발”, “리뷰”] } – { 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″ /> <title>글쓰기 · My log</title> </head> <body> <script src=”https://unpkg.com/decap-cms@^3.0.0/dist/decap-cms.js”></script> <script> function giteaToken() { try { return JSON.parse(localStorage.getItem(‘decap-cms-user’)).token; } catch (e) { return null; } } 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: ” }); fetch(‘/api/r2-upload’, { method: ‘POST’, headers: { ‘Authorization’: ‘token ‘ + giteaToken(), ‘X-Filename’: encodeURIComponent(f.name) }, body: f }).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 cat > content/posts/$(date +%F)-hello.md <<EOF — title: “첫 글” date: $(date +%F) draft: false categories: [“일상”] — log.example.com을 시작합니다. EOF git init -q -b main git add -A git -c user.name=”$GUSER” -c user.email=”$GMAIL” commit -q -m “init” git push -q “http://$GUSER:[email protected]:8095/$GUSER/$REPO.git” main git remote add origin “https://$GITHOST/$GUSER/$REPO.git” echo “== 5. 나머지 컨테이너 시작 ==” cd “$STACK” $DOCKER compose up -d –build printf “첫 빌드 대기” for i in $(seq 45); do [ -f data/public/index.html ] && break printf “.”; sleep 2 done echo if [ -f data/public/index.html ]; then echo “빌드 완료: curl -s http://127.0.0.1:8094 | head 로 확인할 수 있습니다.” else echo “아직 빌드 전입니다. 로그 확인: cd $STACK && $DOCKER compose logs builder” fi echo echo “남은 일 (NPG / DNS):” echo ” 1. DNS: $SITE, $GITHOST A 레코드 → 이 서버 IP” echo ” 2. NPG 프록시 호스트: $SITE → 127.0.0.1:8094 / $GITHOST → 127.0.0.1:8095 (둘 다 SSL 발급)” echo ” 3. https://$SITE/admin 에서 ‘$GUSER’ 계정으로 로그인해 글쓰기 테스트”

스크립트가 하는 일

실행하면 관리자 계정 생성부터 첫 글 빌드까지 사람 손 없이 끝납니다. 입력하는 것은 Gitea 계정 정보와 R2 키뿐입니다.

  1. 안전 확인: ~/log-stack, ~/log-src가 이미 있거나 8094/8095 포트가 쓰이고 있으면 아무것도 만들지 않고 멈춥니다. docker를 sudo 없이 쓸 수 없으면 자동으로 sudo docker를 씁니다.
  2. 설정 파일 작성: docker-compose.yml, nginx 설정, 빌더와 업로더의 Dockerfile·코드를 ~/log-stack 아래에 만듭니다. R2 키는 .env.r2에 권한 600으로 저장합니다.
  3. Gitea 시작: Gitea만 먼저 띄우고 /api/healthz가 응답할 때까지 기다립니다. INSTALL_LOCK을 켜서 웹 설치 마법사 없이 바로 쓸 수 있고, 회원가입은 막아둡니다.
  4. 계정과 토큰: Gitea CLI로 관리자 계정을 만들고 토큰 두 개를 발급합니다. 하나는 설치용(저장소 쓰기, OAuth 앱 생성)이고, 다른 하나는 빌더용(저장소 읽기 전용)입니다. 빌더에는 읽기 토큰만 들어갑니다.
  5. CMS 로그인 앱: Gitea API로 Decap CMS용 OAuth2 앱을 만듭니다. 리디렉트 주소는 https://log.example.com/admin/이고, 브라우저에서 PKCE로 로그인하므로 공개 클라이언트(confidential_client: false)로 만듭니다.
  6. Hugo 소스: ~/log-src에 PaperMod 테마를 받고(.git은 지워서 저장소에 그대로 포함), hugo.toml, CMS 설정, 첫 글을 만듭니다. CMS의 카테고리 선택지와 메뉴의 카테고리 이름을 같게 맞춰둬야 메뉴 링크가 동작합니다.
  7. 첫 push: ENABLE_PUSH_CREATE_USER 설정 덕분에 저장소를 미리 만들지 않아도 push하는 순간 생성됩니다. push에 쓴 토큰은 remote 주소에 남기지 않습니다.
  8. 나머지 컨테이너: web, builder, r2-uploader를 빌드해 띄우고, data/public/index.html이 생길 때까지 최대 90초 기다립니다.

이미지 업로더(app.py)의 핵심

  • 인증: CMS가 보낸 Gitea 토큰으로 /api/v1/user를 호출해 로그인한 사람이 관리자 계정인지 확인합니다. 다른 사람은 403입니다.
  • 변환: jpg/png는 Pillow로 EXIF 회전을 바로잡고, 긴 변 2000px 이하, 품질 80의 webp로 바꿉니다. gif·webp·avif는 그대로 올립니다.
  • 업로드: boto3 없이 표준 라이브러리만으로 S3 호환 API(SigV4 서명, region auto)에 PUT합니다. Cache-Control: max-age=31536000, immutable을 붙여 CDN이 오래 캐시하게 합니다.
  • 파일 이름: log/연/월/일시분초-랜덤4자리-원래이름.webp. 한글 파일명은 -로 바뀌어 image가 되지만 시각과 랜덤값 덕분에 겹치지 않습니다.

환경변수 MAX_MB(기본 20), MAX_WIDTH(2000), WEBP_QUALITY(80), KEY_PREFIX(log)를 .env.r2에 추가하면 기본값을 바꿀 수 있습니다.

실행 후 DNS와 NPG 설정

스크립트가 끝나면 컨테이너는 127.0.0.1에서만 열려 있으니, 도메인을 연결해야 밖에서 보입니다.

1. DNS (Cloudflare): 두 서브도메인의 A 레코드를 서버 IP로 추가합니다.

이름종류값
logA서버 IP
gitA서버 IP

2. NPG 프록시 호스트: 기존 사이트와 같은 방식으로 두 개를 추가하고, 둘 다 SSL 인증서를 발급합니다.

도메인전달 대상
log.example.com127.0.0.1:8094
git.example.com127.0.0.1:8095

이미지 업로드 경로(/api/r2-upload)는 내부 nginx가 업로더로 넘기므로 NPG에 커스텀 location을 넣을 필요가 없습니다. 다만 NPG의 기본 업로드 크기 제한이 작다면 log.example.com 프록시 호스트의 고급 설정에 아래 한 줄을 넣어주세요.

client_max_body_size 25m;

3. 확인: https://log.example.com에서 첫 글이 보이면 연결이 끝난 것입니다.

글쓰기와 이미지 업로드

글은 https://log.example.com/admin에서 쓰고, 저장하면 30초에서 1분 안에 사이트에 반영됩니다.

  1. /admin에 접속해 Gitea로 로그인합니다. 처음 한 번은 Gitea 화면에서 앱 접근을 승인합니다.
  2. “글” 컬렉션에서 새 글을 만들고 제목, 날짜, 카테고리를 채웁니다.
  3. 본문 편집기의 + 메뉴에서 이미지 업로드를 고르고 파일을 선택합니다. 업로드가 끝나면 ![설명](https://img.example.com/log/...webp)이 본문에 들어갑니다. 이미 올라가 있는 이미지라면 주소를 직접 붙여넣어도 됩니다.
  4. 대표 이미지가 필요하면 “대표 이미지” 항목에서 같은 방식으로 올립니다.
  5. 게시하면 Gitea에 커밋되고, 빌더가 변경을 감지해 다시 빌드합니다.

서버에서 직접 글을 쓰고 싶다면 ~/log-src에서 content/posts에 마크다운을 추가한 뒤 git push해도 똑같이 반영됩니다. 이때 push 주소는 https://git.example.com/<아이디>/log.git이며, 비밀번호 대신 Gitea에서 발급한 토큰을 써도 됩니다.

문제 해결과 정리

문제가 생기면 먼저 해당 컨테이너의 로그를 봅니다. 모든 명령은 ~/log-stack에서 실행합니다.

증상확인할 것
사이트가 403이나 빈 화면아직 빌드 전입니다. docker compose logs builder에 built ...가 찍혔는지 확인합니다.
/admin 로그인 후 오류Gitea OAuth 앱의 리디렉트 주소가 https://log.example.com/admin/과 정확히 같은지, git.example.com이 HTTPS로 열리는지 확인합니다.
업로드 실패: 로그인이 필요합니다CMS에 로그인한 계정이 스크립트에서 만든 관리자 계정과 같은지 확인합니다(.env.r2의 ALLOWED_USER).
업로드 실패: R2 403R2 토큰의 권한이 해당 버킷의 Object Read & Write인지, 계정 ID와 버킷 이름이 맞는지 확인합니다.
업로드 실패: 413NPG 프록시 호스트 고급 설정에 client_max_body_size 25m;을 추가합니다.
글을 저장했는데 반영이 안 됨docker compose logs builder에서 Hugo 빌드 오류를 확인합니다. 대부분 글 머리말(front matter) 형식 문제입니다.

설정을 바꾼 뒤에는 docker compose up -d --build로 다시 띄우면 됩니다.

전부 지우기

테스트로 설치했다가 되돌리려면 아래 명령이면 충분합니다. 다른 컨테이너에는 영향이 없습니다. R2에 올린 이미지는 남으니 필요하면 버킷의 log/ 경로를 따로 지웁니다.

cd ~/log-stack && docker compose down --rmi local
cd ~ && sudo rm -rf ~/log-stack ~/log-src

NPG의 프록시 호스트 2개와 DNS 레코드 2개도 함께 지우면 끝입니다.

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

댓글 남기기