n8n으로 블로그 자동화 구축하기: Ghost 연동부터 실전 워크플로우까지

Oracle Cloud 무료 티어에 n8n을 셀프호스팅하고, Ghost Admin API로 블로그 포스팅을 자동화하는 완전 가이드입니다.

1. 왜 n8n인가?

블로그를 운영하다 보면 반복 작업이 쌓입니다:

  • 글 발행하고 SNS에 공유
  • RSS 피드 모니터링해서 관심 주제 정리
  • 주기적인 뉴스레터 발송
  • 백업 확인 알림

Zapier나 Make(Integromat)도 좋지만, 월 수백 개 워크플로우를 무료로 돌리려면 셀프호스팅이 답입니다.

비용 비교 (월 기준):
┌─────────────────┬──────────┬──────────────┐
│ 서비스          │ 무료 한도 │ 유료 시작가   │
├─────────────────┼──────────┼──────────────┤
│ Zapier          │ 100 태스크│ $19.99       │
│ Make            │ 1,000 ops │ $9          │
│ n8n Cloud       │ 무료 없음 │ €20         │
│ n8n Self-hosted │ 무제한   │ 서버비만     │
└─────────────────┴──────────┴──────────────┘

Oracle Cloud 무료 티어(4 OCPU, 24GB RAM)면 n8n + Ghost + DB 전부 돌리고도 남습니다.


2. 아키텍처 구성

┌─────────────────────────────────────────────────────────┐
│                    Oracle Cloud VM                       │
│                  (4 OCPU, 24GB RAM)                     │
│                                                          │
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐ │
│  │   Ghost     │    │    n8n      │    │  PostgreSQL │ │
│  │  (블로그)   │    │ (자동화)    │    │  (n8n DB)   │ │
│  │  :2368      │    │   :5678     │    │   :5432     │ │
│  └──────┬──────┘    └──────┬──────┘    └─────────────┘ │
│         │                  │                            │
│  ┌──────┴──────────────────┴──────┐                    │
│  │     Nginx Proxy Manager        │                    │
│  │  blog.example.dev → :2368      │                    │
│  │  n8n.example.dev  → :5678      │                    │
│  └─────────────┬──────────────────┘                    │
│                │ :443                                   │
└────────────────┼────────────────────────────────────────┘
                 │
         ┌───────┴───────┐
         │  Cloudflare   │
         │  (SSL/CDN)    │
         └───────────────┘

3. n8n 설치 (Docker Compose)

3.1 디렉토리 구조

~/docker/n8n/
├── docker-compose.yml
├── .env
└── shared/          # 워크플로우 간 파일 공유

3.2 docker-compose.yml

volumes:
  n8n_storage:
  postgres_storage:

networks:
  n8n:

services:
  postgres:
    image: postgres:16-alpine
    hostname: postgres
    container_name: n8n-postgres
    networks: ['n8n']
    restart: unless-stopped
    environment:
      - POSTGRES_USER
      - POSTGRES_PASSWORD
      - POSTGRES_DB
    volumes:
      - postgres_storage:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -h localhost -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
      interval: 5s
      timeout: 5s
      retries: 10

  n8n:
    image: n8nio/n8n:latest
    hostname: n8n
    container_name: n8n
    networks: ['n8n']
    restart: unless-stopped
    ports:
      - 5678:5678
    environment:
      - DB_TYPE=postgresdb
      - DB_POSTGRESDB_HOST=postgres
      - DB_POSTGRESDB_USER=${POSTGRES_USER}
      - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
      - N8N_DIAGNOSTICS_ENABLED=false
      - N8N_PERSONALIZATION_ENABLED=false
      - N8N_ENCRYPTION_KEY
      - N8N_USER_MANAGEMENT_JWT_SECRET
    env_file:
      - .env
    volumes:
      - n8n_storage:/home/node/.n8n
      - ./shared:/data/shared
    depends_on:
      postgres:
        condition: service_healthy

3.3 .env 파일

# 보안을 위해 랜덤 생성
POSTGRES_USER=n8n
POSTGRES_PASSWORD=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
POSTGRES_DB=n8n

N8N_ENCRYPTION_KEY=$(openssl rand -base64 32)
N8N_USER_MANAGEMENT_JWT_SECRET=$(openssl rand -base64 32)

3.4 실행

cd ~/docker/n8n
docker compose up -d

# 상태 확인
docker ps --filter 'name=n8n'

4. Nginx Proxy Manager 설정

4.1 Proxy Host 추가

항목
Domain Names n8n.example.dev
Scheme http
Forward Hostname 172.17.0.1
Forward Port 5678
Block Common Exploits
Websockets Support ✅ (필수!)

4.2 SSL 설정

항목
SSL Certificate *.example.dev
Force SSL
HTTP/2 Support

4.3 NPM 옵션 설명

옵션 역할 n8n에서
Block Common Exploits SQL Injection, XSS 등 기본 WAF 규칙 ✅ 권장
Websockets Support WebSocket 프로토콜 프록시 (실시간 통신) 필수

중요: Websockets Support를 켜지 않으면 n8n 에디터가 실시간 동기화되지 않습니다. 워크플로우 편집 시 연결 끊김/지연이 발생합니다.


5. Ghost API 연동

5.1 Ghost에서 Integration 생성

Ghost Admin → Settings → Integrations → Add custom integration

Name: n8n
생성 후 확인:
- Content API Key: 읽기 전용 (조회용)
- Admin API Key: 읽기/쓰기 (포스팅용) ← 이것 사용
- API URL: https://blog.example.dev

5.2 n8n Credential 추가

n8n → Credentials → Add Credential → Ghost

항목
API URL https://blog.example.dev
Admin API Key 64자리:64자리 형태

5.3 API Key 차이점

Key 종류 권한 용도
Content API Key 읽기 프론트엔드 글 조회
Admin API Key 읽기/쓰기 글 생성, 수정, 삭제

6. Webhook → Ghost 포스팅 워크플로우

n8n의 기본 Ghost 노드는 custom_excerpt, meta_title 등 SEO 필드를 지원하지 않습니다. HTTP Request 노드로 Ghost Admin API를 직접 호출하면 모든 필드를 제어할 수 있습니다.

6.1 워크플로우 구조

┌─────────────┐    ┌─────────────┐    ┌──────────────┐
│   Webhook   │───▶│    Code     │───▶│ HTTP Request │
│  (트리거)   │    │ (파싱/변환) │    │  (Ghost API) │
└─────────────┘    └─────────────┘    └──────────────┘

6.2 Webhook 노드 설정

설정
HTTP Method POST (GET 아님!)
Path ghost-post
Authentication None (또는 Header Auth)

주의: GET으로 설정하면 Body를 받을 수 없습니다. 반드시 POST로 설정하세요.

6.3 요청 Body 형식

Markdown 블로그 초안 전체를 content로 전송합니다:

{
  "title": "글 제목",
  "content": "# 제목\n\n> 요약\n\n## 1. 본문...\n\n## 15. 블로그 글 작성 시 포인트 (내부용)\n\n**Ghost SEO 설정:**\n| Post URL | `my-post-slug` |\n...",
  "status": "draft"
}

6.4 Code 노드: Markdown 파싱 + Ghost API Body 생성

블로그 초안에서 필요한 정보를 추출하고, Ghost API 형식으로 변환합니다:

const content = $input.first().json.body.content;

// 1. # 제목 추출
const titleMatch = content.match(/^# (.+)$/m);
const title = titleMatch ? titleMatch[1] : '';

// 2. 본문 추출 (## 1. 부터 ~ 블로그 글 작성 시 포인트 전까지)
const bodyMatch = content.match(/## 1\. [\s\S]*?(?=## \d+\. 블로그 글 작성 시 포인트)/);
const body = bodyMatch ? bodyMatch[0].trim() : '';

// 3. Ghost SEO 설정 추출 (마크다운 표에서)
const postUrlMatch = content.match(/\| Post URL \| `([^`]+)` \|/);
const metaTitleMatch = content.match(/\| Meta title \| (.+?) \|/);
const metaDescMatch = content.match(/\| Meta description \| (.+?) \|/);
const excerptMatch = content.match(/\| Excerpt \| (.+?) \|/);

// 4. Ghost API Body 생성
const ghostBody = {
  posts: [{
    title,
    mobiledoc: JSON.stringify({
      version: "0.3.1",
      markups: [],
      atoms: [],
      cards: [["markdown", { markdown: body }]],
      sections: [[10, 0]]
    }),
    slug: postUrlMatch ? postUrlMatch[1] : '',
    custom_excerpt: excerptMatch ? excerptMatch[1].trim() : '',
    meta_title: metaTitleMatch ? metaTitleMatch[1].trim() : '',
    meta_description: metaDescMatch ? metaDescMatch[1].trim() : '',
    status: "draft"
  }]
};

return { ghostBody };

6.5 Mobiledoc 형식 이해하기

Ghost는 Lexical(5.0+ 기본), HTML, Mobiledoc 세 가지 형식을 지원합니다. Ghost 5.0부터 Lexical이 기본 포맷이지만, Mobiledoc도 여전히 작동하며 HTML을 보내면 Ghost가 자동으로 Lexical로 변환합니다. Markdown을 직접 넣으려면 Mobiledoc의 markdown 카드를 사용합니다:

{
  "version": "0.3.1",
  "markups": [],
  "atoms": [],
  "cards": [["markdown", { "markdown": "## 본문 내용..." }]],
  "sections": [[10, 0]]
}

sections: [[10, 0]]은 "0번 카드를 렌더링하라"는 의미입니다.

6.6 HTTP Request 노드 설정

설정
Method POST
URL https://blog.example.dev/ghost/api/admin/posts/
Authentication Predefined Credential → Ghost Admin API
Body Content Type JSON
Specify Body Using JSON

Body:

={{ $json.ghostBody }}

또는 Expression 모드에서 {{ $json.ghostBody }}

6.7 테스트

curl -X POST https://n8n.example.dev/webhook/ghost-post \
  -H "Content-Type: application/json" \
  -d '{
    "title": "테스트",
    "content": "# 테스트 제목\n\n## 1. 본문\n\n내용입니다.\n\n## 15. 블로그 글 작성 시 포인트 (내부용)\n\n**Ghost SEO 설정:**\n\n| 항목 | 값 |\n|------|-----|\n| Post URL | `test-post` |\n| Meta title | 테스트 메타 제목 |\n| Meta description | 테스트 메타 설명 |\n| Excerpt | 테스트 발췌문 |",
    "status": "draft"
  }'

7. 실전 워크플로우 예시

7.1 예약 발행 시스템

┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│   Schedule  │───▶│  Notion DB  │───▶│    Ghost    │
│  매일 9AM   │    │  조회       │    │ Publish     │
└─────────────┘    └─────────────┘    └─────────────┘

Notion DB에서 "발행일 = 오늘"인 글을 찾아 Ghost에 발행합니다.

7.2 RSS → 요약 → 저장

┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│  RSS Feed   │───▶│   OpenAI    │───▶│    Ghost    │
│  (30분마다) │    │   요약      │    │ Draft 저장  │
└─────────────┘    └─────────────┘    └─────────────┘

관심 블로그의 새 글을 AI로 요약해서 초안으로 저장합니다.

7.3 Ghost 발행 → SNS 공유

┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│   Ghost     │───▶│   Format    │───▶│  Threads/X  │
│  Webhook    │    │   Message   │    │   Post      │
└─────────────┘    └─────────────┘    └─────────────┘

Ghost에서 글 발행 시 Webhook으로 n8n에 알리고, SNS에 자동 공유합니다.

7.4 GitHub 커밋 → 블로그 초안

┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│   GitHub    │───▶│   OpenAI    │───▶│    Ghost    │
│  Webhook    │    │ 글 생성     │    │ Draft 저장  │
└─────────────┘    └─────────────┘    └─────────────┘

의미 있는 커밋이 있으면 AI가 기술 블로그 초안을 작성합니다.


8. Ghost Webhook 설정 (역방향 연동)

Ghost에서 이벤트 발생 시 n8n으로 알림을 보내려면:

8.1 Ghost에서 Webhook 추가

Ghost Admin → Settings → Integrations → n8n → Add Webhook

이벤트 Target URL
Post published https://n8n.example.dev/webhook/ghost-published
Member created https://n8n.example.dev/webhook/ghost-member

8.2 n8n에서 수신

Webhook 노드:
- Path: ghost-published
- Method: POST

Ghost가 보내는 데이터:

{
  "post": {
    "current": {
      "id": "...",
      "title": "새 글 제목",
      "url": "https://blog.example.dev/new-post/",
      "excerpt": "요약..."
    }
  }
}

9. 보안 고려사항

9.1 Webhook 인증

공개 Webhook은 누구나 호출 가능합니다. 보호 방법:

방법 1: Header Auth

n8n Webhook 노드:
- Authentication: Header Auth
- Header Name: X-API-Key
- Header Value: (비밀 키)

방법 2: IP 화이트리스트 (NPM에서)

# Advanced 탭에 추가
allow 1.2.3.4;  # Ghost 서버 IP
deny all;

9.2 API Key 관리

  • .env 파일은 절대 Git에 커밋하지 않음
  • n8n Credentials는 암호화되어 저장됨
  • 주기적으로 API Key 로테이션

10. 트러블슈팅

10.1 Webhook이 "not registered for POST" 에러

원인: Webhook 노드가 GET으로 설정됨
해결: HTTP Method를 POST로 변경

GET은 URL 파라미터로만 데이터 전달 가능합니다. JSON Body를 받으려면 POST 필수.

10.2 Ghost API 422 "should have required property 'posts'"

// ❌ 잘못된 요청
{
  "title": "제목",
  "mobiledoc": "..."
}

// ✅ 올바른 요청
{
  "posts": [{
    "title": "제목",
    "mobiledoc": "..."
  }]
}

Ghost Admin API는 항상 posts 배열로 감싸야 합니다.

10.3 Ghost API 422 "mobiledoc should be string,null"

원인: mobiledoc이 객체로 전달됨 (JSON 파싱됨)
해결: JSON.stringify()로 문자열화

n8n HTTP Request에서 {{ $json.mobiledoc }}을 따옴표 없이 넣으면 객체로 파싱됩니다. Code 노드에서 미리 문자열로 만들어야 합니다.

10.4 "JSON parameter needs to be valid JSON"

원인: mobiledoc 문자열 안의 따옴표가 JSON을 깨뜨림
해결: Code 노드에서 전체 ghostBody를 만들고 {{ $json.ghostBody }}로 전달

복잡한 문자열이 포함된 JSON은 n8n 표현식으로 조립하면 깨지기 쉽습니다. Code 노드에서 완성된 객체를 만들어 전달하세요.

10.5 정규식 매칭 실패 (body가 빈 문자열)

// ❌ 매칭 안 됨 (공백 누락)
/##\d+\. 블로그/

// ✅ 매칭 됨
/## \d+\. 블로그/

마크다운 헤더는 ## 뒤에 공백이 있습니다. 정규식에서 공백을 빠뜨리면 매칭되지 않습니다.

10.6 Websocket 연결 끊김

원인: NPM에서 Websockets Support 미활성화
해결: Proxy Host 설정에서 Websockets Support 체크

10.7 Ghost API 401 에러

원인: Admin API Key 잘못됨
해결: Ghost Integration에서 Key 재생성

11. 핵심 개념 정리

개념 설명
Workflow 노드들의 연결, 자동화 파이프라인
Node 하나의 작업 단위 (트리거, 액션)
Trigger 워크플로우 시작점 (Webhook, Schedule, etc)
Credential 외부 서비스 인증 정보
Expression {{ }} 형태로 데이터 참조
Lexical Ghost 5.0+ 기본 콘텐츠 포맷 (Mobiledoc 대체)
Mobiledoc Ghost의 레거시 콘텐츠 포맷 (Markdown 카드 지원, 여전히 작동)

12. 베스트 프랙티스

  • [ ] Webhook에는 반드시 인증 추가
  • [ ] 에러 처리 노드(Error Trigger) 설정
  • [ ] 중요 워크플로우는 실행 로그 보관
  • [ ] 테스트는 Draft로 먼저, 확인 후 Published로
  • [ ] API Key는 환경변수로 관리
  • [ ] 복잡한 워크플로우는 Sub-workflow로 분리
  • [ ] Ghost 노드 대신 HTTP Request로 SEO 필드까지 제어
  • [ ] JSON 조립은 Code 노드에서 완성된 객체로

13. FAQ

Q: n8n과 Zapier 중 뭐가 좋나요?
A: 소규모면 Zapier가 편합니다. 워크플로우가 많거나 비용을 줄이려면 n8n 셀프호스팅이 유리합니다.

Q: Ghost 무료 티어로 API 사용 가능한가요?
A: Ghost는 셀프호스팅이면 무료입니다. Ghost(Pro)도 모든 플랜에서 API 제공합니다.

Q: n8n이 죽으면 예약 작업은 어떻게 되나요?
A: 놓친 스케줄은 복구되지 않습니다. restart: unless-stopped로 자동 재시작 설정하세요.

Q: Markdown을 그대로 Ghost에 넣을 수 없나요?
A: Ghost API는 Lexical(5.0+ 기본), HTML, Mobiledoc을 받습니다. HTML을 보내면 Ghost가 자동 변환하고, Mobiledoc의 markdown 카드를 사용하면 Markdown을 그대로 저장할 수 있습니다.

Q: 왜 Ghost 노드 대신 HTTP Request를 쓰나요?
A: n8n Ghost 노드는 기본 필드만 지원합니다. custom_excerpt, meta_title, meta_description 등 SEO 필드를 쓰려면 HTTP Request로 직접 API를 호출해야 합니다.

Q: 워크플로우 백업은 어떻게 하나요?
A: n8n UI에서 Export하거나, PostgreSQL DB를 백업하세요.


14. 참고 자료