hr_blog 2.0

Overview
This is the story of building hrletsgo.me — the very blog you're reading right now. It took exactly one day: February 21, 2026. Everyone says AI makes things easy these days, but it's genuinely astonishing progress. I still remember pulling all-nighters for a week just to get a single component working four years ago...
Why build it from scratch?
- My team lead showed me the blog he'd built — link — and, whether he meant it as a challenge or not, I took it as one and built my own.
- The Force Graph visualization for document connections looked particularly interesting.
- I wanted something I could use freely as both a portfolio and a technical blog.
Architecture
┌─────────┐
│ Nginx │ (리버스 프록시 + SSL)
└────┬────┘
┌────┴────┐
┌───┴────┐ ┌──┴───┐
│ Next.js │ │ FastAPI │
│ Frontend │ │ Backend │
└────────┘ └───┬──┘
┌──────┴──┐
┌───┴────┐ ┌──┴─────┐
│ Postgres │ │ MinIO │
│ (DB) │ │ (Images) │
└────────┘ └────────┘
Notes
Backend + Database
I designed a data model on top of PostgreSQL + SQLAlchemy and implemented a FastAPI REST API.
# Post 모델
class Post(Base):
__tablename__ = "posts"
id = Column(Integer, primary_key=True)
slug = Column(String, unique=True, index=True)
title = Column(String, nullable=False)
content = Column(Text, nullable=False)
category = Column(String, nullable=False)
tags = Column(ARRAY(String), default=[])
read_time = Column(Integer)
published = Column(Boolean, default=True)
connections = Column(ARRAY(String), default=[])
created_at = Column(DateTime, default=func.now())
I also implemented safe migrations using advisory locking:
# Advisory Lock으로 동시 마이그레이션 방지
async def safe_migrate(engine):
async with engine.begin() as conn:
await conn.execute(text("SELECT pg_advisory_lock(12345)"))
try:
await run_migrations(conn)
finally:
await conn.execute(text("SELECT pg_advisory_unlock(12345)"))
MinIO is integrated as image storage, so images embedded in Markdown are saved to an S3-compatible backend.
This combination comes naturally when building agent flows.
A database is always necessary, and defining it as an abstract class makes it easy for agents to consume. Agents can figure things out on their own, of course, but richer context never hurts.
Image handling is another area that always requires careful thought — there's a lot more to it than simply storing files.
Frontend
The frontend is built on Next.js 15 (App Router).
Key components:
- DocsProvider: document data context management
- DocsSidebar: per-category document list sidebar
- TableOfContents: auto-generated table of contents from Markdown headings
- MarkdownRenderer: syntax highlighting + image optimization
Force Graph Visualization

This was the most enjoyable part — visualizing inter-document relationships as a Force Graph.
// ForceGraph 컴포넌트
const ForceGraph = ({ posts }: { posts: Post[] }) => {
const nodes = posts.map(p => ({ id: p.slug, label: p.title, category: p.category }));
const links = posts.flatMap(p =>
p.connections.map(c => ({ source: p.slug, target: c }))
);
// D3 force simulation
const simulation = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id(d => d.id))
.force("charge", d3.forceManyBody().strength(-100))
.force("center", d3.forceCenter(width / 2, height / 2));
};
Infrastructure + Deployment
- Nginx reverse proxy configuration (SSL, gzip, caching)
- Docker Compose multi-service orchestration
- PostgreSQL healthcheck + automatic restart
- Admin login (simple password-based auth)
Bonus: Tistory Migration
I also wrote a script to automatically migrate posts from my old Tistory blog using an LLM. The script — batch_import_tistory_llm.py — uses an LLM to handle HTML → Markdown conversion and metadata extraction.
Tech Stack
| Category | Technology |
|---|---|
| Frontend | Next.js 15, React 19, TypeScript |
| Backend | FastAPI, SQLAlchemy, Pydantic |
| Database | PostgreSQL |
| Storage | MinIO (S3-compatible) |
| Proxy | Nginx (SSL, reverse proxy) |
| Deploy | Docker Compose |
| Graph | D3.js Force Layout |