CMS bez bazy danych: treść softpapaya.com na Sveltii i gicie
Bez Postgresa. Bez headless SaaS-a. Bez serwera backendu. Treść softpapaya.com to markdown w gicie, edytowany przez Sveltię, publikowany przez dwa GitHub Actions.
softpapaya.com ma CMS, do którego loguje się osoba nietechniczna, edytuje wpis na bloga albo ogłoszenie o pracę w formularzu i publikuje. Za tym nie stoi żadna baza danych.
Warstwa treści to pliki markdown leżące w tym samym repo gita co kod. Sveltia edytuje te pliki w miejscu przez API GitHuba, a dwa GitHub Actions doprowadzają je do wdrożenia: jedno na build staginga, drugie na produkcję. Całość nie kosztuje nic ponad hosting, który też nic nie kosztuje.
Poprzedni wpis na tej stronie opisywał stronę Astro i Cloudflare tego stacku i o CMS-ie wspomniał tylko mimochodem.
Co właściwie znaczy “bez bazy danych”
Każda kolekcja na stronie żyje jako folder z plikami markdown. Wpisy na bloga, oferty pracy, case studies, członkowie zespołu, opinie, statystyki na homepage:
src/content/blog/en/sveltia-cms-no-database.md
src/content/blog/pl/sveltia-cms-no-database.md
src/content/careers/pl/senior-backend-engineer.md
src/content/team/pl/marcin-balazy.md
Każdy plik to frontmatter YAML plus body w markdownie. Content collections Astro ładują je loaderem glob() i walidują każdy plik schematem Zoda w czasie builda. Popsuta data publishedAt we wpisie wywala build z numerem linii, tak samo jak błąd typu w pliku .ts.
Czyli “baza danych” to system plików. Warstwa zapytań to getCollection("blog") lecące raz w czasie builda. Nie ma wierszy do migracji ani connection stringów do rotowania. Historia treści to historia gita, co oznacza, że git log odpowiada na pytanie “kto i kiedy zmienił stronę kariery”, a git revert cofa złą edycję.
Jak działa auth, gdy nie ma backendu OAuth
Sveltia CMS to UI do edycji. To jeden bundle JavaScriptu, który działa w całości w przeglądarce, ładowany z CDN-a przez stronę admina:
<script is:inline src="https://unpkg.com/@sveltia/cms@0.149.0/dist/sveltia-cms.js"></script>
CMS gada bezpośrednio z REST API GitHuba, żeby czytać i zapisywać pliki w repo. Dzięki temu nie wieszamy w naszym package.json żadnej zależności od niego ani nie odpalamy dla niego kroku builda.
Element warty opisania to auth. Typowy flow git-based CMS-a potrzebuje backendu OAuth, czyli małego serwera, który trzyma client secret i robi handshake z GitHubem w imieniu użytkownika. Nie chciałem stawiać tego serwera. Shell admina wstrzykuje więc PAT GitHuba ze zmiennej środowiskowej Cloudflare prosto w miejsce, które Sveltia czyta przy starcie:
<script is:inline define:vars={{ cmsToken }}>
if (cmsToken) {
localStorage.setItem("sveltia-cms.user", JSON.stringify({
backendName: "github",
token: cmsToken,
}));
}
</script>
Token należy do konta bota na GitHubie i jest trzymany jako CMS_BOT_TOKEN w Cloudflare. Czytany jest po stronie serwera i wpisywany tylko w uwierzytelnioną odpowiedź admina, która sama siedzi za Basic authem w middleware, więc nigdy nie ląduje w bundlu klienta. Skutek uboczny: wszystkie edycje są w historii gita podpisane botem, więc z logów nie da się odczytać, kto co naprawdę napisał. Przyjęliśmy to jako cenę za pominięcie roundtripa OAuth.
Config Sveltii odzwierciedla schemat Zoda
Sveltia renderuje formularze edycji z public/admin/config.yml, a Astro waliduje treść schematem Zoda z src/content.config.ts. To dwa opisy tego samego kształtu trzymane w synchronie ręcznie. Config mówi to wprost na górze:
# Mirror Zod schemas from src/content.config.ts.
# Sveltia edits markdown files in-place - glob() loader stays unchanged.
To uczciwy koszt tego setupu. Dodajesz pole do wpisu na bloga i edytujesz oba pliki: schemat Zoda, żeby build je przyjął, plus config CMS-a, żeby edytor mógł je wypełnić. Rozważałem generowanie jednego z drugiego i uznałem, że duplikacja jest mniejszą robotą niż tooling do tego. Zod i tak łapie kluczowy failure mode: jeśli CMS zapisze pole odrzucane przez schemat, build wywala się od razu.
Jak publikacja przepływa przez staging i main
staging to gałąź, na której pracuje CMS. Klik edytora commituje do staging, odpala repository_dispatch typu sveltia-cms-publish, a preview.yml builduje tę gałąź i deployuje ją na preview URL, gdzie edytor ogląda swoją zmianę wyrenderowaną na prawdziwej infrastrukturze.
main to produkcja. Kiedy staging review wygląda dobrze, edytor wchodzi na /admin/prod i klika tam przycisk deploya. Ten przycisk robi dokładnie jedną rzecz, POST do API GitHuba:
await ghFetch(`/repos/${REPO}/dispatches`, token, {
method: "POST",
body: JSON.stringify({ event_type: "deploy-production" }),
});
Ten dispatch odpala deploy.yml. Workflow merge’uje staging do main (fast-forward jeśli się da, rebase jako fallback), deployuje produkcję, a potem resetuje staging z powrotem do main, żeby następna sesja edycji startowała od czystej bazy.
Reset ma zabezpieczenie, którego nie było w pierwszej wersji workflow. Jeśli staging dostał nowe commity między startem merge’a a wykonaniem resetu, czyli na przykład gdy drugi edytor opublikuje, kiedy pierwszy deploy jest jeszcze w locie, naiwny force-push po cichu nadpisałby tamte edycje. Workflow zapisuje więc SHA staginga w momencie merge’a i pomija reset, jeśli staging się od tego czasu ruszył.
Oba workflow deploya dzielą grupę concurrency, więc naraz leci tylko jeden z nich. Kliknij publish trzy razy pod rząd, a requesty ustawią się w kolejce; wygrywa ostatni.
Konfiguracyjne kwestie warte odnotowania
Sveltia oznacza [skip ci] tylko część commitów
CMS commituje do staging, a każdy push na staging normalnie odpaliłby CI. Ale flow publikacji i tak robi własny build przez dispatch, więc build z pusha jest zbędny. Sveltia ma na taki przypadek opcję skip_ci: true, tyle że w v0.149.0 dodaje marker [skip ci] tylko do commitów typu update. Create’y i delete’y przechodzą bez markera, czyli nowy wpis na bloga odpala zmarnowany run CI. Fix to nadpisanie szablonów commit message dla każdej operacji:
commit_messages:
create: '[skip ci] Create {{collection}} "{{slug}}"'
update: '[skip ci] Update {{collection}} "{{slug}}"'
delete: '[skip ci] Delete {{collection}} "{{slug}}"'
uploadMedia: '[skip ci] Upload "{{path}}"'
deleteMedia: '[skip ci] Delete "{{path}}"'
Teraz każdy commit CMS-a niesie marker i GitHub pomija build z pusha. Uwaga warta powtórzenia, bo dotyczy nie tylko Sveltii: GitHub skanuje cały commit message pod kątem [skip ci], łącznie z body, więc wrzucenie tych znaków gdziekolwiek w opisie commita po cichu zabija workflow.
Przycisk publikacji kłamie, gdzie publikuje
Przycisk Sveltii mówi “Publish Changes”. W naszym setupie strona admina od staginga publikuje na staging, ale edytor czytający “Publish Changes” rozsądnie zakłada, że to idzie na żywo na produkcję. Configu na rename przycisku nie ma. Strona admina chodzi więc po DOM-ie z MutationObserver-em i przepisuje każdy napis “Publish Changes”, który Sveltia wyrenderuje (text nody, atrybut aria-label oraz atrybut title) na “Publish Changes to Staging”. To hack przeciwko cudzemu UI, którego nie kontroluję, i pęknie w dniu, w którym Sveltia przepisze swój interfejs. Przypięcie wersji Sveltii w URL-u CDN-a robi tu realną robotę.
Gdzie to działa, a gdzie się sypie
Setup chodzi na softpapaya.com od miesięcy bez interwencji. Edytorzy publikują kilka razy w tygodniu, a najgorszą frykcją są dwie-trzy minuty czekania między kliknięciem publish a zobaczeniem zmiany na żywo - tyle kosztuje pełny rebuild i deploy przy każdej zmianie. Cała warstwa treści leży na darmowych tierach GitHub Actions i Cloudflare Pages, więc linia z nią na miesięcznej fakturze to zero.
Uczciwe ostrzeżenie: to przestaje działać w momencie, w którym zespół potrzebuje współpracy w czasie rzeczywistym nad tym samym wpisem albo atrybucji commitów per edytor. Dwóch edytorów nie tknie tego samego pliku bez konfliktów gita. Z loga też nie wyczytasz, kto napisał daną zmianę, bo każdy commit jest podpisany botem. Jeśli któreś z tych dwóch jest dla twojego zespołu krytyczne, hostowany CMS jest wart rachunku.
W naszym przypadku nie są, i ten układ trzyma się dobrze.
Marcin Balazy
SoftPapaya