Setup serveru

Návod na nasazení nové instance aplikace GTO na server, s automatickým nasazováním přes GitHub Actions.

Typ instance
Web pro návštěvníky (např. 202020, andesbet, casinoexpertsk) s vlastní lokální databází. Globální super-admin bez veřejného webu (např. admin-es, admin-sk), spravuje sdílený obsah pro weby.
proměnná projektu (unikátní pro tuto instanci) proměnná sdílená s ostatními instancemi hodnota, kterou je nutné ručně doplnit (ID, token…)

1Proměnné — přehled

Před začátkem je třeba připravit tyto hodnoty. V návodu jsou všude zvýrazněné stejnou barvou, aby bylo jasné, kam patří.

Hodnoty vyplněné do tabulky se samy doplní do všech příkazů v návodu a tlačítko Kopírovat zkopíruje už vyplněnou verzi. Nevyplněné hodnoty zůstanou v návodu jako zvýrazněný název. Nic se neukládá: po obnovení nebo zavření stránky hodnoty zmizí.

ProměnnáPopisHodnota
NAMENázev projektu
DOMAINDoména webu
PSQL_USERUživatel lokální PostgreSQL databáze
PSQL_HESLOHeslo k databázi (do DATABASE_URL se zakóduje automaticky)
PORTPort, na kterém poběží appka
HOST_IDFyzický server, na kterém instance běží. Instance na stejném stroji sdílejí hodnotu, jejich buildy pak běží postupně.
GLOBAL_DOMAINDoména globálního webu (super-adminu)
WEBHOOK_API_KEYStejný klíč jako na globálním webuStejný klíč musí mít nastavený všechny weby, které tento super admin spravuje
ECOMAIL_API_KEYAPI klíč účtu v Ecomailu, přes který se odesílají přihlášky k newsletteru. Jen veřejné weby, super-admin ho nemá.
PASSWORD_LOCK_KEYVolitelné: hodnota hlavičky Authorization při volání webů, které jsou za heslem

GOOGLE_CLIENT_ID a GOOGLE_CLIENT_SECRET se vyplňují až v kroku 6, token runneru v kroku 13.

⚠️Do DATABASE_URL se PSQL_HESLO automaticky převede do percent-encoding, aby fungovala connection string (např. Xt9mQZ@Lp#3rN^Yv=Bs! → Xt9mQZ%40Lp%233rN%5EYv%3DBs%21). Do vstupního pole patří heslo v původní podobě.

2Větev na GitHubu

Každá instance má na GitHubu vlastní větev NAME. Po každém pushi do této větve se instance automaticky nasadí. Větev se zakládá z prod:

  1. GitHub → repozitář gto-solutions-cms/cms → Branches → New branch
  2. Název NAME, zdroj prod → Create branch
  3. Novou větev přidat do rulesetu deploy-branches: Settings → Rules → Rulesets → deploy-branches → Target branches → Add target → Include by pattern → NAME → Save changes

Deploy job pro tuto větev se přidává až v kroku 14, takže založení větve zatím nic nenasazuje.

3Připojení na server a deploy klíč

SSH na server

ssh PSQL_USER@DOMAIN

Vytvoření deploy klíče

ssh-keygen -t ed25519 -f ~/.ssh/github -N "" -C "NAME"
cat ~/.ssh/github.pub

Vypsaný veřejný klíč přidat na GitHubu: Settings → Deploy keys → Add deploy key, název NAME, bez „Allow write access“.

Použití klíče pro github.com

printf 'Host github.com\n  IdentityFile ~/.ssh/github\n  IdentitiesOnly yes\n' > ~/.ssh/config
chmod 600 ~/.ssh/config
ssh-keyscan github.com >> ~/.ssh/known_hosts
ssh -T git@github.com   # Hi gto-solutions-cms/cms! You've successfully authenticated

4Složka ~/cms a první verze

Všechno, co k webu patří, je ve složce ~/cms. Na tyto cesty spoléhá deploy:

~/cms/
├── current   → releases/…        # odkaz na verzi, která právě běží (pm2 ji spouští odsud)
├── releases/                    # jedna složka pro každý deploy, drží se poslední 3
├── shared/                      # .env, ecosystem.config.js a obrázky (public/obrazek, public/images)
└── repo.git                     # kód z GitHubu

Každý deploy vytvoří novou složku v releases a nalinkuje do ní soubory ze shared, takže všechny verze používají stejný .env i stejné obrázky.

mkdir -p ~/cms/releases ~/cms/shared/public/obrazek ~/cms/shared/public/images
git clone --bare git@github.com:gto-solutions-cms/cms.git ~/cms/repo.git
git --git-dir ~/cms/repo.git fetch origin "+refs/heads/NAME:refs/remotes/origin/NAME"

REL=~/cms/releases/$(date +%Y%m%d-%H%M%S)-initial
git --git-dir ~/cms/repo.git worktree add --detach "$REL" origin/NAME
ln -s "$REL" ~/cms/current

ln -s ~/cms/shared/.env "$REL/.env"
ln -s ~/cms/shared/public/obrazek "$REL/public/obrazek"
ln -s ~/cms/shared/public/images "$REL/public/images"
⚠️Do složek v ~/cms/releases nic ručně neukládat, staré verze se při dalších deployích mažou. Konfigurace a obrázky patří do ~/cms/shared.

5Ověření přístupu do PostgreSQL

PGPASSWORD='PSQL_HESLO' psql -h localhost -U PSQL_USER -d postgres

Databáze super-adminu je zároveň globální databáze. Spravované weby se do ní nepřipojují, data čtou přes API super-adminu (NEXT_PUBLIC_GLOBAL_WEB_URL a WEBHOOK_API_KEY).

6Google OAuth

Přihlášení do administrace běží přes Google. OAuth klienta je potřeba vytvořit na console.cloud.google.com (APIs & Services → Credentials → Create OAuth client ID → Web application).

Authorized redirect URI:

https://DOMAIN/api/auth/callback/google
ProměnnáPopisHodnota
GOOGLE_CLIENT_IDClient ID OAuth klienta
GOOGLE_CLIENT_SECRETClient Secret OAuth klienta

7Vytvoření .env veřejný websuper-admin

Ve složce ~/cms/shared je třeba vytvořit soubor .env (do verze je nalinkovaný z kroku 4):

NEXT_PUBLIC_IS_SUPER_ADMIN=false

DATABASE_URL=postgres://PSQL_USER:PSQL_HESLO@localhost:5432/PSQL_USERdb

SITE_PUBLIC_URL=https://DOMAIN
BETTER_AUTH_URL=https://DOMAIN
BETTER_AUTH_SECRET=vygenerovat pomocí openssl rand -base64 32

GOOGLE_CLIENT_ID=GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET=GOOGLE_CLIENT_SECRET

NEXT_PUBLIC_SITE_LANGUAGE=cs  # nastavit jazyk webu
SITE_ALLOW_INDEXING=false  # při spuštění webu přepsat na true

NEXT_PUBLIC_GLOBAL_WEB_URL=https://GLOBAL_DOMAIN

WEBHOOK_API_KEY=WEBHOOK_API_KEY
ECOMAIL_API_KEY=ECOMAIL_API_KEY  # newsletter (Ecomail)

Kromě ECOMAIL_API_KEY je pro odesílání přihlášek do Ecomailu potřeba vyplnit i ID seznamu v Ecomailu v administraci webu: Admin → Nastavení webu → E-mail / newsletter. Bez klíče nebo bez ID se přihlášky ukládají jen do databáze webu a do Ecomailu se nedostanou.

SITE_ALLOW_INDEXING určuje, jestli smí vyhledávače web indexovat. Dokud web není veřejný, nechává se false: všechny stránky dostanou noindex, nofollow (prázdná hodnota nebo chybějící proměnná znamená také false). Při spuštění webu ji přepsat na true, jinak ho Google nezaindexuje. Hodnota se zapisuje do buildu, změna se projeví až po novém deployi.

NEXT_PUBLIC_IS_SUPER_ADMIN=true
NEXT_PUBLIC_SITE_LANGUAGE=cs  # nastavit jazyk administrace

DATABASE_URL=postgres://PSQL_USER:PSQL_HESLO@localhost:5432/PSQL_USERdb

BETTER_AUTH_URL=https://DOMAIN
BETTER_AUTH_SECRET=vygenerovat pomocí openssl rand -base64 32

GOOGLE_CLIENT_ID=GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET=GOOGLE_CLIENT_SECRET

WEBHOOK_API_KEY=WEBHOOK_API_KEY
PASSWORD_LOCK_KEY=PASSWORD_LOCK_KEY   # volitelné

Rozdíly proti veřejnému webu

Generování BETTER_AUTH_SECRET

openssl rand -base64 32

8Node (fnm), pnpm a pm2

Na serveru je jen Node 24.19.0 a pm2 musí být nainstalovaný pod ním, protože deploy používá právě tuto verzi.

export FNM_DIR="$HOME/.local/share/fnm"
eval "$(fnm env --shell bash)"
fnm install 24.19.0
fnm default 24.19.0
fnm use 24.19.0

npm i -g pm2
corepack enable pnpm
pnpm -v   # v ~/cms/current vypíše verzi z package.json

9Inicializace databáze veřejný websuper-admin

cd ~/cms/current
nice -n 19 ionice -c 3 pnpm install --frozen-lockfile
pnpm db:push
pnpm db:seed
cd ~/cms/current
nice -n 19 ionice -c 3 pnpm install --frozen-lockfile
pnpm db:push:global
pnpm db:seed:global

Lokální schéma (články, kategorie, stránky…) a výchozí bloky stránek.

Globální schéma (přihlášení, sdílený obsah, seznam webů) a výchozí paleta znaků editoru.

Podle NEXT_PUBLIC_IS_SUPER_ADMIN v .env funguje na webu jen pnpm db:push a na super-adminu jen pnpm db:push:global. Opačný příkaz skončí chybou, takže schéma nejde nahrát do špatné databáze.

10Build a spuštění přes PM2

ecosystem.config.js

Soubor ~/cms/shared/ecosystem.config.js (také není v gitu):

const path = require("path")

module.exports = {
  apps: [
    {
      name: "NAME",
      cwd: path.join(process.env.HOME, "cms", "current"),
      script: "node_modules/next/dist/bin/next",
      args: "start --port PORT",
      exec_mode: "cluster",
      instances: 1,
      listen_timeout: 30000,
      env: { NODE_ENV: "production" },
      max_memory_restart: "1G",
      min_uptime: "30s",
      max_restarts: 5,
      exp_backoff_restart_delay: 2000,
    },
  ],
};

cwd míří na odkaz ~/cms/current, takže pm2 vždy spustí verzi, na kterou odkaz právě ukazuje. exec_mode: "cluster" zajistí, že pm2 reload nejdřív spustí novou verzi a teprve pak vypne starou, takže web při deployi nevypadne.

Build a spuštění

cd ~/cms/current
nice -n 19 ionice -c 3 pnpm build
pm2 start ~/cms/shared/ecosystem.config.js
pm2 save

Doménu v panelu hostingu nasměrovat na 127.0.0.1:PORT. Deploy testuje novou verzi na prvním volném portu z rozsahu PORT + 10000 až PORT + 10009 (např. 13001–13010), alespoň jeden z nich musí být volný.

Správa PM2 přes systemd (Nethost)

Na Nethostu běží PM2 přes uživatelskou systemd službu, konfigurace je v:

~/.config/systemd/user/pm2.service
systemctl --user status pm2
systemctl --user restart pm2
⚠️Soubor ~/.config/systemd/user/pm2.service nemazat. Existuje pro každého uživatele domény a po restartu serveru spouští PM2 se všemi procesy uloženými přes pm2 save.

11Cron pro plánované publikování veřejný websuper-admin

Web musí každou minutu zavolat /api/cron/publish-scheduled-articles. Jedno volání zveřejní články, kterým nastal čas publikace, provede naplánované znovuzařazení článků na homepage a obnoví výpisy, ve kterých vypršela připnutá pozice. Bez cronu se naplánovaný článek sám nikdy neobjeví.

LINE='* * * * * curl -fsS --max-time 55 -H "X-Webhook-Api-Key: $(grep -m1 ^WEBHOOK_API_KEY= $HOME/cms/shared/.env | cut -d= -f2-)" http://127.0.0.1:PORT/api/cron/publish-scheduled-articles >/dev/null 2>>$HOME/cms/cron-error.log'
(crontab -l 2>/dev/null | grep -v publish-scheduled-articles; echo "$LINE") | crontab -
crontab -l
  • Volá se appka přímo na 127.0.0.1:PORT, ne přes veřejnou doménu. Požadavek tak nejde přes Cloudflare a trefí přesně tu instanci, které se má obnovit cache.
  • Klíč se při každém spuštění čte z ~/cms/shared/.env (krok 7). V crontabu tedy není a po změně klíče cron funguje dál.
  • Příkaz je bezpečné spustit opakovaně. Nejdřív z crontabu smaže starý řádek s publish-scheduled-articles a pak přidá nový, takže cron bude vždy jen jednou. Při změně portu stačí příkaz spustit znovu s novým portem.
  • Úspěšná volání nic nevypisují, chyby se zapisují do ~/cms/cron-error.log.

Ověření

Ruční volání udělá totéž co cron, takže ho jde spustit kdykoli:

curl -sS -H "X-Webhook-Api-Key: $(grep -m1 ^WEBHOOK_API_KEY= ~/cms/shared/.env | cut -d= -f2-)" http://127.0.0.1:PORT/api/cron/publish-scheduled-articles
# {"published":[],"reposted":[],"repostedCount":0,"listingPinExpired":false}

Odpověď 401 Unauthorized znamená, že v .env chybí WEBHOOK_API_KEY, nebo appka běží ještě se starým .env (pomůže pm2 reload ~/cms/shared/ecosystem.config.js --update-env).

Super-admin cron nepotřebuje, tento krok se přeskakuje. Články a jejich plánované publikování jsou jen v lokálních databázích webů.

12Vytvoření admin účtu veřejný websuper-admin

Nejprve je třeba otevřít web a přihlásit se do administrace (vytvoří se záznam uživatele). Poté v databázi:

Přihlášení: https://DOMAIN/admin. Účet se schvaluje v lokální databázi webu.

Přihlášení: https://DOMAIN/login. Účet se schvaluje v databázi super-adminu (globální databáze).

PGPASSWORD='PSQL_HESLO' psql -h localhost -U PSQL_USER -d postgres
\c PSQL_USERdb

SELECT * FROM admin_member;   -- je třeba zkopírovat user_id nově vytvořeného uživatele
UPDATE admin_member
SET role = 'admin',
    approved_at = NOW(),
    updated_at = NOW()
WHERE user_id = 'ID';

Fialově zvýrazněné ID se doplní z pole výše. Bez něj příkaz nezmění žádný řádek.

Admin účty webu a super-adminu jsou oddělené. Schválení na jednom neplatí na druhém. Další uživatele už schvaluje admin v administraci (Admin → UživateléSuper-admin → Uživatelé).

13GitHub runner

Runner na serveru přijímá deploye z GitHubu. Každá instance (každý uživatel domény) má vlastní runner.

Na GitHubu

Settings → Actions → Runners → New self-hosted runner → Linux, x64 → zkopírovat token RUNNER_TOKEN (platí 1 hodinu).

Na serveru

mkdir -p ~/actions-runner && cd ~/actions-runner
curl -fsSL -o runner.tar.gz https://github.com/actions/runner/releases/download/v2.337.0/actions-runner-linux-x64-2.337.0.tar.gz
tar xzf runner.tar.gz && rm runner.tar.gz

./config.sh --unattended \
  --url https://github.com/gto-solutions-cms/cms \
  --name NAME-runner \
  --labels deploy-NAME \
  --work _work \
  --token RUNNER_TOKEN

pm2 start ~/actions-runner/run.sh --name ci-runner --interpreter bash --cwd ~/actions-runner
pm2 save
⚠️Bez --labels deploy-NAME runner žádný deploy nedostane. Za run.sh nepsat žádné další argumenty.

Runner se pak na GitHubu ukáže jako Idle a v pm2 logs ci-runner je Listening for Jobs.

14Deploy job a první nasazení

V repozitáři do .github/workflows/deploy.yml přidat větev do on.push.branches a nový job:

on:
  push:
    branches: [..., NAME]

jobs:
  deploy-NAME:
    if: github.ref_name == 'NAME'
    uses: ./.github/workflows/deploy-server.yml
    with:
      runner: deploy-NAME
      host: HOST_ID

Změnu poslat běžnou cestou přes pull requesty:

  1. vlastní větev → master
  2. master → prod
  3. prod → NAME. Toto sloučení je první automatické nasazení.

Průběh je vidět na GitHubu v Actions → Deploy → deploy-NAME.

Co deploy dělá

  1. Stáhne kód do ~/cms/repo.git a vytvoří novou složku v ~/cms/releases. Web mezitím běží dál ze staré složky.
  2. Nalinkuje .env a obrázky ze ~/cms/shared, spustí pnpm install a pnpm build.
  3. Novou verzi na chvíli spustí na prvním volném portu z PORT + 10000 až PORT + 10009 a ověří, že odpovídá. Když ne, skončí a stará verze běží dál.
  4. Přepne ~/cms/current na novou složku a udělá pm2 reload bez výpadku.
  5. Ověří web na PORT. Když neodpovídá, vrátí current na předchozí verzi.
  6. Smaže verze starší než poslední 3.

Každé selhání znamená červený job na GitHubu se zprávou, co se stalo a jestli se něco přepnulo.

⚠️V Settings → General → Pull Requests musí být vypnuté „Automatically delete head branches“, jinak sloučení prod → NAME smaže větev prod.

Instance se stejným HOST_ID se buildí jedna po druhé. Nasazení do všech větví najednou je proto bezpečné, jen poslední doběhne o pár minut později.

15Kontrola

pm2 list                              # přesně 2 procesy online: NAME a ci-runner
git -C ~/cms/current rev-parse --short HEAD   # odpovídá poslednímu commitu větve NAME
readlink ~/cms/current                        # nejnovější složka v ~/cms/releases
curl -I http://localhost:PORT

Veřejný web odpoví 200 OK.

Super-admin odpoví 307 s přesměrováním na /login?callbackUrl=%2F. Bez přihlášení je to správně.

Během buildu web běží normálně dál. Nová verze se přepne až po úspěšném testu.

Nakonec web přidat do super-adminu: Super-admin → Weby → Vytvořit (název, URL, typ).

Ruční zásahy

# restart bez výpadku (např. po úpravě ecosystem.config.js)
pm2 reload ~/cms/shared/ecosystem.config.js --update-env

# návrat na předchozí verzi
ls ~/cms/releases
ln -sfn ~/cms/releases/PREDCHOZI_VERZE ~/cms/current.tmp && mv -Tf ~/cms/current.tmp ~/cms/current
pm2 reload ~/cms/shared/ecosystem.config.js --update-env
# nahrání změny schématu databáze (po deployi verze, která ji přináší)
cd ~/cms/current
pnpm db:push
# nahrání změny schématu databáze (po deployi verze, která ji přináší)
cd ~/cms/current
pnpm db:push:global

Změna NEXT_PUBLIC_* proměnných v .env se projeví až po novém deployi, protože se zapisují do buildu.