Add initial infrastructure and backup scripts for Gitea and homelab deployment
- Create README.md with project layout and quick start instructions - Implement backup scripts for Gitea, including database and repository exports - Add systemd service and timer for automated Gitea backups - Develop bootstrap scripts for homelab and VPS setup - Document architecture and restore procedures - Configure Caddy reverse proxy and Docker Compose for service management - Establish secrets management guidelines
This commit is contained in:
12
backups/scripts/gitea-dump.sh
Normal file
12
backups/scripts/gitea-dump.sh
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
TS="$(date +%F_%H%M%S)"
|
||||
OUT="/srv/backups/gitea/dumps"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
docker exec gitea sh -lc "gitea dump -c /data/gitea/conf/app.ini -f /tmp/gitea-dump-$TS.zip"
|
||||
docker cp "gitea:/tmp/gitea-dump-$TS.zip" "$OUT/"
|
||||
docker exec gitea rm -f "/tmp/gitea-dump-$TS.zip"
|
||||
|
||||
echo "Wrote: $OUT/gitea-dump-$TS.zip"
|
||||
44
backups/scripts/gitea-mirror-export.sh
Normal file
44
backups/scripts/gitea-mirror-export.sh
Normal file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_URL="${GITEA_URL:-https://git.sketchferret.com}"
|
||||
TOKEN_FILE="${TOKEN_FILE:-/srv/secrets/gitea_token}"
|
||||
OUT="${OUT:-/srv/backups/git-mirrors}"
|
||||
OWNER="${OWNER:-spencer}"
|
||||
|
||||
if [[ ! -f "$TOKEN_FILE" ]]; then
|
||||
echo "Missing token file: $TOKEN_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOKEN="$(cat "$TOKEN_FILE")"
|
||||
mkdir -p "$OUT/$OWNER"
|
||||
|
||||
repos_json="$(curl -fsSL -H "Authorization: token $TOKEN" "$GITEA_URL/api/v1/users/$OWNER/repos?limit=1000")"
|
||||
|
||||
mapfile -t urls < <(python3 - <<'PY' "$repos_json"
|
||||
import json,sys
|
||||
for repo in json.loads(sys.argv[1]):
|
||||
print(repo["clone_url"])
|
||||
PY
|
||||
)
|
||||
|
||||
for url in "${urls[@]}"; do
|
||||
name="$(basename "$url" .git)"
|
||||
target="$OUT/$OWNER/$name.git"
|
||||
|
||||
auth_url="$url"
|
||||
if [[ "$url" == https://* ]]; then
|
||||
auth_url="https://${TOKEN}:x-oauth-basic@${url#https://}"
|
||||
fi
|
||||
|
||||
if [[ -d "$target" ]]; then
|
||||
git -C "$target" remote set-url origin "$auth_url"
|
||||
git -C "$target" fetch --prune
|
||||
else
|
||||
git clone --mirror "$auth_url" "$target"
|
||||
fi
|
||||
|
||||
done
|
||||
|
||||
echo "Mirror export complete: $OUT/$OWNER"
|
||||
22
backups/scripts/ops-bundle.sh
Normal file
22
backups/scripts/ops-bundle.sh
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OPS_REPO_PATH="${OPS_REPO_PATH:-/srv/ops}"
|
||||
OUT_BASE="${OUT_BASE:-/srv/backups/ops}"
|
||||
TS="$(date +%F_%H%M%S)"
|
||||
OUT_DIR="$OUT_BASE/$TS"
|
||||
LATEST_DIR="$OUT_BASE/latest"
|
||||
|
||||
if [[ ! -d "$OPS_REPO_PATH/.git" ]]; then
|
||||
echo "Missing git repo at $OPS_REPO_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
git -C "$OPS_REPO_PATH" bundle create "$OUT_DIR/ops.bundle" --all
|
||||
|
||||
mkdir -p "$LATEST_DIR"
|
||||
cp "$OUT_DIR/ops.bundle" "$LATEST_DIR/ops.bundle"
|
||||
|
||||
echo "Wrote bundle: $OUT_DIR/ops.bundle"
|
||||
echo "Updated latest: $LATEST_DIR/ops.bundle"
|
||||
20
backups/scripts/pg-dump.sh
Normal file
20
backups/scripts/pg-dump.sh
Normal file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
TS="$(date +%F_%H%M%S)"
|
||||
OUT="/srv/backups/gitea/pg"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
PGUSER="${PGUSER:-gitea}"
|
||||
PGDATABASE="${PGDATABASE:-gitea}"
|
||||
PGPASSFILE="${PGPASSFILE:-/srv/secrets/postgres_password}"
|
||||
|
||||
if [[ ! -f "$PGPASSFILE" ]]; then
|
||||
echo "Missing $PGPASSFILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export PGPASSWORD="$(cat "$PGPASSFILE")"
|
||||
docker exec -e PGPASSWORD="$PGPASSWORD" gitea-db sh -lc "pg_dump -U '$PGUSER' -d '$PGDATABASE'" > "$OUT/gitea-pg-$TS.sql"
|
||||
|
||||
echo "Wrote: $OUT/gitea-pg-$TS.sql"
|
||||
14
backups/scripts/restore-gitea.md
Normal file
14
backups/scripts/restore-gitea.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Gitea Restore (Postgres)
|
||||
|
||||
1. Stop gitea stack:
|
||||
- `cd /srv/ops/stacks/gitea && docker compose down`
|
||||
2. Restore filesystem data to:
|
||||
- `/srv/data/gitea`
|
||||
- `/srv/data/gitea-postgres` (or restore logical SQL below)
|
||||
3. Start only database:
|
||||
- `cd /srv/ops/stacks/gitea && docker compose up -d gitea-db`
|
||||
4. Import SQL dump (if using logical dump):
|
||||
- `cat /srv/backups/gitea/pg/<dump>.sql | docker exec -i gitea-db psql -U gitea -d gitea`
|
||||
5. Start gitea:
|
||||
- `docker compose up -d gitea`
|
||||
6. Validate web + SSH endpoints.
|
||||
76
backups/scripts/retention.sh
Normal file
76
backups/scripts/retention.sh
Normal file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE="${BASE:-/srv/backups}"
|
||||
KEEP_DAILY_DAYS="${KEEP_DAILY_DAYS:-7}"
|
||||
KEEP_WEEKLY_DAYS="${KEEP_WEEKLY_DAYS:-365}"
|
||||
|
||||
python3 - <<'PY' "$BASE" "$KEEP_DAILY_DAYS" "$KEEP_WEEKLY_DAYS"
|
||||
import datetime as dt
|
||||
import os
|
||||
import sys
|
||||
|
||||
base = sys.argv[1]
|
||||
keep_daily_days = int(sys.argv[2])
|
||||
keep_weekly_days = int(sys.argv[3])
|
||||
now = dt.datetime.now().timestamp()
|
||||
|
||||
if not os.path.isdir(base):
|
||||
print(f"Backup base not found: {base}")
|
||||
sys.exit(0)
|
||||
|
||||
files = []
|
||||
for root, _, names in os.walk(base):
|
||||
parts = set(os.path.normpath(root).split(os.sep))
|
||||
preserve_latest = "latest" in parts
|
||||
for name in names:
|
||||
path = os.path.join(root, name)
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
try:
|
||||
mtime = os.path.getmtime(path)
|
||||
except OSError:
|
||||
continue
|
||||
age_days = (now - mtime) / 86400
|
||||
files.append((path, root, mtime, age_days, preserve_latest))
|
||||
|
||||
to_delete = set()
|
||||
|
||||
# Delete anything older than weekly window (unless it's in a latest folder)
|
||||
for path, _, _, age_days, preserve_latest in files:
|
||||
if not preserve_latest and age_days > keep_weekly_days:
|
||||
to_delete.add(path)
|
||||
|
||||
# For weekly window, keep one file per week per directory (latest by mtime)
|
||||
weekly_candidates = {}
|
||||
for path, root, mtime, age_days, preserve_latest in files:
|
||||
if preserve_latest:
|
||||
continue
|
||||
if keep_daily_days < age_days <= keep_weekly_days:
|
||||
iso = dt.datetime.fromtimestamp(mtime).isocalendar()
|
||||
key = (root, iso.year, iso.week)
|
||||
best = weekly_candidates.get(key)
|
||||
if best is None or mtime > best[1]:
|
||||
weekly_candidates[key] = (path, mtime)
|
||||
|
||||
weekly_keep = {v[0] for v in weekly_candidates.values()}
|
||||
|
||||
for path, _, _, age_days, preserve_latest in files:
|
||||
if preserve_latest:
|
||||
continue
|
||||
if keep_daily_days < age_days <= keep_weekly_days and path not in weekly_keep:
|
||||
to_delete.add(path)
|
||||
|
||||
deleted = 0
|
||||
for path in sorted(to_delete):
|
||||
try:
|
||||
os.remove(path)
|
||||
print(path)
|
||||
deleted += 1
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
print(f"Retention complete for {base} (deleted {deleted} files)")
|
||||
PY
|
||||
|
||||
echo "Policy: daily <= ${KEEP_DAILY_DAYS}d, weekly <= ${KEEP_WEEKLY_DAYS}d"
|
||||
Reference in New Issue
Block a user