Field Log · Entry

Astro · Railway · Cloudflare로 개인 도메인 블로그 "딸깍" 배포 만들기 (2/3)

블로그 운영기 시리즈 2편 — Astro·Railway·Cloudflare 스택으로 개인 도메인 블로그 만들기

1편에서는 Tistory·네이버 자동화가 막혀 결국 개인 도메인 정적 블로그로 옮긴 결정 과정을 적었습니다. 이번 글은 그 결정 다음에 실제로 어떻게 만들었는지의 1차 기록입니다. Astro 5로 정적 사이트를 만들고, Railway에 git push로 자동 배포한 뒤, Cloudflare로 개인 도메인을 연결합니다. 도중에 부딪힌 두 사고(SPA fallback, 한글 SVG 깨짐)도 그대로 남깁니다.

이 글이 답하는 질문

  • Astro인가? Next.js·Hugo·Jekyll과 어떻게 다른가?
  • git push 한 번으로 빌드·배포까지 가는 라인을 어떻게 깔았나?
  • 신규 도메인을 Cloudflare로 묶을 때 실제로 누른 버튼은 무엇이었나?

왜 Astro인가 — Next.js·Hugo·Jekyll과 비교한 선택 이유

블로그 프레임워크 후보는 네 개였습니다. Astro, Next.js, Hugo, Jekyll. 네 축으로 비교했습니다 — 정적 빌드 / MDX 지원 / 한국어 폰트 통제 / Node 친화도.

프레임워크 4축 비교 — Astro·Next.js·Hugo·Jekyll

위 다이어그램: 네 후보를 정적 빌드, MDX, 한국어 폰트 통제, Node 친화도 4축으로 비교. Astro가 4축 모두에서 균형이 좋았던 이유.

프레임워크정적 빌드MDX한국어 폰트Node 친화도
Astro 5기본 (output: ‘static’)공식 통합자유롭게 통제Node 20+
Next.js가능 (export)가능 (mdx-js)자유롭게 통제Node 18+
Hugo강력 (Go 기반)제한적 (shortcode)자유롭게 통제Node 무관
Jekyll표준 (Ruby)미지원 (Liquid)자유롭게 통제Ruby 기반

Next.js는 SSR·이미지 최적화 등 기능이 풍부했지만, 제가 만들 건 글 위주의 정적 콘텐츠 사이트였습니다. 동적 라우팅·서버 컴포넌트가 필요 없는데 Next 빌드 그래프 전체를 끌어오는 건 과합니다. Hugo는 빠르지만 MDX가 안 됩니다. 사내 RAG 프로젝트에서도 마크다운에 React 컴포넌트를 끼워 넣어야 할 일이 자주 있어서 MDX 부재는 큰 감점이었습니다. Jekyll은 1편에서 적은 대로 1년 전 Ruby 환경에서 막혔던 기억이 있습니다.

Astro는 기본값이 정적 빌드(output: 'static')이고, MDX는 npx astro add mdx 한 줄로 들어옵니다. 빌드 산출물이 그냥 HTML/CSS/JS 정적 파일이라 어느 호스팅에든 옮길 수 있습니다. 락인이 약하다는 점이 결정타였습니다.


5분 셋업 — npm create astro부터 첫 페이지까지

설치는 다음 한 줄로 시작합니다.

npm create astro@latest

대화형 프롬프트가 시작되고, “blog” 템플릿을 고르면 5분 안에 src/pages, src/content, src/layouts가 깔린 프로젝트가 생깁니다. npm run dev를 치면 http://localhost:4321에 첫 페이지가 뜹니다.

마크다운에 React 컴포넌트를 섞기 위해 MDX 통합을 추가합니다.

npx astro add mdx

이 명령은 astro.config.mjs에 자동으로 통합을 추가합니다. 제 현재 설정은 다음과 같습니다 — 사이트 도메인, 정적 출력, MDX와 sitemap 통합, 그리고 sharp 이미지 서비스까지.

// astro.config.mjs
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://blog.ruahverce.com',
  output: 'static',
  integrations: [mdx(), sitemap({ filter: (p) => !p.includes('/draft/') })],
  build: { format: 'directory' },
  image: { service: { entrypoint: 'astro/assets/services/sharp' } },
});

build.format: 'directory'/posts/abc/index.html 형태로 빌드해서 trailing slash URL이 깔끔하게 떨어지게 합니다. 이 한 줄이 뒤에서 SPA fallback 사고와도 연결됩니다.


Content Collections — 마크다운에 frontmatter 스키마 강제하기

블로그를 오래 운영하면 frontmatter 실수가 누적됩니다. description 빠뜨리기, 태그 오타, publishDate 형식 어긋나기. 수동 검수로는 한계가 옵니다.

Astro의 Content CollectionsZod 스키마로 frontmatter를 빌드 시점에 검증합니다. 스키마를 어기면 빌드가 실패해 잘못된 글이 배포되지 않습니다.

// src/content.config.ts (핵심 발췌)
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';

const posts = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/posts' }),
  schema: ({ image }) =>
    z.object({
      title: z.string().min(1),
      description: z.string().min(100, '메타 디스크립션은 최소 100자')
                            .max(160, '메타 디스크립션은 최대 160자'),
      publishDate: z.coerce.date(),
      tags: z.array(z.string()).min(3).max(7),
      cover: image().optional(),
      series: z.string().optional(),
      seriesOrder: z.number().int().positive().optional(),
      draft: z.boolean().default(false),
    }).refine(
      (d) => !!d.series === !!d.seriesOrder,
      'series와 seriesOrder는 함께 지정하거나 둘 다 비워야 합니다',
    ),
});

export const collections = { posts };

핵심은 image() 헬퍼와 refine()입니다. image()로 받은 cover는 sharp가 자동 최적화해 Core Web Vitals에 유리한 형태로 빌드됩니다. refine()은 series·seriesOrder처럼 함께 가야 하는 필드의 정합성을 잡아줍니다. PHM 데이터 파이프라인에서도 비슷한 의존 관계 검증을 매번 손으로 짰는데, Zod의 refine이 그 일을 한 줄로 처리해줍니다.

실제 파일에는 updatedDate·coverAlt·author·canonical 같은 옵셔널 필드도 더 있지만, 위 발췌는 핵심만 잡은 것입니다. 운영하면서 필요한 필드를 점진적으로 추가하는 패턴이 깔끔합니다.


Railway 배포 — git push가 곧 빌드·배포

빌드까지 잡혔으면 다음은 호스팅입니다. RailwayNixpacks 기반이라 별도 Dockerfile 없이 Node 빌드를 잡아주고, GitHub repo에 push만 하면 자동으로 빌드·배포가 돌아갑니다.

배포 파이프라인 — git push → Railway 빌드 → Cloudflare CDN → 사용자

위 다이어그램: 로컬 git push → GitHub → Railway 자동 빌드 → Cloudflare CDN → 사용자까지의 파이프라인. 각 구간은 사람 개입 없이 흐릅니다.

연결은 두 단계입니다.

railway login
railway link   # 기존 프로젝트(pputty)에 연결

이후 GitHub repo를 Railway 프로젝트에 연결하면 push마다 빌드가 트리거됩니다. 빌드 명령과 시작 명령은 다음 두 파일로 잡았습니다.

# nixpacks.toml
[phases.build]
cmds = ["npm install", "npm run build"]

[phases.setup]
nixPkgs = ["nodejs_20"]

[start]
cmd = "npm run start"
// railway.json
{
  "$schema": "https://railway.com/railway.schema.json",
  "build": {
    "builder": "NIXPACKS",
    "buildCommand": "npm install && npm run build"
  },
  "deploy": {
    "startCommand": "npm run start",
    "healthcheckPath": "/",
    "healthcheckTimeout": 30,
    "restartPolicyType": "ON_FAILURE",
    "restartPolicyMaxRetries": 10
  }
}

npm run startpackage.json에서 다음과 같이 정의됩니다.

"scripts": {
  "dev": "astro dev",
  "build": "astro build",
  "start": "serve dist -l ${PORT:-3000}"
}

serve로 빌드 결과(dist/)를 그대로 서빙합니다. Railway가 주는 $PORT를 사용하고, 로컬에선 3000으로 떨어집니다.


Cloudflare로 개인 도메인 연결 — DNS·SSL·CDN

Railway가 배포해주는 도메인은 *.up.railway.app 형태입니다. 검색 권위와 브랜딩 모두를 위해 개인 도메인이 필요합니다. Cloudflare Registrar에서 도메인을 사고 Cloudflare에서 DNS·SSL·CDN을 한 번에 잡았습니다.

도메인을 산 뒤 누른 버튼은 다음 세 개입니다.

1. DNS 레코드 추가 — Railway 서비스 도메인을 가리키는 CNAME (또는 A) 레코드를 추가합니다. 호스트는 blog, 값은 Railway가 발급한 *.up.railway.app.

프록시(주황 구름)는 ON. 이걸 켜야 Cloudflare CDN과 SSL이 동작합니다.

2. SSL/TLS 모드: Full (strict) — Cloudflare 대시보드의 SSL/TLS 섹션에서 모드를 Full (strict)로 설정합니다.

Railway가 자체 인증서를 제공하므로 strict 모드가 정상 동작합니다. 이 모드에서는 Cloudflare ↔ Railway 구간도 검증된 TLS로 묶입니다.

3. Custom Domain (Railway 측) — Railway 서비스 settings에서 blog.ruahverce.com을 custom domain으로 등록합니다.

Railway가 발급해주는 검증 토큰을 Cloudflare DNS에 한 번 더 추가하면 모든 단계가 끝납니다.

세 단계 다 합쳐 30분도 걸리지 않았습니다. Cloudflare Registrar는 도메인 마진을 0으로 가져가는 정책이라 연 $10대로 떨어집니다.


부딪힌 두 사고 — serve -s SPA fallback, 한글 SVG 깨짐

순탄하게 흐른 것처럼 적었지만 도중에 두 번 멈췄습니다. 둘 다 커밋 히스토리에 그대로 남아 있습니다.

사고 1 — serve -s의 SPA fallback. 처음에 start 스크립트를 serve -s dist로 잡았습니다. -s(--single)는 모든 경로를 index.html로 떨어뜨리는 SPA용 옵션입니다. 결과는 정적 페이지 전체가 홈으로 리다이렉트되는 사고였습니다. /posts/abc/로 들어가면 home으로 떨어졌습니다. Astro는 정적 다중 페이지 사이트라서 -s가 필요 없습니다. 플래그를 빼는 1글자 수정으로 해결했습니다.

- "start": "serve -s dist -l ${PORT:-3000}"
+ "start": "serve dist -l ${PORT:-3000}"

커밋 046acedfix: serve -s SPA fallback 제거 — 글 페이지가 홈으로 떨어지던 원인

사고 2 — OG 카드 SVG의 한글 깨짐. OG 이미지를 SVG에서 sharp로 라스터화하는 과정에서 한글이 모두 □(tofu)로 나왔습니다. 원인은 두 겹이었습니다. (1) SVG 안 폰트가 Georgia, Courier New 같은 한글 글리프가 없는 폰트로 지정돼 있었고, (2) WSL 환경의 fontconfig에 한국어 폰트가 등록되지 않아 sharp가 fallback으로 잡을 글리프조차 없었습니다.

해결은 Noto Sans KR로 폰트를 통일하고 fontconfig를 설정한 뒤, social asset 일괄 재생성이었습니다. 사내에서 다국어 PDF 보고서를 sharp/headless-chrome으로 뽑던 시기에 같은 문제를 만난 적이 있어서 패턴은 익숙했습니다.

커밋 4599c74fix: regenerate social assets with Korean font support

이 두 사고는 Astro의 문제가 아니라 호스팅 정적 서빙과 라스터 파이프라인의 1차 함정이었습니다. 같은 스택을 깔 사람이 있다면 미리 점검하면 좋겠습니다.


바이브 코딩이라는 가능 조건

1편에서 적은 대로 1년 전이면 이 셋업을 못 만들었을 겁니다. Jekyll·Ruby에서 막혔고, Cloudflare 콘솔의 어떤 토글이 정상인지도 헷갈렸을 겁니다. 지금 가능한 이유는 단순합니다 — AI 에이전트가 환경 셋업·디버깅·리팩터를 같이 돌려주기 때문입니다.

위의 두 사고도 사실 혼자였으면 한나절씩 잡혔을 일입니다. SPA fallback은 serve 문서를 처음부터 읽어야 했을 거고, 한글 SVG는 fontconfig·sharp·Noto Sans KR 조합을 직접 맞춰야 했을 겁니다. 에이전트와 페어링하면서 가설을 빠르게 좁혔고, 두 사고 모두 한 시간 안에 닫혔습니다.

박사과정 일정과 본업 사이에서 글을 쓸 시간은 새벽 1시간, 점심 30분입니다. 이 시간 안에 셋업까지 끝내려면 사람의 의식 자원만으로는 부족합니다. Claude Code 같은 도구로 1주일 만에 배포 라인까지 깔았다는 사실 자체가, “지금 시점이라야 가능했던 결정”이라는 1편의 명제를 강화합니다.

이게 단순한 자랑이 아닙니다. 인프라 진입장벽이 낮아진 시점에 시작해야 콘텐츠 누적의 시간 비용이 회수된다는 게 핵심입니다.


FAQ

Q1. Vercel·Netlify가 더 표준 아닌가요? 정적 사이트만 운영하면 Vercel·Netlify가 더 익숙한 선택입니다. 두 곳 다 Edge Function까지 지원합니다. 제가 Railway를 고른 이유는 풀 Node 런타임이 필요한 long-running 서버(RAG 데모 API, WebSocket, 큐 워커 등)도 같은 dashboard에서 다루고 싶어서였습니다. Vercel·Netlify의 함수는 짧은 응답엔 강하지만 컨테이너 호스팅 같은 자유도는 다른 영역입니다. 정적 콘텐츠만 운영할 거라면 Vercel·Netlify가 동등하거나 더 매끄럽습니다.

Q2. Cloudflare Pages를 쓰는 게 더 자연스럽지 않나요? Cloudflare Pages는 정적 사이트와 Workers 함수에 강합니다. 다만 Workers는 Edge 런타임 제약(짧은 실행 시간, 일부 Node API 미지원)이 있어 풀 Node 컨테이너가 필요한 워크로드엔 한 박자 부족합니다. 또 한 가지 — 호스팅과 DNS 계층을 다른 벤더로 분리하면 한 쪽을 옮길 때 다른 쪽이 그대로 유지되는 통제권 이점이 있습니다. 이 사이트는 그래서 *호스팅(Railway) + DNS·CDN(Cloudflare)*으로 의도적으로 갈랐습니다.

Q3. 개인 도메인 비용은 얼마나 드나요? 보통 연 $10~15입니다. Cloudflare Registrar는 도메인 마진을 0으로 가져가서 도매가 그대로 청구됩니다. .com 기준 연 $10 안팎. SSL·CDN·DNS는 Cloudflare 무료 플랜에 포함됩니다.

Q4. SSL은 자동인가요? 네. Cloudflare 프록시(주황 구름)를 켜면 엣지 측 SSL이 자동입니다. Railway도 자체 도메인(*.up.railway.app)에 자동으로 인증서를 발급합니다. SSL/TLS 모드를 Full (strict) 로 두면 두 구간이 모두 검증된 TLS로 묶입니다.


마무리

  • 스택 선택은 락인 강도와 통제권으로 갈렸습니다. Astro는 정적 빌드 + MDX + 호스팅 락인 약함이라 다음 단계에서도 옮기기 쉽습니다.
  • git push가 곧 배포라는 라인은 nixpacks.toml + railway.json + package.json 세 파일로 잡힙니다. Cloudflare는 DNS·SSL·CDN 계층만 따로.
  • 두 번 멈췄습니다. SPA fallback과 한글 SVG. 둘 다 Astro의 문제는 아니었지만, 같은 스택을 깔 사람이 미리 알면 한나절을 아낍니다.

도착점은 1편에 캡처해둔 것과 같은 화면입니다.

도착점: blog.ruahverce.com 현재 hero — Astro·Railway·Cloudflare 스택으로 운영 중

위 이미지: 본 사이트의 hero 섹션. 이 글에서 다룬 스택으로 빌드·배포되어 서빙되는 결과물.

다음 편 예고 (3/3)

신규 도메인을 띄워도 처음 2주는 구글에 거의 안 잡힙니다. Search Console·네이버 서치어드바이저 등록, sitemap 제출, 내부 링크와 시리즈 구조로 색인을 끌어오는 방법까지 — 인프라가 아니라 검색 엔진과의 첫 대화를 다룹니다.


참고자료