#
Инструкция предполагает, что Jenkins уже установлен и доступен, а у вас есть
права администратора в нём. Здесь только то, что нужно сделать в Jenkins и в
проекте, чтобы код автоматически уезжал на сервер приложения.
Сервер приложения подготовлен заранее: у пользователя deploy есть права на
запись в /opt и доступ к Docker. Эти права в инструкции не настраиваются.
Все адреса в примерах — заглушки: app.example.com (сервер приложения),jenkins.example.com (Jenkins снаружи), git.example.com (git-хостинг).
Замените их на свои.
Что получится #
Разработчик ──push──> Git-хостинг
│ webhook или опрос
▼
Jenkins (уже работает)
Checkout → Test → Run → Deploy
│ ssh + scp
▼
Сервер приложения (app.example.com)
/opt/hello-world
docker compose up -d
Где что живёт:
| Что | Где | Зачем |
|---|---|---|
| Код проекта | git-репозиторий | источник для сборки |
| Приложение | /opt/hello-world на целевом сервере | то, что видит пользователь |
| Приватный ключ SSH | только в Jenkins (credential) | чем Jenkins представляется серверу |
| Публичный ключ SSH | только на целевом сервере | кому сервер разрешает вход |
Принципиальный момент: ключей ровно одна пара и два места хранения.
Приватная половина — в Jenkins, публичная — в authorized_keys на сервере.
Раздавать ключи «пользователям Jenkins» не нужно, и в свою рабочую систему
второй конец тоже класть не нужно.
Что понадобится #
- Jenkins с правами администратора
- Плагины (Manage Jenkins → Plugins → Installed):
Git,Pipeline
(workflow-aggregator),Pipeline: Stage View,Timestamper - Сервер приложения: Docker, пользователь
deployс правами на/opt
и на Docker-демон - Свободный порт под приложение (в примере
8090) - Git на машине разработчика
Где физически лежит приложение #
Деплой идёт по SSH под пользователем deploy в каталог из переменнойDEPLOY_PATH — /opt/hello-world. Абсолютный путь указан явно, чтобы результат
не зависел от домашней директории пользователя:
/opt/
└── hello-world/ # создаётся автоматически при первом деплое
├── docker-compose.yml # скопирован из deploy/docker-compose.yml
└── html/
└── index.html # скопирован из hello.txt (стадия Run)
В варианте со сборкой образа (Шаг 6) в этой же папке появляется Dockerfile,
а html/ больше не нужна — страница запекается в образ.
| Путь | Кто создаёт | Что это |
|---|---|---|
/opt/hello-world | Jenkins (mkdir -p) | каталог приложения, владелец deploy |
/opt/hello-world/docker-compose.yml | Jenkins (scp) | описание контейнера |
/opt/hello-world/html/index.html | Jenkins (scp) | статика, монтируется в nginx |
/usr/share/nginx/html | внутри контейнера | куда смотрит nginx (bind-mount ./html) |
Автоматически создаваемые имена: контейнер hello-world-web-1,
сеть hello-world_default (compose-проект называется по имени папки).
Удаляется одной командой docker compose down в том же каталоге.
Путь на стороне Jenkins (не путать): рабочая копия репозитория при сборке
лежит в workspace агента, например /var/jenkins_home/workspace/hello-world.
Это только место сборки, к развёрнутому приложению отношения не имеет.
Шаг 1. Демо-проект #
Структура репозитория (можно взять любой свой проект — важны Jenkinsfile
и файл deploy/docker-compose.yml):
hello-world/
├── Jenkinsfile
├── hello.sh
├── test.sh
└── deploy/
└── docker-compose.yml # то, что поедет на целевой сервер
hello.sh — «сборка»:
#!/usr/bin/env bash
set -euo pipefail
greeting="Hello, World!"
build="${BUILD_NUMBER:-local}"
printf '%s (build %s)\n' "$greeting" "$build" | tee hello.txt
test.sh — «тест»:
#!/usr/bin/env bash
set -euo pipefail
out=$(bash "$(dirname "$0")/hello.sh")
case "$out" in
"Hello, World!"*) echo "OK: $out" ;;
*) echo "FAIL: $out"; exit 1 ;;
esac
deploy/docker-compose.yml — то, что развернётся на сервере:
services:
web:
image: nginx:alpine
restart: unless-stopped
ports:
- "8090:80"
volumes:
- ./html:/usr/share/nginx/html:ro
Репозиторий:
git init -b master
git add -A
git commit -m "hello-world demo"
git remote add origin https://git.example.com/user/hello-world.git
git push -u origin master
Ветка master должна быть веткой по умолчанию в настройках репозитория.
Шаг 2. SSH-ключ для деплоя #
Отдельный ключ именно для CI, без парольной фразы:
ssh-keygen -t ed25519 -f ~/.ssh/jenkins_deploy -N "" -C "jenkins@docker"
Публичную часть — на сервер приложения в authorized_keys пользователяdeploy (эту операцию выполняет тот, у кого есть доступ на сервер):
echo "ssh-ed25519 AAAA... jenkins@docker" | sudo -u deploy tee -a /home/deploy/.ssh/authorized_keys
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys
sudo cat /home/deploy/.ssh/authorized_keys # проверить, что строка добавилась
Приватную часть — в Jenkins: Manage Jenkins → Credentials → System →
Global → Add Credentials:
- Kind:
SSH Username with private key - ID:
jenkins-deploy - Username:
deploy - Private Key: Enter directly → вставить содержимое
~/.ssh/jenkins_deploy - Passphrase: пусто
Важно: credential должен быть в System → Global, а не в личных credentials
админа. Сборки видят только системное хранилище.
Проверка доступа с машины, где лежит ключ:
ssh -i ~/.ssh/jenkins_deploy -o BatchMode=yes deploy@app.example.com \
"id -un; docker --version; docker compose version; ls -ld /opt"
В выводе ls -ld /opt у deploy должны быть права на запись — это
предварительное условие, в инструкции оно не настраивается.
Шаг 3. Jenkinsfile #
Jenkinsfile в корне репозитория:
pipeline {
agent any
options {
timestamps()
}
environment {
DEPLOY_HOST = 'app.example.com'
DEPLOY_PORT = '8090'
DEPLOY_PATH = '/opt/hello-world'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Test') {
steps {
sh 'bash test.sh'
}
}
stage('Run') {
steps {
sh 'bash hello.sh'
}
}
stage('Deploy') {
steps {
withCredentials([sshUserPrivateKey(credentialsId: 'jenkins-deploy',
keyFileVariable: 'KEY',
usernameVariable: 'DEPLOY_USER')]) {
// ключ, вставленный через веб-форму из Windows, может прийти с CRLF
sh '''
tr -d "\\r" < "$KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
SSH="ssh -i /tmp/deploy_key -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10"
SCP="scp -i /tmp/deploy_key -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10"
$SSH "$DEPLOY_USER@$DEPLOY_HOST" "mkdir -p $DEPLOY_PATH/html"
$SCP hello.txt "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/html/index.html"
$SCP deploy/docker-compose.yml "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/docker-compose.yml"
$SSH "$DEPLOY_USER@$DEPLOY_HOST" "cd $DEPLOY_PATH && docker compose up -d"
curl -sf --retry 10 --retry-delay 2 "http://$DEPLOY_HOST:$DEPLOY_PORT" | grep -q "Hello, World!"
echo "deployed: http://$DEPLOY_HOST:$DEPLOY_PORT"
'''
}
}
}
}
}
Ключевые решения:
withCredentials— ключ попадает во временный файл, в лог не печатается.tr -d "\r"— нормализация CRLF после вставки ключа через веб-форму Windows.- У удалённых команд двойные кавычки:
$DEPLOY_PATHподставляется на
стороне Jenkins. В одинарных кавычках он уехал бы на сервер пустой строкой. - Последний
curl | grep— проверка, что приложение реально отвечает.
Без неё пайплайн зелёный даже когда деплой не сработал. - Не добавляйте
when { branch 'main' }в обычный Pipeline job:BRANCH_NAMEтам пустой, стадия молча пропускается, а сборка остаётся зелёной.
Шаг 4. Создание job #
Jenkins → New Item → имя hello-world → Pipeline → OK.
В настройках job:
- Pipeline → Definition:
Pipeline script from SCM - SCM:
Git - Repository URL:
https://git.example.com/user/hello-world.git - Branch Specifier:
*/master - Script Path:
Jenkinsfile - Credentials: если репозиторий приватный — добавить доступ к нему отдельно
Сохранить. Первая сборка — кнопка Build Now.
Шаг 5. Проверка #
В логе сборки должны пройти все стадии, последняя — Deploy:
[Pipeline] { (Deploy)
+ ssh ... deploy@app.example.com mkdir -p /opt/hello-world/html
+ scp hello.txt ...:/opt/hello-world/html/index.html
+ ssh ... cd /opt/hello-world && docker compose up -d
+ curl -sf ... http://app.example.com:8090
deployed: http://app.example.com:8090
Открыть http://app.example.com:8090/ — страница должна содержатьHello, World! (build N), где N — номер сборки Jenkins. Это и есть
доказательство, что деплой настоящий, а не «зелёный статус».
Что реально развёрнуто на сервере:
ssh -i ~/.ssh/jenkins_deploy deploy@app.example.com \
"docker ps --format '{{.Names}} | {{.Image}} | {{.Ports}}'; ls -l /opt/hello-world"
Файлы приложения при этом лежат в /opt/hello-world, а не в Jenkins:
там же, рядом с docker-compose.yml, лежит html/index.html.
Шаг 6. Автозапуск по пушу #
По умолчанию job запускается только по кнопке. Варианты автоматизации:
A. Webhook (нужен удалённый git-сервер).
В настройках репозитория добавить webhook наhttp://jenkins.example.com:8080/github-webhook/ (событие push),
в настройках job включить Build Triggers →GitHub hook trigger for GITScm polling. Для других git-серверов —
их собственный webhook-плагин и его путь.
B. Опрос репозитория.
В настройках job включить Build Triggers → Poll SCM →H/2 * * * * (проверять каждые ~2 минуты). Работает без webhook,
но с задержкой до интервала опроса.
Шаг 7. Сборка образа (необязательно) #
В примере выше образ не собирается: на сервер копируется файл и монтируется
в готовый nginx:alpine. Если нужен именно свой образ:
Dockerfile в корне репозитория:
FROM nginx:alpine
COPY hello.txt /usr/share/nginx/html/index.html
deploy/docker-compose.yml — вместо image: собираем:
services:
web:
build: .
image: hello-world:latest
restart: unless-stopped
ports:
- "8090:80"
В стадию Deploy добавить копирование Dockerfile и саму сборку на сервере:
$SCP Dockerfile "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/"
$SSH "$DEPLOY_USER@$DEPLOY_HOST" "cd $DEPLOY_PATH && docker build -t hello-world:latest . && docker compose up -d"
docker build выполняется на целевом сервере: Jenkins только отдаёт
команду по SSH, Docker внутри самого Jenkins не нужен.
Типичные ошибки #
| Симптом | Причина | Решение |
|---|---|---|
Stage "Deploy" skipped due to when conditional, сборка зелёная | when { branch 'main' } в обычном Pipeline job: BRANCH_NAME пуст | убрать when или использовать expression |
Permission denied (publickey,password) | публичного ключа CI нет в authorized_keys | дописать ключ (Шаг 2) |
incorrect passphrase supplied | ключ с парольной фразой | отдельный ключ без пароля или плагин ssh-agent |
| Credential создан, но сборка его не видит | credential в личном хранилище админа | создать в System → Global |
mkdir в /opt возвращает Permission denied | у deploy нет прав на /opt | это предусловие: права выдаются заранее, вне CI |
permission denied ... Docker daemon | deploy не в группе docker | usermod -aG docker deploy + перелогин |
| Пуш прошёл, Jenkins собирает старое | правки не закоммичены | Jenkins собирает коммит, а не рабочую папку |
Jenkins-Crumb не принят (403) | не передана сессия cookie | использовать одну сессию для crumb и POST |
Стадия Deploy прошла, но на сервере старая версия | docker compose up -d не увидел изменений | добавить --force-recreate или собирать новый образ |
Полезные команды #
# что развёрнуто на целевом сервере
ssh -i ~/.ssh/jenkins_deploy deploy@app.example.com \
"docker ps --format '{{.Names}} | {{.Image}} | {{.Ports}}'; ls -l /opt/hello-world"
# логи приложения на сервере
ssh -i ~/.ssh/jenkins_deploy deploy@app.example.com \
"cd /opt/hello-world && docker compose logs --tail=50"
# снести приложение
ssh -i ~/.ssh/jenkins_deploy deploy@app.example.com \
"cd /opt/hello-world && docker compose down"
Чек-лист #
- Плагины
Git,Pipeline,Stage Viewустановлены - Job создан как
Pipeline script from SCM, ветка*/master, путьJenkinsfile ssh -i jenkins_deploy deploy@app.example.com "docker --version"отвечает без пароля- Credential
jenkins-deployлежит в System → Global, usernamedeploy deployможет писать в/opt(права выданы заранее)- Сборка проходит все стадии, включая
Deploy http://app.example.com:8090отдаёт страницу с номером сборки- На сервере
/opt/hello-worldсодержитdocker-compose.ymlиhtml/index.html - Пуш в
masterзапускает сборку (webhook илиPoll SCM)
