Выкладки: из Git — в кластеры и на хосты
Раздел хаба «Выкладки» выкладывает ваши приложения из репозитория Git: манифестом в кластеры Kubernetes, Helm-релизом или сценарием хаба. Сборку образов и тесты делает ваш CI (GitHub Actions, GitLab CI, Gitea) или вы сами — nkt берёт на себя вторую половину: «готово → выложить».

Почему так: ключи от хостов и кластеров остаются на хабе. CI получает только право «дёрнуть» вебхук конкретного конвейера, а что и куда выкладывать, решает описание на хабе — из запроса берутся лишь ветка, коммит и тег.
Экспериментальная возможность
Выкладка меняет стеки на хостах — поднимает, заменяет и убирает их, а при смене хоста или имени стека убирает старый. Перед выкладкой — «Сухой прогон», обкатывайте на тестовых хостах, проверяйте журнал задания. Та же плашка — вверху раздела «Выкладки».
Конвейер
«Новый конвейер» → имя и описание в YAML. Правка — в окне с диффом, у описания есть история редакций с заметками; перед записью оно проверяется. Секретов в описании нет — они в «Секретах».
repo: https://github.com/org/app.git
ref: main # push в эту ветку — выкладка
tags: "v*" # и/или теги репозитория по шаблону
action: manifest # manifest | helm | script
manifests: [deploy/k8s.yaml]
clusters: [prod] # кластеры хаба
# group: prod # или кластеры хостов группы
# poll: 5m # опрашивать репозиторий
# registry: ghcr.io/org/app
# registry_tags: '^v?\d+\.\d+\.\d+$'
# registry_poll: 5mВсе поля
| Поле | Для чего | Значение |
|---|---|---|
repo | всегда | https://…, ssh://… или git@host:path. Логин и пароль в адрес не пишутся — токен или ключ задаются в «Секретах» |
ref | ветка | Push в неё — выкладка её вершины. Нужен ref или tags |
tags | теги | Шаблон (glob, v*): тег репозитория по нему — выкладка этого тега |
action | всегда | manifest, helm или script |
manifests | manifest | Список файлов в репозитории |
clusters | manifest, helm | Имена кластеров из раздела «Кластеры» |
group | manifest, helm | Группа хостов: все кластеры, чей первый control plane в этой группе. Можно вместе с clusters |
helm.repo_name, helm.repo_url | helm | Репозиторий чартов (helm repo add) или oci://… |
helm.chart, helm.version | helm | Чарт и версия (пусто — последняя) |
helm.release, helm.namespace | helm | Имя релиза и namespace |
helm.values | helm | Файл values в репозитории |
helm.tag_key | helm | Ключ values, куда подставить тег образа: image.tag |
script | script | Сценарий хаба в репозитории (deploy/deploy.nkt) |
poll | триггер | Интервал опроса ветки, не короче 1m |
registry | триггер | Образ, за тегами которого следить: ghcr.io/org/app, org/app (Docker Hub), registry.example.com:5000/app |
registry_tags | триггер | Регулярное выражение подходящих тегов; по умолчанию ^v?\d+(\.\d+){1,2}$ |
registry_poll | триггер | Интервал проверки registry, по умолчанию 5m |
Все значения проверяются по отдельности: ветки, теги и пути — по строгим шаблонам, git вызывается с --, файлы читаются только внутри checkout (выход наружу, в том числе ссылкой, запрещён).
Подстановки
В манифестах и values подставляются:
| Подстановка | Значение |
|---|---|
{{nkt.tag}} | тег из запроса, кнопки или registry; без тега — короткий коммит (12 знаков) |
{{nkt.commit}} | полный коммит |
{{nkt.ref}} | ветка или тег репозитория |
В сценарии те же значения приходят параметрами TAG, COMMIT, REF (param TAG "тег образа" в начале сценария).
Действия
- manifest — файлы манифестов из репозитория склеиваются, в них делаются подстановки, и на control plane каждого кластера идёт
kubectl apply(через API узла, по тому же каналу, что и остальные запросы к хосту). Каждое применение попадает в библиотеку манифестов хаба («Кластеры» → «Манифесты») с заметкойpipeline <имя> <коммит>. - helm —
helm upgrade --installв каждом кластере: values из файла, тег — вtag_key. На control plane нужен Helm — если его нет, поставьте кнопкой «Установить Helm» во вкладке Kubernetes узла. - script — сценарий хаба из репозитория:
docker stack … up,git clone, перезапуск службы,wait … http— всё, что умеет язык сценариев. Кластер не нужен.
Кластеры берутся только в статусе «готов». Если выкладка не удалась в части кластеров, задание кончается ошибкой «не удалась в N из M», а в журнале видно, где и почему.
Compose-стек
Действие compose выкладывает docker compose-стек из репозитория на хосты хаба — по очереди:
repo: https://codeberg.org/me/shop.git
ref: main
action: compose
compose:
file: deploy/docker-compose.yml # compose-файл в репозитории
project: shop # стек на хосте: /srv/compose/shop
hosts: [web1, web2] # или group: prod; машина хаба — localhost
files: [deploy/nginx.conf, deploy/conf/] # что ещё нужно стеку
wait_timeout: 5m # ждать подъёма и healthcheck
site: shop.example.com # после выкладки проверить по HTTPS (или блоком — см. ниже)В compose-файле и файлах рядом делаются подстановки:
image: ghcr.io/org/shop:{{nkt.tag}}.Файлы из
files(каталоги — целиком) должны лежать внутри каталога compose-файла: на хосте стек — этот каталог, и относительные ссылки compose (./nginx.conf) должны остаться верными. Только текстовые файлы; двоичным место в образе.На каждом хосте: файлы пишутся в
/srv/compose/<project>с историей версий, затемdocker compose configпроверяет стек — если не принял, файлы возвращаются как были. Дальше заданием хоста —pullиup -d --remove-orphans --wait: задание ждёт, пока контейнеры поднимутся и пройдут healthcheck. Разовые сервисы (restart: "no", например заведение администратора в примере Forgejo) завершаются — если завершились только они и с кодом 0, а остальное работает и здорово, выкладка успешна. Следующий хост — только после успеха предыдущего; первая неудача останавливает выкладку.Без Docker — Podman (
podman compose): ожидание — пока все контейнеры проекта работают и здоровы.Перед первым хостом хаб спрашивает у всех хостов, есть ли docker (или podman) и работает ли compose. Если где-то нет — выкладка не начинается, и ни один хост не тронут: иначе часть хостов обновилась бы, а на остальных стек остался бы прежним.
Только готовые образы: сервис с
build:безimage:хаб отвергнет до обращения к хостам — сборку делает CI. Чужой compose-файл сbuild:(без форка) — черезimages:в описании конвейера: хаб поставит сервису готовый образ сpull_policy: always(образ из описания всегда берётся из registry, даже если тот же тег уже на хосте) и уберётbuild::yamlcompose: file: docker-compose.yml # из github.com/postmanlabs/httpbin images: httpbin: kennethreitz/httpbin # сервис: образ ({{nkt.tag}} можно) ports: httpbin: [] # не публиковать 80:80 из файла: 80/443 нужны прокси сайтаports:заменяет публикации сервиса целиком (["127.0.0.1:8080:80"]), пустой список убирает их — в чужом файле без форка.Имя образа —
адрес[:порт]/путь:тег, безhttp://иhttps://: Docker сам ходит в registry по https. Образ из переменной (image: ${APP_IMAGE:-ghcr.io/org/app:latest}) раскрывается значениями.envконвейера — по раскрытому имени хаб подбирает ключ registry из «Секретов». Имя со схемой или без адреса (/test/app:1— пустая переменная перед/) хаб отвергает в описании, а на хосте — до pull, с именем сервиса и тем, что исправить.pull_policy. Приmissing(if_not_present) илиnevercompose pullне скачивает образ, который уже есть на хосте, — тегlatestне обновится, поднимется прежний. Журнал выкладки называет такие сервисы; чтобы образ брался из registry —pull_policy: always(в compose-файле или переменной.env) или образ вimages:.upпосле pull. Образы скачиваются отдельным шагом (с ключами registry из «Секретов»), аupзапускается с--pull never— берёт только что скачанное и в registry второй раз не ходит (иначе приpull_policy: alwaysон пошёл бы туда уже без ключа). Приcompose.pull: falseфлаг не ставится. Compose на хосте безup --pull(очень старый docker compose, podman-compose) — флаг не ставится, журнал предупреждает. Еслиupвсё же упал на скачивании образа, журнал называет причину и что на хосте на самом деле: по ID контейнеров до и послеup— «работает прежняя версия», «стек и до выкладки не работал», «пересозданы до ошибки» или «контейнеров нет».Порты — только на 127.0.0.1. Публикация без адреса (
8080:80) слушает на всех адресах, и docker открывает её своими правилами iptables в обход ufw и firewalld. Поэтому хаб ставит таким публикациям адресbind:— по умолчанию127.0.0.1(8080:80→127.0.0.1:8080:80,443→127.0.0.1::443, в длинной записи —host_ip): наружу стек выходит через прокси сайта. Публикации с указанным адресом остаются как есть;bind_force: trueзаменяет и их. Открыть наружу —bind: 0.0.0.0или явный адрес вports:("0.0.0.0:25:25"). Запись с переменной (${PORT}:80) не разобрать — журнал предупредит. Что стало с каждым портом — в журнале выкладки и сухого прогона.Один порт хоста — одна публикация.
0.0.0.0:8080уже включает127.0.0.1:8080, поэтому две такие публикации (в одном сервисе или в разных, в короткой или длинной записи) не поднимутся вместе: второй bind падает с «port is already allocated». Хаб проверяет это по итоговому compose-файлу (послеports:иbind:): сухой прогон называет пару как проблему, выкладка отказывает ещё до обращения к хостам. Разные конкретные адреса (127.0.0.1и192.168.1.5) и разные протоколы (53/tcpи53/udp) — не конфликт..envстека задаётся в «Секретах» конвейера: хранится на хабе зашифрованным, на хост пишется с правами 0600, в историю версий и журналы не попадает. На хосты он попадает следующей выкладкой — после записи нажмите «Выложить» (вебхук и опрос репозитория на смену.envне срабатывают). Если.envизменился с прошлой выкладки, контейнеры пересоздаются принудительно (--force-recreate, и у docker, и у podman), чтобы взять новые значения..env— не окружение контейнера. docker compose берёт его только для подстановки${ИМЯ}в сам compose-файл. В контейнер переменная попадает, если сервис на неё ссылается (environment: X: ${X}), подключаетenv_file: .envили она перечислена вenv_keys. Сухой прогон называет переменные.env, которые не попадут ни в один контейнер.Секреты, записанные в чужом compose-файле значениями (
APP_SECRET: replace-me…),.envне перекрывает —env_keys:в конвейере: хаб в копии файла заменяет значения этих переменных на${ИМЯ}, сами значения — из.envконвейера. Каких-то нет в.env— выкладка не начнётся, сухой прогон их назовёт.yamlcompose: env_keys: umami: [APP_SECRET, TWO_FACTOR_ENCRYPTION_KEY, DATABASE_URL] db: [POSTGRES_PASSWORD]
Compose по ссылке. В окне нового конвейера — вставьте ссылку на compose-файл (GitHub …/blob/<ветка>/<путь>, GitLab …/-/blob/…, Codeberg/Gitea/Forgejo …/src/branch/…, их raw-варианты), выберите хосты — описание заполнится само: репозиторий, ветка, путь, имя стека. Дальше — обычная правка с диффом. «Примеры» — готовые конвейеры, каждый проверен выкладкой; подробности и шаблоны .env — examples/ репозитория. Выбранный пример помнится: повторное «Заполнить описание» (скажем, после смены хостов) снова заполнит его целиком.
Для своей ссылки хаб скачивает сам compose-файл (один коммит, без истории; закрытый репозиторий — ключами конвейера из «Секретов») и собирает описание по нему:
- сайт — веб-сервис, найденный по портам образов (
EXPOSEиз registry; без них —ports/exposecompose), базы, кэши и очереди (postgres, mysql, redis…) не берутся; порт — внутри контейнера. Рядом с кнопкой появляется выбор «Сайт»: «авто», любой сервис файла или «без сайта»; смена выбора пересобирает описание. Блокsite:остаётся закомментированным — впишите имя и раскомментируйте; - файлы рядом — относительные тома (
./nginx.conf) →files:; - сервисы со сборкой (
build:безimage:) →images:с просьбой указать готовый образ; - образы без версии (
latest) — комментарий «закрепите тег»; - публикации наружу (не на 127.0.0.1) у остальных сервисов — закомментированный
ports::bindпо умолчанию оставит их на loopback; - переменные
${…}— шаблон.envв комментарии: значения по умолчанию — как есть, похожие на пароль или ключ —<секрет>,${X:?…}— «обязательна».
Окно описания одинаково у нового и у сохранённого конвейера на compose: у сохранённого («Описание» в списке) блок «по ссылке» уже заполнен по описанию — ссылка (строка # compose: …, которую пишет «Заполнить описание», а без неё — по repo:, ref: и compose.file), хосты, имя стека, сервис сайта; «Заполнить описание» пересобирает описание по файлу, и перед записью видна разница. Имя конвейера — только для чтения. Хосты и имя стека в этом блоке правят описание сразу — только строки hosts: и project:, остальное не трогается; и наоборот, правка этих строк в тексте меняет поля. Раньше выбранный там хост попадал в описание лишь по «Заполнить описание», и «Сохранить» записывал прежний.
Имя стека (compose.project) — имя проекта docker compose на хосте: каталог /srv/compose/<имя>/ (compose-файл, файлы рядом, .env), контейнеры <имя>-<сервис>-1, сеть <имя>_default, тома <имя>_<том>. По нему выкладка обновляет и удаление убирает именно этот стек; двум конвейерам на одном хосте нужны разные имена. Пусто — имя репозитория из ссылки.
Смена хоста или имени стека. Хаб помнит, где и под каким именем стек выложен последней удачной выкладкой:
- Новое имя стека на том же хосте — замена: прежний стек останавливается перед подъёмом нового (его порты и имена контейнеров не считаются занятыми), новый не поднялся — прежний запускается обратно, поднялся — прежний убирается. Данные не переносятся: у нового свои тома и каталог, он начнёт с пустыми; каталог прежнего (с
./data) — в/srv/compose/.nkt-removed/, откуда его можно забрать. - Другой хост — выкладка идёт на новый, прежний ей не нужен. После успеха стек с прежнего хоста убирается; если тот недоступен или удалён из хаба, выкладка всё равно удачна, а у конвейера появляется пометка «стек … остался на …» с кнопками «Убрать» (когда хост вернётся) и «Забыть». Данные на новый хост тоже не переносятся.
- Сухой прогон пишет, что будет со старым стеком (уберётся, заменится или останется на недоступном хосте), и предупреждает о данных; имя контейнера (
container_name), занятое контейнером другого проекта, — проблема до выкладки, а не ошибкаcompose up. - Имя хоста в описании сравнивается без учёта регистра и пробелов; неизвестное — с подсказкой похожих имён. Сайт конвейера при переезде, если домен ещё указывает на прежний хост, — понятная строка «смените DNS-запись перед выкладкой».
- Удаление конвейера убирает стек там, где он выложен этим конвейером, и оставшиеся старые стеки на доступных хостах — см. «Удаление конвейера compose» ниже.
Закрытый репозиторий без ключей: окно говорит «репозиторий закрыт — задайте имя и „Секреты“, затем „Заполнить описание“ снова», а описание пока собирается по одной ссылке — с заглушками <сервис из compose> и <порт контейнера> в site::
| Пример | Что показывает |
|---|---|
| httpbin | один контейнер |
| Uptime Kuma | compose-файл проекта как есть, данные в ./data |
| umami + PostgreSQL | секреты из .env (env_keys), закреплённая версия (images) |
| n8n + PostgreSQL | три сервиса, файл рядом (files), всё из .env проекта |
| Forgejo + PostgreSQL | без мастера установки — администратор из .env разовым сервисом; второй порт наружу — SSH 2222 (ports с 0.0.0.0) |
| WordPress + MariaDB | сайт за HTTPS-прокси (X-Forwarded-Proto) |
| Plausible | три сервиса с ClickHouse, каталог конфигов (files) |


Хосты проверяются сразу при выборе: рядом с именем — «docker с compose — готов», «нет docker и podman» или «docker без compose». Где нет — кнопка «Установить Docker» (или «Установить compose»): docker.io и плагин compose из репозитория дистрибутива, фоновым заданием хоста в стандартном окне журнала. Уже стоящий docker (в том числе docker-ce из репозитория Docker) не трогается — доставляется только compose. Та же проверка — в мастере «Сайтов», когда цель — сервис стека.
Сухой прогон — кнопка в окне «Выложить» и в редакторе конвейера (там проверяется текущий текст, в том числе несохранённый). Делает всё, что сделала бы выкладка, кроме изменений: берёт репозиторий, делает подстановки, собирает файлы стека, и на каждом хосте:
- docker или podman и версия compose;
- стек новый или есть — какие файлы появятся, изменятся, останутся, что с
.env; compose configна копии стека во временном каталоге рядом со стеками (сам стек и контейнеры не трогаются, копия удаляется);- сервисы и образы: есть ли образ в registry (только манифест, без скачивания), скачан ли на хост. «В registry нет» — ошибка; «проверить не удалось» (закрытый registry без входа, podman без skopeo) — предупреждение;
- порты хоста из публикаций: занятый другим процессом (не этим стеком) — проблема:
upна нём упадёт; - архитектура образа против архитектуры хоста (
uname -m): образ только для amd64 на arm64-хосте — проблема (exec format error). - имена образов после подстановки
.env:http(s)://в адресе или пустой адрес (/путь) — проблема;pull_policyнеalways, а образ уже на хосте — предупреждение «выложится прежний»;pull_policy: neverбез образа — проблема;build:из каталога, которого на хосте нет (context: ../..), — предупреждение, приpull_policy: build— проблема; - ключи registry из «Секретов», которые не подходят ни одному образу стека (опечатка в адресе ключа или образа), — предупреждение.
Ещё сухой прогон проверяет — проблемы (выкладка или сайт не пройдут): ${VAR} в compose без значения в .env (если «переменная» — на деле кусок значения со знаком $, например хеш пароля $2y$10$…, проверка называет файл и строку: в compose такой $ удваивается — $$, в .env значение берётся в одинарные кавычки); демон docker не запущен; мало места для образов или памяти; кто держит 80/443 на хосте (не прокси сайта — certbot и прокси не встанут); имя уже в чужой конфигурации nginx и читает ли nginx conf.d; AAAA-запись имени не на хост; DNS не на хост и порт 80 снаружи закрыт; nkt на хосте старее хаба. Предупреждения: образы без закреплённой версии (latest), сервисы без healthcheck (--wait ждёт только запуска), стек уже есть на хосте, а этот конвейер его не выкладывал, или его выкладывает и другой конвейер. Сведения: свободная память и место, годный сертификат (сколько осталось) или выпуск нового.
Не поднялся стек при настоящей выкладке — хост дописывает в журнал состояние контейнеров (compose ps -a) и последние строки журналов упавших сервисов, а ошибка называет причину: «порт 127.0.0.1:8080 занят», «образ не для этой архитектуры», «контейнер httpbin завершился (код 1)», «не прошёл healthcheck».
Кнопка открывает окно с галочками проверок — по умолчанию включены все: Docker и compose, описание стека, образы, порты хоста, ресурсы, healthcheck, стек на хосте, сайт (DNS, снаружи, хост, сертификат и nginx), версия nkt на хосте. Репозиторий и разбор описания проверяются всегда. Снятая проверка пропускается (хост не ходит в registry, не смотрит порты и т. д.), в журнале — строка «не проверялось: …». Выбор запоминается у конвейера на хабе — общий для администраторов; запоминаются снятые галочки, так что проверка, добавленная в новой версии, будет включена сама. Настоящая выкладка галочки не учитывает: её проверки (движок, демон, .env) — всегда. Старый nkt на хосте галочек не знает и проверяет всё — лишнее хаб в журнал не выводит.
Итог — задание хаба с журналом: «выкладка прошла бы» или число проблем. В журналах выкладки и сухого прогона проблемы (✗) — жирным красным, предупреждения (?, !) — жирным оранжевым. Запись в историю выкладок не делается.

Пример: httpbin. В репозитории nkt — examples/httpbin: стек go-httpbin на готовом образе mccutchen/go-httpbin:2.25.0 и конвейер к нему. Выкладывается прямо по ссылке https://github.com/piqab/nkt/blob/main/examples/httpbin/deploy/docker-compose.yml (имя стека — httpbin) или «Примеры» → «httpbin» в окне нового конвейера: она заполнит ссылку и имена, останется выбрать хост. Сайт — блоком site: в конвейере (закомментирован в описании) или вручную в «Сайтах»: стек httpbin, сервис httpbin, порт 8080. Compose-файл оригинала, postmanlabs/httpbin, собирает образ из исходников (build: '.') — его хаб отвергнет с объяснением.
Сайты
Вкладка «Сайты» — домен на хосте за прокси с сертификатом Let's Encrypt:

- Хост и имена → «Проверить». Снаружи — с nkt-edge с ролью «проверки снаружи», если он подключён, иначе с хаба: указывает ли каждое имя на хост, и отвечают ли порты 80 и 443. «Свободен» (отказ соединения) — путь открыт, certbot сам встанет на порт; «нет ответа» — порт, скорее всего, закрыт файрволом провайдера или NAT, и Let's Encrypt до хоста не дойдёт. Если имя ведёт в частную сеть — пометка: хаб видит порты изнутри. С хоста — какие прокси установлены и работают, кто держит 80/443, стеки в
/srv/composeс сервисами и портами, файрвол. - Прокси: nginx, HAProxy или Caddy — показано, что установлено и что работает. Если не установлено ничего — ставится nginx. Если 80/443 держит контейнер (Traefik, свой Caddy) — предупреждение: такой прокси nkt не настраивает.
- Цель: сервис compose-стека (стек, сервис, порт контейнера) или
адрес:порт(приложение не в compose). Неопубликованный порт сервиса nkt публикует только на 127.0.0.1 — файломcompose.nkt.ymlрядом со стеком (выкладка и профили его учитывают), так что наружу сервис доступен лишь через прокси. Уже опубликованный на всех адресах порт используется как есть, с предупреждением. - «Настроить» — задание хаба: DNS и порты, прокси (установка, если нужно), затем задание хоста — открыть 80/443 в ufw/firewalld (по галочке), опубликовать сервис, сертификат (действующий дольше 20 дней — как есть, иначе
certbot certonly --standalone: прокси на время выпуска останавливается и запускается снова; продлевает nkt так же), конфигурация прокси через «Конфигурации» — с проверкой (nginx -t,haproxy -c,caddy validate), откатом при ошибке и историей версий:- nginx —
conf.d/nkt-<домен>.conf: 80 → редирект на HTTPS, 443 с HTTP/2, WebSocket и заголовкамиX-Forwarded-*; - HAProxy — блок
nkt-sitesвhaproxy.cfg(все сайты nkt хоста: frontend 80/443 с SNI и backend на сайт), сертификат — объединённым PEM в/etc/haproxy/nkt-certs; если 80/443 уже слушает другой frontend — отказ с объяснением; - Caddy —
/etc/caddy/nkt/<домен>.caddy, подключённый из Caddyfile; сертификат Caddy получает и продлевает сам (ключи certbot ему, не root, не прочитать).
- nginx —
- Проверка HTTPS снаружи — код ответа и срок сертификата; «Проверить» в строке сайта повторяет её в любой момент.
«Удалить» — задание хаба: на хосте убирается конфигурация прокси (с записью в историю), снимается публикация сервиса на 127.0.0.1 (сервис пересоздаётся без неё), по галочке — удаляется сертификат (если он не нужен другому сайту); затем сайт удаляется с хаба. Не получилось — сайт остаётся с пометкой «удаление не завершено», кнопка — «Повторить удаление». Сайт из конвейера вернёт следующая выкладка, если не убрать блок site:.
Сайт в конвейере
Сайт можно описать прямо в конвейере action: compose — блоком site: вместо строки:
compose:
file: deploy/docker-compose.yml
project: shop
hosts: [web1] # сайт — только при одном хосте
site:
domains: [shop.example.com, www.shop.example.com]
service: web # сервис стека
port: 80 # порт контейнера
proxy: nginx # необязательно: иначе какой есть на хосте, нет ни одного — nginx
firewall: true # открыть 80/443 (по умолчанию да)
cert: manual # сертификат: manual (по умолчанию), auto или /путь к своему файлу- Первая выкладка: стек, затем сайт — тем же путём, что «Настроить» в мастере (DNS и порты снаружи, прокси, публикация на 127.0.0.1, сертификат, конфигурация, HTTPS). Сайт появляется во вкладке «Сайты» с пометкой «конвейер …».
- Следующие выкладки: сайт настроен и в
site:ничего не менялось — только проверка HTTPS, сертификат заново не выпускается. Поменялись имена, сервис, порт, прокси или хост — сайт настраивается заново (со старого хоста конфигурация прокси убирается). - Не настроился (DNS ещё не указывает на хост, порт 80 закрыт) — выкладка всё равно удалась: стек обновлён. Причина — в журнале выкладки и у сайта во вкладке «Сайты».
port— порт внутри контейнера, тот, что слушает образ, а не порт хоста: в"127.0.0.1:8080:80"это последнее число,80(port: 8080здесь — ошибка с подсказкой).port: autoили безport— порт берётся из образа, если тот объявляет ровно один. Публикация уже есть вports:(127.0.0.1:8080:80) — прокси смотрит на неё (127.0.0.1:8080), второй не будет; нет — nkt публикует сам на 127.0.0.1. До установки прокси и выпуска сертификата хаб сверяет его с портами, которые объявляет образ (EXPOSE): не тот порт — ошибка сайта с подсказкой («образ kennethreitz/httpbin объявляет только порт 80»), сертификат не тратится. Образ на хосте ещё не скачан — егоEXPOSEсухой прогон берёт из registry без скачивания (манифест под архитектуру хоста и конфигурация образа; публичные Docker Hub, ghcr.io, quay.io — без входа), в журнале — «порты образа … — из registry». Registry не ответил — порты известны только изports/exposecompose, а это не доказательство: тогда не ошибка, а предупреждение «сайт проверится после выкладки». Образ портов не объявляет — проверить не с чем; если сайт ответил 502, журнал подскажет проверить порт. Другие публикации у сервиса сайта (у Forgejo — SSH2222:2222вcompose.ports) сайту не мешают: журнал просто отмечает, что они идут отдельно.- Чего нет на хосте — ставится: прокси и
certbot(для выпуска и продления сертификата; с Caddy не нужен) — пакетами дистрибутива, фоновыми заданиями хоста, журнал — в журнале сайта. Хост безapt-get— ошибка с просьбой поставить вручную. Docker и compose проверяются до выкладки (кнопка «Установить Docker»), git на хабе — плашка «Установить git». - Сухой прогон показывает, что будет с сайтом: прокси (или что он будет установлен), certbot, файрвол хоста (выключен, 80/443 уже открыты, будут открыты или правило не записать — тогда открыть вручную), DNS и порты снаружи, есть ли такой сервис в стеке и объявлен ли порт — несовпадение порта считается проблемой.
- Сертификат по умолчанию — вручную (
cert: manualили безcert). Выкладка не запускает certbot и не останавливает прокси: она берёт готовый действующий сертификат Let's Encrypt на все имена из/etc/letsencrypt/live— выпущенный в «Сертификатах», руками или раньше. Нет его или истёк — сайт не настраивается («выпустите в „Сертификатах“ или укажитеcert: auto»), стек при этом выложен; осталось меньше 30 дней — строка в журнале. Продление — автопродление nkt или «Сертификаты».cert: auto— как раньше: годный сертификат (больше 20 дней) берётся, иначе выпускcertbot --standalone, прокси (nginx, HAProxy) на время проверки останавливается — сайты на нём эти секунды недоступны.cert: /etc/ssl/app.pem— свой файл: сертификат на все имена (подходит и*.домен), ключ в том же файле или отдельно —cert_key: /etc/ssl/app.key. Для HAProxy nkt собирает из них один PEM.- Сухой прогон пишет, какой сертификат будет взят и сколько ему осталось, или что его нет (это проблема).
- Прокси настроен вручную. Если у HAProxy на хосте свой frontend на 80/443 или имя уже в чужом
servernginx, nkt эту конфигурацию не трогает: сайт получает состояние «прокси вручную» (причина — в подсказке во вкладке «Сайты»), а выкладки дальше только проверяют HTTPS — ни настройки, ни certbot, ни остановки прокси. Чтобы nkt и не пытался, запишитеsite:строкой. Выйти из этого состояния — поменять сайт вsite:или «Настроить» во вкладке «Сайты». - Строка
site: имяпо-прежнему — только проверка HTTPS после выкладки. - Правка такого сайта в мастере сработает, но следующая выкладка вернёт настройки из
site:, если они расходятся.
Когда выкладывать
- Кнопка «Выложить» — вершина ветки из описания; можно указать другую ветку или тег репозитория и тег образа.
- Вебхук — push в ветку
refили тег по шаблонуtags; запрос из CI с тегом образа (см. ниже). - Опрос (
poll) — хаб сам видит новый коммит в ветке. - Registry (
registry) — хаб видит новый тег образа, новее прежнего (сравнение по числам:v1.10.0новееv1.9.3), и выкладывает веткуrefс этим тегом.
Переключатель «Включён» в списке конвейеров — только об автоматических выкладках (вебхук, опрос, registry); выключенный конвейер помечен «только вручную», кнопка «Выложить» работает всегда.
Опрос и registry начинают работать после первой выкладки кнопкой — только что сохранённый конвейер сам ничего не выкладывает. Выключенный конвейер («Включён» снят) не реагирует ни на что, кроме кнопки. Пока выкладка конвейера идёт, новая от опроса не запускается. Упавшая выкладка не повторяется опросом и registry: хаб запоминает коммит (или тег), на котором она упала, и ждёт нового; в строке конвейера — «ждёт нового коммита», в журнале упавшей выкладки — об этом строка. Кнопкой «Выложить» её можно запустить в любой момент.
Вебхук
Окно «Вебхук» конвейера: адрес (напрямую к хабу — https://хаб/api/hub/hooks/<id>, и через nkt-edge, если он настроен), «Показать секрет» (с записью в журнал действий), «Новый секрет» и готовые инструкции для GitHub, GitLab и шага CI.
Подпись обязательна; поддерживаются:
| Отправитель | Заголовки | Проверка |
|---|---|---|
| GitHub | X-Hub-Signature-256: sha256=…, X-GitHub-Delivery | HMAC-SHA256 тела |
| Gitea, Forgejo | X-Gitea-Signature / X-Forgejo-Signature | HMAC-SHA256 тела |
| GitLab | X-Gitlab-Token | секрет как есть |
| Шаг CI (nkt) | X-NKT-Timestamp, X-NKT-Signature | HMAC-SHA256 строки <отметка>.<тело>, отметка не старше 5 минут |
- Повтор той же доставки отвергается: хаб помнит идентификаторы доставок (у подписи nkt — саму подпись).
- Ответы скупые: и неизвестный адрес, и неверная подпись —
401, чтобы подбором нельзя было узнать настоящий. Тело — до 1 МБ. pingот GitHub отвечаетpong.- Из тела берутся только
ref(refs/heads/…илиrefs/tags/…), коммит (after,checkout_shaилиcommit) иtag. Удаление ветки или тега, другая ветка, тег не по шаблону —200сignoredи причиной.
Подпись nkt из шага CI — когда образ уже собран и нужно выложить ветку с его тегом:
BODY=$(printf '{"ref":"refs/heads/main","commit":"%s","tag":"%s"}' "$SHA" "$TAG")
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$NKT_HOOK_SECRET" -hex | sed 's/^.* //')
curl -fsS -X POST "$NKT_HOOK_URL" \
-H "Content-Type: application/json" \
-H "X-NKT-Timestamp: $TS" -H "X-NKT-Signature: $SIG" \
-d "$BODY"Без ref (только {"tag":"v1.2.3"}) хаб выложит ветку из описания с этим тегом. Ответ 202 — {"status":"deploying","deployment_id":…}.
Готовые конвейеры CI — на странице Примеры CI/CD.
Секреты
Окно «Секреты» открывается кнопкой у конвейера в списке и кнопкой «Секреты» в окне описания. У нового конвейера она сначала сохраняет его с введённым именем, а без имени — с именем из project: описания или из ссылки на compose; окно дальше правит уже сохранённый конвейер. В окне три вкладки: «Репозитории», «Реестры» и «.env». При открытии и после записи ключа вкладка «Репозитории» сразу проверяет доступ сохранёнными ключами: git ls-remote по repo: — «доступ есть, ветка найдена», «нет доступа» с причиной (Permission denied, неверный токен) или «ветки нет»; если в описании есть registry: — вход в registry и число тегов. «Проверить снова» — повтор.
Если в окне описания уже вставлена ссылка на compose, а «Заполнить описание» ещё не нажимали, «Секреты» сначала заполняет описание из ссылки — проверяется её репозиторий, а не адрес-заглушка шаблона (github.com/org/app; если описание так и осталось шаблоном, проверка прямо об этом и говорит). Причины отказа — словами (исходный вывод git — в подсказке):
| Причина | Что значит |
|---|---|
| закрыт или его нет | ключей нет, а сервер просит войти — так он отвечает и на закрытый, и на несуществующий репозиторий |
| токен не подошёл | неверный, истёк или без права на чтение: Forgejo/Gitea — repository: Read, GitHub — Contents: Read, GitLab — read_repository |
| ключ ssh не подходит | не добавлен в ключи развёртывания репозитория |
| ключи приняты, репозитория нет | опечатка в адресе |
| сервер недоступен | имя не разрешается, нет соединения или сертификат не принят |
Токен — просто токеном или логин:токен. Хаб входит так же, как обычный git: сервер отвечает «нужен вход», и git отдаёт логин и токен (без заранее отправленного заголовка — его отбрасывают некоторые прокси перед сервером). Без логина в поле: для GitLab — oauth2, для остальных (GitHub, Forgejo, Gitea, Codeberg) логин не важен. Сохранённые пароли системы (credential.helper) git хаба не использует — только доступ конвейера. Проверка показывает последние четыре знака сохранённого токена: сверьте с токеном на сервере. После записи ключей надпись «репозиторий закрыт» в окне описания пропадает, а «Заполнить описание» отмечает, что файл скачан с ключами конвейера.
Вкладки «Секретов»:
- Репозитории — ключ основного репозитория (
repo:): токен (дляhttps://, например GitHub fine-grained с Contents: read) или закрытый ключ развёртывания (дляssh/git@). Ниже — ключи других репозиториев по началу адреса:github.com/vendor/— ко всем репозиториям vendor,git.example.com/team/lib— к одному. Нужны для submodules: если в репозитории есть.gitmodules, хаб сам скачивает их (git submodule update --init --recursive, сначала только последний коммит), и каждый submodule получает ключ по своему адресу — токен или ключ ssh, разные у разных серверов; относительные адреса submodules (../lib.git) считаются отrepo:. Самое длинное совпавшее начало адреса побеждает; основной ключ относится только кrepo:. «Заменить» — новый ключ вместо прежнего, «Удалить» — убрать. - Реестры — список ключей registry: адрес (
harbor.example.com,ghcr.io,registry.example.com:5000), логин, токен и при необходимости свой CA (PEM текстом или из файла) — для registry с сертификатом не от публичного центра. Ключей сколько угодно, у каждого образа стека свой: сухой прогон проверяет закрытый образ с хаба ключом его registry («есть в registry, проверено ключом конвейера»), выкладка скачивает образы на хосте с ключами всех registry стека — хост получает их только на время скачивания: Docker — во временном конфиге (docker --config), Podman — во временном--authfile; файл 0600 в каталоге данных nkt удаляется сразу после, свойdocker login/podman loginна хосте не нужен. Образы из открытых registry (postgres,redis) — без ключа. Тем же ключом хаб следит за тегами (registry:). Колонка «Вход» — проверка входа в каждый registry его ключом; «Изменить» — адрес, логин, CA (пустой токен — прежний), «Удалить» — убрать. CA хаб использует при проверке, а хост при выкладке кладёт его вcerts.dсвоего движка —/etc/docker/certs.d/<registry>/ca.crtили/etc/containers/certs.d/<registry>/ca.crt(только если файл другой); системное доверие хоста не меняется. Прежний одиночный ключ (логин:токенбез адреса) показан строкой «registry не указан» и относится, как раньше, к registry изregistry:, а без него — к единственному registry стека, кроме Docker Hub; «Изменить» привяжет его к адресу. - .env — для
action: compose: переменные окружения стека (KEY=значениепо строке). Порядок строк важен, только если значение ссылается на другую переменную (FORGEJO_ROOT_URL=https://${FORGEJO_DOMAIN}/): docker compose подставляет в.envлишь то, что задано выше, поэтому такая переменная должна стоять ниже той, на которую ссылается, — иначе сухой прогон назовёт строку и скажет, что поднять.
Хранятся на хабе зашифрованными, в описание, журналы и командные строки не попадают (git получает их через окружение); показать нельзя — только заменить или убрать. Хабу нужна программа git (в образе хаба она есть): если её нет, на вкладке «Конвейеры» — плашка и кнопка «Установить git» (пакет из репозитория дистрибутива, фоновым заданием машины хаба).
.env стека. Каждая выкладка пишет его в /srv/compose/<стек>/.env (0600) и заменяет то, что на хосте; конвейер без .env файл на хосте не трогает. «Править .env» на вкладке «.env» открывает текущий .env в редакторе (значения — секреты: показ по подтверждению, с записью в журнал действий); перед записью — разница по ключам: добавлены, изменены, удалены (значения не показываются). Вставка нового .env целиком сначала показывает, какие имена переменных появятся и пропадут. «История .env» там же: каждая смена — версия (зашифрована); разница — только по именам (+ добавлена, − убрана, ~ изменено значение); «Показать значения» — администратору, с записью в журнал действий; «Вернуть» делает версию текущей (на хосты — со следующей выкладкой). Если .env на хосте правили вручную после прошлой выкладки, журнал выкладки и сухой прогон об этом скажут: хаб сравнивает файл с хэшем того, что записал сам.

История и откат
Каждая выкладка — задание хаба с журналом: что (коммит, ветка, тег), кто запустил (кнопка, вебхук, опрос, registry, откат), когда и чем кончилось. «Откатить» у прежней удачной выкладки выкладывает заново её коммит и тег — образ уже лежит в registry, пересборки нет. Галочка «вернуть и .env той выкладки» возвращает версию .env, с которой она шла; без неё .env остаётся текущим.
Удаление конвейера compose — задание хаба: сайт конвейера, затем на каждом хосте compose down (контейнеры и сеть) и каталог стека, затем запись на хабе с историей. Без галочки «тома» каталог стека не стирается, а переносится в /srv/compose/.nkt-removed/<стек>-<время> — данные bind-mount (./data) и тома docker сохраняются; с галочкой — down -v и каталог удаляется целиком. Образы и сертификат — тоже по галочкам.
Что и где убирается. Окно удаления показывает план по каждому хосту, где стек этого конвейера выложен на деле (по запомненному положению и оставшимся старым стекам; у конвейера, выложенного до v1.11.139, — по описанию):
- «уберётся» — с галочкой; снять галочку — стек на этом хосте останется (окно предупредит, что им больше никто не будет управлять);
- «оставлен — тот же стек у конвейера …» — стек с тем же именем на том же хосте числится и за другим конвейером: это один каталог
/srv/compose/<имя>, и удаление одного конвейера не сносит выкладку другого; - «хост недоступен — останется» и «хоста нет на хабе».
Хосты из описания или группы, куда этот конвейер ни разу не выкладывался, не трогаются. Удаление без выбора (через API) ведёт себя как раньше: неработающий хост — ошибка, конвейер остаётся с пометкой «удаление не завершено» (и выключенным), повтор — «Повторить удаление» с теми же галочками.
«Только убрать с выбранных» — стек убирается с отмеченных хостов, а конвейер остаётся и работает на остальных: отмеченные хосты уходят из hosts: его описания (новая версия в истории). Недоступно, если стек выбирается группой (group:) или в описании не осталось бы ни одного хоста — тогда удаляется конвейер.

Конвейеры manifest, helm и script удаляются только с хаба: выложенное в кластерах остаётся.
Пример
Готовый пример — examples/hello-app: приложение на Python, Dockerfile, CI для GitHub Actions, GitLab CI и Gitea Actions и три варианта выкладки — манифест, Helm, хост с Docker Compose. Разбор по шагам — Примеры CI/CD.
Хаб не виден из интернета
- Включите опрос репозитория или registry — открывать ничего не нужно.
- Или поставьте на VPS nkt-edge — маленькую программу с сертификатом Let's Encrypt, которая принимает только вебхуки и передаёт их хабу по туннелю, который хаб сам к ней держит.
Если не выкладывается
| Симптом | Причина и что делать |
|---|---|
Вебхук 401 | Неверный секрет или подпись; повтор доставки («Redeliver» в GitHub шлёт тот же ID); у подписи nkt — часы CI и хаба расходятся больше чем на 5 минут. Причина — в журнале действий хаба (pipeline.hook.rejected) |
200 ignored: другая ветка | Push не в ref; тег — не по шаблону tags |
ни один кластер не подходит | Имена в clusters не совпадают с разделом «Кластеры», у группы нет кластеров или кластер не «готов» |
unknown field "site_port" / «старая версия nkt не знает полей» | nkt на хосте старее хаба: хаб повторяет запрос без новых полей (порт сайта не проверяется, ручная правка .env не отслеживается) — обновите nkt на хосте |
описание конвейера, строка N «…»: … | Ошибка YAML — строка описания, её текст и пояснение: сбит отступ, у ключа и значение, и блок (site: имя и строки ниже), неизвестный ключ (со списком допустимых), табуляция, не то значение |
| сайт — «чужой сертификат» | Прокси отвечает сертификатом другого сайта: конфигурация этого не действует (не настроилась — см. журнал, или перекрыта другим server) |
ufw … '/etc/ufw/user.rules' is not writable | Служба nkt под песочницей systemd, /etc/ufw не открыт: с 1.11.64 правило ufw добавляется в обход песочницы (а в службе новых установок /etc/ufw открыт). Сайт при этом не останавливается — порты можно открыть вручную: ufw allow 80,443/tcp |
на хабе нет программы git | Кнопка «Установить git» на вкладке «Конвейеры» или apt install git на машине хаба |
в репозитории нет ветки или тега | Опечатка в ref, тег ещё не отправлен (git push origin v1.0.0) |
git …: Authentication failed / Permission denied (publickey) | Закрытый репозиторий без ключа в «Секретах», у токена нет права чтения, ключ не добавлен как deploy key |
в репозитории нет файла … | Путь в manifests, helm.values или script — от корня репозитория |
Helm на хосте не установлен | Поставьте Helm на control plane: вкладка Kubernetes узла → Helm → «Установить Helm» |
Поды ImagePullBackOff | Образ закрытый: сделайте пакет публичным или добавьте в кластер imagePullSecret; тег образа не совпадает с тем, что собрал CI |
| Опрос или registry молчат | Первой выкладки кнопкой ещё не было; конвейер выключен; registry_tags не пропускает теги (по умолчанию — только версии вида 1.2.3) |
выкладка этого конвейера уже идёт | Дождитесь конца текущей — журнал в «Истории» |