Headless CMS на Go — самая минималистичная система управления сайтом
- суббота, 28 марта 2026 г. в 00:00:11
Когда очередной лендинг требует «просто принимать заявки и показывать новости», разработчик оказывается перед выбором: поднять Laravel/Django с кучей зависимостей, купить SaaS-подписку, или написать что-то своё. Я выбрал третий путь — и это оказалось интереснее, чем я ожидал.
В этой статье разбираю архитектурные решения, которые принял при написании LightHeadless — минималистичного headless CMS на Go. Ни одного внешнего веб-фреймворка, никакого ORM, никакого CGO, никакого Redis. Только стандартная библиотека, три зависимости и 125 тестов с детектором гонок.

Типичный лендинг для малого бизнеса решает две задачи: собирает заявки и показывает актуальные новости или акции. Требования к бэкенду при этом скромные:
REST API для публичных запросов (запись заявки, получение новостей)
Административная панель для менеджеров
Интеграция с CRM (Bitrix24 как самый распространённый вариант в СНГ)
Простое развёртывание на дешёвом VPS
Ни один из готовых инструментов не закрывал этот набор без существенного оверхеда. WordPress с плагинами — это PHP и MySQL. Strapi — это Node.js и сложная конфигурация. Ghost — блог-движок, не CRM-интегратор. Directus — избыточен для одной страницы.
Решение: написать специализированный инструмент на Go, который влезает в один бинарник и разворачивается командой ./cms.
Первая нетривиальная задача: как использовать SQLite без CGO?
Классический драйвер mattn/go-sqlite3 требует cgo и компилятора C. Это означает сложную кросс-компиляцию и невозможность собрать бинарник командой CGO_ENABLED=0 go build. Для проекта, который должен легко собираться под Linux с Windows-машины, это неприемлемо.
Решение — modernc.org/sqlite. Это полноценный порт SQLite на Go, транслированный из C автоматическими инструментами. API совместимо с database/sql, CGO не нужен.
import ( “database/sql” _ “modernc.org/sqlite” )
func Open(path string) (*sql.DB, error) { db, err := sql.Open(“sqlite”, path) if err != nil { return nil, err }
import ( "database/sql" _ "modernc.org/sqlite" ) func Open(path string) (*sql.DB, error) { db, err := sql.Open("sqlite", path) if err != nil { return nil, err } // WAL-режим: конкурентные читатели + один писатель без блокировок pragmas := []string{ "PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", "PRAGMA foreign_keys=ON", "PRAGMA synchronous=NORMAL", } for _, p := range pragmas { if _, err := db.Exec(p); err != nil { return nil, fmt.Errorf("pragma %q: %w", p, err) } } return db, nil }
WAL` (Write-Ahead Logging) здесь принципиален: он позволяет параллельно читать БД пока идёт запись, что важно при одновременных запросах к публичному API и работе в административной панели.
Итог: CGO_ENABLED=0 go build -o cms ./cmd/server — и бинарник готов. Никаких зависимостей на хосте.
В Go-сообществе не утихает дискуссия: Gin, Echo, Chi или net/http? Для этого проекта ответ однозначный — net/http.
Причины:
Размер: добавление Gin увеличивает дерево зависимостей на десятки пакетов.
Полноты stdlib достаточно: маршрутизация по HTTP-методам и путям нужна простая, без path parameters {id} в сложном стиле.
Прозрачность: каждый слой middleware виден и понятен без погружения в чужой фреймворк.
Роутер — стандартный http.ServeMux с явной проверкой метода там, где нужно:
mux := http.NewServeMux() // Публичный API mux.Handle("/api/leads", rateLimiter(http.HandlerFunc(h.CreateLead))) mux.Handle("/api/news", http.HandlerFunc(h.ListNews)) mux.Handle("/api/news/", http.HandlerFunc(h.GetNews)) // Статика и загрузки mux.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadPath)))) // Административная панель mux.Handle("/admin/", authMiddleware(sessionStore, adminHandler))
Middleware оборачивают http.Handler в классическом Go-стиле:
func rateLimiter(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ip := realIP(r) if !limiter.Allow(ip) { http.Error(w, "Too Many Requests", http.StatusTooManyRequests) return } next.ServeHTTP(w, r) }) }
Цепочка выглядит так: logger → rateLimiter → auth → csrfCheck → handler. Каждый слой — отдельная функция, тестируется независимо.
Отправка заявки в CRM — операция с непредсказуемым временем ответа. Делать её синхронно в обработчике — значит увеличивать время ответа API и зависеть от доступности внешнего сервиса.
Классическое решение — очередь + воркеры. В мире Python это Celery + Redis, в мире Node.js — Bull + Redis. В Go можно обойтись каналами:
type Worker struct { db *sql.DB queue chan Job webhook string wg sync.WaitGroup } func NewWorker(db *sql.DB, webhook string, poolSize int) *Worker { w := &Worker{ db: db, queue: make(chan Job, 100), // буфер 100 задач webhook: webhook, } for i := 0; i < poolSize; i++ { w.wg.Add(1) go w.process() } return w } func (w *Worker) process() { defer w.wg.Done() for job := range w.queue { w.sendToBitrix(job) } } func (w *Worker) Enqueue(job Job) { select { case w.queue <- job: // задача принята default: // очередь полна — логируем, не теряем заявку log.Printf("bitrix queue full, lead %d will be retried manually", job.LeadID) } }
Контекст с таймаутом 30 секунд даёт воркерам шанс завершить текущие задачи перед завершением процесса. После kill или Ctrl+C сервер дожидается окончания HTTP-запросов (http.Server.Shutdown) и завершения очереди.
Административная панель использует HTML-формы с HTMX. Без CSRF-защиты любая страница в браузере менеджера могла бы отправить форму от его имени.
Классический подход — синхронизирующий токен (Synchronizer Token Pattern):
func generateCSRFToken(sessionID, secret string) string { mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(sessionID)) return hex.EncodeToString(mac.Sum(nil)) } func validateCSRFToken(r *http.Request, sessionID, secret string) bool { token := r.FormValue("csrf_token") if token == "" { token = r.Header.Get("X-CSRF-Token") // для HTMX-запросов } expected := generateCSRFToken(sessionID, secret) return hmac.Equal([]byte(token), []byte(expected)) }
Токен привязан к сессии через HMAC: без знания секрета подделать его невозможно. hmac.Equal использует константное время сравнения, исключая timing-атаки. Токен встраивается в каждую форму через шаблон:
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
Для HTMX-запросов (которые отправляются через hx-post) токен добавляется в заголовок через глобальный конфиг:
document.body.addEventListener('htmx:configRequest', (event) => { event.detail.headers['X-CSRF-Token'] = document.querySelector('meta[name="csrf-token"]').content; });
Публичный API для записи заявок открыт без аутентификации — типичная мишень для спам-ботов. Ограничение: не более 10 запросов в минуту с одного IP.
type IPLimiter struct { visitors sync.Map // map[string]*rate.Entry mu sync.Mutex } type entry struct { count int resetAt time.Time } func (l *IPLimiter) Allow(ip string) bool { now := time.Now() val, _ := l.visitors.LoadOrStore(ip, &entry{resetAt: now.Add(time.Minute)}) e := val.(*entry) l.mu.Lock() defer l.mu.Unlock() if now.After(e.resetAt) { e.count = 0 e.resetAt = now.Add(time.Minute) } if e.count >= 10 { return false } e.count++ return true }
sync.Map оптимизирована для случая «много читателей, мало писателей» — именно наш сценарий. Раз в 5 минут фоновая горутина чистит устаревшие записи:
func (l *IPLimiter) cleanup() { for range time.Tick(5 * time.Minute) { l.visitors.Range(func(key, val interface{}) bool { e := val.(*entry) if time.Now().After(e.resetAt) { l.visitors.Delete(key) } return true }) } }
func (l *IPLimiter) Allow(ip string) bool { now := time.Now() val, _ := l.visitors.LoadOrStore(ip, &entry{resetAt: now.Add(time.Minute)}) e := val.(*entry) l.mu.Lock() defer l.mu.Unlock() if now.After(e.resetAt) { e.count = 0 e.resetAt = now.Add(time.Minute) } if e.count >= 10 { return false } e.count++ return true }
sync.Map оптимизирована для случая «много читателей, мало писателей» — именно наш сценарий. Раз в 5 минут фоновая горутина чистит устаревшие записи:
func (l *IPLimiter) cleanup() { for range time.Tick(5 * time.Minute) { l.visitors.Range(func(key, val interface{}) bool { e := val.(*entry) if time.Now().After(e.resetAt) { l.visitors.Delete(key) } return true }) } }
Никакого Redis, никакого Memcached — всё в памяти процесса. Для нагрузок лендинга это достаточно.
Один из принципов проекта — нулевая конфигурация для старта. При первом запуске сервер сам создаёт схему, генерирует пароль администратора и выводит его в консоль:
func firstRun(db *sql.DB) error { var count int db.QueryRow("SELECT COUNT(*) FROM settings").Scan(&count) if count > 0 { return nil // уже инициализировано } password := generateRandomPassword(12) hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) _, err := db.Exec(`INSERT INTO settings (site_name, admin_email, admin_password) VALUES ('My Site', 'admin@example.com', ?)`, string(hash)) if err != nil { return err } fmt.Printf(` ╔══════════════════════════════════════╗ ║ FIRST RUN SETUP ║ ║ ║ ║ Admin email: admin@example.com ║ ║ Admin password: %-20s ║ ║ ║ ║ Change password after first login! ║ ╚══════════════════════════════════════╝ `, password) return nil }
Параметры запуска — флаги командной строки с приоритетом у переменных окружения:
# Все три варианта эквивалентны ./cms -port 8080 -db ./data.db CMS_PORT=8080 CMS_DB_PATH=./data.db ./cms # или просто ./cms (дефолты: port=8080, db=./cms.db)
Проект небольшой, но безопасность — не опция:
Угроза | Защита |
|---|---|
SQL injection | Параметризованные запросы везде, никакой конкатенации строк |
XSS |
|
CSRF | HMAC-SHA256 токены, привязанные к сессии |
Session hijacking | Криптографически случайные 32-байтовые ID |
Brute force | Rate limit 10 req/min на IP для публичного API |
Password storage | bcrypt с cost=10 |
Path traversal |
|
Особый акцент на html/template вместо text/template: первый контекстно-осведомлён и автоматически экранирует данные в зависимости от того, куда они вставляются — в HTML-атрибут, JavaScript-блок или URL. Это исключает целый класс XSS-уязвимостей.
Итоговые характеристики:
Бинарник: ~20 МБ (включает все шаблоны и статику через embed.FS)
Зависимости runtime: ноль (SQLite внутри бинарника)
Время ответа /api/leads: < 100 мс
Тестов: 125, все проходят с -race
Строк кода: ~5 900
Три внешних зависимости:
golang.org/x/crypto — bcrypt modernc.org/sqlite — pure Go SQLite github.com/yuin/goldmark — рендеринг Markdown в HTML
Это сознательное ограничение: каждая зависимость — это потенциальный supply chain риск, сложность обновлений и увеличение времени сборки.
Что можно было сделать иначе:
Использовать chi вместо голого ServeMux — немного удобнее path parameters, цена минимальная
Добавить sqlx для сканирования строк в структуры — убрало бы часть бойлерплейта
Рассмотреть bbolt вместо SQLite для хранения сессий — меньше блокировок
Go-стандартная библиотека закрывает 90% потребностей типичного веб-бэкенда. Добавление фреймворков оправдано, когда задача сложнее: группировка маршрутов, middleware-дерево, кодогенерация. Для специализированного сервиса с ограниченным набором эндпоинтов — stdlib достаточна и прозрачна.
Отказ от CGO через modernc.org/sqlite — самое нетривиальное решение в проекте, и оно полностью себя оправдало: кросс-компиляция работает из коробки, производительности хватает для целевой нагрузки.
Детектор гонок (go test -race) нашёл реальный баг в rate limiter во время разработки — это именно тот инструмент, который нужен при любой работе с горутинами.
Без цифровых помощников я бы конечно не справился (без этого теперь никуда))
Пощупать проект и доработать под себя можно здесь https://github.com/dev993848/lightheadless-cms