Частина посилань на цій сторінці партнерські: якщо ви зареєструєтесь через них, сайт отримає комісію, для вас ціна не змінюється. На ціни й рекомендації це не впливає.

Кожен туторіал «як задеплоїти статичний сайт з GitHub Actions» закінчується однаково: згенеруй SSH-ключ, встав приватну половину в секрети репозиторію, зроби rsync збірки на сервер. Воно працює з першого разу - і це й є проблема. Щойно ви створили інтерактивний обліковий запис на своєму сервері, і тримає його система, яка виконує код із пул-реквестів.

Ця стаття - та сама схема, але з запобіжниками: ключ, який уміє рівно одну річ, host key, який ви запінили самі, і три перевірки, що не дають зламаній збірці затерти робочий сайт. Саме цей конвеєр возить цей блог, і він коштує хвилин пʼятнадцять додаткової роботи.

Серверна частина припускає, що машина у вас є. Якщо ні - дешеві тарифи ми порівняли в найкращому VPS для самохостингу.

Що насправді дає наївний деплой-ключ

Рядок у ~/.ssh/authorized_keys без опцій - це логін. Хто тримає приватний ключ, може:

  • відкрити інтерактивну оболонку від імені цього користувача;
  • прочитати все, що читає цей користувач, включно з іншими проєктами на машині й будь-яким .env, що там лежить;
  • виконати sudo, якщо він у користувача є;
  • прокидати порти і використати ваш сервер як перевалочний вузол усередину всього, куди той дістає.

Нічого з цього не потрібно, щоб скопіювати теку з HTML. А приватний ключ живе в CI, де зміна воркфлоу, скомпрометована стороння дія або неправильно налаштований тригер pull_request_target можуть його прочитати.

Ми тримали рівно таку схему місяцями, не замислюючись, - і це чесна причина, чому ця стаття взагалі існує.

Привʼяжіть ключ до однієї команди

OpenSSH дозволяє навішувати опції на кожен ключ в authorized_keys. Головна з них - command=, яка підміняє те, що попросив клієнт, тим, що вказали ви:

command="/usr/bin/rrsync -wo /home/deploy/site",restrict ssh-ed25519 AAAA... github-actions

По частинах:

  • rrsync постачається разом з rsync (/usr/bin/rrsync у Debian і Ubuntu; у старіших випусках це може бути стиснутий скрипт у /usr/share/doc/rsync/scripts/). Це обгортка, яка приймає лише серверний виклик rsync і відхиляє решту.
  • -wo означає «тільки запис»: ключ може заливати в теку і не може з неї викачувати. Якщо ваш деплой лише пушить, беріть суворіший варіант.
  • Шлях у кінці - це вʼязниця. Усе поза ним відхиляється, включно з хитрощами через ...
  • restrict одним словом вимикає прокидання портів, forwarding агента, X11 і виділення PTY. На будь-якому OpenSSH останніх років це краще, ніж перелічувати no-port-forwarding,no-agent-forwarding,... руками.

Перевіряйте прямо, зі свого ноутбука з цим приватним ключем:

ssh -i deploy_key deploy@your-server 'id'      # має впасти
rsync -rltz -e "ssh -i deploy_key" ./public/ deploy@your-server:/   # має спрацювати

Падіння першої команди - це і є весь сенс. Якщо вона друкує ідентифікатор користувача, опції не застосувалися: перевірте, що весь рядок в одну лінію і що він починається з command=, а не з типу ключа.

Зверніть увагу на шлях призначення в rsync: він рахується від вʼязниці, тож / означає ту теку, до якої ви ключ привʼязали.

Запініть host key, а не вимикайте перевірку

Друга половина туторіального скорочення - StrictHostKeyChecking=no, тобто вимкнена перевірка, що сервер, на який ви відправляєте, це той сервер, про який ви думаєте. Замість цього запініть його. На сервері:

ssh-keyscan -t ed25519 your-server.example

Покладіть цей рядок у секрет репозиторію і запишіть його в known_hosts усередині джоби. Тепер зміна host key валить деплой голосно, а не тихо відправляє ваш сайт тому, хто відповів за цією адресою.

Воркфлоу

name: Build and deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: deploy
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          submodules: recursive

      - name: Build
        run: your-build-command

      - name: Guard against an empty build
        run: |
          set -euo pipefail
          test -s public/index.html
          test -s public/sitemap.xml

      - name: Deploy over rsync
        env:
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
          DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
          DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
        run: |
          set -euo pipefail
          mkdir -p ~/.ssh
          printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
          rsync -rltz --delete --omit-dir-times \
            -e "ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o UserKnownHostsFile=~/.ssh/known_hosts" \
            public/ "$DEPLOY_USER@$DEPLOY_HOST:/"

      - name: Smoke test
        run: |
          set -euo pipefail
          for url in https://example.com/ https://example.com/sitemap.xml; do
            code=$(curl -s -o /dev/null -w '%{http_code}' --retry 3 --retry-delay 5 "$url")
            echo "$url -> $code"
            test "$code" = "200"
          done

Частини, які тут не для краси:

concurrency без cancel-in-progress. Два деплої одночасно в одну теку з --delete - це спосіб віддати відвідувачам наполовину скопійований сайт. Черга не коштує нічого.

Захист від порожньої збірки. Саме --delete перетворює rsync з заливання на деплой, і він же перетворює збірку, яка мовчки нічого не зібрала, на видалений сайт. Три рядки test -s дешевші за такий простій. Перевіряйте файли, які мусять існувати, і хоча б один, що зʼявляється тільки коли збірка справді відпрацювала.

IdentitiesOnly=yes. Без цього ssh може спершу запропонувати інші ключі з агента і дістати відмову, не дійшовши до вашого.

--omit-dir-times. У вʼязниці «тільки запис» спроби rsync виставити час модифікації тек можуть шумно падати. Це прибирає шум, нічого не послаблюючи.

Смоук-тест ходить на публічну адресу, а не на сервер по ssh. Це єдиний крок, який доводить, що результат бачить відвідувач: він ловить мертвий контейнер, протермінований сертифікат і проксі, що дивиться не в ту теку, - а rsync про це нічого не знає.

Від чого це захищає, а від чого ні

Захищає від реалістичного сценарію: витік секрету CI перетворюється на доступ до оболонки. Ключ пише в одну теку і більше нікуди, не читає назад, не прокидає порти, не відкриває термінал.

Не захищає від того, хто може змінити файл воркфлоу, бо він підмінить сам вміст, який ви відправляєте. Якщо вам дорогий сайт, захистіть гілку: вимагайте рев’ю і тримайте деплой тільки на push у гілку за замовчуванням, ніколи на pull_request_target, який виконує код форка з вашими секретами.

Не захищає і сама тека. Усе, що віддається з цього шляху, ключ може перезаписати. Тримайте ціль деплою окремо від конфігів, томів з даними і решти, що є на сервері.

Ретрофіт, по кроках

Якщо необмежений ключ у вас уже є, виправлення займає кілька хвилин:

  1. Перевірте, що rrsync на сервері є: ls -l /usr/bin/rrsync.
  2. Відредагуйте authorized_keys і додайте command="/usr/bin/rrsync -wo /шлях/до/сайту",restrict перед наявним рядком ключа.
  3. З ноутбука з цим ключем переконайтеся, що ssh ... 'id' падає, а тестовий rsync проходить.
  4. Перезапустіть воркфлоу через workflow_dispatch і подивіться, що смоук-тест зелений.
  5. Тільки після цього видаліть резервну копію старого рядка.

Заодно поміняти ключ - розумна звичка, особливо якщо він колись потрапляв у логи.

Далі

Серверна частина тієї самої зв’язки: Caddy і Docker у продакшні - автоматичний HTTPS, кілька сайтів на одній машині й заголовки кешу, від яких залежить, чи побачать читачі щойно опубліковану сторінку.