Автоматизация деплоя: от пуша до прогретого кеша

Разберу устройство: что происходит от момента пуша до того, как пользователь видит актуальную версию сайта.
Структура пайплайна
Весь процесс разбит на три последовательных джоба:
push → [test] → [build & deploy] → [warm cache]
Такое разделение не случайное. Каждый джоб имеет чёткую зону ответственности и может упасть независимо. Если тесты не прошли — сборка не запустится. Если деплой упал — прогрев кеша не произойдёт. Цепочка атомарная.
yaml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run test
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
- name: Sync to server
run: rsync -az --delete .output/public/ user@host:/var/www/blog/
warm:
needs: deploy
runs-on: ubuntu-latest
steps:
- name: Warm cache
run: |
curl -s https://blog.macrulez.ru/ > /dev/null
# ... остальные URL
cache: npm в setup-node — экономия минуты на каждом джобе. GitHub кеширует ~/.npm между запусками, npm ci при попадании в кеш работает в разы быстрее.
Тесты
Тестирую три вещи: типы, юниты и E2E.
Проверка типов через vue-tsc --noEmit. Не сборка, просто типы. Падает быстро, ловит половину багов ещё до запуска браузера.
Юниты на Vitest — composables и утилиты. Здесь не мокаю API: использую реальные хэндлеры через msw, которые возвращают фикстуры в том же формате что и прод. Однажды моки разошлись с реальным API и тесты зеленели пока прод лежал — с тех пор такой подход.
E2E на Playwright — критичные сценарии: главная страница рендерится, пост открывается, пагинация переключает страницы. Запускаются против собранного приложения (preview-режим), не против дев-сервера.
typescript
test('paginator works', async ({ page }) => {
await page.goto('/');
await page.click('[aria-label="Pagination"] a:has-text("Вперёд")');
await expect(page.locator('.home__latest-posts'))
.toContainText('Страница 2');
});
Общее время тестов — около 40 секунд. Приемлемо.
Деплой
Приложение собирается в статику (nuxt generate), результат синхронизируется на сервер через rsync.
Флаг --delete важен: удаляет на сервере файлы, которых больше нет в сборке. Без него старые чанки накапливаются, и при хэш-коллизии (редко, но бывает) пользователь может получить устаревший JS.
bash
rsync -az --delete \
--exclude='.cache' \
.output/public/ \
deploy@host:/var/www/blog/public/
Соединение — по SSH-ключу, хранится в secrets.DEPLOY_KEY. Никаких паролей в конфиге.
Весь деплой занимает около 15 секунд. Nginx подхватывает новые файлы без перезапуска — статика есть статика.
Прогрев кеша
Вот часть, которую часто упускают.
После деплоя страницы не закешированы на CDN и в памяти Nginx. Первый пользователь после выкладки получает холодный ответ — медленнее обычного. Особенно заметно на страницах с SSG-генерацией тяжёлых блоков.
Прогрев — это простой обход всех публичных URL сразу после деплоя:
bash
SITE="https://blog.macrulez.ru"
URLS=(
"/"
"/about"
)
# Подтягиваем актуальный список постов из API
POSTS=$(curl -s "$SITE/api/posts?per_page=100" | jq -r '.data[].slug')
for slug in $POSTS; do
URLS+=("/post/$slug")
done
for url in "${URLS[@]}"; do
STATUS=$(curl -o /dev/null -s -w "%{http_code}" "$SITE$url")
echo "$STATUS $SITE$url"
done
Скрипт логирует HTTP-статус каждого URL. Если что-то вернуло не 200 — видно сразу в логах Actions, деплой помечается как подозрительный.
Время прогрева зависит от количества постов. Сейчас — около 20 секунд на ~30 URL.
Визуализация пайплайна
GitHub Actions показывает граф джобов прямо в интерфейсе: видно что запущено, что ждёт, что упало. Для простой линейной цепочки этого достаточно.
Интереснее смотреть на тайминги в разрезе коммитов. В Settings → Actions есть метрики, но они базовые. Для нормального трекинга я вывожу итоговую сводку в $GITHUB_STEP_SUMMARY — она появляется на вкладке Summary каждого запуска:
bash
echo "## Deploy summary" >> $GITHUB_STEP_SUMMARY
echo "| Step | Duration |" >> $GITHUB_STEP_SUMMARY
echo "|------|----------|" >> $GITHUB_STEP_SUMMARY
echo "| Tests | ${TEST_TIME}s |" >> $GITHUB_STEP_SUMMARY
echo "| Build | ${BUILD_TIME}s |" >> $GITHUB_STEP_SUMMARY
echo "| Deploy | ${DEPLOY_TIME}s |" >> $GITHUB_STEP_SUMMARY
echo "| Warm | ${WARM_TIME}s |" >> $GITHUB_STEP_SUMMARY
Выглядит как простая таблица, но даёт мгновенный ответ на вопрос «почему деплой занял 5 минут вместо двух» — видно на каком шаге просадка.
Вывод лога при деплое:
out: ::group::📦 Git update
out: > macrulez-blog-nuxt@0.1.0 prepare
out: > husky install
err: husky - install command is DEPRECATED
out: added 978 packages in 19s
out: ::endgroup::
out: ::group::🔨 Building project
out: > macrulez-blog-nuxt@0.1.0 build
out: > nuxt build
out: ┌ Building Nuxt for production...
out: │
out: ● Nuxt 3.21.1 (with Nitro 2.13.1, Vite 7.3.1 and Vue 3.5.28)
out: │
out: ● Nitro preset: node-server
out: ℹ Building client...
out: ℹ vite v7.3.1 building client environment for production...
out: ℹ transforming...
out: ℹ ✓ 3975 modules transformed.
out: ℹ rendering chunks...
out: ℹ computing gzip size...
out: ℹ .nuxt/dist/client/manifest.json 61.00 kB │ gzip: 6.86 kB
out: ℹ .nuxt/dist/client/_nuxt/contact-image.DYyKJ5in.png 209.16 kB
out: ℹ .nuxt/dist/client/_nuxt/about-image.BCfFxlHI.png 259.57 kB
out: ℹ .nuxt/dist/client/_nuxt/blog-avatar.D8Nych3_.png 309.60 kB
out: ℹ .nuxt/dist/client/_nuxt/_tag_.Dt37dkH6.css 0.49 kB │ gzip: 0.27 kB
out: ℹ .nuxt/dist/client/_nuxt/login.CIaAWTPG.css 1.37 kB │ gzip: 0.59 kB
out: ℹ .nuxt/dist/client/_nuxt/about.Jt1W5ijn.css 1.50 kB │ gzip: 0.62 kB
out: ℹ .nuxt/dist/client/_nuxt/index.B3RS2Sfu.css 1.66 kB │ gzip: 0.68 kB
out: ℹ .nuxt/dist/client/_nuxt/error-500.I1Dtv2V5.css 1.88 kB │ gzip: 0.72 kB
out: ℹ .nuxt/dist/client/_nuxt/index.ArWbQgZx.css 2.13 kB │ gzip: 0.71 kB
out: ℹ .nuxt/dist/client/_nuxt/contact.D9Huk_36.css 2.44 kB │ gzip: 0.86 kB
out: ℹ .nuxt/dist/client/_nuxt/post-card.RjwGa90D.css 2.45 kB │ gzip: 0.88 kB
out: ℹ .nuxt/dist/client/_nuxt/error-404.DL_4WIao.css 3.53 kB │ gzip: 1.09 kB
out: ℹ .nuxt/dist/client/_nuxt/_slug_.0L87NiVY.css 3.70 kB │ gzip: 1.11 kB
out: ℹ .nuxt/dist/client/_nuxt/post-editor.CkdUSkcS.css 3.73 kB │ gzip: 1.10 kB
out: ℹ .nuxt/dist/client/_nuxt/default.DyKQEuwm.css 6.73 kB │ gzip: 1.71 kB
out: ℹ .nuxt/dist/client/_nuxt/entry.czOdnh4F.css 83.82 kB │ gzip: 17.35 kB
out: ℹ .nuxt/dist/client/_nuxt/BbNl6jr_.js 0.09 kB │ gzip: 0.10 kB
out: ℹ .nuxt/dist/client/_nuxt/DlAUqK2U.js 0.09 kB │ gzip: 0.10 kB
out: ℹ .nuxt/dist/client/_nuxt/CkUYLzSk.js 0.10 kB │ gzip: 0.11 kB
out: ℹ .nuxt/dist/client/_nuxt/Gi6I4Gst.js 0.15 kB │ gzip: 0.13 kB
out: ℹ .nuxt/dist/client/_nuxt/B1va3MmD.js 0.18 kB │ gzip: 0.15 kB
out: ℹ .nuxt/dist/client/_nuxt/FrcY3C94.js 0.21 kB │ gzip: 0.16 kB
out: ℹ .nuxt/dist/client/_nuxt/fhuzIZ2o.js 0.22 kB │ gzip: 0.20 kB
out: ℹ .nuxt/dist/client/_nuxt/DzgKki1a.js 0.25 kB │ gzip: 0.19 kB
out: ℹ .nuxt/dist/client/_nuxt/DbItnlRl.js 0.31 kB │ gzip: 0.24 kB
out: ℹ .nuxt/dist/client/_nuxt/CeQk0imn.js 0.33 kB │ gzip: 0.24 kB
out: ℹ .nuxt/dist/client/_nuxt/Br8PssjK.js 0.35 kB │ gzip: 0.25 kB
out: ℹ .nuxt/dist/client/_nuxt/CnLkB2KK.js 0.35 kB │ gzip: 0.25 kB
out: ℹ .nuxt/dist/client/_nuxt/Bms2fKNH.js 0.35 kB │ gzip: 0.26 kB
out: ℹ .nuxt/dist/client/_nuxt/58WOu5V3.js 0.41 kB │ gzip: 0.28 kB
out: ℹ .nuxt/dist/client/_nuxt/CjLU9Jfo.js 0.42 kB │ gzip: 0.34 kB
out: ℹ .nuxt/dist/client/_nuxt/C5gx4mTC.js 0.49 kB │ gzip: 0.35 kB
out: ℹ .nuxt/dist/client/_nuxt/C4LP7Hcl.js 0.61 kB │ gzip: 0.33 kB
...
...
out: ℹ .nuxt/dist/client/_nuxt/BsT-ppPF.js 81.66 kB │ gzip: 22.45 kB
out: ℹ .nuxt/dist/client/_nuxt/BWh-Nszc.js 97.76 kB │ gzip: 26.82 kB
out: ℹ .nuxt/dist/client/_nuxt/IKAAvmBR.js 97.77 kB │ gzip: 28.43 kB
out: ℹ .nuxt/dist/client/_nuxt/ZXZMmLxz.js 100.56 kB │ gzip: 33.63 kB
out: ℹ .nuxt/dist/client/_nuxt/B-h6JCHE.js 103.18 kB │ gzip: 46.07 kB
out: ℹ .nuxt/dist/client/_nuxt/Bx4MALJp.js 148.54 kB │ gzip: 41.99 kB
out: ℹ .nuxt/dist/client/_nuxt/BNtvGa-1.js 191.89 kB │ gzip: 72.02 kB
out: ℹ .nuxt/dist/client/_nuxt/DGN8GczM.js 261.23 kB │ gzip: 76.80 kB
out: ℹ .nuxt/dist/client/_nuxt/5J0xJHOV.js 441.64 kB │ gzip: 141.47 kB
out: ℹ .nuxt/dist/client/_nuxt/DSqlfwJV.js 452.69 kB │ gzip: 107.14 kB
out: ℹ .nuxt/dist/client/_nuxt/vjMZa8SK.js 490.50 kB │ gzip: 137.43 kB
out: ℹ .nuxt/dist/client/_nuxt/DXTABfx-.js 773.49 kB │ gzip: 257.59 kB
err: WARN
err: (!) Some chunks are larger than 500 kB after minification. Consider:
err: - Using dynamic import() to code-split the application
err: - Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
err: - Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
out: ℹ ✓ built in 1m 44s
out: ✔ Client built in 104342ms
out: ℹ Building server...
out: ℹ vite v7.3.1 building ssr environment for production...
out: ℹ transforming...
out: ℹ ✓ 186 modules transformed.
out: ℹ rendering chunks...
out: ℹ ✓ built in 14.24s
out: ✔ Server built in 14833ms
out: [nitro] ✔ Generated public .output/public
out: [nitro] ℹ Building Nuxt Nitro server (preset: node-server, compatibility date: 2026-03-13)
out: [nitro] ✔ Nuxt Nitro server built
out: ├─ .output/server/chunks/_/error-500.mjs (4.9 kB) (2.06 kB gzip)
out: ├─ .output/server/chunks/_/error-500.mjs.map (182 B) (154 B gzip)
out: ├─ .output/server/chunks/build/_id_-2mKg9QA4.mjs (4.61 kB) (1.48 kB gzip)
out: ├─ .output/server/chunks/build/_id_-2mKg9QA4.mjs.map (3.45 kB) (1.04 kB gzip)
out: ├─ .output/server/chunks/build/_id_-sK9yVLLB.mjs (2.23 kB) (985 B gzip)
out: ├─ .output/server/chunks/build/_id_-sK9yVLLB.mjs.map (1.53 kB) (585 B gzip)
out: ├─ .output/server/chunks/build/_plugin-vue_export-helper-1tPrXgE0.mjs (254 B) (201 B gzip)
out: ├─ .output/server/chunks/build/_plugin-vue_export-helper-1tPrXgE0.mjs.map (406 B) (248 B gzip)
out: ├─ .output/server/chunks/build/_slug_-BSsjX5lP.mjs (10.7 kB) (3.26 kB gzip)
out: ├─ .output/server/chunks/build/_slug_-BSsjX5lP.mjs.map (9.44 kB) (2.25 kB gzip)
out: ├─ .output/server/chunks/build/_tag_-CezjcFyT.mjs (5.8 kB) (1.8 kB gzip)
out: ├─ .output/server/chunks/build/_tag_-CezjcFyT.mjs.map (4.65 kB) (1.21 kB gzip)
out: ├─ .output/server/chunks/build/about-nxRjRYOc.mjs (5.35 kB) (1.62 kB gzip)
out: ├─ .output/server/chunks/build/about-nxRjRYOc.mjs.map (913 B) (326 B gzip)
...
...
out: ├─ .output/server/chunks/routes/api/drafts.get.mjs (1.44 kB) (679 B gzip)
out: ├─ .output/server/chunks/routes/api/drafts.get.mjs.map (1.39 kB) (372 B gzip)
out: ├─ .output/server/chunks/routes/api/posts.post.mjs (1.13 kB) (583 B gzip)
out: ├─ .output/server/chunks/routes/api/posts.post.mjs.map (1.01 kB) (309 B gzip)
out: ├─ .output/server/chunks/routes/api/posts/_id_.mjs (5.62 kB) (1.46 kB gzip)
out: ├─ .output/server/chunks/routes/api/posts/_id_.mjs.map (5.97 kB) (860 B gzip)
out: ├─ .output/server/chunks/routes/api/uploads.post.mjs (2.23 kB) (908 B gzip)
out: ├─ .output/server/chunks/routes/api/uploads.post.mjs.map (2.2 kB) (479 B gzip)
out: ├─ .output/server/chunks/routes/renderer.mjs (18.2 kB) (6.1 kB gzip)
out: ├─ .output/server/chunks/routes/renderer.mjs.map (751 B) (263 B gzip)
out: ├─ .output/server/chunks/virtual/_virtual_spa-template.mjs (94 B) (100 B gzip)
out: ├─ .output/server/chunks/virtual/_virtual_spa-template.mjs.map (90 B) (98 B gzip)
out: ├─ .output/server/index.mjs (353 B) (204 B gzip)
out: └─ .output/server/package.json (889 B) (388 B gzip)
out: Σ Total size: 2.88 MB (724 kB gzip)
out: [nitro] ✔ You can preview this build using node .output/server/index.mjs
out: │
out: └ ✨ Build complete!
out: ::endgroup::
out: ::group::🔄 PM2 restart
out: [PM2] Applying action deleteProcessId on app [blog.macrulez.ru](ids: [ 28 ])
out: [PM2] [blog.macrulez.ru](28) ✓
out: ┌────┬──────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
out: │ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │
out: ├────┼──────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
out: │ 29 │ macrulez.ru │ default │ 0.0.0 │ fork │ 1140573 │ 2h │ 0 │ online │ 0% │ 11.3mb │ root │ disabled │
out: │ 18 │ retrosteam.ru │ default │ 0.0.0 │ fork │ 558476 │ 20D │ 0 │ online │ 0% │ 8.4mb │ root │ disabled │
out: └────┴──────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
out: [PM2] Starting /var/www/blog.macrulez.ru/.output/server/index.mjs in fork_mode (1 instance)
out: [PM2] Done.
out: ┌────┬─────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
out: │ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │
out: ├────┼─────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
out: │ 30 │ blog.macrulez.ru │ default │ 0.1.0 │ fork │ 1142034 │ 0s │ 0 │ online │ 0% │ 27.4mb │ root │ disabled │
out: │ 29 │ macrulez.ru │ default │ 0.0.0 │ fork │ 1140573 │ 2h │ 0 │ online │ 0% │ 11.3mb │ root │ disabled │
out: │ 18 │ retrosteam.ru │ default │ 0.0.0 │ fork │ 558476 │ 20D │ 0 │ online │ 0% │ 8.4mb │ root │ disabled │
out: └────┴─────────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
out: [PM2] Saving current process list...
out: [PM2] Successfully saved in /root/.pm2/dump.pm2
out: ::endgroup::
out: ::group::🔥 Warming up cache
out: ⏳ Waiting for server to start...
out: 🔥 Warming up: https://blog.macrulez.ru/
out: ✅ https://blog.macrulez.ru/ - OK
out: 🔥 Warming up: https://blog.macrulez.ru/about
out: ✅ https://blog.macrulez.ru/about - OK
out: 🔥 Warming up: https://blog.macrulez.ru/contact
out: ✅ https://blog.macrulez.ru/contact - OK
out: ::endgroup::
out: ::group::📊 Final status
out: ┌────┬─────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
out: │ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │
out: ├────┼─────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
out: │ 30 │ blog.macrulez.ru │ default │ 0.1.0 │ fork │ 1142034 │ 6s │ 0 │ online │ 0% │ 88.6mb │ root │ disabled │
out: │ 29 │ macrulez.ru │ default │ 0.0.0 │ fork │ 1140573 │ 2h │ 0 │ online │ 0% │ 16.2mb │ root │ disabled │
out: │ 18 │ retrosteam.ru │ default │ 0.0.0 │ fork │ 558476 │ 20D │ 0 │ online │ 0% │ 8.2mb │ root │ disabled │
out: └────┴─────────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
out: 🎉 Deploy completed!
out: ::endgroup::
==============================================
✅ Successfully executed commands to all host.
==============================================
Run npm ci --no-audit --no-fund
npm warn deprecated glob@10.5.0: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
> macrulez-blog-nuxt@0.1.0 prepare
> husky install
husky - install command is DEPRECATED
added 978 packages in 16s
> macrulez-blog-nuxt@0.1.0 test:ci
> vitest run --run && node ./scripts/show-test-summary.js
RUN v1.6.1 /home/runner/work/macrulez-blog/macrulez-blog
stdout | tests/integration/posts.spec.ts > integration: posts list and first post page > fetches posts list and opens first post page
[test-summary] apiList=***/macrulez-blog/posts, items=10, first=2d-rezhim-na-three-js-i-vsplyvayushchiy-spisok-aviakompaniy, frontend=skipped
stderr | tests/integration/posts.spec.ts > integration: posts list and first post page > fetches posts list and opens first post page
✓ tests/integration/posts.spec.ts (1 test) 2338ms
[test-summary] apiList=***/macrulez-blog/posts, items=10, first=2d-rezhim-na-three-js-i-vsplyvayushchiy-spisok-aviakompaniy, frontend=skipped
✓ tests/example.spec.ts (1 test) 3ms
Test Files 2 passed (2)
Tests 2 passed (2)
Start at 16:38:59
Duration 2.88s (transform 71ms, setup 1ms, collect 85ms, tests 2.34s, environment 0ms, prepare 167ms)
Test Summary
┌────────────┬────────────────────────────────────────────────────────────────┐
│ API │ ***/macrulez-blog/posts │
├────────────┼────────────────────────────────────────────────────────────────┤
│ Items │ 10 │
├────────────┼────────────────────────────────────────────────────────────────┤
│ First │ 2d-rezhim-na-three-js-i-vsplyvayushchiy-spisok-aviakompaniy │
├────────────┼────────────────────────────────────────────────────────────────┤
│ Frontend │ skipped │
└────────────┴────────────────────────────────────────────────────────────────┘
Run: npm run test:ci
Итог
Весь пайплайн от пуша до прогретого кеша занимает около 2.5 минут. Тесты — 40 секунд, сборка — 50 секунд, деплой — 15 секунд, прогрев — 20 секунд, накладные расходы Actions — остальное.
Главное что поменялось после автоматизации — деплой перестал быть событием. Пуш в master и через пару минут всё готово. Нет ритуала «сначала запущу тесты, потом соберу, потом залью», нет забытых шагов, нет ручных ошибок.
Кеш-прогрев оказался неожиданно важной частью — первые недели после запуска я его не делал и несколько раз замечал просадку метрик сразу после деплоя. Теперь первым посетителем после выкладки всегда является сам пайплайн.