Návod na nasazení nové instance aplikace GTO na server, s automatickým nasazováním přes GitHub Actions.
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á | Popis | Hodnota |
|---|---|---|
| NAME | Název projektu | |
| DOMAIN | Doména webu | |
| PSQL_USER | Uživatel lokální PostgreSQL databáze | |
| PSQL_HESLO | Heslo k databázi (do DATABASE_URL se zakóduje automaticky) | |
| PORT | Port, na kterém poběží appka | |
| HOST_ID | Fyzický server, na kterém instance běží. Instance na stejném stroji sdílejí hodnotu, jejich buildy pak běží postupně. | |
| GLOBAL_DOMAIN | Doména globálního webu (super-adminu) | |
| WEBHOOK_API_KEY | Stejný klíč jako na globálním webuStejný klíč musí mít nastavený všechny weby, které tento super admin spravuje | |
| ECOMAIL_API_KEY | API 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_KEY | Volitelné: 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.
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ě.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:
gto-solutions-cms/cms → Branches → New branchprod → Create branchDeploy job pro tuto větev se přidává až v kroku 14, takže založení větve zatím nic nenasazuje.
ssh PSQL_USER@DOMAIN
ssh-keygen -t ed25519 -f ~/.ssh/github -N "" -C "NAME"
cat ~/.ssh/github.pubVypsaný veřejný klíč přidat na GitHubu: Settings → Deploy keys → Add deploy key, název NAME, bez „Allow write access“.
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 authenticatedVš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"
~/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.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).
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á | Popis | Hodnota |
|---|---|---|
| GOOGLE_CLIENT_ID | Client ID OAuth klienta | |
| GOOGLE_CLIENT_SECRET | Client Secret OAuth klienta |
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é
DATABASE_URL míří na vlastní databázi, ta je globální.SITE_PUBLIC_URL, NEXT_PUBLIC_GLOBAL_WEB_URL a ECOMAIL_API_KEY, super-admin nemá veřejný web.openssl rand -base64 32
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.jsoncd ~/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.
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.
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ý.
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
~/.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.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 -l127.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.~/cms/shared/.env (krok 7). V crontabu tedy není a po změně klíče cron funguje dál.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.~/cms/cron-error.log.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ů.
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é).
Runner na serveru přijímá deploye z GitHubu. Každá instance (každý uživatel domény) má vlastní runner.
Settings → Actions → Runners → New self-hosted runner → Linux, x64 → zkopírovat token RUNNER_TOKEN (platí 1 hodinu).
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
--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.
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_IDZměnu poslat běžnou cestou přes pull requesty:
mastermaster → prodprod → NAME. Toto sloučení je první automatické nasazení.Průběh je vidět na GitHubu v Actions → Deploy → deploy-NAME.
~/cms/repo.git a vytvoří novou složku v ~/cms/releases. Web mezitím běží dál ze staré složky..env a obrázky ze ~/cms/shared, spustí pnpm install a pnpm build.~/cms/current na novou složku a udělá pm2 reload bez výpadku.current na předchozí verzi.Každé selhání znamená červený job na GitHubu se zprávou, co se stalo a jestli se něco přepnulo.
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.
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).
# 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:globalZměna NEXT_PUBLIC_* proměnných v .env se projeví až po novém deployi, protože se zapisují do buildu.