Я активно использую n8n через Easypanel для автоматизации бизнес-процессов. Столкнулся с проблемой: после каждого обновления контейнера все сохранённые credentials (API ключи, токены доступа, пароли от сервисов) слетали. Приходилось вручную заходить и заново настраивать все подключения к Telegram, Google Sheets, базам данных и другим сервисам.
Обновлять n8n нужно было регулярно - выходят новые фичи, исправления безопасности, но каждый раз тратить 20-30 минут на восстановление credentials было неприемлемо.
Покопавшись в проблеме, понял что дело в отсутствии постоянного ключа шифрования (N8N_ENCRYPTION_KEY). При пересоздании контейнера генерировался новый ключ, и старые зашифрованные данные становились нечитаемыми.
Решил автоматизировать процесс: написал bash-скрипт, который не только обновляет n8n, но и проверяет защиту данных перед обновлением. Бонусом добавил отправку уведомлений в Telegram - теперь вижу когда и что обновилось, не заходя на сервер.
Скрипт работает уже несколько месяцев в продакшене, обновляет два экземпляра n8n каждую ночь. Ни одного слёта credentials. Делюсь решением - возможно, кому-то пригодится.
1. Создать файл
nano /usr/local/bin/n8n_update.sh
Вставить код скрипта (расположенный ниже), сохраните. Или скопирайте код, создайте новый текстовый файл вручную и разместите его тут "/usr/local/bin/"
2. Выдайте права на выполнение
chmod +x /usr/local/bin/n8n_update.sh
3. Добавьте в автозапуск данный файл
(crontab -l 2>/dev/null; echo "0 0 * * * /usr/local/bin/n8n_update.sh") | crontab -
Если требуется отправка уведомлений в личную ТГ группы, то в SEND_TO_TG выставьте true, а вместо "***" вставьте Токен своего бота и ИД чата куда отправлять сообщения об обновлении.
cat > /usr/local/bin/n8n_maintenance.sh << 'EOF' #!/bin/bash set -euo pipefail export TZ=Europe/Moscow # ==================== # НАСТРОЙКИ # ==================== # Telegram BOT_TOKEN="***" CHAT_ID="-***" SEND_TO_TG=false # n8n обновление IMAGE_NAME="n8nio/n8n:latest" VERSION_FILE="/var/log/n8n_version.txt" # Бэкапы баз данных POSTGRES_URL="postgres://postgres:asdasda123dasd@kirill:5432/vechkasov?sslmode=disable" BACKUP_HOST="p650555.backup.ihc.ru" BACKUP_USER="asdasda123dasd" BACKUP_PASS="asdasda123dasd" REMOTE_DIR="backup_easy_panel" # Общие настройки DATE=$(date +%Y-%m-%d) TMP_DIR="/tmp/n8n_maintenance" LOG_FILE="/var/log/n8n_maintenance.log" mkdir -p "$TMP_DIR" LOG_SUMMARY="" # ==================== # ФУНКЦИИ # ==================== send_telegram() { local message="$1" if [ "$SEND_TO_TG" = true ]; then curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \ -d "chat_id=${CHAT_ID}" \ -d "parse_mode=HTML" \ --data-urlencode "text=${message}" > /dev/null 2>&1 || true fi } log_error() { local error_message="$1" echo "$(date '+%Y-%m-%d %H:%M:%S') - ОШИБКА: ${error_message}" >> $LOG_FILE send_telegram "❌ <b>[n8n ОШИБКА]</b> ${error_message}" } get_n8n_version() { local image="$1" docker run --rm --entrypoint="" "$image" n8n --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -n1 || echo "unknown" } # ==================== # НАЧАЛО РАБОТЫ # ==================== echo "========================================" >> $LOG_FILE echo "$(date '+%Y-%m-%d %H:%M:%S') - Скрипт обслуживания n8n запущен" >> $LOG_FILE # Проверка Docker if ! command -v docker &> /dev/null; then log_error "Docker не найден в системе" exit 1 fi if ! docker info > /dev/null 2>&1; then log_error "Нет доступа к Docker" exit 1 fi # ==================== # БЛОК 1: БЭКАПЫ БАЗ ДАННЫХ # ==================== echo "$(date '+%Y-%m-%d %H:%M:%S') - [БЭКАП] Начало резервного копирования" >> $LOG_FILE # PostgreSQL (общий дамп) if [ -n "${POSTGRES_URL:-}" ]; then PG_USER=$(echo "$POSTGRES_URL" | sed -E 's|postgres://([^:]+):.*|\1|') PG_PASS=$(echo "$POSTGRES_URL" | sed -E 's|postgres://[^:]+:([^@]+)@.*|\1|') PG_DB=$(echo "$POSTGRES_URL" | sed -E 's|.*/([^/?]+).*|\1|') PG_HOST=$(echo "$POSTGRES_URL" | sed -E 's|.*@([^:/]+).*|\1|') PG_CONTAINER=$(docker ps --format "{{.Names}}" | grep -m1 "^${PG_HOST}" || true) if [ -z "$PG_CONTAINER" ]; then PG_CONTAINER=$(docker ps --format "{{.Names}} {{.Image}}" | grep postgres | head -n1 | awk '{print $1}') fi if [ -n "$PG_CONTAINER" ]; then PG_FILE="${TMP_DIR}/psql_full_${PG_DB}_${DATE}.sql.gz" echo "$(date '+%Y-%m-%d %H:%M:%S') - [БЭКАП] Дамп PostgreSQL ($PG_DB)" >> $LOG_FILE docker exec -e PGPASSWORD="$PG_PASS" "$PG_CONTAINER" pg_dump -U "$PG_USER" "$PG_DB" | gzip > "$PG_FILE" SIZE=$(du -m "$PG_FILE" | cut -f1) export SSHPASS="$BACKUP_PASS" sshpass -e ssh -o StrictHostKeyChecking=no ${BACKUP_USER}@${BACKUP_HOST} "mkdir -p ${REMOTE_DIR}/db" 2>/dev/null || true sshpass -e scp -o StrictHostKeyChecking=no "$PG_FILE" ${BACKUP_USER}@${BACKUP_HOST}:${REMOTE_DIR}/db/ 2>/dev/null || true LOG_SUMMARY+="✅ PostgreSQL (<code>$PG_DB</code>) — ${SIZE} MB " else LOG_SUMMARY+="⚠️ PostgreSQL контейнер не найден " fi fi # n8n контейнеры (индивидуальные дампы БЕЗ execution_data) for N8N_CONTAINER in $(docker ps --format "{{.Names}}" | grep n8n || true); do SHORT_NAME=$(echo "$N8N_CONTAINER" | cut -d'.' -f1) REMOTE_N8N_DIR="${REMOTE_DIR}/n8n/${SHORT_NAME}" echo "$(date '+%Y-%m-%d %H:%M:%S') - [БЭКАП] Обработка n8n: $N8N_CONTAINER" >> $LOG_FILE DB_NAME=$(docker exec "$N8N_CONTAINER" printenv DB_POSTGRESDB_DATABASE 2>/dev/null || echo "") DB_USER=$(docker exec "$N8N_CONTAINER" printenv DB_POSTGRESDB_USER 2>/dev/null || echo "") DB_PASS=$(docker exec "$N8N_CONTAINER" printenv DB_POSTGRESDB_PASSWORD 2>/dev/null || echo "") DB_HOST=$(docker exec "$N8N_CONTAINER" printenv DB_POSTGRESDB_HOST 2>/dev/null || echo "") DB_PORT=$(docker exec "$N8N_CONTAINER" printenv DB_POSTGRESDB_PORT 2>/dev/null || echo "") DB_TYPE=$(docker exec "$N8N_CONTAINER" printenv DB_TYPE 2>/dev/null || echo "") DB_SSL=$(docker exec "$N8N_CONTAINER" printenv DB_POSTGRESDB_SSL_DISABLED 2>/dev/null || echo "") if [ -n "$DB_NAME" ]; then PG_CONTAINER=$(docker ps --format "{{.Names}}" | grep -m1 "^${DB_HOST}" || true) if [ -z "$PG_CONTAINER" ]; then PG_CONTAINER=$(docker ps --format "{{.Names}} {{.Image}}" | grep postgres | head -n1 | awk '{print $1}' || true) fi if [ -n "$PG_CONTAINER" ]; then PG_FILE="${TMP_DIR}/psql_${DB_NAME}_${DATE}.sql.gz" echo "$(date '+%Y-%m-%d %H:%M:%S') - [БЭКАП] Дамп БД $DB_NAME (БЕЗ execution_data)" >> $LOG_FILE # Дамп БЕЗ таблицы execution_data docker exec -e PGPASSWORD="$DB_PASS" "$PG_CONTAINER" pg_dump -U "$DB_USER" "$DB_NAME" \ --exclude-table-data=execution_data 2>/dev/null | gzip > "$PG_FILE" || true SIZE=$(du -m "$PG_FILE" 2>/dev/null | cut -f1 || echo "0") export SSHPASS="$BACKUP_PASS" sshpass -e ssh -o StrictHostKeyChecking=no ${BACKUP_USER}@${BACKUP_HOST} "mkdir -p ${REMOTE_N8N_DIR}" 2>/dev/null || true sshpass -e scp -o StrictHostKeyChecking=no "$PG_FILE" ${BACKUP_USER}@${BACKUP_HOST}:${REMOTE_N8N_DIR}/ 2>/dev/null || true LOG_SUMMARY+="✅ n8n <code>$SHORT_NAME</code> (<code>$DB_NAME</code>) — ${SIZE} MB " else LOG_SUMMARY+="⚠️ n8n <code>$SHORT_NAME</code> — PostgreSQL не найден " fi fi # Ключ и config.json KEY_FILE="${TMP_DIR}/encryption_key_${SHORT_NAME}.txt" CONFIG_FILE="${TMP_DIR}/config_${SHORT_NAME}.json" # ИСПРАВЛЕНО: Сначала пробуем из переменной окружения, потом из файла config ENC_KEY=$(docker exec "$N8N_CONTAINER" printenv N8N_ENCRYPTION_KEY 2>/dev/null || echo "") if [ -z "$ENC_KEY" ]; then ENC_KEY=$(docker exec "$N8N_CONTAINER" cat /home/node/.n8n/config 2>/dev/null | \ grep '"encryptionKey"' | sed -E 's/.*"encryptionKey": *"([^"]+)".*/\1/' || echo "") fi echo "$ENC_KEY" > "$KEY_FILE" cat > "$CONFIG_FILE" <<CONFIGEOF { "N8N_ENCRYPTION_KEY": "$ENC_KEY", "DB_TYPE": "$DB_TYPE", "DB_POSTGRESDB_HOST": "$DB_HOST", "DB_POSTGRESDB_PORT": "$DB_PORT", "DB_POSTGRESDB_DATABASE": "$DB_NAME", "DB_POSTGRESDB_USER": "$DB_USER", "DB_POSTGRESDB_PASSWORD": "$DB_PASS", "DB_POSTGRESDB_SSL_DISABLED": "$DB_SSL" } CONFIGEOF export SSHPASS="$BACKUP_PASS" sshpass -e ssh -o StrictHostKeyChecking=no ${BACKUP_USER}@${BACKUP_HOST} "mkdir -p ${REMOTE_N8N_DIR}" 2>/dev/null || true [ -s "$KEY_FILE" ] && sshpass -e scp -o StrictHostKeyChecking=no "$KEY_FILE" ${BACKUP_USER}@${BACKUP_HOST}:${REMOTE_N8N_DIR}/encryption_key.txt 2>/dev/null || true [ -s "$CONFIG_FILE" ] && sshpass -e scp -o StrictHostKeyChecking=no "$CONFIG_FILE" ${BACKUP_USER}@${BACKUP_HOST}:${REMOTE_N8N_DIR}/config.json 2>/dev/null || true done # Очистка старых бэкапов (>7 дней) echo "$(date '+%Y-%m-%d %H:%M:%S') - [БЭКАП] Очистка старых дампов (>7 дней)" >> $LOG_FILE CUTOFF_DATE=$(date -d '7 days ago' +%Y-%m-%d) sshpass -e ssh -o StrictHostKeyChecking=no ${BACKUP_USER}@${BACKUP_HOST} bash <<SSHEOF mkdir -p ${REMOTE_DIR}/db ${REMOTE_DIR}/n8n find ${REMOTE_DIR}/db -type f -name "*.sql.gz" 2>/dev/null | while read file; do FILE_DATE=\$(echo "\$file" | grep -oP '\d{4}-\d{2}-\d{2}' | head -1) if [[ "\$FILE_DATE" < "$CUTOFF_DATE" ]]; then rm -f "\$file" 2>/dev/null || true fi done find ${REMOTE_DIR}/n8n -type f -name "*.sql.gz" 2>/dev/null | while read file; do FILE_DATE=\$(echo "\$file" | grep -oP '\d{4}-\d{2}-\d{2}' | head -1) if [[ "\$FILE_DATE" < "$CUTOFF_DATE" ]]; then rm -f "\$file" 2>/dev/null || true fi done SSHEOF echo "$(date '+%Y-%m-%d %H:%M:%S') - [БЭКАП] Завершено" >> $LOG_FILE # ==================== # БЛОК 2: ОБНОВЛЕНИЕ N8N # ==================== echo "$(date '+%Y-%m-%d %H:%M:%S') - [ОБНОВЛЕНИЕ] Проверка обновлений n8n" >> $LOG_FILE CURRENT_VERSION=$(get_n8n_version "$IMAGE_NAME") echo "$(date '+%Y-%m-%d %H:%M:%S') - [ОБНОВЛЕНИЕ] Текущая версия: $CURRENT_VERSION" >> $LOG_FILE PREVIOUS_VERSION="" if [ -f "$VERSION_FILE" ]; then PREVIOUS_VERSION=$(cat "$VERSION_FILE") fi LOCAL_DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' $IMAGE_NAME 2>/dev/null | sed 's/.*@//' || echo "none") REMOTE_DIGEST=$(curl -s "https://registry.hub.docker.com/v2/repositories/n8nio/n8n/tags/latest" \ | grep -o '"digest":"sha256:[^"]*' | head -n1 | cut -d'"' -f4 || echo "") if [ -z "$REMOTE_DIGEST" ]; then echo "$(date '+%Y-%m-%d %H:%M:%S') - [ОБНОВЛЕНИЕ] Не удалось получить удалённый digest" >> $LOG_FILE else echo "$(date '+%Y-%m-%d %H:%M:%S') - [ОБНОВЛЕНИЕ] Локальный: $LOCAL_DIGEST" >> $LOG_FILE echo "$(date '+%Y-%m-%d %H:%M:%S') - [ОБНОВЛЕНИЕ] Удалённый: $REMOTE_DIGEST" >> $LOG_FILE fi BASE_IMAGE_UPDATED=false NEW_VERSION="" if [ "$LOCAL_DIGEST" != "$REMOTE_DIGEST" ] && [ -n "$REMOTE_DIGEST" ] && [ "$REMOTE_DIGEST" != "none" ]; then echo "$(date '+%Y-%m-%d %H:%M:%S') - [ОБНОВЛЕНИЕ] Найдено обновление!" >> $LOG_FILE docker pull $IMAGE_NAME >> $LOG_FILE 2>&1 || true NEW_VERSION=$(get_n8n_version "$IMAGE_NAME") echo "$(date '+%Y-%m-%d %H:%M:%S') - [ОБНОВЛЕНИЕ] Новая версия: $NEW_VERSION" >> $LOG_FILE if [ "$CURRENT_VERSION" != "$NEW_VERSION" ] && [ "$NEW_VERSION" != "unknown" ]; then BASE_IMAGE_UPDATED=true echo "$NEW_VERSION" > "$VERSION_FILE" LOG_SUMMARY+=" 🔄 <b>n8n обновлён:</b> $CURRENT_VERSION → $NEW_VERSION " fi else echo "$(date '+%Y-%m-%d %H:%M:%S') - [ОБНОВЛЕНИЕ] Образ актуален" >> $LOG_FILE fi # Обновление сервисов SERVICES=$(docker service ls --format '{{.Name}}' 2>/dev/null | grep n8n || true) if [ -z "$SERVICES" ]; then LOG_SUMMARY+=" ⚠️ Сервисы n8n не найдены" else UPDATED_COUNT=0 TOTAL_COUNT=0 for SERVICE_NAME in $SERVICES; do TOTAL_COUNT=$((TOTAL_COUNT + 1)) echo "$(date '+%Y-%m-%d %H:%M:%S') - [ОБНОВЛЕНИЕ] Проверка: $SERVICE_NAME" >> $LOG_FILE if [ "$BASE_IMAGE_UPDATED" = true ]; then if docker service update --image $IMAGE_NAME --with-registry-auth --update-order stop-first "$SERVICE_NAME" >> $LOG_FILE 2>&1; then LOG_SUMMARY+="✅ $SERVICE_NAME обновлён " UPDATED_COUNT=$((UPDATED_COUNT + 1)) else LOG_SUMMARY+="❌ $SERVICE_NAME ошибка обновления " fi fi done if [ $UPDATED_COUNT -eq 0 ] && [ "$BASE_IMAGE_UPDATED" = false ]; then LOG_SUMMARY+=" 📌 <b>Версия n8n:</b> <code>$CURRENT_VERSION</code>" fi fi # Очистка старых образов echo "$(date '+%Y-%m-%d %H:%M:%S') - [ОБНОВЛЕНИЕ] Очистка старых образов" >> $LOG_FILE docker image prune -f >> $LOG_FILE 2>&1 || true # ==================== # ЗАВЕРШЕНИЕ # ==================== rm -rf "$TMP_DIR" 2>/dev/null || true echo "$(date '+%Y-%m-%d %H:%M:%S') - Скрипт завершён" >> $LOG_FILE echo "========================================" >> $LOG_FILE # Отправка в Telegram if [ "${SEND_TO_TG}" = true ]; then MESSAGE="🔧 <b>Обслуживание n8n завершено</b> 📅 <code>${DATE}</code> <b>💾 Бэкапы:</b> ${LOG_SUMMARY} <i>🗑️ Файлы старше 7 дней удалены</i>" curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \ -d "chat_id=${CHAT_ID}" \ -d "parse_mode=HTML" \ --data-urlencode "text=${MESSAGE}" > /dev/null 2>&1 || true fi EOF chmod +x /usr/local/bin/n8n_maintenance.sh echo "✅ Скрипт обновлён! Теперь берёт ключ сначала из ENV, потом из config" echo "Тестируем..." sudo /usr/local/bin/n8n_maintenance.sh