Kostra prezentace s vlastní administrací pro Martina Reinera. Obsah: - kolekce Products (obrázky, volné parametry, cena, dostupnost, skrytí), Media se zmenšeninami, Pages, Users bez veřejné registrace - veřejná část: výpis nabídky, detail položky, statické stránky - /api/health pro Docker HEALTHCHECK Provoz: - Dockerfile, docker-compose.yml (vývoj) a docker-compose.prod.yml (nasazení z Gitea registry) - CI v Gitea Actions: lint -> testy -> build -> push image - úvodní migrace databáze Stránky jsou force-dynamic, aby se úprava položky projevila hned; schéma se za běhu nedomýšlí (push: false), migrace se commitují. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
23 KiB
Návrh webové platformy — Martin Reiner, zbraně a střelivo
Pracovní návrh vývojové a produkční platformy. Stav: před scaffoldem. Datum: 2026-07-30 Revize: databáze změněna z PostgreSQL na SQLite po upřesnění rozsahu (viz 2. Volba stacku). Revize: deploy přepsán na Gitea Actions + Gitea registry a sjednocen se zavedeným postupem z Dashboardu — pojmenování repa, rozdělení compose souborů i podoba CI (viz 4. a 7.). Stav: naskafoldováno. Projekt existuje, lint/testy/build i produkční image ověřeny. Produkční větev je
release(výchozí větev repa na Giteji), nemain.
Obsah
- 1. Zadání
- 2. Volba stacku
- 3. Topologie
- 4. Pojmenování repozitáře
- 5. Struktura repa
- 6. Konfigurační soubory
- 7. Deploy
- 8. Zálohy
- 9. Cesta dál
- 10. Otevřené otázky
1. Zadání
| Parametr | Hodnota |
|---|---|
| Typ webu | Prezentace + jednoduchá administrace obsahu (CMS) |
| Obsah | Položky zboží: obrázek/obrázky, pár parametrů, cena. Nic víc. |
| Administrace | Přihlášení heslem přímo na webu, přidat / upravit / odebrat položku. Žádné zákaznické účty, žádný košík. |
| Produkce | Stávající server s Dockerem v domácí síti (tentýž, kde běží Dashboard) |
| HTTPS | Stávající domácí Caddy, veřejná IP, Let's Encrypt |
| Jazyk | TypeScript / JavaScript |
| K dispozici | Gitea (vlastní), Gitea Actions, server s Dockerem, Caddy |
| Požadavek | Oddělené dev a prod prostředí, vývoj na notebooku, nasazení na jiném stroji |
2. Volba stacku
| Volba | Důvod |
|---|---|
| Payload CMS 3 | Admin rozhraní i veřejný web jsou jedna Next.js aplikace, ne dvě služby. Schéma obsahu se píše v TypeScriptu → typy pro frontend zdarma. Odpadá oddělený headless CMS. |
| Next.js | Součást Payloadu 3, není to volba navíc. |
SQLite (@payloadcms/db-sqlite) |
Payload bez databáze neběží, ale na tenhle rozsah stačí soubor. Jeden editor, žádné souběžné zápisy, desítky až stovky položek — Postgres by tu neřešil nic, jen přidal službu, heslo, healthcheck a druhý bod selhání. |
| Docker Compose | docker-compose.yml pro vývoj, docker-compose.prod.yml pro nasazení — stejné rozdělení jako u Dashboardu. Prostředí (prod / staging) se liší jen .env. |
| Gitea Actions | CI už je k dispozici, takže image staví runner, ne produkční server. Server pak jen docker compose pull — deploy trvá sekundy místo minut a produkce nikdy nekompiluje. |
| Gitea container registry | Vestavěná součást Gitey, netřeba Harbor ani GHCR. Běží za stejným Caddy, takže má platný certifikát a odpadá otravování s insecure-registries. |
Zvažované a zamítnuté varianty:
- PostgreSQL — v první verzi návrhu. Zamítnut po upřesnění rozsahu: pro obrázek, pár parametrů a cenu je to služba navíc bez přínosu. Payload adaptér se mění za jeden řádek, takže případný pozdější přechod je export/import pár set řádků, ne přestavba.
- Astro + Decap CMS — jednodušší provoz (obsah v gitu, žádná DB), ale slabší admin UX, nutný GitHub OAuth a rebuild po každé editaci. Horší cesta k budoucímu e-shopu.
- Astro + soubory v gitu — nejjednodušší provoz, ale položky by musel přidávat vývojář, ne majitel. Neplní zadání.
- Astro + Directus — dvě samostatné služby místo jedné, víc pohyblivých dílů bez odpovídajícího přínosu.
3. Topologie
┌─ notebook (vývoj) ─────┐
│ pnpm dev → :3000 │
│ DB = soubor v ./data │
└───────────┬────────────┘
│ git push
▼
┌─ gitea.doubynet.eu ────────────────────────┐
│ repo release → produkce, devel → staging│
│ Actions: lint → test → build image │
│ registry: honza/zbrane-reiner-web:<sha> │
└───────────┬────────────────────────────────┘
│ docker compose pull (server si sáhne pro image)
▼
┌─ server s Dockerem ────────┐
│ prod: app :3000 │
│ staging: app :3001 │
│ data + media volumes │
└───────────▲────────────────┘
│ reverse_proxy
┌───────────┴─────────────────┐
│ Caddy │
│ reiner-zbrane.cz → 3000│
│ dev.reiner-zbrane.cz → 3001│
│ gitea.doubynet.eu → Gitea│
└─────────────────────────────┘
Prod i staging běží na jednom serveru jako dva izolované Compose projekty — jiný name:, jiné porty, jiná volumes, jiný databázový soubor. Stačí stroj, který už máte, a na něm dva kontejnery navíc.
4. Pojmenování repozitáře
Rozhodnutí: zůstat u Zbrane-Reiner-Web
První verze tohoto dokumentu navrhovala web-reiner-zbrane (malá písmena, prefix podle typu). Zamítnuto — v ~/Dokumenty/Repo/ už je zavedená jiná konvence a ta má přednost před teorií:
Curator Dashboard Dokumentace GOGUpdater
PlanetaryTime RWCalc SQLmem Trainyard Zbrane-Reiner-Web
TitleCase, žádný prefix podle typu, název = co to je. Zavést pro jeden projekt jiné pravidlo je horší než jakákoli nedokonalost toho stávajícího — konvence má cenu jen když platí všude.
Velká písmena Dockeru nevadí, protože je CI srovná — přesně jak to dělá Dashboard:
echo "IMAGE=gitea.doubynet.eu/$(echo '${{ github.repository }}' \
| tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
Honza/Zbrane-Reiner-Web → honza/zbrane-reiner-web. Bez ručního zásahu.
Propis do konfigurace
Gitea repo gitea.doubynet.eu/Honza/Zbrane-Reiner-Web (private)
adresář ~/Dokumenty/Repo/Zbrane-Reiner-Web
větve release (produkce), devel (staging)
doména reiner-zbrane.cz → Caddy → server:3000
staging dev.reiner-zbrane.cz → Caddy → server:3001
na serveru /srv/reiner-zbrane (release)
/srv/reiner-zbrane-staging (devel)
compose name reiner-zbrane / reiner-zbrane-staging
volumes reiner-zbrane_data, reiner-zbrane_media
databáze /app/data/reiner.db (uvnitř volume data)
image gitea.doubynet.eu/honza/zbrane-reiner-web:<tag>
Jméno repa (Zbrane-Reiner-Web) a jméno nasazení (reiner-zbrane) se záměrně liší. Repo je katalogová položka, nasazení je runtime — sufix -Web v cestách na serveru a ve jménech volumes nenese žádnou informaci, tam je webem všechno.
Doplňky k pojmenování
- Repo držet private — skončí v něm struktura
.env, migrace a časem integrace na dodavatele. - Přejmenovat před nasazením, ne po něm. Název se propisuje do jmen Docker volumes; změna po nasazení znamená ruční migraci dat.
- Přibude-li druhý projekt téhož klienta, vznikne
Zbrane-Reiner-Strelnice— stejný vzor, řadí se vedle sebe. - Monorepo — pokud by víc webů sdílelo komponenty nebo design, ne N repozitářů, ale jedno repo s
apps/*apackages/ui. Tento návrh do monorepa přejde bez přestavby.
5. Struktura repa
Zbrane-Reiner-Web/
├─ src/
│ ├─ collections/ # Products.ts, Media.ts, Pages.ts, Users.ts ← schéma obsahu
│ ├─ app/(frontend)/ # veřejný web
│ │ ├─ page.tsx # výpis nabídky
│ │ ├─ nabidka/[slug]/ # detail položky
│ │ ├─ [slug]/ # statické stránky z kolekce Pages
│ │ └─ api/health/ # endpoint pro Docker HEALTHCHECK
│ ├─ app/(payload)/ # admin, generuje Payload
│ ├─ lib/format.ts # formátování ceny a stavu
│ └─ payload.config.ts
├─ migrations/ # verzované DB migrace, commitují se
├─ .gitea/workflows/ci.yml # lint → test → build → push do registry
├─ Dockerfile
├─ .dockerignore
├─ docker-compose.yml # lokální vývoj (build z Dockerfile)
├─ docker-compose.prod.yml # produkce (image z registry)
├─ data/ # databázový soubor, NIKDY v gitu
├─ .env.example # v gitu
├─ .env # NIKDY v gitu
├─ PROJECT.md # cíle a fáze projektu
├─ CHANGELOG.md
└─ README.md
Rozdělení na docker-compose.yml a docker-compose.prod.yml (místo jednoho souboru s overridy) je převzaté z Dashboardu — prod soubor je samostatný, čitelný a na serveru je jediné, co tam z repa musí být.
Obsahový model a přístup
| Kolekce | Obsah |
|---|---|
Products |
název, obrázky (vazba na Media), parametry (ráže, délka hlavně, stav…), cena, popis, příznak „prodáno" |
Media |
nahrané obrázky, Payload k nim sám generuje zmenšeniny |
Pages |
statické stránky — o nás, kontakt, otevírací doba |
Users |
jeden účet pro majitele. Registrace vypnutá, účet se založí jednou při prvním spuštění. |
Administrace je na /admin — přihlášení e-mailem a heslem, za ním rovnou seznam položek s tlačítky přidat / upravit / smazat. Žádná další role, žádné zákaznické účty. Veřejná část položky jen vypisuje.
Přihlašování ven z internetu. Admin je dostupný na veřejné doméně, takže: silné heslo, zapnout Payloadu
maxLoginAttempts+lockTimea zvážit two-factor. Alternativa, pokud stačí správa z domácí sítě — pustit/adminv Caddy jen z LAN.
6. Konfigurační soubory
Dockerfile
Záměrně bez Next standalone outputu: image je o pár set MB větší, ale Payload CLI (migrace) v něm funguje bez ladění file-tracingu. Na tento objem provozu je to správný kompromis.
Skutečná podoba je v Dockerfile; podstatné jsou tři věci, které nejsou zřejmé:
# `corepack enable` jen vytvoří shim; bez `prepare` by si kontejner stahoval
# pnpm z npmjs.com až při startu — a bez internetu by vůbec nenaběhl.
RUN corepack enable && corepack prepare pnpm@11.18.0 --activate
# Placeholder inline, ne přes ENV — jinak zůstane secret ve vrstvě image
# (a `docker build` na to právem nadává).
RUN PAYLOAD_SECRET=build-time-placeholder-nahrazen-za-behu pnpm build
# sqlite jen kvůli zálohám (`.dump`), ~1,5 MB
RUN apk add --no-cache sqlite
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
# Migrace proběhnou při startu, teprve pak naskočí server.
CMD ["sh", "-c", "pnpm payload migrate && pnpm start"]
HEALTHCHECK je převzatý ze vzoru Dashboardu — docker ps pak rovnou ukazuje, jestli aplikace opravdu odpovídá, ne jen jestli proces běží. Míří na /api/health, který záměrně nesahá do databáze: kdyby ji kontroloval, výpadek databáze by Docker „léčil“ restartem aplikace, což nepomůže.
Ověřeno: image naběhne i s --network none, tedy bez internetu.
docker-compose.yml (lokální vývoj)
# Lokální vývoj: build z Dockerfile. Databáze je soubor v ./data/.
name: reiner-zbrane-dev
services:
app:
build: .
ports:
- "3000:3000"
env_file: .env
volumes:
- ./src:/app/src # změny bez rebuildu
- ./data:/app/data
- ./media:/app/media
command: pnpm dev
restart: unless-stopped
Na notebooku se ale běžně ani tohle nepoužije — Payload se pouští nativně přes pnpm dev kvůli rychlému hot-reloadu. Tenhle soubor slouží k ověření, že se obraz vůbec postaví a rozjede.
docker-compose.prod.yml (produkce i staging)
# Produkční nasazení: image se STAHUJE z Gitea registry (nestaví se lokálně).
# Na serveru (jednorázově): docker login gitea.doubynet.eu
# Nasazení / aktualizace:
# docker compose -f docker-compose.prod.yml pull
# docker compose -f docker-compose.prod.yml up -d
name: ${PROJECT:-reiner-zbrane}
services:
app:
image: gitea.doubynet.eu/honza/zbrane-reiner-web:${IMAGE_TAG:-latest}
ports:
- "${APP_PORT:-3000}:3000" # viz poznámka o Caddy níže
env_file: .env
volumes:
- data:/app/data # databázový soubor
- media:/app/media # nahrané obrázky přežijí redeploy
restart: unless-stopped
volumes:
data:
media:
Jedna služba, dva volumes, žádné heslo k databázi. Celý stav webu = tyhle dva volumes.
.env.example
# Vzor konfigurace. Zkopíruj do `.env` a vyplň. `.env` NIKDY necommituj.
PROJECT=reiner-zbrane
APP_PORT=3000
IMAGE_TAG=latest
DATABASE_URI=file:./data/reiner.db
# Klíč pro podpis session cookie – v produkci náhodný řetězec min. 32 znaků!
PAYLOAD_SECRET=zmen-me-na-nahodny-retezec
NEXT_PUBLIC_SERVER_URL=https://reiner-zbrane.cz
Staging má PROJECT=reiner-zbrane-staging, APP_PORT=3001, IMAGE_TAG=devel, vlastní PAYLOAD_SECRET a NEXT_PUBLIC_SERVER_URL=https://dev.reiner-zbrane.cz. DATABASE_URI zůstává stejné — soubor je v jiném volume, takže se nemají jak potkat.
IMAGE_TAG je zároveň páka na rollback: přepsat na předchozí SHA, pull, up -d.
Caddyfile (na stávajícím domácím Caddy)
reiner-zbrane.cz, www.reiner-zbrane.cz {
reverse_proxy 192.168.1.50:3000
}
dev.reiner-zbrane.cz {
basic_auth { honza <bcrypt-hash> } # staging schovaný před světem i roboty
reverse_proxy 192.168.1.50:3001
}
Hash hesla: caddy hash-password.
Caddy už tímhle způsobem obsluhuje Gitea i Dashboard, takže přibývají jen dva bloky.
Síťová poznámka. Pokud Caddy běží na jiném stroji než Docker, musí porty poslouchat na LAN, ne na
127.0.0.1, a přístup se omezí firewallem:ufw allow from <IP-Caddy> to any port 3000,3001 proto tcp ufw default deny incomingPokud Caddy běží na tomtéž stroji (jak to vypadá u Dashboardu), je to jednodušší a bezpečnější — v
docker-compose.prod.ymlstačí publikovat port jen na loopback:"127.0.0.1:${APP_PORT}:3000". Ven se pak nedostane nic mimo Caddy a žádnáufwpravidla netřeba.
7. Deploy
Postup je záměrně stejný jako u Dashboardu — CI postaví a nahraje image, server si ho stáhne. Žádný nový vzor k naučení.
Pipeline
| Krok | Kde běží |
|---|---|
1. git push do devel / release |
notebook |
| 2. lint + typy + testy | Gitea Actions runner |
3. build image, tag <sha> + devel/latest, push do registry |
Gitea Actions runner |
4. docker compose pull && up -d, migrace v entrypointu |
server (ručně) |
| 5. Caddy posílá provoz beze změny | Caddy |
.gitea/workflows/ci.yml
name: CI
# Spustí se při pushi do devel/release a jde i ručně spustit z GUI.
on:
push:
branches: [devel, release]
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Instalace pnpm
run: corepack enable && corepack prepare pnpm@latest --activate
- name: Instalace závislostí
run: pnpm install --frozen-lockfile
- name: ESLint
run: pnpm lint
- name: Typová kontrola
run: pnpm exec tsc --noEmit
- name: Build (ověření, že projde)
run: pnpm build
build-and-push:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Docker jméno musí být malými písmeny (Honza/Zbrane-Reiner-Web -> honza/zbrane-reiner-web)
- name: Název image
run: |
echo "IMAGE=gitea.doubynet.eu/$(echo '${{ github.repository }}' \
| tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
- name: Značka podle větve
run: |
if [ "${{ github.ref_name }}" = "release" ]; then
echo "MOVING_TAG=latest" >> "$GITHUB_ENV"
else
echo "MOVING_TAG=devel" >> "$GITHUB_ENV"
fi
- name: Přihlášení do Gitea registry
run: |
echo '${{ secrets.REGISTRY_TOKEN }}' \
| docker login gitea.doubynet.eu -u '${{ github.actor }}' --password-stdin
- name: Build image
run: docker build -t "$IMAGE:$MOVING_TAG" -t "$IMAGE:${{ github.sha }}" .
- name: Push image
run: |
docker push "$IMAGE:$MOVING_TAG"
docker push "$IMAGE:${{ github.sha }}"
Oproti Dashboardu přibyl jen plovoucí tag podle větve — release → latest, devel → devel. Bez toho by staging a produkce ukazovaly na tentýž latest a nešly by oddělit.
Záměrně bez docker/build-push-action — Gitea si actions tahá z github.com, takže každá závislost navíc je další věc, která se může rozbít. Holé docker funguje vždycky. Stejný důvod, proč to tak má Dashboard.
Nasazení na serveru
cd /srv/reiner-zbrane-staging # nebo /srv/reiner-zbrane
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
Migrace se pustí samy v entrypointu kontejneru. Server nic nekompiluje a nemá zdrojové kódy — jen docker-compose.prod.yml, .env a dva volumes.
Co je potřeba nastavit jednou
Na Giteji — repo → Settings → Secrets: REGISTRY_TOKEN (osobní token s oprávněním write:package). Stejný secret, jaký už používá Dashboard.
Runner — act_runner už běží kvůli Dashboardu, další projekt nepotřebuje nic navíc.
Na serveru — jednorázově:
mkdir -p /srv/reiner-zbrane /srv/reiner-zbrane-staging
# do každého: docker-compose.prod.yml + .env
docker login gitea.doubynet.eu # aby šlo image stáhnout
Dva adresáře na serveru
| Cesta | Větev | Tag | Port | Doména |
|---|---|---|---|---|
/srv/reiner-zbrane |
release |
latest |
3000 | reiner-zbrane.cz |
/srv/reiner-zbrane-staging |
devel |
devel |
3001 | dev.reiner-zbrane.cz |
Každý má vlastní .env. Liší se PROJECT, APP_PORT, IMAGE_TAG a PAYLOAD_SECRET.
Denní workflow
# vývoj
git switch -c feature/kontaktni-formular
pnpm dev # localhost:3000
# změna schématu obsahu → migrace do gitu
pnpm payload migrate:create pridani-formulare
git add . && git commit && git push
# staging
git switch devel && git merge feature/kontaktni-formular && git push
ssh server 'cd /srv/reiner-zbrane-staging && docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d'
# produkce, až to na dev.reiner-zbrane.cz sedí
git switch release && git merge devel && git push
ssh server 'cd /srv/reiner-zbrane && docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d'
Rollback
sed -i 's/^IMAGE_TAG=.*/IMAGE_TAG=<predchozi-sha>/' .env
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
Funguje, protože každý build je otagovaný svým SHA a v registry zůstává. Migrace jsou ale jednosměrné — pokud verze mezitím změnila schéma databáze, patří k rollbacku i obnova zálohy.
8. Zálohy
S CMS je databáze jediná kopie obsahu — na rozdíl od statického webu ji nemáte v gitu. Nepodceňovat.
Cron na serveru, denně:
cd /srv/reiner-zbrane
docker compose -f docker-compose.prod.yml exec -T app \
sqlite3 /app/data/reiner.db .dump | gzip > /backup/db-$(date +%F).sql.gz
docker run --rm -v reiner-zbrane_media:/m -v /backup:/b alpine \
tar czf /b/media-$(date +%F).tgz -C /m .
find /backup -mtime +14 -delete
.dump běží v transakci, takže je konzistentní i za provozu — soubor nekopírovat obyčejným cp, ten za běhu s WAL zachytí rozpracovaný zápis. Obnova: zcat db-2026-07-30.sql.gz | sqlite3 reiner.db.
Zálohy odvézt i mimo server (rsync/restic na NAS nebo do cloudu) — záloha na stejném stroji neřeší selhání disku.
Obnovu jednou za čas opravdu vyzkoušet na stagingu. Záloha, kterou jste nikdy nenačetli, není záloha.
9. Cesta dál
Nic z toho neřešit teď — ruční pull && up -d vydrží hodně dlouho.
- Automatický deploy stagingu — buď Watchtower sledující tag
devel, nebo krok navíc v CI, který se přes SSH přihlásí na server a spustípull && up -d. Ušetří jeden příkaz po každém pushi dodevel; u produkce je ruční krok spíš výhoda. - Buildx cache v CI — první build instaluje závislosti od nuly. S
--cache-fromproti registry se sestavení zkrátí na desítky sekund. - E-shop — Payload má oficiální plugin pro platby; přechod nevyžaduje změnu infrastruktury.
- Postgres, ale až kdyby přišly souběžné zápisy (objednávky, víc editorů současně). Mění se jeden adaptér v
payload.config.tsa přidává jedna služba vdocker-compose.prod.yml; data se převedou exportem a importem. Do té doby by to byla údržba navíc bez užitku.
10. Otevřené otázky
- Příjmení: „Reirenr" vs. „Reiner" — vypadá to jako překlep. Celý dokument počítá s
reiner. Potvrdit před scaffoldem, protože se to propíše do jmen Docker volumes a přejmenování po nasazení znamená ruční migraci dat. - Skutečná doména — do
NEXT_PUBLIC_SERVER_URLa Caddyfile. Název repa už na ní nezávisí, ten je daný konvencí. - Běží Caddy na tomtéž stroji jako Docker? — pokud ano, publikovat porty jen na
127.0.0.1a vynechatufwpravidla. Pokud ne, doplnit IP serveru (v návrhu placeholder192.168.1.50). - Porty 3000/3001 volné? — na serveru už běží Dashboard na
:8000, kolize nehrozí, ale ověřit. - Smazat prázdný adresář
Web-ZaS.
Další krok
Po potvrzení otevřených otázek:
git initv tomto repu, remote nagitea.doubynet.eu/Honza/Zbrane-Reiner-Web, větverelease+devel- vygenerovat Payload 3 + Next.js projekt se SQLite adaptérem
- napsat
Dockerfile,.dockerignore,docker-compose.yml,docker-compose.prod.yml,.env.example,.gitea/workflows/ci.yml,README.md,PROJECT.md,CHANGELOG.md - přidat kolekce
Products,Media,Pages,Users, vypnout veřejnou registraci a založit jediný účet pro majitele - postavit veřejný výpis položek a detail položky