Skip to content

Commit da315e9

Browse files
committed
backup/restore scripts
1 parent 45bbd46 commit da315e9

File tree

5 files changed

+295
-2
lines changed

5 files changed

+295
-2
lines changed

.github/workflows/deploy.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
with:
2727
ref: ${{ github.event.workflow_run.head_sha }}
2828

29-
- name: Upload compose.yaml to VPS
29+
- name: Upload config and scripts to VPS
3030
uses: appleboy/scp-action@v0.1.7
3131
with:
3232
host: ${{ secrets.VPS_HOST }}
@@ -52,6 +52,7 @@ jobs:
5252
fi
5353
5454
cd "${{ secrets.VPS_APP_DIR }}"
55+
5556
IMAGE_TAG="sha-${{ github.event.workflow_run.head_sha }}"
5657
printf 'IMAGE_TAG=%s\n' "$IMAGE_TAG" > .env.deploy
5758
export IMAGE_TAG

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,4 +179,7 @@ cython_debug/
179179

180180
# End of https://www.toptal.com/developers/gitignore/api/django
181181

182+
# Backups
183+
backups/
184+
182185
AGENTS.md

bin/db-backup

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/usr/bin/env bash
2+
3+
### Create a PostgreSQL database backup via Docker Compose.
4+
###
5+
### Runs pg_dump inside the postgres container and streams the
6+
### compressed output to the host filesystem. No extra volumes needed.
7+
###
8+
### Usage:
9+
### $ ./bin/db-backup
10+
###
11+
### Cron example (daily at 03:00):
12+
### 0 3 * * * cd /path/to/maw.sh && ./bin/db-backup >> ./backups/cron.log 2>&1
13+
###
14+
### Environment overrides:
15+
### BACKUP_DIR Backup directory (default: ./backups)
16+
### RETENTION_DAYS Retention period (default: 7)
17+
### COMPOSE_FILE Compose file path (default: compose.yaml)
18+
19+
set -euo pipefail
20+
21+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22+
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
23+
24+
BACKUP_DIR="${BACKUP_DIR:-${PROJECT_DIR}/backups}"
25+
RETENTION_DAYS="${RETENTION_DAYS:-7}"
26+
COMPOSE_FILE="${COMPOSE_FILE:-${PROJECT_DIR}/compose.yaml}"
27+
BACKUP_PREFIX="backup"
28+
29+
# ── Helpers ───────────────────────────────────────────────────────
30+
msg_info() { echo -e "$(date +'%Y-%m-%d %H:%M:%S') \e[1m[INFO]\e[0m $*"; }
31+
msg_success() { echo -e "$(date +'%Y-%m-%d %H:%M:%S') \e[32m[OK]\e[0m $*"; }
32+
msg_error() { echo -e "$(date +'%Y-%m-%d %H:%M:%S') \e[31m[ERROR]\e[0m $*" >&2; }
33+
34+
# ── Pre-flight checks ────────────────────────────────────────────
35+
mkdir -p "$BACKUP_DIR"
36+
37+
if ! docker compose -f "$COMPOSE_FILE" ps --status running --format '{{.Name}}' 2>/dev/null |
38+
grep -q 'postgres'; then
39+
msg_error "PostgreSQL container is not running."
40+
msg_error "Start it with: docker compose -f ${COMPOSE_FILE} up -d postgres"
41+
exit 1
42+
fi
43+
44+
# ── Create backup ────────────────────────────────────────────────
45+
backup_filename="${BACKUP_PREFIX}_$(date +'%Y_%m_%dT%H_%M_%S').sql.gz"
46+
backup_path="${BACKUP_DIR}/${backup_filename}"
47+
48+
msg_info "Starting database backup..."
49+
50+
# -T disables pseudo-TTY so piping works correctly.
51+
# Env vars POSTGRES_USER / POSTGRES_DB are set inside the container
52+
# by the compose environment block.
53+
if docker compose -f "$COMPOSE_FILE" exec -T postgres \
54+
bash -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' |
55+
gzip >"$backup_path"; then
56+
57+
# Guard against an empty dump (e.g. auth failure that exits 0)
58+
if [[ ! -s "$backup_path" ]]; then
59+
msg_error "Backup file is empty — removing ${backup_filename}"
60+
rm -f "$backup_path"
61+
exit 1
62+
fi
63+
64+
backup_size=$(du -h "$backup_path" | cut -f1)
65+
msg_success "Created ${backup_filename} (${backup_size})"
66+
else
67+
msg_error "pg_dump failed"
68+
rm -f "$backup_path"
69+
exit 1
70+
fi
71+
72+
# ── Prune old backups ────────────────────────────────────────────
73+
pruned=$(find "$BACKUP_DIR" -maxdepth 1 -name "${BACKUP_PREFIX}_*.sql.gz" \
74+
-mtime +"$RETENTION_DAYS" -delete -print | wc -l)
75+
76+
if [[ "$pruned" -gt 0 ]]; then
77+
msg_info "Pruned ${pruned} backup(s) older than ${RETENTION_DAYS} days"
78+
fi
79+
80+
remaining=$(find "$BACKUP_DIR" -maxdepth 1 -name "${BACKUP_PREFIX}_*.sql.gz" | wc -l)
81+
msg_success "Done. ${remaining} backup(s) in ${BACKUP_DIR}"

bin/db-restore

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
#!/usr/bin/env bash
2+
# shellcheck disable=SC2016
3+
4+
### Restore a PostgreSQL database from a backup file.
5+
###
6+
### Drops and recreates the database, then loads the SQL dump.
7+
### Stops django/celery to prevent active connections during restore.
8+
###
9+
### Usage:
10+
### $ ./bin/db-restore backups/backup_2026_02_27T03_00_00.sql.gz
11+
### $ ./bin/db-restore --latest
12+
### $ ./bin/db-restore --list
13+
###
14+
### Environment overrides:
15+
### BACKUP_DIR Backup directory (default: ./backups)
16+
### COMPOSE_FILE Compose file path (default: compose.yaml)
17+
18+
set -euo pipefail
19+
20+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21+
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
22+
23+
BACKUP_DIR="${BACKUP_DIR:-${PROJECT_DIR}/backups}"
24+
COMPOSE_FILE="${COMPOSE_FILE:-${PROJECT_DIR}/compose.yaml}"
25+
BACKUP_PREFIX="backup"
26+
27+
# ── Helpers ───────────────────────────────────────────────────────
28+
msg_info() { echo -e "$(date +'%Y-%m-%d %H:%M:%S') \e[1m[INFO]\e[0m $*"; }
29+
msg_success() { echo -e "$(date +'%Y-%m-%d %H:%M:%S') \e[32m[OK]\e[0m $*"; }
30+
msg_warn() { echo -e "$(date +'%Y-%m-%d %H:%M:%S') \e[33m[WARN]\e[0m $*"; }
31+
msg_error() { echo -e "$(date +'%Y-%m-%d %H:%M:%S') \e[31m[ERROR]\e[0m $*" >&2; }
32+
33+
usage() {
34+
echo "Usage: $(basename "$0") [OPTIONS] [BACKUP_FILE]"
35+
echo ""
36+
echo "Arguments:"
37+
echo " BACKUP_FILE Path to a .sql.gz backup file"
38+
echo ""
39+
echo "Options:"
40+
echo " --latest Restore the most recent backup"
41+
echo " --list List available backups and exit"
42+
echo " --no-confirm Skip the confirmation prompt"
43+
echo " -h, --help Show this help message"
44+
}
45+
46+
list_backups() {
47+
local backups
48+
backups=$(find "$BACKUP_DIR" -maxdepth 1 \
49+
-name "${BACKUP_PREFIX}_*.sql.gz" \
50+
-printf '%T@ %p\n' 2>/dev/null |
51+
sort -rn | cut -d' ' -f2-)
52+
53+
if [[ -z "$backups" ]]; then
54+
msg_error "No backups found in ${BACKUP_DIR}"
55+
exit 1
56+
fi
57+
58+
echo "Available backups (newest first):"
59+
echo ""
60+
while IFS= read -r f; do
61+
local size
62+
size=$(du -h "$f" | cut -f1)
63+
echo " $(basename "$f") (${size})"
64+
done <<<"$backups"
65+
}
66+
67+
find_latest() {
68+
find "$BACKUP_DIR" -maxdepth 1 -name "${BACKUP_PREFIX}_*.sql.gz" -printf '%T@ %p\n' 2>/dev/null |
69+
sort -rn | head -1 | cut -d' ' -f2-
70+
}
71+
72+
docker_compose() {
73+
docker compose -f "$COMPOSE_FILE" "$@"
74+
}
75+
76+
# ── Parse arguments ──────────────────────────────────────────────
77+
BACKUP_FILE=""
78+
NO_CONFIRM=false
79+
80+
while [[ $# -gt 0 ]]; do
81+
case "$1" in
82+
--latest)
83+
BACKUP_FILE="$(find_latest)"
84+
if [[ -z "$BACKUP_FILE" ]]; then
85+
msg_error "No backups found in ${BACKUP_DIR}"
86+
exit 1
87+
fi
88+
shift
89+
;;
90+
--list)
91+
list_backups
92+
exit 0
93+
;;
94+
--no-confirm)
95+
NO_CONFIRM=true
96+
shift
97+
;;
98+
-h | --help)
99+
usage
100+
exit 0
101+
;;
102+
-*)
103+
msg_error "Unknown option: $1"
104+
usage
105+
exit 1
106+
;;
107+
*)
108+
BACKUP_FILE="$1"
109+
shift
110+
;;
111+
esac
112+
done
113+
114+
if [[ -z "$BACKUP_FILE" ]]; then
115+
msg_error "No backup file specified."
116+
echo ""
117+
usage
118+
echo ""
119+
list_backups 2>/dev/null || true
120+
exit 1
121+
fi
122+
123+
# ── Validate backup file ────────────────────────────────────────
124+
if [[ ! -f "$BACKUP_FILE" ]]; then
125+
msg_error "File not found: ${BACKUP_FILE}"
126+
exit 1
127+
fi
128+
129+
if [[ ! -s "$BACKUP_FILE" ]]; then
130+
msg_error "File is empty: ${BACKUP_FILE}"
131+
exit 1
132+
fi
133+
134+
backup_size=$(du -h "$BACKUP_FILE" | cut -f1)
135+
msg_info "Backup file: $(basename "$BACKUP_FILE") (${backup_size})"
136+
137+
# ── Pre-flight checks ───────────────────────────────────────────
138+
if ! docker_compose ps --status running --format '{{.Name}}' 2>/dev/null |
139+
grep -q 'postgres'; then
140+
msg_error "PostgreSQL container is not running."
141+
msg_error "Start it with: docker compose -f ${COMPOSE_FILE} up -d postgres"
142+
exit 1
143+
fi
144+
145+
# ── Confirmation ─────────────────────────────────────────────────
146+
if [[ "$NO_CONFIRM" != true ]]; then
147+
msg_warn "This will DROP and RECREATE the database, replacing all data."
148+
msg_warn "Active services (django, celery) will be stopped during restore."
149+
echo ""
150+
read -rp "Are you sure you want to continue? [y/N] " confirm
151+
if [[ "${confirm,,}" != "y" ]]; then
152+
msg_info "Restore cancelled."
153+
exit 0
154+
fi
155+
fi
156+
157+
# ── Stop app services to drop active connections ─────────────────
158+
STOPPED_SERVICES=()
159+
for svc in django celery celerybeat; do
160+
if docker_compose ps --status running --format '{{.Name}}' 2>/dev/null |
161+
grep -q "$svc"; then
162+
msg_info "Stopping ${svc}..."
163+
docker_compose stop "$svc"
164+
STOPPED_SERVICES+=("$svc")
165+
fi
166+
done
167+
168+
# ── Terminate remaining connections ──────────────────────────────
169+
msg_info "Terminating remaining database connections..."
170+
# Vars expand inside the container, not the host
171+
docker_compose exec -T postgres \
172+
bash -c 'psql -U "$POSTGRES_USER" -d postgres -c "
173+
SELECT pg_terminate_backend(pid)
174+
FROM pg_stat_activity
175+
WHERE datname = '"'"'$POSTGRES_DB'"'"'
176+
AND pid <> pg_backend_pid();
177+
"' 2>/dev/null || true
178+
179+
# ── Drop and recreate database ───────────────────────────────────
180+
msg_info "Dropping and recreating database..."
181+
docker_compose exec -T postgres \
182+
bash -c 'dropdb -U "$POSTGRES_USER" --if-exists "$POSTGRES_DB"'
183+
184+
docker_compose exec -T postgres \
185+
bash -c 'createdb -U "$POSTGRES_USER" -O "$POSTGRES_USER" "$POSTGRES_DB"'
186+
187+
# ── Restore ──────────────────────────────────────────────────────
188+
msg_info "Restoring from $(basename "$BACKUP_FILE")..."
189+
190+
if gunzip -c "$BACKUP_FILE" |
191+
docker_compose exec -T postgres \
192+
bash -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" --single-transaction' \
193+
>/dev/null; then
194+
msg_success "Database restored successfully."
195+
else
196+
msg_error "Restore failed. The database may be in an inconsistent state."
197+
msg_error "Consider restoring from another backup or re-running this script."
198+
exit 1
199+
fi
200+
201+
# ── Restart stopped services ────────────────────────────────────
202+
if [[ ${#STOPPED_SERVICES[@]} -gt 0 ]]; then
203+
msg_info "Restarting services: ${STOPPED_SERVICES[*]}..."
204+
docker_compose start "${STOPPED_SERVICES[@]}"
205+
fi
206+
207+
msg_success "Restore complete."

bin/django-entrypoint.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
#!/bin/bash
1+
#!/usr/bin/env bash
2+
23
set -euo pipefail
34

45
python manage.py migrate --noinput

0 commit comments

Comments
 (0)