Когда очередной лендинг требует «просто принимать заявки и показывать новости», разработчик оказывается перед выбором: поднять 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.
Никакого CGO — pure Go SQLite через modernc
Первая нетривиальная задача: как использовать 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. Каждый слой — отдельная функция, тестируется независимо.
Асинхронная очередь для Bitrix24 на горутинах
Отправка заявки в 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) и завершения очереди.
CSRF-защита на HMAC-SHA256
Административная панель использует 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; });
Rate limiting без внешних зависимостей
Публичный 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 — всё в памяти процесса. Для нагрузок лендинга это достаточно.
Первый запуск: UX без конфиг-файлов
Один из принципов проекта — нулевая конфигурация для старта. При первом запуске сервер сам создаёт схему, генерирует пароль администратора и выводит его в консоль:
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)
Безопасность: базовый OWASP-чеклист
Проект небольшой, но безопасность — не опция:
Угроза |
Защита |
|---|---|
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
Комментарии (24)

WantedPotato
27.03.2026 08:05embed.FSхранит данные в ОЗУ, как понимаю. Поэтому КМК лучше сделать прямую загрузку ресурсов с какой-то директории.
ANTON62 Автор
27.03.2026 08:05Да, это в принципе верно, но частично. Просто был выбран компромисс в пользу embed.FS потому что, это единый бинарник — атомарное развёртывание, нет риска рассинхронизации файлов и кода и нет зависимости от рабочего каталога и прав на файловую систему, а также для шаблонов и статики размером в несколько сотен КБ разница на практике нулевая
xSVPx
Лендинг в виде бинарника на 20мб :)? Ну такое себе. 100мс до ттфб - это очевидный фейл. Что там говорит гугловский инструмент для проверки?
В целом уже и не страшно за пользователей этого всего. Сами виноваты. Если бизнес не умеет выбирать на инженерные задачи инженеров - он должен умереть. И вы ему прекрасно в этом поможете. И это отлично.
ANTON62 Автор
Да нет) Это CMS в виде бинарника, а к ней прикручивается фронт на чем угодно HTML, HTMX, React, Vue
Kahelman
«какие ваши предложения…»
Вы на каком объеме дискового пространства лендинг сделаете?
ANTON62 Автор
Не совсем понял вопрос. Лендинг можно уместить и на 100Кб (простой html файл), а можно и намного больше, это уже вопрос к фронтенду. А эта CMS может обслуживать бекенд не только под лендинг, а еще под сайты визитки, и простые корпоративные сайты где несколько статичных страниц, да новости.
Да, можно использовать Supabase, Google Sheets и другие инструменты, этот проект для тех кто хочет хранить все у себя "дома". Чтоб просто и быстро.
xSVPx
Никогда не делал, делал простенький им, но давненько.
С графикой аж 1.9мб.
Без яндексовского и гугловского js для мониторинга код со стороны юзера - по ощущениям килобайт 30.
3-5кб стили и html.
5-10кб исполняемая на сервере часть.
Остальное графика, текст итп. Все вместе для всех страниц да - около 20мб, может и больше даже. Фотки весят.
100 баллов по performance в лайтхаузе. Но бест пректис меньше -73, Яндекс с гуглом жгут.
Чей-то какая-то ерунда теперь вместо первого байта(помнится около 60мс требовалось), пишет что 9мс на получение тела понадобилось. Но похоже в сумме все(92 файла) приезжают за 0.9с. (speed index)
Результат неидеальный конечно.
Но это кстати, наверное еще и с поисковым индексом, он один раз приходит.