Шпаргалка по автодеплою статического сайта на свой сервер, который уже настроен по статье о первичной настройке и защите и отдаёт сайт через 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_keys | Shell и любые команды, кроме 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 его проверяет |
-wo | write-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_USER | deployer |
DEPLOY_PATH | / |
DEPLOY_URL | https://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 failed | DEPLOY_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 или версия не совпала при зелёном rsync | rrsync пишет не в ту папку, что 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→ версия из тега