Files
HonzaandClaude Opus 5 c8e435139e
CI / test (push) Failing after 1m42s
CI / build-and-push (push) Has been skipped
Scaffold webu na Payload CMS 3 + Next.js nad SQLite
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>
2026-07-30 13:13:23 +02:00

23 KiB
Raw Permalink Blame History

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), ne main.

Obsah


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-Webhonza/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/* a packages/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 + lockTime a zvážit two-factor. Alternativa, pokud stačí správa z domácí sítě — pustit /admin v 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 Dashboardudocker 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 incoming

Pokud Caddy běží na tomtéž stroji (jak to vypadá u Dashboardu), je to jednodušší a bezpečnější — v docker-compose.prod.yml stačí publikovat port jen na loopback: "127.0.0.1:${APP_PORT}:3000". Ven se pak nedostane nic mimo Caddy a žádná ufw pravidla 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ětvereleaselatest, develdevel. 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.

Runneract_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.

  1. 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 do devel; u produkce je ruční krok spíš výhoda.
  2. Buildx cache v CI — první build instaluje závislosti od nuly. S --cache-from proti registry se sestavení zkrátí na desítky sekund.
  3. E-shop — Payload má oficiální plugin pro platby; přechod nevyžaduje změnu infrastruktury.
  4. 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.ts a přidává jedna služba v docker-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_URL a 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.1 a vynechat ufw pravidla. Pokud ne, doplnit IP serveru (v návrhu placeholder 192.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:

  1. git init v tomto repu, remote na gitea.doubynet.eu/Honza/Zbrane-Reiner-Web, větve release + devel
  2. vygenerovat Payload 3 + Next.js projekt se SQLite adaptérem
  3. napsat Dockerfile, .dockerignore, docker-compose.yml, docker-compose.prod.yml, .env.example, .gitea/workflows/ci.yml, README.md, PROJECT.md, CHANGELOG.md
  4. přidat kolekce Products, Media, Pages, Users, vypnout veřejnou registraci a založit jediný účet pro majitele
  5. postavit veřejný výpis položek a detail položky