Илья Овсенев
← Все статьи

Содержание

  1. Исходные данные
  2. 1. Схема и модель угроз
  3. 2. Пользователь для деплоя
  4. 3. Ключ для CI
  5. 4. Ограниченный ключ в authorized_keys
  6. 5. Папка сайта: группа и setgid
  7. 6. Проверка ограничений до настройки CI
  8. 7. Отпечаток сервера
  9. 8. Секреты GitHub
  10. 9. Workflow
  11. 10. Инцидент: --chmod затёр setgid
  12. 11. Первый деплой и проверка
  13. 12. Диагностика
  14. Итог: чеклист

Деплой статики из GitHub Actions по SSH с ограниченным ключом

Шпаргалка по автодеплою статического сайта на свой сервер, который уже настроен по статье о первичной настройке и защите и отдаёт сайт через Caddy (статья о reverse proxy). Что внутри: отдельный пользователь для деплоя, ключ, который умеет только записывать файлы в одну папку, права на папку сайта, секреты GitHub, workflow с проверкой выкатившейся версии — и инцидент, когда зелёный деплой тихо сломал права. Это не экспертный гайд, а то, что я сам сделал и на чём споткнулся.

Исходные данные

  • Сервер: Ubuntu 26.04, rsync 3.4.1 (с ним идёт /usr/bin/rrsync), пользователь demo с sudo, вход только по ключу, UFW, fail2ban.
  • Caddy отдаёт статику example.com из /var/www/example.
  • Репозиторий на GitHub, исходники сайта — в папке site/ (у меня Astro, сборка на Node 22; для другой сборки меняются только шаги сборки).
  • Локальная машина — Windows, команды на ней — в PowerShell 7.3+ (pwsh).

example.com, <IP-сервера> и /var/www/example подставляем свои.

💡 Команды на сервере выполняем от demo, где нужен root — с sudo.


1. Схема и модель угроз

Зачем этот раздел: чтобы GitHub Actions мог выкладывать сайт, у него должен быть SSH-ключ от сервера. Этот ключ лежит в секретах GitHub, и его нужно считать уже утёкшим: секрет можно случайно вывести в лог, после записи на диск раннера его прочитает любой следующий шаг, а изменить workflow может любой с правом записи в репозиторий. Задача — сделать так, чтобы утечка ключа давала минимум: только запись в одну папку, без shell и без чтения.

Как работает выкатка:

git tag v1.2 && git push origin v1.2
   ↓
GitHub Actions: npm ci → astro check → build → version.txt → проверка сборки
   ↓
rsync по SSH с ключом id_deploy  →  deployer@сервер
   ↓
sshd: ключ разрешает только command="rrsync -wo -munge /var/www/example"
   ↓
rrsync проверяет команду и пути → настоящий rsync пишет только в /var/www/example
   ↓
setgid на папке: файлы получают группу caddy → Caddy их читает
   ↓
проверка: https://example.com/version.txt == то, что собрали

Что получит тот, кто украдёт ключ:

ЗащитаЧто отнимает у утёкшего ключа
Отдельный пользователь deployer без sudo и dockerПрава root и доступ к остальным файлам demo
command="rrsync …" в authorized_keysShell и любые команды, кроме rsync
-wo у rrsyncЧтение файлов с сервера
-munge у rrsyncЧтение через сайт по залитым симлинкам
Корень песочницы /var/www/exampleЗапись куда-либо ещё
restrictПроброс портов, агента, X11, терминал

Остаётся перезаписать сайт — в том числе подложить свой контент под нашим доменом и HTTPS — или забить диск. Неприятно, но это лечится повторным деплоем и отзывом ключа, а не переустановкой сервера.


2. Пользователь для деплоя

Зачем: деплоить от demo нельзя — у него sudo и группа docker, то есть фактически root (почему docker = root — в статье про Docker, раздел 2). Отдельный пользователь без прав — первая граница.

sudo adduser --disabled-password --comment "" deployer
  • --disabled-password — без пароля: войти можно только по ключу;
  • --comment "" — не спрашивать имя, телефон и прочие поля.

Проверка групп:

id deployer
  • ✅ uid=1001(deployer) gid=1001(deployer) groups=1001(deployer),100(users) — ни sudo, ни docker
  • ❌ Есть 27(sudo) или docker → утёкший ключ = root на сервере, убрать: sudo deluser deployer sudo, sudo deluser deployer docker

💡 Shell у deployer остаётся обычный (/bin/bash), не nologin: sshd запускает команду из authorized_keys через shell пользователя, с nologin rsync не запустится. Ограничение даёт command=, а не отсутствие shell.


3. Ключ для CI

Зачем: отдельный ключ только для деплоя. Его можно отозвать, не трогая свой ключ входа на сервер.

На локальной машине:

ssh-keygen -t ed25519 -C "github-actions-deploy" -f $env:USERPROFILE\.ssh\id_deploy -N '' -q
  • -t ed25519 — тип ключа, как в статье о первичной настройке;
  • -C — комментарий, по нему ключ узнаётся в authorized_keys;
  • -f — куда сохранить: появятся id_deploy (приватный) и id_deploy.pub (публичный);
  • -N '' — пустая passphrase: CI не сможет её ввести;
  • -q — без лишнего вывода.

⚠️ -N '' — для PowerShell 7.3+ (pwsh). Встроенный Windows PowerShell 5.1 пустую строку программе не передаёт: -N заберёт следующий аргумент -q, и ключ получится с паролем -q. В 5.1 пишут -N '""' — а в 7.3+ это, наоборот, пароль "". Версия — $PSVersionTable.PSVersion. Ошибку в любом случае поймает проверка ниже.

Проверка, что пароля нет:

ssh-keygen -y -f $env:USERPROFILE\.ssh\id_deploy

-y — вывести публичную часть по приватному ключу.

  • ✅ Сразу печатает ssh-ed25519 AAAA… github-actions-deploy
  • ❌ Спрашивает Enter passphrase → ключ зашифрован, удалить оба файла и сгенерировать заново

💡 Пустая passphrase здесь — осознанно: защита строится на ограничениях в authorized_keys (раздел 4), а не на пароле ключа. Пароль всё равно пришлось бы положить в те же секреты GitHub рядом с ключом.

Копируем публичный ключ в буфер — он понадобится в разделе 4:

Get-Content $env:USERPROFILE\.ssh\id_deploy.pub -Raw | Set-Clipboard

4. Ограниченный ключ в authorized_keys

Зачем: обычная строка в authorized_keys даёт владельцу ключа полноценный вход. Опции перед ключом сужают его до одной команды.

rrsync — скрипт-обёртка из пакета rsync: sshd запускает его вместо любой команды, которую прислал клиент, а он пропускает только rsync и только внутри заданной папки. Проверяем, что он есть:

ls -l /usr/bin/rrsync
  • ✅ -rwxr-xr-x 1 root root … /usr/bin/rrsync
  • ❌ No such file or directory → sudo apt install rsync

Создаём папку для ключей deployer:

sudo mkdir -p /home/deployer/.ssh
sudo chmod 700 /home/deployer/.ssh

700 — доступ только владельцу: если в папку могут писать группа или остальные, sshd ключи не примет.

Записываем ключ с ограничениями. Вместо ssh-ed25519 AAAA… вставляем содержимое буфера из раздела 3:

sudo tee /home/deployer/.ssh/authorized_keys > /dev/null << 'EOF'
command="/usr/bin/rrsync -wo -munge /var/www/example",restrict ssh-ed25519 AAAA… github-actions-deploy
EOF

⚠️ Всё одной строкой. Перенос разорвёт запись: ключ либо не сработает, либо — хуже — окажется на отдельной строке и сработает без ограничений.

ЧастьЧто даёт
command="…"Принудительная команда: что бы клиент ни прислал, sshd запустит её. Запрос клиента попадает в переменную SSH_ORIGINAL_COMMAND, и rrsync его проверяет
-wowrite-only: на сервер писать можно, читать с сервера через rsync нельзя
-mungeЗалитые симлинки портятся (rsync --munge-links: к цели дописывается префикс /rsyncd-munged/) и никуда не ведут, пока на сервере нет папки /rsyncd-munged
/var/www/exampleКорень песочницы: пути клиента считаются от него, выйти выше нельзя
restrictОтключает проброс портов и агента, X11, терминал (PTY) — и всё, что добавят в будущих версиях OpenSSH

⚠️ Без -munge ключ даёт чтение через сайт. -wo запрещает читать через rsync, но залить симлинк он не мешает. Залили x -> /etc/… или на приватный ключ TLS в данных Caddy — и скачали через https://example.com/x: Caddy идёт по симлинку и отдаёт всё, что может прочитать пользователь caddy. Проверено на демо-сервере: симлинк на /etc/hostname в папке сайта отдаётся снаружи с 200 OK. В сборке статики симлинков обычно нет, деплою -munge не мешает.

💡 restrict лучше списка no-port-forwarding,no-agent-forwarding,…: список придётся дополнять с каждой новой возможностью OpenSSH, а restrict запрещает и их.

Отдаём папку владельцу и закрываем файл:

sudo chown -R deployer:deployer /home/deployer/.ssh
sudo chmod 600 /home/deployer/.ssh/authorized_keys
sudo wc -l /home/deployer/.ssh/authorized_keys
  • chown -R — владелец deployer: файлы создавались через sudo и принадлежат root, sshd такие ключи не примет;
  • 600 — читать и писать может только владелец;
  • wc -l — сколько строк в файле.
  • ✅ 1 /home/deployer/.ssh/authorized_keys
  • ❌ 2 и больше → запись разорвалась, переписать файл

Посмотреть ограничения, не выводя ключ целиком:

sudo cut -c1-60 /home/deployer/.ssh/authorized_keys

cut -c1-60 — только первые 60 символов строки.

  • ✅ command="/usr/bin/rrsync -wo -munge /var/www/example",restri

5. Папка сайта: группа и setgid

Зачем: файлы, которые зальёт deployer, должен читать Caddy. Обычно новый файл получает группу того, кто его создал, — то есть deployer. Caddy в эту группу не входит, и при строгих правах получит 403.

setgid на папке меняет правило: новые файлы и папки внутри получают группу самой папки, а не создателя. Отдаём папку группе caddy — и всё, что зальёт deployer, Caddy сможет читать.

sudo chown -R deployer:caddy /var/www/example
sudo find /var/www/example -type d -exec chmod 2775 {} \;
ls -ld /var/www/example
  • chown -R deployer:caddy — владелец deployer (он пишет), группа caddy (Caddy читает);
  • find … -type d -exec chmod 2775 {} \; — всем папкам, включая саму /var/www/example, выставить 2775: 2 — setgid, 775 — владелец и группа пишут, остальные читают;
  • ls -ld — права самой папки, а не её содержимого.
  • ✅ drwxrwsr-x … deployer caddy … /var/www/example — s вместо x в правах группы
  • ❌ drwxrwxr-x → setgid не встал, повторить find

💡 Тестовый index.html из статьи о reverse proxy трогать не нужно: первый деплой заменит его, --delete удалит лишнее.


6. Проверка ограничений до настройки CI

Зачем проверять: отладка на стороне GitHub мучительна: каждая попытка — это коммит, пуш и ожидание раннера. Ограничения проверяем руками с локальной машины, пока всё под рукой.

На локальной машине, по очереди.

Ключ принимается:

ssh -i $env:USERPROFILE\.ssh\id_deploy -o IdentitiesOnly=yes deployer@<IP-сервера> true; $LASTEXITCODE
  • -i — подключиться этим ключом;
  • -o IdentitiesOnly=yes — предлагать серверу только его. Без опции ssh переберёт ключи из ~/.ssh и упрётся в MaxAuthTries 3 из статьи о первичной настройке;
  • true — rrsync отдельно разрешает эту команду для проверки связи;
  • $LASTEXITCODE — код выхода последней команды.
  • ✅ 0
  • ❌ Permission denied (publickey) → на сервере не тот ключ: сравнить ssh-keygen -y -f …\id_deploy с authorized_keys
  • ❌ Enter passphrase → ключ с паролем, раздел 3

Shell не даётся:

ssh -i $env:USERPROFILE\.ssh\id_deploy -o IdentitiesOnly=yes deployer@<IP-сервера>
  • ✅ PTY allocation request failed on channel 0 и /usr/bin/rrsync error: Not invoked via sshd, соединение закрывается
  • ❌ Приглашение deployer@…:~$ → command= не сработал, утёкший ключ давал бы shell. Проверить, что запись в authorized_keys одной строкой

Произвольная команда отклоняется:

ssh -i $env:USERPROFILE\.ssh\id_deploy -o IdentitiesOnly=yes deployer@<IP-сервера> "cat /etc/passwd"
  • ✅ /usr/bin/rrsync error: SSH_ORIGINAL_COMMAND does not run rsync
  • ❌ Выводится содержимое файла → ограничение не работает

💡 Сам rsync с Windows не проверить: в Git Bash его нет. Это не блокер — на раннере ubuntu-latest rsync есть всегда, проверит первый деплой.


7. Отпечаток сервера

Зачем: при первом подключении ssh проверяет, что сервер — тот самый, по его ключу хоста (отпечатку). Человек отвечает yes на вопрос, а в CI отвечать некому: отпечаток нужно заранее положить в known_hosts раннера. Иначе остаётся либо падать, либо отключать проверку — и тогда CI отдаст выгрузку любому, кто ответит по этому адресу. Сам ключ так не утечёт: при входе по ключу приватная часть на сервер не уходит.

На сервере:

ssh-keyscan -t ed25519 <IP-сервера> 2>/dev/null
  • ssh-keyscan — подключиться к SSH-серверу и вывести его ключ хоста в формате known_hosts;
  • -t ed25519 — только ключ этого типа;
  • 2>/dev/null — убрать служебные строки # … SSH-2.0-OpenSSH….
  • ✅ Одна строка <IP-сервера> ssh-ed25519 AAAA… — её и сохраняем для секрета DEPLOY_KNOWN_HOSTS

⚠️ Снимать на самом сервере, а не с раннера и не через интернет. ssh-keyscan по сети вернёт ключ того, кто ответил по этому адресу, — та же подмена, от которой отпечаток должен защищать. На сервере запрос к своему же адресу никуда не уходит.

⚠️ Адрес в начале строки должен совпадать с DEPLOY_HOST (раздел 8). Подключаемся по домену — снимаем ssh-keyscan -t ed25519 example.com. SSH на нестандартном порту — добавляем -p ПОРТ, адрес в строке станет [адрес]:ПОРТ.


8. Секреты GitHub

Зачем: ключ, адрес и путь не должны лежать в репозитории. Секреты GitHub отдаются workflow при запуске и маскируются в логах.

Где: репозиторий → Settings → Secrets and variables → Actions → Repository secrets → New repository secret.

⚠️ Repository, не Environment. Секреты окружения (Environment secrets) видны только job с environment: — в нашем workflow его нет, и они просто не подхватятся.

СекретЗначение
DEPLOY_KEYПриватный ключ id_deploy целиком, со строками -----BEGIN… и -----END…
DEPLOY_KNOWN_HOSTSСтрока из ssh-keyscan (раздел 7)
DEPLOY_HOST<IP-сервера> или домен — тот же, что в DEPLOY_KNOWN_HOSTS
DEPLOY_USERdeployer
DEPLOY_PATH/
DEPLOY_URLhttps://example.com
DEPLOY_PORTпорт SSH; не задан — 22

⚠️ DEPLOY_PATH = / — не опечатка. rrsync считает пути относительно корня из authorized_keys: / для клиента — это /var/www/example на сервере. Указать другую папку клиент не может, в этом и смысл. Без rrsync здесь был бы полный путь.

💡 Несекретные значения (DEPLOY_HOST, DEPLOY_USER, DEPLOY_PATH, DEPLOY_URL, DEPLOY_PORT) тоже маскируются: в логе будет *** отдал 200 вместо адреса, а / и deployer превратятся в *** везде, где встретятся. Если мешает — их можно перенести во вкладку Variables и обращаться через ${{ vars.DEPLOY_URL }}; секретами остаются DEPLOY_KEY и DEPLOY_KNOWN_HOSTS.

Копируем приватный ключ в буфер:

Get-Content $env:USERPROFILE\.ssh\id_deploy -Raw | Set-Clipboard

-Raw — скопировать файл как есть, одной строкой с его переводами строк. Без него PowerShell читает файл построчно, и Set-Clipboard склеит строки через CRLF: ключ не сломается, но в буфере будет уже не тот файл.

💡 GitHub маскирует секреты в логах (***) по точному совпадению строки. Если значение преобразовать (base64, обрезка, часть строки), маскирование не сработает. Поэтому значение секрета в скрипте не преобразуем перед выводом.


9. Workflow

Зачем: файл .github/workflows/deploy.yml в репозитории описывает, что делает GitHub Actions: собрать сайт, проверить сборку, выгрузить на сервер и убедиться, что выкатилось именно то, что собрали.

Файл целиком, разбор — ниже:

name: Сборка и деплой

on:
  push:
    tags: ["v*"]
  workflow_dispatch:

permissions:
  contents: read

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

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v6

      - uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: npm
          cache-dependency-path: site/package-lock.json

      - name: Установить зависимости
        working-directory: site
        run: npm ci

      - name: Проверить типы
        working-directory: site
        run: npx --yes astro check

      - name: Собрать
        working-directory: site
        run: npm run build

      - name: Записать версию
        id: version
        env:
          REF_TYPE: ${{ github.ref_type }}
          REF_NAME: ${{ github.ref_name }}
        run: |
          if [ "$REF_TYPE" = "tag" ]; then
            version="$REF_NAME"
          else
            version="$(git rev-parse --short HEAD)"
          fi
          printf '%s\n' "$version" > site/dist/version.txt
          echo "value=$version" >> "$GITHUB_OUTPUT"
          echo "версия: $version"

      - name: Проверить сборку
        run: |
          test -f site/dist/index.html  || { echo "нет главной";      exit 1; }
          test -f site/dist/404.html    || { echo "нет страницы 404"; exit 1; }
          test -f site/dist/version.txt || { echo "нет метки версии"; exit 1; }
          echo "файлов в сборке: $(find site/dist -type f | wc -l)"

      - name: Настроить SSH
        env:
          KEY: ${{ secrets.DEPLOY_KEY }}
          KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
        run: |
          test -n "$KEY"         || { echo "DEPLOY_KEY не задан";         exit 1; }
          test -n "$KNOWN_HOSTS" || { echo "DEPLOY_KNOWN_HOSTS не задан"; exit 1; }
          mkdir -p ~/.ssh && chmod 700 ~/.ssh
          printf '%s\n' "$KEY" > ~/.ssh/id_deploy
          chmod 600 ~/.ssh/id_deploy
          printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
          chmod 600 ~/.ssh/known_hosts

      - name: Выгрузить на сервер
        env:
          HOST: ${{ secrets.DEPLOY_HOST }}
          USER: ${{ secrets.DEPLOY_USER }}
          PATH_ON_SERVER: ${{ secrets.DEPLOY_PATH }}
          PORT: ${{ secrets.DEPLOY_PORT }}
        run: |
          test -n "$HOST"           || { echo "DEPLOY_HOST не задан"; exit 1; }
          test -n "$USER"           || { echo "DEPLOY_USER не задан"; exit 1; }
          test -n "$PATH_ON_SERVER" || { echo "DEPLOY_PATH не задан"; exit 1; }
          rsync -rlptzv --delete \
            --chmod=D2775,F644 \
            -e "ssh -i ~/.ssh/id_deploy -p ${PORT:-22}" \
            site/dist/ \
            "$USER@$HOST:$PATH_ON_SERVER"

      - name: Проверить, что выкатилась нужная версия
        env:
          URL: ${{ secrets.DEPLOY_URL }}
          EXPECTED: ${{ steps.version.outputs.value }}
        run: |
          test -n "$URL" || { echo "DEPLOY_URL не задан"; exit 1; }
          url="${URL%/}"
          code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 20 "$url/")
          echo "$url отдал $code"
          test "$code" = "200" || exit 1
          actual=$(curl -s --max-time 20 "$url/version.txt" | tr -d '[:space:]')
          echo "ожидали: $EXPECTED, получили: $actual"
          test "$actual" = "$EXPECTED"

9.1. Когда запускается: тег или кнопка

on:
  push:
    tags: ["v*"]
  workflow_dispatch:
  • push: tags: ["v*"] — запуск, когда в репозиторий пришёл тег, начинающийся с v;
  • workflow_dispatch — кнопка Run workflow во вкладке Actions.
ДействиеЧто происходит
git push origin mainНичего не деплоится
git tag v1.2 + git push origin v1.2Релизная выкатка, версия v1.2
Actions → Сборка и деплой → Run workflowПромежуточная выкатка, версия — короткий хеш коммита (при запуске на ветке)

Зачем без автодеплоя на каждый пуш: выкатка — осознанное действие. Пуш незаконченной правки в main не должен сразу оказываться на сайте.

Тег — именованная метка на коммите. Живёт отдельно от веток и не уходит при обычном git push:

git tag v1.0              # создать на текущем коммите
git push origin v1.0      # отправить на GitHub — это и запускает деплой
git tag                   # список тегов
git tag -d v1.0           # удалить локально
git push origin :v1.0     # удалить на GitHub
  • ❌ src refspec v1.0 does not match any → тег не создан локально
  • ❌ 'v1.0' does not appear to be a git repository → пропущен origin: синтаксис git push <remote> <что>

⚠️ Правку workflow сначала закоммитить и запушить, потом ставить тег. Тег указывает на коммит, и GitHub берёт workflow из него: тег на старом коммите выкатит старую версию файла.

9.2. Права и очередь

permissions:
  contents: read

concurrency:
  group: deploy
  cancel-in-progress: false
  • permissions: contents: read — токен GITHUB_TOKEN, который получает workflow, может только читать репозиторий. По умолчанию права берутся из настроек репозитория и могут быть шире, чем нужно деплою;
  • concurrency: group: deploy — две выкатки одновременно не идут: вторая ждёт первую;
  • cancel-in-progress: false — текущую не отменять: прерванный rsync оставит сайт наполовину обновлённым.

💡 Даже без concurrency второй rsync не пройдёт: rrsync держит блокировку на папку и ответит Another instance of rrsync is already accessing this directory. Но лучше, чтобы выкатка ждала, а не падала.

💡 Ждать в группе может только один запуск: новый отменяет ожидающий. Пушим v1.2 и v1.3, пока идёт v1.1, — выкатится v1.3, а v1.2 пропустится.

9.3. Сборка

- uses: actions/checkout@v6
- uses: actions/setup-node@v6
  with:
    node-version: 22
    cache: npm
    cache-dependency-path: site/package-lock.json
  • checkout — скачать репозиторий на раннер;
  • setup-node — поставить Node.js для сборки; node-version — та же мажорная версия, что локально, иначе сборка может отличаться;
  • cache: npm + cache-dependency-path — кэшировать пакеты npm между запусками, ключ кэша — package-lock.json из site/.

Дальше три шага в папке site/ (working-directory):

  • npm ci — поставить зависимости ровно по package-lock.json. npm install мог бы обновить версии, и сборка в CI разошлась бы с локальной;
  • npx --yes astro check — проверка типов Astro. --yes — npx не спросит, ставить ли astro, если его нет локально: в CI некому ответить. На вопрос самого astro о доустановке @astrojs/check он не отвечает — @astrojs/check и typescript должны быть в devDependencies;
  • npm run build — сборка в site/dist/.

⚠️ Версии экшенов — @v6, не @v4. С @v4 в логе предупреждение Node.js 20 is deprecated … actions/checkout@v4, actions/setup-node@v4: экшены @v4 написаны под Node 20, который убирают из раннеров. Переменная FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 его не снимает — помогает только мажорная версия экшена. Проверить любой экшен: его репозиторий → action.yml на нужном теге → runs: using: — node24 или node20.

💡 node-version в setup-node — Node для сборки сайта, с Node, на котором работают сами экшены, он не связан.

9.4. Метка версии

- name: Записать версию
  id: version
  env:
    REF_TYPE: ${{ github.ref_type }}
    REF_NAME: ${{ github.ref_name }}
  run: |
    if [ "$REF_TYPE" = "tag" ]; then
      version="$REF_NAME"
    else
      version="$(git rev-parse --short HEAD)"
    fi
    printf '%s\n' "$version" > site/dist/version.txt
    echo "value=$version" >> "$GITHUB_OUTPUT"
  • github.ref_type — tag при запуске по тегу, branch — при запуске кнопкой на ветке (кнопку можно нажать и на теге — тогда тоже tag);
  • версия — имя тега или короткий хеш коммита;
  • version.txt кладётся в сборку и уедет на сайт вместе с ней;
  • $GITHUB_OUTPUT — передать версию следующим шагам как steps.version.outputs.value.

Зачем: проверка «сайт отдал 200» после выкатки ничего не доказывает — 200 вернёт и старая версия, и ответ из кэша. Сверка version.txt с тем, что собрали, ловит недоехавший rsync и выкатку не в ту папку.

⚠️ github.ref_name передаём через env, а не подставляем ${{ … }} прямо в текст скрипта. Имя тега задаёт человек, и тег с кавычками и командой внутри выполнился бы на раннере.

9.5. Проверка сборки

test -f site/dist/index.html || { echo "нет главной"; exit 1; }

test -f — файл существует; нет — сообщение и exit 1, workflow падает до выгрузки.

Зачем: дальше rsync идёт с --delete. Пустой или недособранный dist/ с --delete снесёт рабочий сайт и заменит его ничем. Список файлов — ключевые страницы своего сайта.

9.6. SSH на раннере

env:
  KEY: ${{ secrets.DEPLOY_KEY }}
  KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
  test -n "$KEY" || { echo "DEPLOY_KEY не задан"; exit 1; }
  printf '%s\n' "$KEY" > ~/.ssh/id_deploy
  printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
  • test -n — значение не пустое. Опечатка в имени секрета даёт пустую строку, а не ошибку;
  • printf '%s\n' — записать значение как есть, с переводом строки в конце: без него ssh не прочитает ключ;
  • chmod 600 на ключ — с более открытыми правами ssh откажется его использовать.

⚠️ Секреты — только через env. Подстановка ${{ secrets.DEPLOY_KEY }} прямо в текст скрипта: многострочный ключ разорвёт команду, а значение с кавычками выполнится как код.

⚠️ Отпечаток обязателен: шаг падает, если DEPLOY_KNOWN_HOSTS пуст. Запасной вариант «нет отпечатка — снимем ssh-keyscan с раннера» означал бы доверие первому, кто ответил (раздел 7).

9.7. rsync

rsync -rlptzv --delete \
  --chmod=D2775,F644 \
  -e "ssh -i ~/.ssh/id_deploy -p ${PORT:-22}" \
  site/dist/ \
  "$USER@$HOST:$PATH_ON_SERVER"
  • -r — рекурсивно, -l — симлинки как симлинки (с -munge на сервере они приедут испорченными — раздел 4), -p — права, -t — время изменения (по нему rsync пропускает неизменённые файлы), -z — сжимать при передаче, -v — список файлов в лог;
  • --delete — удалить на сервере то, чего нет в сборке: иначе удалённые страницы останутся на сайте;
  • --chmod=D2775,F644 — права явно: папкам 2775 (с setgid), файлам 644. Без этого файлы приедут с правами по umask раннера;
  • -e "ssh …" — через ssh с ключом деплоя; ${PORT:-22} — порт из секрета, не задан — 22;
  • site/dist/ с косой чертой в конце — выгрузить содержимое папки. Без черты rsync положит на сервер саму папку dist внутрь /var/www/example;
  • цель deployer@<IP-сервера>:/ — / внутри песочницы rrsync.

💡 -rlptz вместо привычного -a: -a включает ещё -o -g -D — владельца, группу и спецфайлы. Сменить владельца без root нельзя, а группу файлам и так даёт setgid (раздел 5).

⚠️ В --chmod — именно D2775, а не D755. Почему — раздел 10.

⚠️ Без rrsync пустой DEPLOY_PATH превратил бы цель в домашнюю папку пользователя, и --delete вычистил бы её. С rrsync пустой путь — это корень песочницы, но проверка test -n "$PATH_ON_SERVER" страхует, если ограничение в authorized_keys сломается.

9.8. Проверка выкатки

url="${URL%/}"
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 20 "$url/")
test "$code" = "200" || exit 1
actual=$(curl -s --max-time 20 "$url/version.txt" | tr -d '[:space:]')
test "$actual" = "$EXPECTED"
  • ${URL%/} — убрать / в конце адреса, иначе получится https://example.com//version.txt;
  • curl -s -o /dev/null -w "%{http_code}" — без вывода страницы, только код ответа; --max-time 20 — не ждать дольше 20 секунд;
  • tr -d '[:space:]' — убрать перевод строки из version.txt, иначе сравнение всегда ложно;
  • test "$actual" = "$EXPECTED" — версия на сайте совпала с собранной; нет — workflow красный.

10. Инцидент: --chmod затёр setgid

Зачем этот раздел: деплой был зелёный, сайт работал — а права на сервере тихо сломались. Здесь — как это выглядело и почему сразу не видно.

Что произошло. Первая версия workflow выгружала с --chmod=D755,F644. После первого деплоя (сокращено: колонки ссылок, размера и даты убраны):

drwxr-xr-x deployer caddy    .            ← было drwxrwsr-x, s пропал
-rw-r--r-- deployer deployer 404.html     ← группа deployer, не caddy
drwxr-xr-x deployer deployer _astro

Почему. --chmod применяется и к самой целевой папке. D755 выставил ей права 755 — без setgid, который мы поставили в разделе 5. Две защиты подрались: новые файлы и папки получили группу deployer.

Почему не заметно. Права 644 дают чтение всем, Caddy читает файлы как «остальные». Сайт работает, дефект замаскирован. Сломается позже — если права ужесточить до 640.

Решение — setgid прямо в --chmod:

--chmod=D2775,F644

Папки — 2775, бит сохраняется; новые подпапки (_astro) тоже получают setgid.

Починка на сервере после такого деплоя:

sudo chgrp -R caddy /var/www/example
sudo find /var/www/example -type d -exec chmod 2775 {} \;
  • chgrp -R caddy — вернуть группу caddy всему внутри;
  • find … chmod 2775 — вернуть setgid всем папкам.

💡 Урок: смотреть на результат на сервере, а не на зелёный статус пайплайна. Дефект, замаскированный работающим сайтом, найдётся только явной проверкой (раздел 11).


11. Первый деплой и проверка

Зачем: зелёный workflow говорит, что шаги не упали. Права файлов на сервере он не проверяет — это делаем руками, один раз после первого деплоя.

На локальной машине — коммит с workflow запушен, ставим тег:

git tag v1.0
git push origin v1.0

Во вкладке Actions репозитория появится запуск «Сборка и деплой».

  • ✅ Все шаги зелёные, в последнем ожидали: v1.0, получили: v1.0
  • ❌ Красный шаг → таблица в разделе 12

На сервере — права после выгрузки:

ls -la /var/www/example | head -5

head -5 — первые пять строк: total, сама папка (.), .. (это /var/www, его не смотрим) и первые файлы.

  • ✅ У папок drwxrwsr-x deployer caddy, у файлов -rw-r--r-- deployer caddy
  • ❌ Группа deployer или drwxr-xr-x у папок → setgid слетел, раздел 10

Снаружи — версия на сайте:

curl.exe -s https://example.com/version.txt
  • ✅ v1.0

12. Диагностика

СимптомПричинаЧто делать
Permission denied (publickey)На сервере не тот публичный ключ или права на .sshСравнить ssh-keygen -y -f id_deploy с authorized_keys; chown/chmod из раздела 4
Enter passphrase при ручной проверке; в CI — отказ аутентификацииКлюч с паролемСгенерировать заново (раздел 3)
Host key verification failedDEPLOY_KNOWN_HOSTS не от этого адреса или портаСнять заново под DEPLOY_HOST (раздел 7)
rrsync error: Not invoked via sshd при ручном ssh✅ Так и должно быть—
rrsync error: … has been disabled on this serverКлиент прислал запрещённую опцию rsyncФлаги — как в разделе 9.7
Another instance of rrsync is already accessing this directoryПараллельная выкаткаДождаться первой; concurrency в workflow
403 на сайте после деплояГруппа файлов не caddy, setgid слетелls -la, раздел 10
404 на version.txt или версия не совпала при зелёном rsyncrrsync пишет не в ту папку, что root в Caddyfile; или отвечает кэш перед сайтомСверить sudo cut -c1-60 /home/deployer/.ssh/authorized_keys и root в /etc/caddy/Caddyfile
404 на файл, который в сборке есть как симлинк-munge портит симлинки — так и задуманоКласть в public/ сам файл, а не симлинк; -L вместо -l не поможет: rrsync его запрещает (option -L has been disabled on this server)
Секрет «не задан», хотя он естьСекрет в Environment, а не в RepositoryПеренести в Repository secrets
src refspec … does not match anyТег не созданgit tag vX перед git push origin vX

Попытки подключения CI — на сервере:

sudo grep deployer /var/log/auth.log | tail -20

grep deployer — строки sshd про пользователя deployer; tail -20 — последние 20.

  • ✅ Accepted publickey for deployer from … на каждый деплой

Итог: чеклист

  • deployer без sudo и docker (id deployer)
  • Ключ ed25519 без passphrase (ssh-keygen -y -f id_deploy не спрашивает пароль)
  • В authorized_keys одна строка command="/usr/bin/rrsync -wo -munge /var/www/example",restrict … (sudo wc -l → 1)
  • Папка сайта deployer:caddy, drwxrwsr-x (ls -ld /var/www/example)
  • Ключ принимается (ssh … true → 0), shell и произвольная команда — rrsync error
  • Отпечаток снят на сервере, адрес в нём совпадает с DEPLOY_HOST
  • Секреты в Repository secrets, DEPLOY_PATH = /
  • В workflow секреты и ref_name — через env, в rsync — --chmod=D2775,F644
  • Первый деплой: ls -la — группа caddy и s у папок на месте
  • Первый деплой: curl.exe -s https://example.com/version.txt → версия из тега