Logo Craft Homelab Docs Нейросети Хостинг Обучение Контакты
Деплой на VPS по git push через OIDC: инструмент cd без SSH-ключей в CI Трендовые github проекты в нашем телеграм канале. Подпишись →
12 ноября 2026 г.

Короткоживущий токен вместо приватного ключа: как 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строка субъекта: репозиторий, ветка или окружение
repositoryowner/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.

Пять шагов одного деплоя

  1. CI-задача запрашивает OIDC-токен. В GitHub Actions нужны permissions: id-token: write и contents: read, поле aud равно публичному адресу сервера.
  2. Клиент упаковывает код через git archive и отправляет tar.gz на POST /v1/deploy?project=app&sha=<commit> с заголовком Authorization: Bearer <token>. Логи сборки стримятся обратно в CI.
  3. cdd проверяет подпись RS256 по закэшированным JWKS, iss по списку разрешённых издателей, точное совпадение aud с URL сервера, exp/nbf, а затем сверяет repository, ref и environment с правилами проекта.
  4. Архив распаковывается с проверкой на симлинки, жёсткие ссылки, абсолютные пути и выход за каталог (Zip Slip). Способ сборки определяет шаблон на сервере: node (npm ci, опционально npm run build, удаление dev-зависимостей, запуск npm start), static (файлы отдаёт встроенный Caddy) или dockerfile — его включает только администратор. Сборка идёт в отдельном BuildKit-контейнере с лимитом 1 ГБ RAM, 1 vCPU и 15 минут, без доступа к секретам и файловой системе хоста.
  5. Новый контейнер стартует с портом на 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Без простояИзоляция по умолчанию
cdOIDC-токен с проверкой claimsнетнет, 1–3 сжёсткая, задаёт сервер
SSH + ComposeSSH-ключдазависит от composeкак описано в compose
Dokkugit push по SSHдада, через checksстандартный Docker
KamalSSH + registryдада, через kamal-proxyстандартный Docker
Coolify / Dokploywebhook или GitHub Appдада, через health-checkнастраивается в UI

Практическое правило выбора. Один VPS с несколькими лёгкими сервисами на Node.js, Go или статикой, требование убрать SSH-ключи из CI, готовность работать через CLI и терпеть пару секунд простоя — cd подходит. Нужна панель с графиками и кнопками — Coolify или Dokploy. Нужны базы и сервисы из шаблонов — Dokku. Нужен zero-downtime или несколько серверов — Kamal, ценой SSH-доступа и внешнего registry.