feat: added restore to backup bb16bbfb
Steve Simkins · 2026-06-17 07:59 3 file(s) · +265 −22
apps/backup/Dockerfile +5 −0
7 7
COPY apps/backup/backup.sh /usr/local/bin/backup.sh
8 8
RUN chmod +x /usr/local/bin/backup.sh
9 9
10 +
# restore.sh ships for reference; run it on the HOST (it shells out to `docker run`
11 +
# and the backup container mounts the data volumes read-only).
12 +
COPY apps/backup/restore.sh /usr/local/bin/restore.sh
13 +
RUN chmod +x /usr/local/bin/restore.sh
14 +
10 15
COPY apps/backup/crontab /etc/cron.d/backup-cron
11 16
RUN chmod 0644 /etc/cron.d/backup-cron && crontab /etc/cron.d/backup-cron
12 17
apps/backup/README.md +34 −22
110 110
111 111
## Restoring from a Backup
112 112
113 -
1. List available backups for a service (e.g. `jotts`):
113 +
Use `restore.sh` to download a backup from R2 and write it into the target Docker volume.
114 +
Run it on the **host** (it shells out to `docker run`; the backup container mounts the data
115 +
volumes read-only). It reads the same `.env` as the backup (`R2_ENDPOINT`, `R2_BUCKET`,
116 +
AWS keys, optional `*_VOLUME` overrides) — `source .env` first, or prefix the env inline.
114 117
115 118
```sh
116 -
aws s3 ls s3://andromeda-backups/jotts/ --endpoint-url https://<account-id>.r2.cloudflarestorage.com
119 +
restore.sh <app|all> [--timestamp <ts>] [--list] [--yes]
117 120
```
118 121
119 -
2. Download the backup you want to restore:
122 +
- `<app>` — one of: `jotts sipp cellar posts feeds library bookmarks parcels easel`, or `all`.
123 +
- `--timestamp <ts>` — restore a specific backup (e.g. `2026-04-04T060000Z`); the
124 +
  `.sqlite.gz` suffix is optional. Default: the latest backup. Not valid with `all`.
125 +
- `--list` — list available backups for the app(s) and exit (no restore).
126 +
- `--yes` — skip the interactive confirmation prompt.
120 127
121 -
```sh
122 -
aws s3 cp s3://andromeda-backups/jotts/2026-04-04T060000Z.sqlite.gz ./restore.sqlite.gz \
123 -
  --endpoint-url https://<account-id>.r2.cloudflarestorage.com
124 -
```
128 +
> **Warning:** Restoring overwrites live data. Stop the target service before restoring and
129 +
> restart it after for a clean restore — `restore.sh` does **not** stop or start services.
125 130
126 -
3. Decompress it:
131 +
Examples:
127 132
128 133
```sh
129 -
gunzip restore.sqlite.gz
130 -
```
134 +
# Load credentials/volume overrides
135 +
set -a; . ./.env; set +a
131 136
132 -
4. Stop the target service so nothing is writing to the database:
137 +
# See what's available
138 +
restore.sh jotts --list
133 139
134 -
```sh
135 -
docker compose -f /path/to/jotts/docker-compose.yml down
136 -
```
140 +
# Restore the latest jotts backup (prompts for confirmation)
141 +
restore.sh jotts
137 142
138 -
5. Copy the restored database into the volume:
143 +
# Restore a specific backup, no prompt
144 +
restore.sh jotts --timestamp 2026-04-04T060000Z --yes
139 145
140 -
```sh
141 -
docker run --rm -v jotts_jotts-data:/data -v $(pwd):/backup debian:bookworm-slim \
142 -
  cp /backup/restore.sqlite /data/jotts.sqlite
146 +
# Restore every app's latest backup
147 +
restore.sh all
143 148
```
144 149
145 -
6. Restart the service:
150 +
For each app the script: resolves the backup key (latest or `--timestamp`), downloads and
151 +
decompresses it, runs `PRAGMA integrity_check`, confirms the target volume exists, then
152 +
copies the database to the volume root via a throwaway `debian:bookworm-slim` container.
146 153
147 -
```sh
148 -
docker compose -f /path/to/jotts/docker-compose.yml up -d
149 -
```
154 +
### Manual restore (fallback)
155 +
156 +
1. List backups: `aws s3 ls s3://andromeda-backups/jotts/ --endpoint-url https://<account-id>.r2.cloudflarestorage.com`
157 +
2. Download: `aws s3 cp s3://andromeda-backups/jotts/<timestamp>.sqlite.gz ./restore.sqlite.gz --endpoint-url <endpoint>`
158 +
3. Decompress: `gunzip restore.sqlite.gz`
159 +
4. Stop the target service so nothing is writing to the database.
160 +
5. Copy into the volume: `docker run --rm -v jotts_jotts-data:/data -v $(pwd):/backup debian:bookworm-slim cp /backup/restore.sqlite /data/jotts.sqlite`
161 +
6. Restart the service.
150 162
151 163
## Configuration
152 164
apps/backup/restore.sh (added) +226 −0
1 +
#!/bin/sh
2 +
set -eu
3 +
4 +
# Restore SQLite backups from Cloudflare R2 into Docker volumes.
5 +
#
6 +
# Usage:
7 +
#   restore.sh <app|all> [--timestamp <ts>] [--list] [--yes]
8 +
#
9 +
#   <app>            One of the registered apps, or "all".
10 +
#   --timestamp <ts> Restore a specific backup (e.g. 2026-04-04T060000Z).
11 +
#                    Default: latest. The .sqlite.gz suffix is optional.
12 +
#   --list           List available backups for the app(s) and exit.
13 +
#   --yes            Skip the interactive confirmation prompt.
14 +
#
15 +
# Env (same as backup.sh): R2_ENDPOINT, R2_BUCKET, AWS_ACCESS_KEY_ID,
16 +
# AWS_SECRET_ACCESS_KEY, and optional <APP>_VOLUME overrides.
17 +
#
18 +
# Run this on the HOST. It shells out to `docker run` to write into volumes;
19 +
# the backup container itself mounts those volumes read-only.
20 +
21 +
BUCKET="${R2_BUCKET:-andromeda-backups}"
22 +
23 +
# app | volume-internal filename | volume env var | default volume name
24 +
APPS="jotts:jotts.sqlite:JOTTS_VOLUME:jotts_jotts-data
25 +
sipp:sipp.sqlite:SIPP_VOLUME:sipp_sipp-data
26 +
cellar:cellar.sqlite:CELLAR_VOLUME:cellar_cellar-data
27 +
posts:posts.sqlite:POSTS_VOLUME:posts_posts-data
28 +
feeds:feeds.sqlite:FEEDS_VOLUME:feeds_feeds-data
29 +
library:library.sqlite:LIBRARY_VOLUME:library_library-data
30 +
bookmarks:bookmarks.sqlite:BOOKMARKS_VOLUME:bookmarks_bookmarks-data
31 +
parcels:parcels.db:PARCELS_VOLUME:parcels_parcels_data
32 +
easel:easel.sqlite:EASEL_VOLUME:easel_easel-data"
33 +
34 +
RESTORE_IMAGE="debian:bookworm-slim"
35 +
36 +
usage() {
37 +
  sed -n '6,19p' "$0" | sed 's/^# \{0,1\}//'
38 +
  exit "${1:-0}"
39 +
}
40 +
41 +
die() {
42 +
  echo "ERROR: $*" >&2
43 +
  exit 1
44 +
}
45 +
46 +
# Look up a field for an app from the APPS registry.
47 +
# $1 app name, $2 field index (2=file, 3=env var, 4=default volume)
48 +
app_field() {
49 +
  echo "$APPS" | while IFS=: read -r name file env def; do
50 +
    [ "$name" = "$1" ] || continue
51 +
    case "$2" in
52 +
      2) echo "$file" ;;
53 +
      3) echo "$env" ;;
54 +
      4) echo "$def" ;;
55 +
    esac
56 +
    break
57 +
  done
58 +
}
59 +
60 +
app_exists() {
61 +
  echo "$APPS" | cut -d: -f1 | grep -qx "$1"
62 +
}
63 +
64 +
# Resolve the Docker volume name for an app, honoring the <APP>_VOLUME override.
65 +
resolve_volume() {
66 +
  app="$1"
67 +
  env_var=$(app_field "$app" 3)
68 +
  def=$(app_field "$app" 4)
69 +
  # Indirect env lookup that works in POSIX sh.
70 +
  val=$(eval "printf '%s' \"\${$env_var:-}\"")
71 +
  if [ -n "$val" ]; then
72 +
    echo "$val"
73 +
  else
74 +
    echo "$def"
75 +
  fi
76 +
}
77 +
78 +
list_backups() {
79 +
  app="$1"
80 +
  echo "Backups for $app (s3://$BUCKET/$app/):"
81 +
  aws s3 ls "s3://$BUCKET/$app/" --endpoint-url "$R2_ENDPOINT" \
82 +
    | awk '{print "  " $1 " " $2 "  " $4}' \
83 +
    || echo "  (none or unreachable)"
84 +
}
85 +
86 +
# Echo the object key (filename only) to restore for an app.
87 +
resolve_key() {
88 +
  app="$1"
89 +
  if [ -n "$TIMESTAMP" ]; then
90 +
    case "$TIMESTAMP" in
91 +
      *.sqlite.gz) echo "$TIMESTAMP" ;;
92 +
      *) echo "${TIMESTAMP}.sqlite.gz" ;;
93 +
    esac
94 +
    return
95 +
  fi
96 +
  aws s3 ls "s3://$BUCKET/$app/" --endpoint-url "$R2_ENDPOINT" \
97 +
    | awk '{print $4}' | grep -v '^$' | sort | tail -1
98 +
}
99 +
100 +
restore_app() {
101 +
  app="$1"
102 +
103 +
  key=$(resolve_key "$app")
104 +
  if [ -z "$key" ]; then
105 +
    echo "WARN: no backups found for $app, skipping"
106 +
    return 1
107 +
  fi
108 +
109 +
  volume=$(resolve_volume "$app")
110 +
  internal_file=$(app_field "$app" 2)
111 +
  src="s3://$BUCKET/$app/$key"
112 +
113 +
  if ! docker volume inspect "$volume" >/dev/null 2>&1; then
114 +
    echo "WARN: docker volume '$volume' not found for $app, skipping"
115 +
    return 1
116 +
  fi
117 +
118 +
  if [ "$ASSUME_YES" != "1" ]; then
119 +
    echo
120 +
    echo "About to restore:"
121 +
    echo "  app:    $app"
122 +
    echo "  source: $src"
123 +
    echo "  volume: $volume -> /$internal_file"
124 +
    printf "Overwrite live data? [y/N] "
125 +
    read -r answer
126 +
    case "$answer" in
127 +
      y|Y|yes|YES) ;;
128 +
      *) echo "Skipped $app"; return 0 ;;
129 +
    esac
130 +
  fi
131 +
132 +
  workdir=$(mktemp -d)
133 +
  trap 'rm -rf "$workdir"' EXIT INT TERM
134 +
135 +
  echo "$(date -u) Downloading $src ..."
136 +
  aws s3 cp "$src" "$workdir/$key" --endpoint-url "$R2_ENDPOINT" \
137 +
    || { echo "WARN: download failed for $app"; rm -rf "$workdir"; trap - EXIT INT TERM; return 1; }
138 +
139 +
  echo "$(date -u) Decompressing ..."
140 +
  gunzip "$workdir/$key"
141 +
  decompressed="$workdir/${key%.gz}"
142 +
143 +
  echo "$(date -u) Verifying integrity ..."
144 +
  check=$(sqlite3 "$decompressed" 'PRAGMA integrity_check;' 2>&1 || true)
145 +
  if [ "$check" != "ok" ]; then
146 +
    echo "WARN: integrity check failed for $app: $check"
147 +
    rm -rf "$workdir"; trap - EXIT INT TERM
148 +
    return 1
149 +
  fi
150 +
151 +
  echo "$(date -u) Writing into volume $volume ..."
152 +
  docker run --rm \
153 +
    -v "$volume:/data" \
154 +
    -v "$workdir:/backup:ro" \
155 +
    "$RESTORE_IMAGE" \
156 +
    cp "/backup/$(basename "$decompressed")" "/data/$internal_file"
157 +
158 +
  rm -rf "$workdir"
159 +
  trap - EXIT INT TERM
160 +
161 +
  echo "$(date -u) OK: $app restored from $key"
162 +
  echo "WARNING: stop the '$app' service before restoring and restart it after for a clean"
163 +
  echo "         restore. This script does not stop or start services."
164 +
  return 0
165 +
}
166 +
167 +
# --- arg parsing ---------------------------------------------------------------
168 +
169 +
[ $# -ge 1 ] || usage 1
170 +
171 +
TARGET=""
172 +
TIMESTAMP=""
173 +
DO_LIST=0
174 +
ASSUME_YES=0
175 +
176 +
while [ $# -gt 0 ]; do
177 +
  case "$1" in
178 +
    -h|--help) usage 0 ;;
179 +
    --list) DO_LIST=1 ;;
180 +
    --yes|-y) ASSUME_YES=1 ;;
181 +
    --timestamp)
182 +
      shift
183 +
      [ $# -ge 1 ] || die "--timestamp requires a value"
184 +
      TIMESTAMP="$1"
185 +
      ;;
186 +
    --timestamp=*) TIMESTAMP="${1#*=}" ;;
187 +
    -*) die "unknown option: $1" ;;
188 +
    *)
189 +
      [ -z "$TARGET" ] || die "unexpected argument: $1"
190 +
      TARGET="$1"
191 +
      ;;
192 +
  esac
193 +
  shift
194 +
done
195 +
196 +
[ -n "$TARGET" ] || usage 1
197 +
: "${R2_ENDPOINT:?R2_ENDPOINT is required}"
198 +
199 +
if [ "$TARGET" = "all" ]; then
200 +
  if [ -n "$TIMESTAMP" ] && [ "$DO_LIST" != "1" ]; then
201 +
    die "--timestamp cannot be combined with 'all' (timestamps differ per app)"
202 +
  fi
203 +
  TARGETS=$(echo "$APPS" | cut -d: -f1)
204 +
else
205 +
  app_exists "$TARGET" || die "unknown app: $TARGET (valid: $(echo "$APPS" | cut -d: -f1 | tr '\n' ' '))"
206 +
  TARGETS="$TARGET"
207 +
fi
208 +
209 +
if [ "$DO_LIST" = "1" ]; then
210 +
  for app in $TARGETS; do
211 +
    list_backups "$app"
212 +
  done
213 +
  exit 0
214 +
fi
215 +
216 +
failures=0
217 +
total=0
218 +
for app in $TARGETS; do
219 +
  total=$((total + 1))
220 +
  restore_app "$app" || failures=$((failures + 1))
221 +
done
222 +
223 +
if [ "$failures" -gt 0 ] && [ "$failures" -eq "$total" ]; then
224 +
  die "all restores failed ($failures/$total)"
225 +
fi
226 +
echo "$(date -u) Restore complete ($((total - failures))/$total succeeded)"