Трендовые github проекты в нашем телеграм канале. Подпишись → Короткоживущий токен вместо приватного ключа: как cd выкатывает сервисы на один VPS
Типичная картина маленького проекта: один Linux-сервер, Docker, Caddy или Nginx впереди, на нём пара бэкендов, бот и статический сайт. Хочется, чтобы git push в main выкатывал новую версию. Обычно это решают тремя способами, и у каждого есть заметный изъян.
- SSH из CI. Приватный ключ лежит в секретах репозитория, раннер заходит на сервер и делает
git pull && docker compose up -d --build. Ключ живёт годами, пользователь на сервере обычно может слишком много, и утечка секрета из любого подключённого репозитория даёт доступ ко всему хосту. - PaaS-панели вроде Coolify или Dokploy. Удобный веб-интерфейс, но ради перезапуска нескольких контейнеров на сервер приезжает целый комплекс со своей базой, авторизацией и поверхностью атаки.
- Webhook со статическим токеном. HTTP-эндпоинт перезапускает сборку по секрету в заголовке. Секрет по-прежнему долгоживущий, а изоляции сборки и запуска на сервере никто не обеспечивает.
Инструмент cd (MIT) закрывает эту задачу с жёстким набором требований: в CI нет долгоживущих секретов, каждый репозиторий может деплоить ровно одно своё приложение, сборка и запуск изолированы на стороне сервера, неудачный релиз откатывается сам, внешние СУБД и веб-интерфейсы не нужны.
Что даёт OIDC в CI
GitHub Actions и GitLab CI умеют выдавать задаче короткоживущий JWT, подписанный ключом платформы. Сервер-получатель скачивает публичные ключи издателя (JWKS) через /.well-known/openid-configuration, проверяет подпись, срок жизни и поля токена:
| Claim | Что в нём |
|---|---|
iss | издатель, например https://token.actions.githubusercontent.com |
aud | адрес получателя; токен для другого сервера не подойдёт |
sub | строка субъекта: репозиторий, ветка или окружение |
repository | owner/repository |
ref | ссылка, вызвавшая запуск, например refs/heads/main |
environment | окружение, например production |
exp / nbf / iat | временные рамки в пределах нескольких минут |
Перехваченный токен живёт минуты и привязан к конкретному URL сервера, поэтому его нельзя переиспользовать позже или против другой машины. Красть из настроек репозитория просто нечего.
Из чего состоит cd
- cdd — демон под systemd за обратным прокси. Принимает HTTP API, проверяет токены, собирает образы, меняет контейнеры.
- cdctl — утилита администратора: регистрирует проекты, задаёт лимиты RAM и CPU, генерирует блоки для Caddy, показывает нагрузку.
- deploy.sh — клиент для CI. Для GitHub Actions оформлен как готовый Action, для GitLab CI есть шаблон для
include.
cdd и cdctl написаны на Go без сторонних зависимостей: go.mod пустой, JWT и обнаружение JWKS сделаны на стандартной библиотеке, Docker вызывается через обычный CLI. Весь код укладывается примерно в 2,7 тыс. строк, его реально прочитать целиком перед установкой. Проекты хранятся JSON-файлами в /etc/cdd/projects, состояние — в /var/lib/cdd.
Пять шагов одного деплоя
- CI-задача запрашивает OIDC-токен. В GitHub Actions нужны
permissions: id-token: writeиcontents: read, полеaudравно публичному адресу сервера. - Клиент упаковывает код через
git archiveи отправляет tar.gz наPOST /v1/deploy?project=app&sha=<commit>с заголовкомAuthorization: Bearer <token>. Логи сборки стримятся обратно в CI. cddпроверяет подпись RS256 по закэшированным JWKS,issпо списку разрешённых издателей, точное совпадениеaudс URL сервера,exp/nbf, а затем сверяетrepository,refиenvironmentс правилами проекта.- Архив распаковывается с проверкой на симлинки, жёсткие ссылки, абсолютные пути и выход за каталог (Zip Slip). Способ сборки определяет шаблон на сервере:
node(npm ci, опциональноnpm run build, удаление dev-зависимостей, запускnpm start),static(файлы отдаёт встроенный Caddy) илиdockerfile— его включает только администратор. Сборка идёт в отдельном BuildKit-контейнере с лимитом 1 ГБ RAM, 1 vCPU и 15 минут, без доступа к секретам и файловой системе хоста. - Новый контейнер стартует с портом на
127.0.0.1, демон до 90 секунд опрашивает/healthz. Успех — старый контейнер удаляется. Провал — новый уничтожается, причина уходит в лог CI, старый возвращается в работу.
Итог задачи CI получает одной строкой: deployed, failed / rolled back, archive rejected, server busy, bad request или unauthorized.
Workflow в репозитории приложения выглядит так:
permissions:
contents: read
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: Dmitry-Fedotov-Dev/cd@v1
with:
server: ${{ vars.CD_SERVER }}
project: app
На сервере проект привязывается к репозиторию, ветке, окружению и лимитам:
sudo cdctl project add app-repo me/app \
--branch main \
--environment production \
--type node \
--domain app.example.com \
--data /app/data \
--memory 256m
Ограничения контейнера задаёт сервер
Репозиторий не может переопределить параметры запуска. Каждый контейнер работает от UID 10001, корневая ФС смонтирована read-only, писать можно только в /tmp в памяти и в выделенный том данных. Сбрасываются все capabilities (--cap-drop=ALL), включён no-new-privileges, ограничены CPU, RAM и число процессов, swap отключён. У каждого приложения своя Docker-сеть, порты публикуются только на 127.0.0.1, так что обойти прокси снаружи нельзя.
Репозиторий в такой схеме может запустить деплой, читать свои логи и сделать откат. Менять ресурсы, порты и правила доступа он не может. Ещё один практический плюс: директивы безопасности больше не нужно вручную переписывать в каждый docker-compose.yml.
Сам cdd работает от root, потому что ходит в Docker. Поэтому юнит systemd дополнительно урезан:
[Service]
ExecStart=/usr/local/bin/cdd -config /etc/cdd/cdd.json
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=full
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
Дополнительные функции
cdctl domain setupсохраняет API-ключ Porkbun в файл, доступный только root. После этого--domainпри добавлении проекта создаёт A-запись, дописывает блок в Caddyfile, валидирует его и перезагружает Caddy, остальные записи не трогаются.cdctl project clone shop-anna --from shopделает независимую копию приложения со своим портом, env-файлом, каталогом данных и доменом. Push в репозиторийshopраскатывается на все клоны — удобно, когда одно приложение нужно нескольким клиентам.init.shподключает новый репозиторий: генерирует workflow, прописывает переменнуюCD_SERVERи запускает первый деплой.
Где cd упирается в свои рамки
- Простой при каждом релизе. Старый контейнер останавливается до старта нового, потому что порт на
127.0.0.1один. Для быстрых сервисов на Go это 1,2–2,5 секунды, для приложений с долгим стартом — заметно больше. Blue-green сознательно не сделан: он потребовал бы динамически управлять конфигурацией прокси. - Один деплой за раз. Второй запрос во время сборки получает
busy, и CI-задача падает. Очереди на сервере нет намеренно, чтобы HTTP-соединения из CI не висели; повторы настраиваются черезconcurrencyили перезапуск в CI. - Один проект — один контейнер.
docker compose, сайдкары и базы не поддерживаются, PostgreSQL или Redis администратор поднимает отдельно. Затоgit pushгарантированно не пересоздаст и не очистит тома с данными. - Root у демона. Уязвимость в распаковщике архивов или валидаторе токенов может дать атакующему хост.
- Автодомены только для пары Porkbun + Caddy. С Cloudflare, Route53 или Nginx DNS и конфиги придётся вести руками.
- Веб-интерфейса нет, всё через
cdctlи логи в CI. - Нет лимитов на дисковый I/O и сетевой трафик, поэтому хостить чужой недоверенный код на таком сервере не стоит.
Сравнение с альтернативами
| Инструмент | Аутентификация из CI | Долгоживущий секрет в CI | Без простоя | Изоляция по умолчанию |
|---|---|---|---|---|
| cd | OIDC-токен с проверкой claims | нет | нет, 1–3 с | жёсткая, задаёт сервер |
| SSH + Compose | SSH-ключ | да | зависит от compose | как описано в compose |
| Dokku | git push по SSH | да | да, через checks | стандартный Docker |
| Kamal | SSH + registry | да | да, через kamal-proxy | стандартный Docker |
| Coolify / Dokploy | webhook или GitHub App | да | да, через health-check | настраивается в UI |
Практическое правило выбора. Один VPS с несколькими лёгкими сервисами на Node.js, Go или статикой, требование убрать SSH-ключи из CI, готовность работать через CLI и терпеть пару секунд простоя — cd подходит. Нужна панель с графиками и кнопками — Coolify или Dokploy. Нужны базы и сервисы из шаблонов — Dokku. Нужен zero-downtime или несколько серверов — Kamal, ценой SSH-доступа и внешнего registry.