セルフホスト出来るRSSリーダーとしてはかなり使いやすいFreshRSS。頻繁にフィードが追加されるわけでもないので、たまにフォード一覧をエクスポートしておき、移行時もエクスポートしたフィードをインポートすればよいのですが、設定まで含めてもっと手軽に復元出来るように、バックアップ・復元スクリプトを作ってみました。
復元時は以下のような動作をします。
- 既存コンテナ前提 —
docker stop→ ボリューム上書き →docker compose up -dの流れで、セットアップ済み環境に対して安全に上書きします - docker-compose.yml は任意復元 — 復元先の設定を活かしたい場合はスキップできます
- tailscale serve を復元先ドメインで再設定 — 元ホストのドメインではなく、復元先の
tailscale statusから取得したドメインで設定し直します - FreshRSSのURL設定の確認を促す — 復元後、管理画面で「FreshRSSのURL」が復元先ドメインになっているか確認するよう案内します
/tmpに作業ディレクトリを作って各ファイルをまとめ、最後にfreshrss_backup_<timestamp>.tar.gz1ファイルとして出力latest.tar.gzシンボリックリンクも更新
バックアップ
/opt/lxd-data/freshrss/20250524_153000/.tar.gz のようなタイムスタンプ付き1ファイルで保存されます。バックアップ中はコンテナを一時停止して整合性を確保します。
mkdir -p /opt/lxd-data/freshrss
cd /opt/lxd-data/freshrss
nano freshrss-backup.sh
#!/bin/bash
set -euo pipefail
# =============================================================
# FreshRSS バックアップスクリプト
#
# バックアップ対象:
# - Dockerボリューム <project>_freshrss_data
# - Dockerボリューム <project>_freshrss_extensions
# - docker-compose.yml
#
# 出力:
# /opt/lxd-data/freshrss/freshrss_backup_<timestamp>.tar.gz
# └── freshrss_backup_<timestamp>/
# ├── freshrss_data.tar.gz
# ├── freshrss_extensions.tar.gz
# ├── docker-compose.yml
# └── backup-info.txt
#
# 実行環境: LXDコンテナ内 (root または sudo)
# =============================================================
FRESHRSS_DIR="/opt/docker/freshrss"
BACKUP_BASE="/opt/lxd-data/freshrss"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
BACKUP_NAME="freshrss_backup_${TIMESTAMP}"
BACKUP_ARCHIVE="${BACKUP_BASE}/${BACKUP_NAME}.tar.gz"
WORK_DIR="/tmp/${BACKUP_NAME}"
# ── カラー出力 ────────────────────────────────────
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
echo ""
echo "════════════════════════════════════════"
echo " FreshRSS バックアップ"
echo "════════════════════════════════════════"
echo ""
# ── 前提確認 ──────────────────────────────────────
if ! command -v docker &>/dev/null; then
echo -e "${RED}ERROR: Docker がインストールされていません${NC}"
exit 1
fi
if ! docker inspect freshrss &>/dev/null; then
echo -e "${RED}ERROR: freshrss コンテナが存在しません${NC}"
exit 1
fi
# ── ボリューム名の自動検出 ────────────────────────
_detect_volume() {
local suffix="$1"
local project
project=$(cd "${FRESHRSS_DIR}" && docker compose config --format json 2>/dev/null \
| python3 -c "import json,sys; print(json.load(sys.stdin).get('name',''))" 2>/dev/null) || project=""
if [ -n "$project" ] && docker volume inspect "${project}_${suffix}" &>/dev/null; then
echo "${project}_${suffix}"
elif docker volume inspect "${suffix}" &>/dev/null; then
echo "${suffix}"
else
echo ""
fi
}
VOL_DATA=$(_detect_volume "freshrss_data")
VOL_EXTENSIONS=$(_detect_volume "freshrss_extensions")
if [ -z "$VOL_DATA" ] || [ -z "$VOL_EXTENSIONS" ]; then
echo -e "${RED}ERROR: Dockerボリュームが見つかりません${NC}"
echo " 現在のボリューム一覧:"
docker volume ls | grep -i freshrss || echo " (freshrss関連ボリュームなし)"
exit 1
fi
echo -e " ボリューム検出:"
echo -e " data : ${VOL_DATA}"
echo -e " extensions : ${VOL_EXTENSIONS}"
# ── 作業ディレクトリ準備 ──────────────────────────
echo ""
echo "==> [1/4] 作業ディレクトリを準備..."
mkdir -p "${WORK_DIR}"
mkdir -p "${BACKUP_BASE}"
echo -e " ${GREEN}✓ ${WORK_DIR}${NC}"
# クリーンアップトラップ
trap 'rm -rf "${WORK_DIR}"' EXIT
# ── docker-compose.yml のバックアップ ────────────
echo ""
echo "==> [2/4] docker-compose.yml をバックアップ..."
if [ -f "${FRESHRSS_DIR}/docker-compose.yml" ]; then
cp "${FRESHRSS_DIR}/docker-compose.yml" "${WORK_DIR}/docker-compose.yml"
echo -e " ${GREEN}✓ docker-compose.yml${NC}"
else
echo -e " ${YELLOW}⚠ ${FRESHRSS_DIR}/docker-compose.yml が見つかりません(スキップ)${NC}"
fi
# ── Dockerボリュームのバックアップ ───────────────
echo ""
echo "==> [3/4] Dockerボリュームをバックアップ..."
CONTAINER_STATUS=$(docker inspect --format='{{.State.Status}}' freshrss 2>/dev/null || echo "unknown")
echo -e " コンテナ状態: ${CONTAINER_STATUS}"
if [ "$CONTAINER_STATUS" = "running" ]; then
echo -e " ${YELLOW}⚠ バックアップのためコンテナを一時停止します${NC}"
docker stop freshrss
STOPPED_BY_SCRIPT=true
else
STOPPED_BY_SCRIPT=false
fi
echo -n " ${VOL_DATA} ... "
docker run --rm \
-v "${VOL_DATA}":/source:ro \
-v "${WORK_DIR}":/backup \
alpine \
tar czf /backup/freshrss_data.tar.gz -C /source .
echo -e "${GREEN}✓${NC}"
echo -n " ${VOL_EXTENSIONS} ... "
docker run --rm \
-v "${VOL_EXTENSIONS}":/source:ro \
-v "${WORK_DIR}":/backup \
alpine \
tar czf /backup/freshrss_extensions.tar.gz -C /source .
echo -e "${GREEN}✓${NC}"
if [ "$STOPPED_BY_SCRIPT" = "true" ]; then
docker start freshrss
echo -e " ${GREEN}✓ コンテナを再起動しました${NC}"
fi
# ── メタデータ保存 ────────────────────────────────
TAILSCALE_DOMAIN=""
if command -v tailscale &>/dev/null && tailscale status &>/dev/null 2>&1; then
TAILSCALE_DOMAIN=$(tailscale status --json | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(d.get('Self', {}).get('DNSName', '').rstrip('.'))
" 2>/dev/null) || TAILSCALE_DOMAIN=""
fi
cat > "${WORK_DIR}/backup-info.txt" <<EOF
backup_timestamp=${TIMESTAMP}
backup_date=$(date "+%Y-%m-%d %H:%M:%S")
source_host=$(hostname)
tailscale_domain=${TAILSCALE_DOMAIN}
freshrss_dir=${FRESHRSS_DIR}
container_status_at_backup=${CONTAINER_STATUS}
vol_data=${VOL_DATA}
vol_extensions=${VOL_EXTENSIONS}
files=docker-compose.yml,freshrss_data.tar.gz,freshrss_extensions.tar.gz
EOF
# ── tar.gz にまとめる ─────────────────────────────
echo ""
echo "==> [4/4] アーカイブを作成..."
echo -n " ${BACKUP_ARCHIVE} ... "
tar czf "${BACKUP_ARCHIVE}" -C "/tmp" "${BACKUP_NAME}"
echo -e "${GREEN}✓${NC}"
# ── latest シンボリックリンク更新 ─────────────────
ln -sfn "${BACKUP_ARCHIVE}" "${BACKUP_BASE}/latest.tar.gz"
echo -e " ${GREEN}✓ ${BACKUP_BASE}/latest.tar.gz -> ${BACKUP_ARCHIVE}${NC}"
# ── 完了 ─────────────────────────────────────────
echo ""
echo "════════════════════════════════════════"
echo -e " ${GREEN}✅ バックアップ完了!${NC}"
echo "════════════════════════════════════════"
echo ""
echo " 📦 アーカイブ: ${BACKUP_ARCHIVE}"
echo ""
echo " 📊 内容:"
tar tzf "${BACKUP_ARCHIVE}" | while read f; do
echo " ${f}"
done
echo ""
echo " 💾 サイズ: $(du -sh "${BACKUP_ARCHIVE}" | cut -f1)"
echo ""
BACKUP_COUNT=$(find "${BACKUP_BASE}" -maxdepth 1 -name "freshrss_backup_*.tar.gz" | wc -l)
echo " 🗂 保存済みバックアップ数: ${BACKUP_COUNT}"
echo ""
if [ "$BACKUP_COUNT" -gt 5 ]; then
echo -e " ${YELLOW}ヒント: バックアップが5件を超えています。古いものの削除を検討してください:${NC}"
echo " ls ${BACKUP_BASE}/*.tar.gz"
echo " rm ${BACKUP_BASE}/freshrss_backup_20XXXXXX_XXXXXX.tar.gz"
echo ""
fi
echo "════════════════════════════════════════"
echo ""
バックアップを実行。
sudo bash freshrss-backup.sh
復元
下記でスクリプトを作成し、バックアップしたファイルを入れて復元します。
mkdir -p /opt/lxd-data/freshrss
cd /opt/lxd-data/freshrss
nano freshrss-restore.sh
#!/bin/bash
set -euo pipefail
# =============================================================
# FreshRSS 復元スクリプト
#
# 前提条件:
# - 復元先に添付セットアップスクリプトでFreshRSSが導入済み
#
# 使い方:
# bash freshrss-restore.sh # /opt/lxd-data/freshrss/ から対話選択
# bash freshrss-restore.sh <ファイル or ディレクトリ> # 直接指定
# 例) bash freshrss-restore.sh /opt/lxd-data/freshrss/freshrss_backup_20260524_002810.tar.gz
# 例) bash freshrss-restore.sh /mnt/usb/freshrss_backup_20260524_002810.tar.gz
#
# 実行環境: LXDコンテナ内 (root または sudo)
# =============================================================
FRESHRSS_DIR="/opt/docker/freshrss"
BACKUP_BASE="/opt/lxd-data/freshrss"
TAILSCALE_PORT=3309
PORT=6060
# ── カラー出力 ────────────────────────────────────
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
NC='\033[0m'
echo ""
echo "════════════════════════════════════════"
echo " FreshRSS 復元スクリプト"
echo "════════════════════════════════════════"
echo ""
# ── 前提確認 ──────────────────────────────────────
if ! command -v docker &>/dev/null; then
echo -e "${RED}ERROR: Docker がインストールされていません${NC}"
exit 1
fi
if ! command -v tailscale &>/dev/null; then
echo -e "${RED}ERROR: tailscale がインストールされていません${NC}"
exit 1
fi
if ! tailscale status &>/dev/null 2>&1; then
echo -e "${RED}ERROR: tailscale が接続されていません。tailscale up を実行してください${NC}"
exit 1
fi
if ! docker inspect freshrss &>/dev/null; then
echo -e "${RED}ERROR: freshrss コンテナが存在しません${NC}"
echo "先にセットアップスクリプトを実行してください"
exit 1
fi
# ── 復元先ボリューム名の自動検出 ──────────────────
_detect_volume() {
local suffix="$1"
local project
project=$(cd "${FRESHRSS_DIR}" && docker compose config --format json 2>/dev/null \
| python3 -c "import json,sys; print(json.load(sys.stdin).get('name',''))" 2>/dev/null) || project=""
if [ -n "$project" ] && docker volume inspect "${project}_${suffix}" &>/dev/null; then
echo "${project}_${suffix}"
elif docker volume inspect "${suffix}" &>/dev/null; then
echo "${suffix}"
else
echo ""
fi
}
VOL_DATA=$(_detect_volume "freshrss_data")
VOL_EXTENSIONS=$(_detect_volume "freshrss_extensions")
if [ -z "$VOL_DATA" ] || [ -z "$VOL_EXTENSIONS" ]; then
echo -e "${RED}ERROR: 復元先のDockerボリュームが見つかりません${NC}"
docker volume ls | grep -i freshrss || echo " (freshrss関連ボリュームなし)"
exit 1
fi
echo -e " 復元先ボリューム:"
echo -e " data : ${VOL_DATA}"
echo -e " extensions : ${VOL_EXTENSIONS}"
# ── バックアップ選択 ──────────────────────────────
ARCHIVE=""
if [ "${1:-}" != "" ]; then
# 引数で直接指定
ARCHIVE="$1"
if [ ! -f "$ARCHIVE" ]; then
echo -e "${RED}ERROR: 指定されたファイルが見つかりません: ${ARCHIVE}${NC}"
exit 1
fi
else
# /opt/lxd-data/freshrss/ から対話選択
mapfile -t ARCHIVE_LIST < <(find "${BACKUP_BASE}" -maxdepth 1 -name "freshrss_backup_*.tar.gz" | sort -r)
if [ ${#ARCHIVE_LIST[@]} -eq 0 ]; then
echo -e "${RED}ERROR: バックアップが見つかりません: ${BACKUP_BASE}/*.tar.gz${NC}"
exit 1
fi
echo ""
echo "利用可能なバックアップ:"
echo ""
for i in "${!ARCHIVE_LIST[@]}"; do
FNAME=$(basename "${ARCHIVE_LIST[$i]}")
SIZE=$(du -sh "${ARCHIVE_LIST[$i]}" 2>/dev/null | cut -f1)
# アーカイブ内の backup-info.txt からホスト名を取得
SOURCE_HOST=$(tar xzOf "${ARCHIVE_LIST[$i]}" --wildcards "*/backup-info.txt" 2>/dev/null \
| grep "^source_host=" | cut -d= -f2) || SOURCE_HOST=""
LATEST_MARK=""
if [ "$(readlink -f ${BACKUP_BASE}/latest.tar.gz 2>/dev/null)" = "${ARCHIVE_LIST[$i]}" ]; then
LATEST_MARK=" ${CYAN}[latest]${NC}"
fi
printf " %2d) %s %-8s 元ホスト: %s%b\n" \
"$((i+1))" "$FNAME" "$SIZE" "$SOURCE_HOST" "$LATEST_MARK"
done
echo ""
read -rp "復元するバックアップ番号を入力 [1-${#ARCHIVE_LIST[@]}]: " SEL
if ! [[ "$SEL" =~ ^[0-9]+$ ]] || [ "$SEL" -lt 1 ] || [ "$SEL" -gt ${#ARCHIVE_LIST[@]} ]; then
echo -e "${RED}ERROR: 無効な選択です${NC}"
exit 1
fi
ARCHIVE="${ARCHIVE_LIST[$((SEL-1))]}"
fi
echo ""
echo -e " 復元元: ${CYAN}${ARCHIVE}${NC}"
# ── アーカイブを展開して情報表示 ─────────────────
WORK_DIR=$(mktemp -d)
trap 'rm -rf "${WORK_DIR}"' EXIT
echo -n " アーカイブを展開中 ... "
tar xzf "${ARCHIVE}" -C "${WORK_DIR}"
INNER_DIR=$(find "${WORK_DIR}" -mindepth 1 -maxdepth 1 -type d | head -1)
echo -e "${GREEN}✓${NC}"
# 必要ファイルの確認
for f in freshrss_data.tar.gz freshrss_extensions.tar.gz; do
if [ ! -f "${INNER_DIR}/${f}" ]; then
echo -e "${RED}ERROR: アーカイブ内に ${f} が見つかりません${NC}"
exit 1
fi
done
INFO_FILE="${INNER_DIR}/backup-info.txt"
if [ -f "$INFO_FILE" ]; then
SOURCE_HOST=$(grep "^source_host=" "$INFO_FILE" | cut -d= -f2)
BACKUP_DATE=$(grep "^backup_date=" "$INFO_FILE" | cut -d= -f2-)
SOURCE_DOMAIN=$(grep "^tailscale_domain=" "$INFO_FILE" | cut -d= -f2)
SRC_VOL_DATA=$(grep "^vol_data=" "$INFO_FILE" | cut -d= -f2)
echo ""
echo " 📋 バックアップ情報:"
echo " 取得日時 : ${BACKUP_DATE}"
echo " 元ホスト : ${SOURCE_HOST}"
echo " 元ドメイン : ${SOURCE_DOMAIN}"
echo " 元ボリューム: ${SRC_VOL_DATA}"
fi
# ── 復元先ドメイン取得 ────────────────────────────
TAILSCALE_DOMAIN=$(tailscale status --json | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(d.get('Self', {}).get('DNSName', '').rstrip('.'))
" 2>/dev/null)
if [ -z "$TAILSCALE_DOMAIN" ]; then
echo -e "${RED}ERROR: Tailscaleドメインを取得できませんでした${NC}"
exit 1
fi
echo ""
echo " 🌐 復元先URL: https://${TAILSCALE_DOMAIN}:${TAILSCALE_PORT}"
echo ""
# ── 最終確認 ──────────────────────────────────────
echo -e "${YELLOW}⚠️ 警告: ${VOL_DATA} / ${VOL_EXTENSIONS} は上書きされます${NC}"
read -rp "続行しますか? [y/N]: " CONFIRM
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
echo "キャンセルしました"
exit 0
fi
# ── [1/5] コンテナ停止 ───────────────────────────
echo ""
echo "==> [1/5] コンテナを停止..."
docker stop freshrss
echo -e " ${GREEN}✓ freshrss 停止${NC}"
# ── [2/5] ボリューム復元 ─────────────────────────
echo ""
echo "==> [2/5] Dockerボリュームを復元..."
echo -n " ${VOL_DATA} ... "
docker run --rm \
-v "${VOL_DATA}":/target \
-v "${INNER_DIR}":/backup:ro \
alpine \
sh -c "rm -rf /target/* /target/..?* /target/.[!.]* 2>/dev/null; tar xzf /backup/freshrss_data.tar.gz -C /target"
echo -e "${GREEN}✓${NC}"
echo -n " ${VOL_EXTENSIONS} ... "
docker run --rm \
-v "${VOL_EXTENSIONS}":/target \
-v "${INNER_DIR}":/backup:ro \
alpine \
sh -c "rm -rf /target/* /target/..?* /target/.[!.]* 2>/dev/null; tar xzf /backup/freshrss_extensions.tar.gz -C /target"
echo -e "${GREEN}✓${NC}"
# ── [3/5] docker-compose.yml 復元(任意) ────────
echo ""
echo "==> [3/5] docker-compose.yml の処理..."
if [ -f "${INNER_DIR}/docker-compose.yml" ]; then
read -rp " docker-compose.yml も復元しますか? [y/N]: " RESTORE_COMPOSE
if [[ "$RESTORE_COMPOSE" =~ ^[Yy]$ ]]; then
cp "${INNER_DIR}/docker-compose.yml" "${FRESHRSS_DIR}/docker-compose.yml"
echo -e " ${GREEN}✓ docker-compose.yml を復元${NC}"
else
echo -e " ${YELLOW}スキップ(現在の docker-compose.yml を維持)${NC}"
fi
else
echo -e " ${YELLOW}バックアップに docker-compose.yml がありません(スキップ)${NC}"
fi
# ── [4/5] コンテナ再起動 ─────────────────────────
echo ""
echo "==> [4/5] コンテナを起動..."
cd "${FRESHRSS_DIR}"
docker compose up -d
echo " ⏳ FreshRSSの起動を待機中(最大60秒)..."
for i in $(seq 1 12); do
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:${PORT}/" 2>/dev/null) || true
if [ -n "$HTTP_CODE" ] && [ "$HTTP_CODE" != "000" ]; then
echo -e "\n ${GREEN}✓ FreshRSS 起動完了 (HTTP ${HTTP_CODE})${NC}"
break
fi
if [ "$i" -eq 12 ]; then
echo -e "\n${RED}ERROR: FreshRSSがタイムアウトしました${NC}"
docker logs freshrss --tail=20
exit 1
fi
sleep 5
echo -n "."
done
# ── [5/5] tailscale serve 再設定 ─────────────────
echo ""
echo "==> [5/5] tailscale serve を再設定..."
tailscale serve --https=${TAILSCALE_PORT} off 2>/dev/null || true
tailscale serve --bg --https=${TAILSCALE_PORT} "http://localhost:${PORT}" || {
echo -e "${RED}ERROR: tailscale serve の設定に失敗しました${NC}"
exit 1
}
echo -e " ${GREEN}✓ tailscale serve 設定完了${NC}"
echo ""
tailscale serve status
# ── 完了 ─────────────────────────────────────────
echo ""
echo "════════════════════════════════════════"
echo -e " ${GREEN}✅ 復元完了!${NC}"
echo "════════════════════════════════════════"
echo ""
echo " 🌐 URL: https://${TAILSCALE_DOMAIN}:${TAILSCALE_PORT}"
echo ""
echo -e " ${YELLOW}⚠️ FreshRSSの設定確認事項${NC}"
echo " ブラウザでログイン後、以下を確認してください:"
echo " 設定 > 認証 > FreshRSSのURL"
echo " → https://${TAILSCALE_DOMAIN}:${TAILSCALE_PORT}"
echo " (元ホストのURLのままの場合は更新が必要)"
echo ""
echo "════════════════════════════════════════"
echo ""
対話式で、バックアップ一覧から番号選択して復元するには下記です。
sudo bash freshrss-restore.sh
もし、タイムスタンプを直接指定するなら下記です。引数にパスを直接渡せるので、USBや別ディレクトリに置いたアーカイブも指定可能です。
bash freshrss-restore.sh /mnt/usb/freshrss_backup_20260524_002810.tar.gz


