Skip to content

Commit 3c50687

Browse files
author
peruna
committed
backup doc
1 parent 18fd554 commit 3c50687

File tree

5 files changed

+272
-20
lines changed

5 files changed

+272
-20
lines changed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,4 +307,7 @@ Compose foundation adapted from [nicedexter](https://github.com/nicedexter).
307307

308308
## TODO
309309

310-
- You tell me
310+
- Remaining known constraints (accepted for now):
311+
- `code-interpreter-api` still needs `SYS_ADMIN` + `apparmor:unconfined` for current nsjail runtime.
312+
- `api-proxy` remains dual-homed (`lan` + `wan`) because host port publishing fails when attached only to internal networks in this Docker/Colima setup.
313+
- Search egress is intentionally auditable-not-allowlisted (to preserve SearX/Firecrawl web fetch behavior).

backup/README.md

Lines changed: 84 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,123 @@
1-
# Backups
1+
# Backups and Restore
22

3-
Create `~/Library/LaunchAgents/com.YOURUSER.librechat.backup.plist` (replace `YOURUSER`).
3+
This directory provides:
4+
- `backup_librechat.sh`: snapshots stack named volumes to tarballs.
5+
- `restore_librechat.sh`: restores a chosen snapshot (or latest per volume).
46

7+
Backup layout:
8+
- `~/Backups/LibreChatBackups/volumes/<volume>/<volume>-YYYY-MM-DD_HH-MM-SS.tar.gz`
9+
- If you use Colima, keep `BACKUP_ROOT` under your home directory (`/Users/...`) so Docker bind-mounts are visible.
10+
11+
## Install scripts
12+
13+
```bash
14+
mkdir -p ~/.local/bin ~/Library/Logs ~/Library/LaunchAgents
15+
cp backup/backup_librechat.sh ~/.local/bin/backup_librechat.sh
16+
cp backup/restore_librechat.sh ~/.local/bin/restore_librechat.sh
17+
chmod +x ~/.local/bin/backup_librechat.sh ~/.local/bin/restore_librechat.sh
518
```
19+
20+
## LaunchAgent (daily automatic backup)
21+
22+
Create `~/Library/LaunchAgents/com.YOURUSER.librechat.backup.plist` (replace `YOURUSER`):
23+
24+
```xml
625
<?xml version="1.0" encoding="UTF-8"?>
726
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
827
<plist version="1.0">
928
<dict>
1029
<key>Label</key><string>com.YOURUSER.librechat.backup</string>
11-
1230
<key>ProgramArguments</key>
1331
<array>
1432
<string>/bin/zsh</string>
1533
<string>-lc</string>
1634
<string>/Users/YOURUSER/.local/bin/backup_librechat.sh</string>
1735
</array>
18-
1936
<key>EnvironmentVariables</key>
2037
<dict>
2138
<key>DOCKER_CONTEXT</key><string>colima-aiarm</string>
22-
<key>PROJECT_NET</key><string>librechat-stack_lan</string>
23-
<key>RETENTION_DAYS</key><string>14</string>
39+
<key>PROJECT_NAME</key><string>librechat-stack</string>
40+
<key>BACKUP_ROOT</key><string>/Users/YOURUSER/Backups/LibreChatBackups</string>
41+
<key>RETENTION_DAYS</key><string>30</string>
2442
</dict>
25-
26-
<!-- Run every day at 03:15 local -->
2743
<key>StartCalendarInterval</key>
2844
<dict>
2945
<key>Hour</key><integer>3</integer>
3046
<key>Minute</key><integer>15</integer>
3147
</dict>
32-
3348
<key>RunAtLoad</key><true/>
34-
3549
<key>WorkingDirectory</key><string>/Users/YOURUSER</string>
3650
<key>StandardOutPath</key><string>/Users/YOURUSER/Library/Logs/librechat-backup.log</string>
3751
<key>StandardErrorPath</key><string>/Users/YOURUSER/Library/Logs/librechat-backup.log</string>
3852
</dict>
3953
</plist>
54+
```
4055

56+
Load/reload:
57+
58+
```bash
59+
launchctl bootout "gui/$(id -u)" ~/Library/LaunchAgents/com.YOURUSER.librechat.backup.plist 2>/dev/null || true
60+
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.YOURUSER.librechat.backup.plist
61+
launchctl enable "gui/$(id -u)/com.YOURUSER.librechat.backup"
62+
launchctl kickstart -k "gui/$(id -u)/com.YOURUSER.librechat.backup"
4163
```
4264

43-
Load it:
65+
Check backup agent logs:
4466

67+
```bash
68+
tail -n 200 ~/Library/Logs/librechat-backup.log
4569
```
46-
mkdir -p ~/.local/bin ~/Library/Logs ~/Library/LaunchAgents
47-
chmod +x ~/.local/bin/backup_librechat.sh
48-
launchctl unload ~/Library/LaunchAgents/com.YOURUSER.librechat.backup.plist
49-
launchctl load -w ~/Library/LaunchAgents/com.YOURUSER.librechat.backup.plist
70+
71+
## Manual backup test
72+
73+
```bash
74+
~/.local/bin/backup_librechat.sh
75+
ls -lah "$HOME/Backups/LibreChatBackups/volumes"
5076
```
5177

52-
Run it once by hand to verify:
78+
## Restore runbook
79+
80+
1. Stop the stack first (restore script refuses to run while the compose project is up):
5381

82+
```bash
83+
docker compose --env-file .env \
84+
-f docker-compose.yml \
85+
-f compose.hardening.yml \
86+
-f optional/code-interpreter/compose.yml \
87+
-f optional/local-search/compose.yml \
88+
down
5489
```
55-
~/.local/bin/backup_librechat.sh
56-
open "$HOME/Backups/LibreChatBackups"
90+
91+
2. Inspect what would be restored:
92+
93+
```bash
94+
~/.local/bin/restore_librechat.sh --list
95+
```
96+
97+
3. Restore latest per volume:
98+
99+
```bash
100+
~/.local/bin/restore_librechat.sh --yes
57101
```
102+
103+
Or restore a specific timestamp (example):
104+
105+
```bash
106+
~/.local/bin/restore_librechat.sh --snapshot 2026-03-06_03-15-00 --yes
107+
```
108+
109+
4. Start stack again:
110+
111+
```bash
112+
docker compose --env-file .env \
113+
-f docker-compose.yml \
114+
-f compose.hardening.yml \
115+
-f optional/code-interpreter/compose.yml \
116+
-f optional/local-search/compose.yml \
117+
up -d
118+
```
119+
120+
5. Verify:
121+
- login works at `http://127.0.0.1:3081`
122+
- previous chats/files are present
123+
- API health: `curl -fsS http://127.0.0.1:3081/login >/dev/null && echo ok`

backup/backup_librechat.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,10 @@ backup_volume() {
5858
}
5959

6060
prune_old() {
61+
local prune_dir="$BACKUP_ROOT/volumes"
62+
mkdir -p "$prune_dir"
6163
log "Pruning backups older than ${RETENTION_DAYS} days in $BACKUP_ROOT"
62-
find "$BACKUP_ROOT/volumes" -type f -name '*.tar.gz' -mtime +"$RETENTION_DAYS" -print -delete || true
64+
find "$prune_dir" -type f -name '*.tar.gz' -mtime +"$RETENTION_DAYS" -print -delete || true
6365
}
6466

6567
main() {

backup/restore_librechat.sh

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
#!/usr/bin/env bash
2+
set -Eeuo pipefail
3+
4+
# ========= CONFIG =========
5+
BACKUP_ROOT="${BACKUP_ROOT:-$HOME/Backups/LibreChatBackups}"
6+
DOCKER_CONTEXT="${DOCKER_CONTEXT:-colima-aiarm}"
7+
PROJECT_NAME="${PROJECT_NAME:-librechat-stack}"
8+
VOLUMES_DEFAULT="mongo_data meili_data pgdata2 lbc_api_uploads lbc_api_images lbc_app_logs lbc_app_api_logs lbc_app_data"
9+
VOLUMES="${VOLUMES:-$VOLUMES_DEFAULT}"
10+
SNAPSHOT="${SNAPSHOT:-}" # expected format: YYYY-MM-DD_HH-MM-SS
11+
ASSUME_YES=0
12+
LIST_ONLY=0
13+
14+
log() { printf '%s %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$*"; }
15+
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
16+
17+
usage() {
18+
cat <<'EOF'
19+
Usage:
20+
restore_librechat.sh [--snapshot YYYY-MM-DD_HH-MM-SS] [--volumes "v1 v2"] [--yes] [--list]
21+
22+
Options:
23+
--snapshot <stamp> Restore a specific snapshot timestamp.
24+
If omitted, restores the latest backup per volume.
25+
--volumes "<list>" Space-delimited volume keys (defaults to all stack volumes).
26+
--yes Skip interactive confirmation.
27+
--list Show resolved restore plan and exit.
28+
-h, --help Show this help.
29+
30+
Environment:
31+
BACKUP_ROOT, DOCKER_CONTEXT, PROJECT_NAME, VOLUMES, SNAPSHOT
32+
EOF
33+
}
34+
35+
while [[ $# -gt 0 ]]; do
36+
case "$1" in
37+
--snapshot)
38+
[[ $# -ge 2 ]] || die "--snapshot requires a value"
39+
SNAPSHOT="$2"
40+
shift 2
41+
;;
42+
--volumes)
43+
[[ $# -ge 2 ]] || die "--volumes requires a value"
44+
VOLUMES="$2"
45+
shift 2
46+
;;
47+
--yes)
48+
ASSUME_YES=1
49+
shift
50+
;;
51+
--list)
52+
LIST_ONLY=1
53+
shift
54+
;;
55+
-h|--help)
56+
usage
57+
exit 0
58+
;;
59+
*)
60+
die "Unknown argument: $1"
61+
;;
62+
esac
63+
done
64+
65+
if ! command -v docker >/dev/null 2>&1; then
66+
die "docker CLI not found"
67+
fi
68+
docker context use "${DOCKER_CONTEXT}" >/dev/null 2>&1 || true
69+
70+
volume_exists() { docker volume inspect "$1" >/dev/null 2>&1; }
71+
72+
resolve_volume() {
73+
local short="$1"
74+
local prefixed="${PROJECT_NAME}_${short}"
75+
if volume_exists "${prefixed}"; then
76+
echo "${prefixed}"
77+
elif volume_exists "${short}"; then
78+
echo "${short}"
79+
else
80+
echo ""
81+
fi
82+
}
83+
84+
resolve_archive() {
85+
local short="$1"
86+
local dir="${BACKUP_ROOT}/volumes/${short}"
87+
[[ -d "${dir}" ]] || return 1
88+
89+
if [[ -n "${SNAPSHOT}" ]]; then
90+
local explicit="${dir}/${short}-${SNAPSHOT}.tar.gz"
91+
[[ -f "${explicit}" ]] || return 1
92+
echo "${explicit}"
93+
return 0
94+
fi
95+
96+
ls -1t "${dir}/${short}-"*.tar.gz 2>/dev/null | head -n 1
97+
}
98+
99+
ensure_stack_stopped() {
100+
local running
101+
running="$(
102+
docker ps \
103+
--filter "label=com.docker.compose.project=${PROJECT_NAME}" \
104+
--format '{{.Names}}'
105+
)"
106+
if [[ -n "${running}" ]]; then
107+
printf '%s\n' "${running}" | sed 's/^/ - /'
108+
die "compose project '${PROJECT_NAME}' is running; stop it before restore"
109+
fi
110+
}
111+
112+
restore_volume() {
113+
local short="$1"
114+
local vol archive archive_dir archive_name
115+
116+
vol="$(resolve_volume "${short}")"
117+
[[ -n "${vol}" ]] || die "volume '${short}' not found (looked for '${PROJECT_NAME}_${short}' and '${short}')"
118+
119+
archive="$(resolve_archive "${short}")" || die "no backup archive found for '${short}' (snapshot='${SNAPSHOT:-latest}')"
120+
archive_dir="$(dirname "${archive}")"
121+
archive_name="$(basename "${archive}")"
122+
123+
printf '%s -> %s (%s)\n' "${short}" "${vol}" "${archive_name}"
124+
if [[ "${LIST_ONLY}" -eq 1 ]]; then
125+
return 0
126+
fi
127+
128+
log "Restoring ${short} from ${archive_name}"
129+
docker run --rm \
130+
-v "${vol}:/data" \
131+
-v "${archive_dir}:/backup:ro" \
132+
alpine:3 sh -lc "set -e; find /data -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +; tar -xzf /backup/${archive_name} -C /data"
133+
}
134+
135+
main() {
136+
local requested_list_only="${LIST_ONLY}"
137+
[[ -d "${BACKUP_ROOT}" ]] || die "backup root not found: ${BACKUP_ROOT}"
138+
ensure_stack_stopped
139+
140+
log "Restore plan (snapshot: ${SNAPSHOT:-latest-per-volume})"
141+
LIST_ONLY=1
142+
for v in ${VOLUMES}; do
143+
restore_volume "${v}"
144+
done
145+
146+
if [[ "${requested_list_only}" -eq 1 ]]; then
147+
log "List-only mode complete"
148+
return 0
149+
fi
150+
151+
if [[ "${ASSUME_YES}" -ne 1 ]]; then
152+
printf 'Type YES to overwrite the listed volumes: '
153+
read -r ack
154+
[[ "${ack}" == "YES" ]] || die "restore cancelled by user"
155+
156+
fi
157+
158+
LIST_ONLY=0
159+
log "Applying restore"
160+
for v in ${VOLUMES}; do
161+
restore_volume "${v}"
162+
done
163+
164+
log "Restore complete"
165+
}
166+
167+
main "$@"

scripts/ci/smoke.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,20 @@ if ! docker port api-proxy 81/tcp | grep -q '127.0.0.1:80'; then
164164
echo "api-proxy is missing host bind 127.0.0.1:80->81 for sandpack ingress" >&2
165165
exit 1
166166
fi
167+
api_proxy_compose_block="$(
168+
compose \
169+
--env-file .env \
170+
"${compose_files[@]}" \
171+
config | awk '
172+
/^ api-proxy:$/ { in_block=1; print; next }
173+
/^ [^ ]/ && in_block { exit }
174+
in_block { print }
175+
'
176+
)"
177+
if grep -q '^ environment:' <<<"${api_proxy_compose_block}"; then
178+
echo "api-proxy should not have explicit environment proxy wiring in compose config" >&2
179+
exit 1
180+
fi
167181
if docker port sandpack-bundler 80/tcp >/dev/null 2>&1; then
168182
echo "sandpack-bundler should not expose host port 80 directly" >&2
169183
exit 1

0 commit comments

Comments
 (0)