---
title: "CI/CD в Jenkins: сборка и деплой на удалённый сервер"
description: "# Инструкция предполагает, что **Jenkins уже установлен и доступен**, а у вас есть права администратора в нём. Здесь только то, что нужно сделать в Jenkins и в проекте, чтобы код автоматически уезжал на сервер приложения. Сервер приложения подготовлен заранее: у…"
url: "https://itoservice.ru/docs/ci-cd-%d0%b2-jenkins-%d1%81%d0%b1%d0%be%d1%80%d0%ba%d0%b0-%d0%b8-%d0%b4%d0%b5%d0%bf%d0%bb%d0%be%d0%b9-%d0%bd%d0%b0-%d1%83%d0%b4%d0%b0%d0%bb%d1%91%d0%bd%d0%bd%d1%8b%d0%b9-%d1%81%d0%b5%d1%80%d0%b2/"
updated: "2026-10-26"
category: Инструкции
---

# CI/CD в Jenkins: сборка и деплой на удалённый сервер

#

Инструкция предполагает, что **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, username `deploy`
- `deploy` может писать в `/opt` (права выданы заранее)
- Сборка проходит все стадии, включая `Deploy`
- `http://app.example.com:8090` отдаёт страницу с номером сборки
- На сервере `/opt/hello-world` содержит `docker-compose.yml` и `html/index.html`
- Пуш в `master` запускает сборку (webhook или `Poll SCM`)
