Деплой с един скрипт - rsync, SSH и Docker Compose
Малък bash скрипт, който синхронизира статии и картинки към отдалечен сървър и вдига сайта наново с Docker Compose.
Зависимости
bashrsyncopenssh-clientdockerdocker compose Употреба
chmod +x deploy.sh
./deploy.sh
Не всеки проект има нужда от 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 контейнер. Работният процес изглежда така:
- Пишеш или редактираш статия локално.
- Пускаш
./deploy.sh --dry-run, за да видиш какво ще замине. - Пускаш
./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 прави локалната папка абсолютен господар на сървъра. Това е удобно, докато не го забравиш. Пробният режим е точно за такива моменти - когато не си сигурен, пусни първо него.