Назад към каталога
Илюстрация на автоматизация: терминал, зъбчати колела и конвейер от задачи
Автоматизация v1.0.0 • 4.10.2026 г. • 🐚 bash

Деплой с един скрипт - rsync, SSH и Docker Compose

Малък bash скрипт, който синхронизира статии и картинки към отдалечен сървър и вдига сайта наново с Docker Compose.

Зависимости

bashrsyncopenssh-clientdockerdocker compose

Употреба

chmod +x deploy.sh ./deploy.sh
bashrsyncsshdockerdocker-composedeployautomation

Не всеки проект има нужда от CI/CD pipeline, GitHub Actions и Kubernetes. Понякога имаш малък сайт, един сървър в локалната мрежа и желание да натиснеш една команда, след което промените просто да се появят.

Точно за такъв случай е този скрипт. Работи на bash, ползва само инструменти, които така или иначе са налични на всяка Linux машина (rsync и ssh), и върши три неща: качва статиите, качва картинките и изгражда Docker контейнера наново. Ще го минем част по част и ще обясним защо е направено точно така.

Целият скрипт

#!/bin/bash
#
# Деплой на сайта: синхронизира статии и картинки към сървъра
# и изгражда Docker контейнера наново.
#
# Употреба:
#   ./deploy.sh             - истински деплой
#   ./deploy.sh --dry-run   - само показва какво ще се промени

set -euo pipefail
trap 'echo "❌ Скриптът спря на ред $LINENO"' ERR

# --- Настройки ---------------------------------------------------------------
LOCAL_PROJECT="/mnt/c/Users/fedia/Desktop/trainbrakes"
REMOTE_SERVER="root@10.20.20.18"
REMOTE_DIR="/root/vlak"

ARTICLES_DIR="src/content/articles"
IMAGES_DIR="public/images"

SSH_CMD="ssh -o BatchMode=yes -o ConnectTimeout=10"
RSYNC_FLAGS=(-avz -e "$SSH_CMD" --exclude='*.bak' --exclude='*.backup' --exclude='.DS_Store')

# --- Пробен режим ------------------------------------------------------------
DRY_RUN=false
if [[ "${1:-}" == "--dry-run" ]]; then
    DRY_RUN=true
    RSYNC_FLAGS+=(--dry-run)
    echo "🧪 Пробен режим - нищо няма да бъде променено"
    echo ""
fi

# --- Проверки преди старт ----------------------------------------------------
for dir in "$ARTICLES_DIR" "$IMAGES_DIR"; do
    if [[ ! -d "$LOCAL_PROJECT/$dir" ]]; then
        echo "❌ Липсва локалната папка: $LOCAL_PROJECT/$dir"
        exit 1
    fi
done

# Със --delete празна локална папка би изтрила всичко на сървъра
if [[ -z "$(ls -A "$LOCAL_PROJECT/$ARTICLES_DIR")" ]]; then
    echo "❌ Папката със статии е празна. Спирам, за да не изтрия съдържанието на сървъра."
    exit 1
fi

# --- Статии (огледално копие) ------------------------------------------------
echo "📁 Копиране на статии..."
rsync "${RSYNC_FLAGS[@]}" --delete \
    "$LOCAL_PROJECT/$ARTICLES_DIR/" \
    "$REMOTE_SERVER:$REMOTE_DIR/$ARTICLES_DIR/"

# --- Картинки (само добавяне и обновяване) -----------------------------------
echo ""
echo "🖼️ Копиране на картинки..."
rsync "${RSYNC_FLAGS[@]}" \
    "$LOCAL_PROJECT/$IMAGES_DIR/" \
    "$REMOTE_SERVER:$REMOTE_DIR/$IMAGES_DIR/"

if $DRY_RUN; then
    echo ""
    echo "🧪 Край на пробния режим. Docker не е пипан."
    exit 0
fi

# --- Rebuild -----------------------------------------------------------------
echo ""
echo "✅ Файловете са копирани"
echo ""
echo "🔄 Rebuild на Docker..."
$SSH_CMD "$REMOTE_SERVER" "cd '$REMOTE_DIR' && docker compose up -d --build && docker image prune -f"

echo ""
echo "✅ Готово! Сайтът е обновен."
echo "   Отвори: http://10.20.20.18/"

Сценарият

Проектът живее локално в папка на Windows, а скриптът се пуска от WSL - пътят /mnt/c/Users/... подсказва това. Сайтът върви на отделна машина в мрежата (10.20.20.18) в Docker контейнер. Работният процес изглежда така:

  1. Пишеш или редактираш статия локално.
  2. Пускаш ./deploy.sh --dry-run, за да видиш какво ще замине.
  3. Пускаш ./deploy.sh и сайтът се обновява.

Без git pull на сървъра и без ръчни SCP-та.

Настройки

LOCAL_PROJECT="/mnt/c/Users/fedia/Desktop/trainbrakes"
REMOTE_SERVER="root@10.20.20.18"
REMOTE_DIR="/root/vlak"

ARTICLES_DIR="src/content/articles"
IMAGES_DIR="public/images"

Всичко, което е специфично за средата, е събрано в началото. Ако сървърът смени адреса си или проектът се премести, редактираш няколко реда, а не търсиш пътища из целия скрипт. Относителните пътища на двете синхронизирани папки са отделени в собствени променливи, защото се използват на няколко места - при проверките, при rsync и в отдалечената команда.

Следващите два реда са по-интересни:

SSH_CMD="ssh -o BatchMode=yes -o ConnectTimeout=10"
RSYNC_FLAGS=(-avz -e "$SSH_CMD" --exclude='*.bak' --exclude='*.backup' --exclude='.DS_Store')

BatchMode=yes казва на SSH никога да не пита за парола или потвърждение. Ако ключът не работи, връзката просто пропада с ясна грешка, вместо скриптът да увисне в очакване на въвеждане. ConnectTimeout=10 прави същото за недостъпен сървър: след десет секунди се отказва, а не чака минути.

Същата SSH команда се подава и на rsync през -e, така че и двата инструмента се държат еднакво.

Флаговете на rsync са в bash масив, а не в един дълъг низ. Причината е кавичките: при масив шаблони като '*.bak' стигат до rsync непроменени и shell-ът не се опитва да ги разшири като файлови имена. Освен това към масива лесно се добавят флагове по-късно, както ще видим при пробния режим.

Защита срещу тихи грешки

set -euo pipefail
trap 'echo "❌ Скриптът спря на ред $LINENO"' ERR

Тези два реда заместват повтарящите се проверки на изходния код след всяка команда:

  • -e - скриптът спира при първата команда, която върне грешка.
  • -u - използването на непосочена променлива е грешка (хваща опечатки в имената).
  • -o pipefail - ако част от pipe се провали, целият pipe се счита за неуспешен.

trap ... ERR изпълнява командата всеки път, когато нещо се провали, и показва на кой ред е станало. Така при проблем не гледаш празен терминал, а знаеш къде да търсиш.

Резултатът е важен за целия процес: ако rsync се провали, скриптът не стига до Docker. Не искаш да изграждаш контейнер върху наполовина обновено съдържание.

Пробен режим

DRY_RUN=false
if [[ "${1:-}" == "--dry-run" ]]; then
    DRY_RUN=true
    RSYNC_FLAGS+=(--dry-run)
    ...
fi

Ако скриптът е пуснат с --dry-run, към флаговете на rsync се добавя --dry-run. Rsync прави всичко, освен реалното копиране и изтриване: показва кои файлове би прехвърлил и кои би изтрил. Ранният изход преди Docker стъпката гарантира, че в този режим сървърът не се променя по никакъв начин.

Това си струва най-вече заради флага --delete, за който след малко. Преди да пуснеш реален деплой след по-голямо преподреждане на статиите, едно изпълнение с --dry-run показва точно какво ще изчезне от сървъра.

${1:-} е вариант на $1 със стойност по подразбиране (празен низ). Без него set -u би спрял скрипта, когато е пуснат без аргументи.

Проверки преди старт

for dir in "$ARTICLES_DIR" "$IMAGES_DIR"; do
    if [[ ! -d "$LOCAL_PROJECT/$dir" ]]; then
        echo "❌ Липсва локалната папка: $LOCAL_PROJECT/$dir"
        exit 1
    fi
done

if [[ -z "$(ls -A "$LOCAL_PROJECT/$ARTICLES_DIR")" ]]; then
    echo "❌ Папката със статии е празна. Спирам, за да не изтрия съдържанието на сървъра."
    exit 1
fi

Първият блок проверява, че двете локални папки съществуват. В WSL това не е формалност: ако Windows дискът не е монтиран по някаква причина, пътят /mnt/c/... просто не води никъде.

Вторият блок е за статиите и има сериозна причина. Статиите се синхронизират с --delete, което значи, че сървърът става точно огледало на локалната папка. Празна (или неправилно сочеща) локална папка би означавала празен сървър след деплоя. Проверката ls -A връща всички записи, включително скритите, и ако няма нито един, скриптът спира, преди rsync да е стартирал.

Синхронизиране на статиите

rsync "${RSYNC_FLAGS[@]}" --delete \
    "$LOCAL_PROJECT/$ARTICLES_DIR/" \
    "$REMOTE_SERVER:$REMOTE_DIR/$ARTICLES_DIR/"

Какво носят флаговете:

  • -a (archive) - рекурсивно копиране със запазени права, времена и символни връзки.
  • -v - подробен изход, за да се вижда какво точно се пренася.
  • -z - компресия по време на трансфера. В локална мрежа ефектът е малък, но за текстови файлове не вреди.
  • --delete - изтрива от сървъра всичко, което го няма в локалната папка.

Защо --delete

Благодарение на него сървърът винаги е точно огледало на локалната папка. Изтриеш ли статия локално, при следващия деплой тя изчезва и от сайта. Без този флаг старите файлове биха оставали завинаги.

Цената е, че локалната папка е единственият източник на истина. Ако някой качи или редактира статия директно на сървъра, следващият деплой ще я изтрие. Затова всички промени се правят само локално.

Изключенията

Файловете *.bak, *.backup и .DS_Store не се качват. Резервните копия от редактора и системните файлове на macOS нямат работа в продукция. Има и една подробност в поведението на rsync: изключените файлове са защитени от --delete. Ако на сървъра случайно има .bak файл, rsync няма да го пипне. За да се изтриват и такива, е нужен флагът --delete-excluded.

Наклонената черта накрая

Двата пътя завършват с / и това е съществено:

# копира СЪДЪРЖАНИЕТО на articles в целевата папка
rsync -av src/articles/ server:/dest/articles/

# създава папка articles ВЪТРЕ в целевата (dest/articles/articles)
rsync -av src/articles server:/dest/articles/

Със завършваща черта rsync разбира “съдържанието на тази папка”, без нея - “самата папка”. В скрипта е използван първият вариант, за да се получи точно огледало.

Картинките

rsync "${RSYNC_FLAGS[@]}" \
    "$LOCAL_PROJECT/$IMAGES_DIR/" \
    "$REMOTE_SERVER:$REMOTE_DIR/$IMAGES_DIR/"

Почти същата команда, но без --delete. Разликата е съзнателна: картинките само се добавят и обновяват, а старите остават на сървъра. Така случайно изтрита или преместена локална снимка не може да счупи статия, която все още я използва. Компромисът е, че с времето на сървъра могат да се натрупат файлове, които вече никой не ползва - те се чистят на ръка, когато наистина се налага.

Rebuild през SSH

$SSH_CMD "$REMOTE_SERVER" "cd '$REMOTE_DIR' && docker compose up -d --build && docker image prune -f"

След като файловете са на място, на сървъра се изпълнява една верига от команди:

  • cd '$REMOTE_DIR' - влизаме в папката на проекта, където е docker-compose.yml. Променливата се разширява локално, а single quotes около нея пазят пътя цял, дори да съдържа интервали.
  • docker compose up -d --build - изгражда образа наново и стартира контейнера на заден план.
  • docker image prune -f - маха неизползваните (dangling) образи, които остават след всеки build. Без това дискът на сървъра бавно се пълни.

Връзката между командите е &&, така че prune се изпълнява само ако build-ът е минал успешно. Ако нещо се счупи, SSH връща грешка, set -e спира скрипта, а trap показва реда.

Rebuild на образа при всеки деплой е нужен, когато съдържанието се вгражда в образа по време на build. Ако при вас то се монтира като volume, често е достатъчно само копирането на файловете - това зависи от Dockerfile и docker-compose.yml на проекта.

Какво е нужно, за да работи

Скриптът е написан да върви без интерактивност, така че изисква:

  • SSH достъп с ключ. BatchMode=yes не позволява въвеждане на парола. Ако ключ още няма:
ssh-keygen -t ed25519
ssh-copy-id root@10.20.20.18
  • rsync и на двете машини - и локално, и на сървъра.
  • Docker с Compose plugin на сървъра, плюс docker-compose.yml в REMOTE_DIR.

В домашна лабораторна мрежа деплой като root е разбираем и удобен. В по-сериозна среда е по-добре да се ползва отделен потребител с достъп до Docker, а не root.

Кога е подходящ този подход

Подходящ е, когато:

  • проектът е личен или от малък екип;
  • сървърът е един и е достъпен по SSH;
  • искаш бързо и предвидимо публикуване без допълнителна инфраструктура.

Не е подходящ, когато имаш нужда от история на деплойте, лесно връщане към предишна версия, автоматични проверки преди публикуване или работа на много хора едновременно. Тогава git-базиран деплой с CI е по-добрият път.

Накратко

Скриптът проверява, че има какво да качва, показва при поискване какво ще промени, синхронизира статиите като огледало, добавя новите картинки и изгражда контейнера наново. При всеки проблем спира и казва къде е станал.

Най-важното за запомняне: --delete прави локалната папка абсолютен господар на сървъра. Това е удобно, докато не го забравиш. Пробният режим е точно за такива моменти - когато не си сигурен, пусни първо него.