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:
2026-03-04 14:42:46 -05:00
commit c93dcb5daf
21 changed files with 531 additions and 0 deletions

View 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"

View 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"

View 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"

View 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"

View 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.

View 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"

View File

@@ -0,0 +1,7 @@
[Unit]
Description=Run Gitea backup scripts
After=docker.service
[Service]
Type=oneshot
ExecStart=/bin/bash -lc '/srv/ops/backups/scripts/gitea-dump.sh && /srv/ops/backups/scripts/pg-dump.sh && /srv/ops/backups/scripts/gitea-mirror-export.sh && /srv/ops/backups/scripts/ops-bundle.sh && /srv/ops/backups/scripts/retention.sh'

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Nightly Gitea backup timer
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.target