결론부터

지금 보고 계신 이 블로그는 집에 있는 라즈베리파이 한 대에서 돌아갑니다. 화면과 서버를 직접 만들어 Podman 컨테이너로 올리고, Cloudflare Tunnel 로 yazasulab.com 도메인을 연결했습니다.

구성은 단순합니다. React 화면과 FastAPI 서버, SQLite 파일 DB 를 컨테이너 2개(nginx, uvicorn)로 나눠 Podman pod 하나로 실행합니다.

RAM 1GB 짜리 라즈베리파이 3 로도 충분합니다. 두 컨테이너를 합쳐 메모리 100MB 남짓이고, 같은 라즈베리파이에서 주식 자동매매 봇 도 함께 돌아갑니다.

도메인은 포트포워딩 대신 Cloudflare Tunnel 로 연결했습니다. 공유기 포트를 열지 않아도 되고, HTTPS 인증서도 Cloudflare 가 알아서 붙여 줍니다.

이번에도 AI(Claude Code)와 대화하며 만들었습니다. 첫 커밋부터 도메인 연결까지 열흘 남짓 걸렸습니다.

기간

커밋

PR

자동 테스트

코드

운영 비용

열흘 남짓

58개

35개

68개

Python 약 2,100줄, TypeScript 약 3,500줄

도메인 값과 전기료

그림 1. 완성된 블로그 첫 화면

무엇을 만들었나

글을 읽는 블로그 화면과, 글을 쓰고 관리하는 관리자 화면 두 부분입니다.

블로그 : 게시판 탭, 검색(제목과 본문), 페이지 번호, 글 읽기, 댓글과 답글. 댓글은 로그인 없이 닉네임만으로 쓰고, 이미지 캡차(그림 속 글자 맞히기)로 스팸을 막습니다.

관리자 : 대시보드(글·댓글·조회수 통계)와 게시판·게시글·댓글·관리자 관리. 글은 TipTap 에디터로 씁니다. 이미지는 붙여넣기나 끌어다 놓기로 올리고, 표와 코드, 링크(새 창 열기 선택), YouTube 영상과 쇼츠도 넣을 수 있습니다.

검색엔진 대응 : 네이버 서치어드바이저에 등록할 수 있게 sitemap.xml, rss.xml, robots.txt 를 DB 에서 바로 만들고, 화면마다 제목·설명·대표 이미지 정보를 넣습니다.

전체 아키텍처

그림 2. 전체 구조

방문자가 yazasulab.com 에 들어오면 Cloudflare 가 HTTPS 를 처리하고, 터널을 통해 집에 있는 라즈베리파이로 요청을 넘깁니다. 라즈베리파이 안에서는 nginx 가 화면 파일을 내주고, /api 로 시작하는 요청만 FastAPI 로 보냅니다. 데이터는 SQLite 파일 하나와 이미지 폴더가 전부입니다.

코드는 PC(Windows 의 WSL, 맥)에서 고치고 GitHub 에 올립니다. 배포는 PC 에서 이미지를 빌드해 라즈베리파이로 보내는 방식입니다.

구분

사용한 것

고른 이유

화면

React 19, TypeScript, Vite, Bootstrap 5, TipTap 3

자료가 많고, TipTap 은 이미지·표·영상 같은 블록을 직접 만들기 쉬움

서버

Python 3.14, FastAPI, SQLAlchemy 2

코드가 짧고, API 문서(/docs)가 자동으로 생김

DB

SQLite (WAL 모드)

DB 서버 없이 파일 하나. 개인 블로그 규모에 충분

실행

Podman (rootless), nginx

데몬 없이 일반 사용자 권한으로 실행하고, pod 로 컨테이너 둘을 묶음

도메인

Cloudflare DNS, Cloudflare Tunnel

포트를 열지 않고 HTTPS 까지 해결

검색 로봇도 읽을 수 있게: nginx SSI

React 로 만든 화면은 처음 받는 HTML 이 거의 비어 있고, JS 가 실행되어야 내용이 채워집니다. 구글은 JS 를 실행해서 읽지만, 네이버 로봇(Yeti)은 JS 를 거의 실행하지 않아서 빈 화면만 보고 갈 수 있습니다.

Next.js 같은 서버 렌더링 프레임워크로 바꾸면 라즈베리파이에 Node 서버를 하나 더 띄워야 합니다. 그래서 이미 쓰고 있는 nginx 의 SSI(Server Side Includes) 기능을 썼습니다.

그림 3. 글 화면 하나가 만들어지는 과정

nginx 가 index.html 을 보낼 때, 안에 있는 include 자리에서 백엔드에 HTML 조각을 요청합니다.

FastAPI 가 DB 에서 글을 읽어 head 조각(제목, 설명, canonical, Open Graph, JSON-LD)과 body 조각(본문 텍스트, 첫 화면 데이터)을 만듭니다.

로봇은 완성된 HTML 을 그대로 읽어 가고, 브라우저에서는 React 가 같은 모양의 화면으로 이어받아 깜빡임 없이 그립니다.

<head>

<!--# include virtual="/api/seo/head" stub="default_head" -->

</head>

<body>

<div id="root"><!--# include virtual="/api/seo/body" --></div>

</body>

조각을 만드는 /api/seo/ 주소는 nginx 내부에서만 부를 수 있게 막아 두었습니다. 검색 결과 화면과 없는 글은 noindex , 관리자 화면은 noindex,nofollow 로 수집에서 뺍니다.

서버 구성: 라즈베리파이 3 + Podman

그림 4. 라즈베리파이 서버 구성

하드웨어 : 라즈베리파이 3 Model B(RAM 1GB), SD 카드 64GB, 64bit Debian 13(trixie).

Podman pod : frontend(nginx)와 backend(uvicorn) 컨테이너를 pod 하나로 묶었습니다. 같은 pod 안에서는 localhost 를 같이 쓰므로, nginx 는 127.0.0.1:8000 으로 백엔드를 부릅니다.

rootless : 일반 사용자 권한으로 실행합니다. 대신 1024 보다 작은 포트는 쓸 수 없어서 8080 포트로 띄웁니다.

SQLite 는 WAL 모드 로 씁니다. 글을 저장하는 중에도 읽기가 막히지 않고, SD 카드 쓰기를 줄이도록 설정했습니다. 쓰기가 겹치지 않게 uvicorn 워커도 1개만 둡니다.

헬스체크 : 30초마다 /api/health 를 확인해서, 세 번 연속 응답이 없으면 컨테이너를 다시 시작합니다.

백업과 로그

매일 03:00 에 systemd 타이머가 DB 와 이미지를 tar.gz 로 백업하고 최근 5일치만 남깁니다. DB 는 실행 중에도 안전하게 복사되도록 SQLite backup API 를 씁니다.

로그는 일자별 파일로 30일 보관합니다. 라즈베리파이 OS 는 시스템 로그(journald)를 RAM 에만 잠깐 두기 때문에, 재부팅하면 지난 로그가 사라집니다.

어떤 글에서도 쓰지 않는 업로드 이미지는 하루가 지나면 백업 뒤에 자동으로 지웁니다.

배포: PC 에서 빌드하고 라즈베리파이로 보내기

라즈베리파이 3 에서 프론트엔드를 빌드하면 느리고 메모리도 모자랍니다. 그래서 이미지는 PC 에서 arm64 용으로 빌드하고, 완성된 이미지만 SSH 로 보냅니다.

develop 브랜치에서 작업하고 master 로 PR 을 올립니다. GitHub Actions 가 백엔드 테스트(pytest)와 프론트엔드 lint·빌드를 돌립니다.

통과하면 머지하고 ./deploy/deploy.sh 를 실행합니다.

스크립트가 라즈베리파이의 CPU 종류( uname -m )를 보고 linux/arm64 이미지를 빌드합니다. 프론트엔드 빌드 단계는 결과물이 정적 파일이라 PC 에서 네이티브로 돌려 빠릅니다.

이미지를 SSH 로 보내고, 라즈베리파이에서 pod 를 다시 띄웁니다. DB 볼륨은 그대로 남습니다.

podman save -m backend:태그 frontend:태그 | gzip | ssh 라즈베리파이 "gunzip | podman load"

한 번은 -m 옵션을 빠뜨려서, frontend 태그로 실행했는데 backend 가 뜨는 일이 있었습니다. 이미지 두 개를 한 아카이브에 담을 때는 -m 을 붙여야 각각 따로 저장됩니다.

도메인 연결: Cloudflare Tunnel

그림 5. 포트포워딩과 Cloudflare Tunnel 비교

처음에는 공유기(ipTIME)의 DDNS 주소에 포트포워딩(80 → 8080)을 걸어 접속했습니다. 바로 쓸 수 있지만 http 만 되고, 주소가 집 IP 를 그대로 가리키며, 공유기 포트를 밖에 열어 둬야 합니다.

그래서 도메인(yazasulab.com)을 사서 Cloudflare Tunnel 로 연결했습니다. 터널은 연결 방향이 반대입니다. 라즈베리파이의 cloudflared 가 Cloudflare 로 먼저 연결해 두고, 방문자의 요청은 그 연결을 타고 들어옵니다. 그래서 들어오는 포트를 열 필요가 없습니다.

도메인을 Cloudflare 에 추가합니다. 도메인을 산 곳에서 네임서버를 Cloudflare 가 알려 준 두 개로 바꿉니다.

Cloudflare Zero Trust 대시보드에서 터널을 만듭니다. 안내에 나오는 명령으로 라즈베리파이에 cloudflared 를 설치하면 systemd 서비스로 등록되어, 재부팅해도 자동으로 다시 연결됩니다.

터널에 공개 주소를 추가합니다. yazasulab.com 과 www.yazasulab.com 을 http://localhost:8080 으로 연결하면, DNS 레코드는 Cloudflare 가 알아서 만들어 줍니다.

블로그의 사이트 주소 설정을 바꿉니다. canonical, sitemap, RSS 에 들어가는 절대 주소( SITE_URL )를 새 도메인으로 바꿔야 검색엔진이 새 주소로 수집합니다.

cloudflared 는 서울에 있는 Cloudflare 서버 여러 곳과 연결 4개를 유지합니다. 하나가 끊겨도 나머지 연결로 계속 접속됩니다. 이 정도는 무료 플랜으로 충분합니다.

운영하며 챙긴 것들

로그인 : 같은 IP 에서 10분에 10번 틀리면 10분 동안 막습니다. 처음 만들어진 관리자 계정은 비밀번호를 바꾸기 전까지 다른 관리 기능을 쓸 수 없습니다.

댓글 : 이미지 캡차, 스팸봇용 숨김 입력칸, 같은 IP 당 1분 5개·1시간 30개 제한을 함께 씁니다.

본문 : 에디터에서 쓴 HTML 은 화면에 그리기 전에 DOMPurify 로 걸러서, 글 안에 스크립트가 섞여도 실행되지 않습니다.

조회수 : 같은 방문자가 30분 안에 같은 글을 다시 보면 조회수를 올리지 않습니다.

AI 와 함께 만든 과정

코드는 대부분 Claude Code 와 대화하며 만들었습니다. "에디터 안에서 스크롤되게 해 줘", "상세 화면이 맨 밑에서 열리는데 원인을 찾아 줘"처럼 말로 요청하면, AI 가 원인을 찾아 고치고 로컬에서 화면을 직접 띄워 확인한 뒤 PR 과 배포까지 진행합니…