-
Notifications
You must be signed in to change notification settings - Fork 8
385 lines (326 loc) · 17.7 KB
/
Copy pathapp-backup-supabase.yml
File metadata and controls
385 lines (326 loc) · 17.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
name: lomi · backup · supabase · db
on:
workflow_dispatch:
schedule:
# Off-platform encrypted DB copy every 6 hours. This is the disaster-recovery
# copy in our own S3; Supabase's own backups/PITR cover lower-RPO needs.
- cron: "17 */6 * * *"
concurrency:
group: lomi-backup-supabase-db
cancel-in-progress: false
permissions:
contents: read
jobs:
backup:
name: Back up database
runs-on: ubuntu-latest
timeout-minutes: 60
env:
SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }}
BACKUP_S3_BUCKET: ${{ secrets.BACKUP_S3_BUCKET }}
BACKUP_S3_ENDPOINT: ${{ secrets.BACKUP_S3_ENDPOINT }}
BACKUP_S3_ACCESS_KEY_ID: ${{ secrets.BACKUP_S3_ACCESS_KEY_ID }}
BACKUP_S3_SECRET_ACCESS_KEY: ${{ secrets.BACKUP_S3_SECRET_ACCESS_KEY }}
BACKUP_S3_PREFIX: ${{ vars.BACKUP_S3_PREFIX }}
BACKUP_S3_REGION: ${{ vars.BACKUP_S3_REGION }}
AGE_PUBLIC_KEY: ${{ secrets.BACKUP_AGE_PUBLIC_KEY }}
steps:
- name: Validate configuration
run: |
set -euo pipefail
missing=0
normalize_env_value() {
local name="$1"
local required="${2:-required}"
local raw="${!name-}"
local normalized
normalized="$(printf '%s' "$raw" | tr -d '\r\n' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
if [ -z "$normalized" ]; then
if [ "$required" = "required" ]; then
echo "::error::Missing required secret: ${name}"
missing=1
fi
return
fi
if [ "$raw" != "$normalized" ]; then
echo "::warning::${name} contained leading/trailing whitespace or line breaks; using a trimmed single-line value."
fi
export "$name=$normalized"
printf '%s=%s\n' "$name" "$normalized" >> "$GITHUB_ENV"
}
for name in \
SUPABASE_DB_URL \
BACKUP_S3_BUCKET \
BACKUP_S3_ENDPOINT \
BACKUP_S3_ACCESS_KEY_ID \
BACKUP_S3_SECRET_ACCESS_KEY \
AGE_PUBLIC_KEY
do
normalize_env_value "$name"
done
normalize_env_value BACKUP_S3_PREFIX optional
normalize_env_value BACKUP_S3_REGION optional
if [ "$missing" -ne 0 ]; then
exit 1
fi
if printf '%s' "$SUPABASE_DB_URL" | grep -q '[[:space:]]'; then
echo "::error::SUPABASE_DB_URL must be a single-line Postgres connection string with no literal whitespace."
exit 1
fi
case "$SUPABASE_DB_URL" in
postgres://*|postgresql://*) ;;
*)
echo "::error::SUPABASE_DB_URL must start with postgres:// or postgresql://."
exit 1
;;
esac
- name: Checkout
uses: actions/checkout@v5
# GitHub-hosted runners are IPv4-only; the direct Supabase host is IPv6-only.
- name: Set up IPv6 egress
uses: ./.github/actions/setup-ipv6-warp
- name: Install backup tools
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y age jq curl gnupg lsb-release
# Install the PostgreSQL 17 client. pg_dump/pg_dumpall must be >= the server
# major version (Supabase runs Postgres 17), which is newer than Ubuntu's default.
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
| sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/pgdg.gpg
echo "deb [signed-by=/etc/apt/trusted.gpg.d/pgdg.gpg] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \
| sudo tee /etc/apt/sources.list.d/pgdg.list >/dev/null
sudo apt-get update
sudo apt-get install -y postgresql-client-17
# The ubuntu-latest runner ships a running PostgreSQL 16 cluster, so the
# pg_dump/pg_dumpall on PATH are the postgresql-common "pg_wrapper" shim, which
# resolves to the default local cluster's version (16) even after client 17 is
# installed. That caused "server version mismatch: server 17.x, pg_dumpall 16.x".
# Put the v17 binaries at the front of PATH (for all later steps) so the real
# v17 tools are used and the wrapper is bypassed.
echo "/usr/lib/postgresql/17/bin" >> "$GITHUB_PATH"
pg_dumpall_bin="/usr/lib/postgresql/17/bin/pg_dumpall"
"$pg_dumpall_bin" --version
case "$("$pg_dumpall_bin" --version)" in
*" 17."*) echo "pg_dumpall 17 confirmed." ;;
*) echo "::error::Expected pg_dumpall 17 at ${pg_dumpall_bin}."; exit 1 ;;
esac
# Derive the direct (IPv6) connection from SUPABASE_DB_URL. If the secret still
# points at the shared pooler, rewrite it to the direct host; otherwise use it
# as-is. The result is exported as BACKUP_DB_URL for the steps below.
- name: Resolve database connection
run: |
set -euo pipefail
url="$SUPABASE_DB_URL"
rest="${url#*://}"
userinfo="${rest%%@*}"
hostpath="${rest#*@}"
user="${userinfo%%:*}"
pass="${userinfo#*:}"
hostport="${hostpath%%/*}"
dbname="${hostpath#*/}"
dbname="${dbname%%\?*}"
host="${hostport%%:*}"
[ -n "$dbname" ] || dbname="postgres"
case "$host" in
*.pooler.supabase.com)
ref="${user#*.}"
backup_db_url="postgresql://postgres:${pass}@db.${ref}.supabase.co:5432/${dbname}"
echo "::notice::Pooler host detected; using direct IPv6 connection db.${ref}.supabase.co:5432 over Cloudflare WARP."
;;
*)
backup_db_url="$url"
echo "::notice::Using SUPABASE_DB_URL host ${host} as-is."
;;
esac
echo "::add-mask::$pass"
echo "::add-mask::$backup_db_url"
printf 'BACKUP_DB_URL=%s\n' "$backup_db_url" >> "$GITHUB_ENV"
- name: Preflight database connection
run: |
set -euo pipefail
echo "Checking database reachability before backup..."
db_host="$(printf '%s' "$BACKUP_DB_URL" | sed -E 's#^[a-z]+://[^@]+@([^:/]+).*#\1#')"
db_port="$(printf '%s' "$BACKUP_DB_URL" | sed -nE 's#^[a-z]+://[^@]+@[^:/]+:([0-9]+)/.*#\1#p')"
echo "Target: ${db_host}:${db_port:-5432}"
stderr_file="$(mktemp)"
trap 'rm -f "$stderr_file"' EXIT
if PGCONNECT_TIMEOUT=20 psql "$BACKUP_DB_URL" -v ON_ERROR_STOP=1 -tAc "SELECT 1" >/dev/null 2>"$stderr_file"; then
echo "Database connection OK."
exit 0
fi
err="$(tr '\n' ' ' <"$stderr_file" | sed 's/ */ /g')"
if printf '%s' "$err" | grep -qiE 'connection to database not available|EAUTHQUERY|ECIRCUITBREAKER'; then
echo "::error::Pooler reported it cannot reach Postgres (EAUTHQUERY/ECIRCUITBREAKER). This backup targets the direct host, so SUPABASE_DB_URL likely still points at the pooler and was not rewritten. Verify the secret is a valid Supabase connection string."
exit 1
fi
if printf '%s' "$err" | grep -qiE 'Network is unreachable|No route to host|could not connect to server'; then
echo "::error::Cannot reach ${db_host}. The direct Supabase host is IPv6-only; confirm the Cloudflare WARP step established IPv6 egress (see the 'Verify IPv6 connectivity' step)."
exit 1
fi
if printf '%s' "$err" | grep -qi 'password authentication failed'; then
echo "::error::Authentication failed for the direct connection (user 'postgres'). Update the password in the SUPABASE_DB_URL secret from Supabase Dashboard → Project Settings → Database."
exit 1
fi
if printf '%s' "$err" | grep -qiE 'could not translate host name|Name or service not known|nodename nor servname'; then
echo "::error::Cannot resolve ${db_host}. Check the project ref encoded in SUPABASE_DB_URL."
exit 1
fi
if printf '%s' "$err" | grep -qiE 'timeout expired|timed out'; then
echo "::error::Connection to ${db_host} timed out. Likely an IPv6/WARP issue; re-run in a few minutes."
exit 1
fi
echo "::error::Database preflight failed: ${err}"
exit 1
- name: Create database dump
id: db-dump
run: |
set -euo pipefail
export AWS_ACCESS_KEY_ID="$BACKUP_S3_ACCESS_KEY_ID"
export AWS_SECRET_ACCESS_KEY="$BACKUP_S3_SECRET_ACCESS_KEY"
export AWS_DEFAULT_REGION="${BACKUP_S3_REGION:-auto}"
export AWS_EC2_METADATA_DISABLED="true"
timestamp="$(date -u +'%Y%m%dT%H%M%SZ')"
backup_dir="supabase-db-${timestamp}"
archive="${backup_dir}.tar.gz"
encrypted_archive="${archive}.age"
checksum_file="${encrypted_archive}.sha256"
manifest_file="${backup_dir}.manifest.json"
prefix="${BACKUP_S3_PREFIX:-supabase}"
latest_manifest="latest-db-manifest.json"
endpoint_args=()
if [ -n "${BACKUP_S3_ENDPOINT:-}" ]; then
endpoint_args=(--endpoint-url "$BACKUP_S3_ENDPOINT")
fi
mkdir -p "$backup_dir"
# The three dumps below replicate `supabase db dump` (--role-only, schema,
# --data-only --use-copy) using the native PG17 client, so we can run them
# over the direct IPv6 connection without Docker. The flags and sed filters
# are taken verbatim from `supabase db dump --dry-run` to preserve Supabase's
# filtering (excluded internal schemas, stripped reserved roles, idempotent
# IF NOT EXISTS clauses). Do not "simplify" these into a plain pg_dump: that
# would include Supabase internals and break restores.
# Roles (equivalent of: supabase db dump --role-only)
pg_dumpall -d "$BACKUP_DB_URL" \
--roles-only \
--role "postgres" \
--quote-all-identifiers \
--no-role-passwords \
--no-comments \
| sed -E 's/^\\(un)?restrict .*$/-- &/' \
| sed -E "s/^CREATE ROLE \"(anon|authenticated|authenticator|cli_login_.*|dashboard_user|pgbouncer|postgres|service_role|supabase_.*|pgsodium_keyholder|pgsodium_keyiduser|pgsodium_keymaker|pgtle_admin)\"/-- &/" \
| sed -E "s/^ALTER ROLE \"(anon|authenticated|authenticator|cli_login_.*|dashboard_user|pgbouncer|postgres|service_role|supabase_.*|pgsodium_keyholder|pgsodium_keyiduser|pgsodium_keymaker|pgtle_admin)\"/-- &/" \
| sed -E "s/ (NOSUPERUSER|NOREPLICATION)//g" \
| sed -E "s/^-- (.* SET \"(pgaudit.*|pgrst.*|session_replication_role|statement_timeout|track_io_timing)\" .*)/\1/" \
| sed -E "s/GRANT \".*\" TO \"(anon|authenticated|authenticator|cli_login_.*|dashboard_user|pgbouncer|postgres|service_role|supabase_.*|pgsodium_keyholder|pgsodium_keyiduser|pgsodium_keymaker|pgtle_admin)\"/-- &/" \
| sed -E "/^--/d" \
| uniq > "$backup_dir/roles.sql"
echo "RESET ALL;" >> "$backup_dir/roles.sql"
# Schema (equivalent of: supabase db dump)
pg_dump -d "$BACKUP_DB_URL" \
--schema-only \
--quote-all-identifiers \
--role "postgres" \
--exclude-schema "information_schema|pg_*|_analytics|_realtime|_supavisor|auth|etl|extensions|pgbouncer|realtime|storage|supabase_functions|supabase_migrations|cron|dbdev|graphql|graphql_public|net|pgmq|pgsodium|pgsodium_masks|pgtle|repack|tiger|tiger_data|timescaledb_*|_timescaledb_*|topology|vault" \
| sed -E 's/^\\(un)?restrict .*$/-- &/' \
| sed -E 's/^CREATE SCHEMA "/CREATE SCHEMA IF NOT EXISTS "/' \
| sed -E 's/^CREATE TABLE "/CREATE TABLE IF NOT EXISTS "/' \
| sed -E 's/^CREATE SEQUENCE "/CREATE SEQUENCE IF NOT EXISTS "/' \
| sed -E 's/^CREATE VIEW "/CREATE OR REPLACE VIEW "/' \
| sed -E 's/^CREATE FUNCTION "/CREATE OR REPLACE FUNCTION "/' \
| sed -E 's/^CREATE TRIGGER "/CREATE OR REPLACE TRIGGER "/' \
| sed -E 's/^CREATE PUBLICATION "supabase_realtime/-- &/' \
| sed -E 's/^CREATE EVENT TRIGGER /-- &/' \
| sed -E 's/^ WHEN TAG IN /-- &/' \
| sed -E 's/^ EXECUTE FUNCTION /-- &/' \
| sed -E 's/^ALTER EVENT TRIGGER /-- &/' \
| sed -E 's/^ALTER PUBLICATION "supabase_realtime_/-- &/' \
| sed -E 's/^ALTER FOREIGN DATA WRAPPER (.+) OWNER TO /-- &/' \
| sed -E 's/^ALTER DEFAULT PRIVILEGES FOR ROLE "supabase_admin"/-- &/' \
| sed -E 's/^GRANT ALL ON FOREIGN DATA WRAPPER (.+) TO "postgres" WITH GRANT OPTION/-- &/' \
| sed -E "s/^GRANT (.+) ON (.+) \"(information_schema|pg_*|_analytics|_realtime|_supavisor|auth|etl|extensions|pgbouncer|realtime|storage|supabase_functions|supabase_migrations|cron|dbdev|graphql|graphql_public|net|pgmq|pgsodium|pgsodium_masks|pgtle|repack|tiger|tiger_data|timescaledb_*|_timescaledb_*|topology|vault)\"/-- &/" \
| sed -E "s/^REVOKE (.+) ON (.+) \"(information_schema|pg_*|_analytics|_realtime|_supavisor|auth|etl|extensions|pgbouncer|realtime|storage|supabase_functions|supabase_migrations|cron|dbdev|graphql|graphql_public|net|pgmq|pgsodium|pgsodium_masks|pgtle|repack|tiger|tiger_data|timescaledb_*|_timescaledb_*|topology|vault)\"/-- &/" \
| sed -E 's/^(CREATE EXTENSION IF NOT EXISTS "pg_tle").+/\1;/' \
| sed -E 's/^(CREATE EXTENSION IF NOT EXISTS "pgsodium").+/\1;/' \
| sed -E 's/^(CREATE EXTENSION IF NOT EXISTS "pgmq").+/\1;/' \
| sed -E 's/^COMMENT ON EXTENSION (.+)/-- &/' \
| sed -E 's/^CREATE POLICY "cron_job_/-- &/' \
| sed -E 's/^ALTER TABLE "cron"/-- &/' \
| sed -E 's/^SET transaction_timeout = 0;/-- &/' \
| sed -E "/^--/d" > "$backup_dir/schema.sql"
# Data (equivalent of: supabase db dump --data-only --use-copy -x storage.buckets_vectors -x storage.vector_indexes)
{
echo "SET session_replication_role = replica;"
echo
pg_dump -d "$BACKUP_DB_URL" \
--data-only \
--quote-all-identifiers \
--role "postgres" \
--exclude-schema "information_schema|pg_*|graphql|graphql_public|pgsodium|pgsodium_masks|pgtle|repack|tiger|tiger_data|timescaledb_*|_timescaledb_*|topology|vault|etl|extensions|pgbouncer|realtime|supabase_migrations|_analytics|_realtime|_supavisor" \
--exclude-table "auth.schema_migrations" \
--exclude-table "storage.migrations" \
--exclude-table "supabase_functions.migrations" \
--schema "*" \
--exclude-table "storage.buckets_vectors" \
--exclude-table "storage.vector_indexes" \
| sed -E 's/^\\(un)?restrict .*$/-- &/'
echo "RESET ALL;"
} > "$backup_dir/data.sql"
db_content_sha="$(
sha256sum "$backup_dir/roles.sql" "$backup_dir/schema.sql" "$backup_dir/data.sql" \
| sha256sum \
| awk '{print $1}'
)"
previous_db_content_sha=""
if aws s3 cp "s3://${BACKUP_S3_BUCKET}/${prefix}/db/latest-manifest.json" "$latest_manifest" "${endpoint_args[@]}" --only-show-errors; then
previous_db_content_sha="$(jq -r '.db_content_sha // empty' "$latest_manifest")"
fi
if [ "$db_content_sha" = "$previous_db_content_sha" ]; then
echo "DB_CHANGED=false" >> "$GITHUB_ENV"
echo "Database dump content matches the latest uploaded backup; skipping DB archive upload."
exit 0
fi
{
echo "Backup created at ${timestamp}"
echo
echo "Restore order:"
echo "1. roles.sql"
echo "2. schema.sql"
echo "3. data.sql"
} > "$backup_dir/RESTORE.txt"
tar -czf "$archive" "$backup_dir"
age --recipient "$AGE_PUBLIC_KEY" --output "$encrypted_archive" "$archive"
sha256sum "$encrypted_archive" > "$checksum_file"
jq -n \
--arg created_at "$timestamp" \
--arg backup_file "$encrypted_archive" \
--arg checksum_file "$checksum_file" \
--arg db_content_sha "$db_content_sha" \
'{
created_at: $created_at,
backup_file: $backup_file,
checksum_file: $checksum_file,
db_content_sha: $db_content_sha
}' > "$manifest_file"
echo "DB_CHANGED=true" >> "$GITHUB_ENV"
echo "DB_BACKUP_FILE=$encrypted_archive" >> "$GITHUB_ENV"
echo "DB_CHECKSUM_FILE=$checksum_file" >> "$GITHUB_ENV"
echo "DB_MANIFEST_FILE=$manifest_file" >> "$GITHUB_ENV"
- name: Upload database backup to object storage
if: env.DB_CHANGED == 'true'
run: |
set -euo pipefail
export AWS_ACCESS_KEY_ID="$BACKUP_S3_ACCESS_KEY_ID"
export AWS_SECRET_ACCESS_KEY="$BACKUP_S3_SECRET_ACCESS_KEY"
export AWS_DEFAULT_REGION="${BACKUP_S3_REGION:-auto}"
export AWS_EC2_METADATA_DISABLED="true"
prefix="${BACKUP_S3_PREFIX:-supabase}"
destination="s3://${BACKUP_S3_BUCKET}/${prefix}/db"
endpoint_args=()
if [ -n "${BACKUP_S3_ENDPOINT:-}" ]; then
endpoint_args=(--endpoint-url "$BACKUP_S3_ENDPOINT")
fi
aws s3 cp "$DB_BACKUP_FILE" "$destination/$DB_BACKUP_FILE" "${endpoint_args[@]}" --only-show-errors
aws s3 cp "$DB_CHECKSUM_FILE" "$destination/$DB_CHECKSUM_FILE" "${endpoint_args[@]}" --only-show-errors
aws s3 cp "$DB_MANIFEST_FILE" "$destination/latest-manifest.json" "${endpoint_args[@]}" --only-show-errors
echo "Uploaded encrypted database backup to $destination/$DB_BACKUP_FILE"