backupdrill 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,6 +42,16 @@ The [`plugin/`](plugin/) folder is a plugin for Claude Code and Cursor. It teach
42
42
 
43
43
  Details are in [plugin/README.md](plugin/README.md).
44
44
 
45
+ ## Use it from Pi
46
+
47
+ This npm package is also a Pi package. Installing it gives Pi the same skill the Claude Code plugin uses:
48
+
49
+ ```sh
50
+ pi install npm:backupdrill
51
+ ```
52
+
53
+ Start a new Pi session and ask it to back up your Supabase project or run a restore drill. The skill runs the CLI through `npx`, so Node.js 20 or newer has to be on your `PATH`. Everything else it needs, and where your keys go, is in [plugin/README.md](plugin/README.md).
54
+
45
55
  ## Usage
46
56
 
47
57
  Configure via **env vars**, a **config file**, or **flags** (precedence: flags > env > file).
@@ -76,6 +86,7 @@ All recognized variables:
76
86
  | `BACKUPDRILL_SUPABASE_STORAGE_*` | Storage-file source (endpoint/region/keys/buckets) — see [Backing up Storage files](#backing-up-storage-files-too) | — |
77
87
  | `BACKUPDRILL_PG_DUMP` | Path to the `pg_dump` binary to use | `pg_dump` on `PATH` |
78
88
  | `BACKUPDRILL_PG_RESTORE` | Path to `pg_restore` (used by `drill` and `restore`) | next to `BACKUPDRILL_PG_DUMP`, else `pg_restore` on `PATH` |
89
+ | `BACKUPDRILL_PING_URL` | Healthchecks.io ping URL for `backup` and `drill` — see [Alerts with Healthchecks.io](#alerts-with-healthchecksio) | — |
79
90
 
80
91
  ### Config file
81
92
 
@@ -224,10 +235,32 @@ Want this running on a schedule without paying for a server? Run it in GitHub Ac
224
235
  2. Add these repo secrets (Settings → Secrets and variables → Actions):
225
236
  - **Required:** `BACKUPDRILL_DATABASE_URL`, `BACKUPDRILL_S3_BUCKET`, `BACKUPDRILL_S3_ACCESS_KEY_ID`, `BACKUPDRILL_S3_SECRET_ACCESS_KEY`
226
237
  - **Optional (destination):** `BACKUPDRILL_S3_ENDPOINT` (for R2/B2), `BACKUPDRILL_S3_REGION`, `BACKUPDRILL_PROJECT_NAME`
238
+ - **Optional (alerts):** `BACKUPDRILL_PING_URL` — see [Alerts with Healthchecks.io](#alerts-with-healthchecksio)
227
239
  - **Optional (Storage files):** set all four of `BACKUPDRILL_SUPABASE_STORAGE_ENDPOINT`, `BACKUPDRILL_SUPABASE_STORAGE_REGION`, `BACKUPDRILL_SUPABASE_STORAGE_ACCESS_KEY_ID`, `BACKUPDRILL_SUPABASE_STORAGE_SECRET_ACCESS_KEY` to also back up Storage files; leave them unset to back up the database only.
228
240
 
229
241
  Before switching to a **daily** schedule, read the egress-cost section above — every run is egress on your Supabase bill.
230
242
 
243
+ ## Alerts with Healthchecks.io
244
+
245
+ A scheduled backup can fail quietly, or not run at all: a cron entry that never fires, a runner that dies, a workflow GitHub disabled. `backup` and `drill` can report every run to a [Healthchecks.io](https://healthchecks.io) check (a self-hosted Healthchecks works the same), and Healthchecks alerts you when a run fails or a success ping doesn't arrive on time.
246
+
247
+ Create a check whose schedule and grace time match your job, copy its ping URL, then:
248
+
249
+ ```bash
250
+ export BACKUPDRILL_PING_URL="https://hc-ping.com/<uuid>"
251
+ backupdrill backup
252
+ ```
253
+
254
+ `--ping-url <url>` and a `"pingUrl"` key in the config file work too (same precedence as everything else). Each run sends a start ping, then either a success or a failure ping:
255
+
256
+ - `<url>/start` before it begins, so Healthchecks shows how long the run took and alerts if it runs past the grace time;
257
+ - `<url>` when it succeeds, with a one-line summary in the body (tables, Storage files and the snapshot name; for `drill`, rows restored and restore time);
258
+ - `<url>/fail` when it fails, with the error in the body. For a drill that ran but didn't pass, that's the failed checks. Before it's sent, the CLI filters out the connection strings, passwords, hostnames, IP addresses, project refs and bucket keys it can recognise, plus the parts of Postgres errors that usually quote row values or SQL (`DETAIL`, `CONTEXT`, `Command was`). It's a filter, not a guarantee: a sensitive value it doesn't recognise can still reach Healthchecks. The full error stays in the job's own output.
259
+
260
+ Both pings of a run carry the same `rid`, so Healthchecks pairs them even if two runs overlap. If you schedule both `backup` and `drill`, give each its own check: on a shared check, the daily backup's success pings would keep it up while a weekly drill that stopped running went unnoticed.
261
+
262
+ A ping never changes the result of a run. Each request times out after 10 seconds and is retried twice on network errors and 5xx responses; if it still fails, the CLI prints a warning and carries on, with the same exit code it would have had without a ping URL.
263
+
231
264
  ## Roadmap
232
265
 
233
266
  - [x] Database backup → your bucket, with checksummed manifest
package/dist/config.d.ts CHANGED
@@ -41,7 +41,17 @@ interface RawConfig {
41
41
  schemas?: string[];
42
42
  storage?: Partial<StorageConfig>;
43
43
  supabaseStorage?: Partial<SupabaseStorageConfig>;
44
+ pingUrl?: string;
44
45
  }
46
+ /**
47
+ * 监控 ping 网址(Healthchecks.io 那种):flag > 环境变量 > 配置文件,空串算没配
48
+ * (GitHub Actions 里没设的 secret 展开就是空串)。不放进 loadConfig:配置缺项时
49
+ * loadConfig 直接抛错,而这恰恰是要报 /fail 的情况,所以网址得先单独读出来。
50
+ */
51
+ export declare function resolvePingUrl(opts: {
52
+ flag?: string;
53
+ configPath: string;
54
+ }): Promise<string | undefined>;
45
55
  /** 合并优先级:显式 flag > 环境变量 > 配置文件。 */
46
56
  export declare function loadConfig(opts: {
47
57
  configPath: string;
package/dist/config.js CHANGED
@@ -54,9 +54,30 @@ async function fromFile(path) {
54
54
  catch (error) {
55
55
  if (error.code === "ENOENT")
56
56
  return {};
57
- throw new Error(`Could not read config file ${path}: ${error.message}`);
57
+ // JSON.parse 的报错会引用出错位置附近的原文,里面可能正好是密钥。本机日志里留着它方便排错;
58
+ // 发给监控(Healthchecks 会把它放进告警邮件)的只用 pingMessage 这句不带原文的
59
+ throw Object.assign(new Error(`Could not read config file ${path}: ${error.message}`), { pingMessage: `Could not read config file ${path}. The job's log has the details.` });
58
60
  }
59
61
  }
62
+ /**
63
+ * 监控 ping 网址(Healthchecks.io 那种):flag > 环境变量 > 配置文件,空串算没配
64
+ * (GitHub Actions 里没设的 secret 展开就是空串)。不放进 loadConfig:配置缺项时
65
+ * loadConfig 直接抛错,而这恰恰是要报 /fail 的情况,所以网址得先单独读出来。
66
+ */
67
+ export async function resolvePingUrl(opts) {
68
+ let file = {};
69
+ try {
70
+ file = await fromFile(opts.configPath);
71
+ }
72
+ catch {
73
+ // 配置文件读不了时由 loadConfig 报错,这里只是拿不到文件里的网址
74
+ }
75
+ // 文件内容可能是 null、数组这类非对象的合法 JSON:那是 loadConfig 要报的错,这里只是拿不到网址
76
+ const filePingUrl = file && typeof file === "object" ? file.pingUrl : undefined;
77
+ return [opts.flag, process.env.BACKUPDRILL_PING_URL, filePingUrl]
78
+ .map((value) => (typeof value === "string" ? value.trim() : undefined))
79
+ .find(Boolean);
80
+ }
60
81
  // spread 里显式的 undefined 会覆盖前一层的值(CLI 未传的 flag 正是 undefined),
61
82
  // 合并前必须剔除,否则 env/配置文件里的 storage 配置会被整体清空。
62
83
  function definedOnly(obj) {
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
- import { loadConfig } from "./config.js";
3
+ import { loadConfig, resolvePingUrl } from "./config.js";
4
+ import { backupSummary, createPinger, drillOutcome, pingMessageOf, sensitiveValuesOf } from "./ping.js";
4
5
  import { runBackup } from "./backup.js";
5
6
  import { runEstimate } from "./estimate.js";
6
7
  import { runDrill } from "./drill.js";
@@ -37,6 +38,10 @@ const EGRESS_PRICING = {
37
38
  pro: { planLabel: "Pro", includedGb: 250, pricePerGb: 0.09, hardCap: false },
38
39
  team: { planLabel: "Team", includedGb: 250, pricePerGb: 0.09, hardCap: false },
39
40
  };
41
+ // backup 和 drill 共用:按 Healthchecks.io 的约定报告开始、成功、失败
42
+ const PING_FLAG = "--ping-url <url>";
43
+ const PING_HELP = "Healthchecks.io ping URL (or BACKUPDRILL_PING_URL): pings <url>/start, then <url> on " +
44
+ "success or <url>/fail on failure. A ping that fails only prints a warning";
40
45
  const program = new Command();
41
46
  program
42
47
  .name("backupdrill")
@@ -55,9 +60,13 @@ program
55
60
  .option("--endpoint <url>", "S3-compatible endpoint (required for R2/B2; omit for AWS S3)")
56
61
  .option("--region <region>", "bucket region (default: auto)")
57
62
  .option("--prefix <prefix>", "key prefix inside the bucket (default: backupdrill)")
63
+ .option(PING_FLAG, PING_HELP)
58
64
  .action(async (opts) => {
65
+ const ping = createPinger(await resolvePingUrl({ flag: opts.pingUrl, configPath: opts.config }));
66
+ await ping.start();
67
+ let config;
59
68
  try {
60
- const config = await loadConfig({
69
+ config = await loadConfig({
61
70
  configPath: opts.config,
62
71
  overrides: {
63
72
  databaseUrl: opts.databaseUrl,
@@ -79,10 +88,13 @@ program
79
88
  `${storageNote}.`);
80
89
  // stdout 输出机器可读结果,便于在 CI / GitHub Action 里消费
81
90
  process.stdout.write(JSON.stringify({ ok: true, manifest }) + "\n");
91
+ await ping.success(backupSummary(manifest));
82
92
  }
83
93
  catch (error) {
84
- log.error(error.message);
94
+ const message = error.message;
95
+ log.error(message);
85
96
  process.exitCode = 1;
97
+ await ping.fail(`backupdrill ${TOOL_VERSION}: backup failed. ${pingMessageOf(error)}`, sensitiveValuesOf(config, opts.databaseUrl));
86
98
  }
87
99
  });
88
100
  program
@@ -101,13 +113,17 @@ program
101
113
  "Note: connects as the sandbox superuser, so RLS policies are NOT enforced — " +
102
114
  "verify data and business invariants here, not RLS behavior")
103
115
  .option("--keep", "if the drill fails, keep the sandbox container running for inspection")
116
+ .option(PING_FLAG, PING_HELP)
104
117
  .action(async (opts) => {
118
+ const ping = createPinger(await resolvePingUrl({ flag: opts.pingUrl, configPath: opts.config }));
119
+ await ping.start();
120
+ let config;
105
121
  try {
106
122
  if (opts.checkCmd !== undefined && !String(opts.checkCmd).trim()) {
107
123
  throw new Error("--check-cmd is empty (did an env var fail to expand?) — refusing to run a drill " +
108
124
  "that would report a check that never executes");
109
125
  }
110
- const config = await loadConfig({
126
+ config = await loadConfig({
111
127
  configPath: opts.config,
112
128
  overrides: {
113
129
  projectName: opts.projectName,
@@ -136,12 +152,20 @@ program
136
152
  `${report.restoredTableCount} tables / ${report.restoredRowTotal.toLocaleString()} rows ` +
137
153
  `restored in ${report.restoreSeconds}s`);
138
154
  process.stdout.write(JSON.stringify({ ok: report.pass, report }) + "\n");
139
- if (!report.pass)
155
+ const outcome = drillOutcome(report);
156
+ if (outcome.pass) {
157
+ await ping.success(outcome.text);
158
+ }
159
+ else {
140
160
  process.exitCode = 1;
161
+ await ping.fail(outcome.text, sensitiveValuesOf(config));
162
+ }
141
163
  }
142
164
  catch (error) {
143
- log.error(error.message);
165
+ const message = error.message;
166
+ log.error(message);
144
167
  process.exitCode = 1;
168
+ await ping.fail(`backupdrill ${TOOL_VERSION}: drill failed. ${pingMessageOf(error)}`, sensitiveValuesOf(config));
145
169
  }
146
170
  });
147
171
  program
package/dist/ping.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { BackupConfig } from "./config.js";
2
+ import type { DrillReport } from "./drill.js";
3
+ import type { Manifest } from "./manifest.js";
4
+ export interface Pinger {
5
+ start(): Promise<void>;
6
+ success(summary: string): Promise<void>;
7
+ /** sensitive:这次运行用到的连接串、密钥等,发出去之前从 reason 里抹掉 */
8
+ fail(reason: string, sensitive?: string[]): Promise<void>;
9
+ }
10
+ export declare function createPinger(rawUrl: string | undefined, opts?: {
11
+ timeoutMs?: number;
12
+ attempts?: number;
13
+ retryDelayMs?: number;
14
+ }): Pinger;
15
+ /** backup 成功时发给监控的一行。快照名取对象键里的目录名(和 drill --snapshot 认的是同一个) */
16
+ export declare function backupSummary(manifest: Manifest): string;
17
+ /** drill 跑完(没抛错)后发哪种 ping、带什么:通过 = 成功;没通过 = 失败,列出没过的检查 */
18
+ export declare function drillOutcome(report: DrillReport): {
19
+ pass: boolean;
20
+ text: string;
21
+ };
22
+ /** 报错可以自带一句发给监控用的说法(pingMessage),比如不能复述配置文件原文的那种 */
23
+ export declare function pingMessageOf(error: unknown): string;
24
+ /**
25
+ * 失败原因发出去之前抹掉连接信息。先按值抹(这次运行确实用到的连接串、密码、主机、密钥),
26
+ * 再按形状兜底:pg / S3 的报错会带主机名、IP、整条连接串,配置文件解析失败时 JSON.parse
27
+ * 的报错还会带一截文件原文,这些都不是事先知道的值。
28
+ */
29
+ export declare function scrubForPing(text: string, sensitive: string[]): string;
30
+ /** 这次运行里不该出现在 ping 里的值:连接串、密码、主机、项目 ref、桶和 Storage 的密钥。 */
31
+ export declare function sensitiveValuesOf(config: BackupConfig | undefined, extraDatabaseUrl?: string): string[];
package/dist/ping.js ADDED
@@ -0,0 +1,217 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { log } from "./log.js";
3
+ import { projectRefOf, refFromStorageEndpoint } from "./restore-engine.js";
4
+ import { TOOL_VERSION } from "./version.js";
5
+ // 按 Healthchecks.io 的 ping 约定报告一次 backup / drill(https://healthchecks.io/docs/http_api/):
6
+ // 开始 <url>/start,成功 <url>,失败 <url>/fail,失败原因放请求体。三次请求带同一个 rid,
7
+ // Healthchecks 才能把开始和结束配成一对算耗时,同一个检查并发跑两次也不会串。
8
+ // 监控是旁路:ping 出任何问题只打警告,绝不改变备份/演练本身的结果和退出码。
9
+ /** 每次请求的上限:Healthchecks 建议必须设超时,卡住的 /start 不能拖住备份本身 */
10
+ const TIMEOUT_MS = 10_000;
11
+ /** 网络错误和 5xx 重试;4xx(404 = 网址写错)重试也没用 */
12
+ const ATTEMPTS = 3;
13
+ const RETRY_DELAY_MS = 1_000;
14
+ /** 失败原因只给个梗概:Healthchecks 会把请求体原样放进告警邮件和事件日志 */
15
+ const BODY_LIMIT = 1_000;
16
+ const NO_PING = {
17
+ start: async () => { },
18
+ success: async () => { },
19
+ fail: async () => { },
20
+ };
21
+ export function createPinger(rawUrl, opts = {}) {
22
+ if (!rawUrl)
23
+ return NO_PING;
24
+ let base;
25
+ try {
26
+ base = new URL(rawUrl);
27
+ if (base.protocol !== "https:" && base.protocol !== "http:")
28
+ throw new Error("not http(s)");
29
+ }
30
+ catch {
31
+ // 网址本身不对也只是监控没接上:备份照跑,Healthchecks 那边会因为收不到 ping 报警
32
+ log.warn("The ping URL is not a valid http(s) URL, so no pings will be sent. The run carries on.");
33
+ return NO_PING;
34
+ }
35
+ // fetch 拒绝带用户名密码的网址,而且拒绝时的报错里有整条网址;在这里先拦下,警告里不复述它
36
+ if (base.username || base.password) {
37
+ log.warn("The ping URL contains a username or password, which pings can't send, so no pings will be sent. The run carries on.");
38
+ return NO_PING;
39
+ }
40
+ const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS;
41
+ const attempts = opts.attempts ?? ATTEMPTS;
42
+ const retryDelayMs = opts.retryDelayMs ?? RETRY_DELAY_MS;
43
+ const rid = randomUUID();
44
+ const endpoint = (suffix) => {
45
+ const url = new URL(base.href);
46
+ // 改 pathname 而不是拼字符串:slug 网址可能带 ?create=1,后缀要落在问号前面。
47
+ // 结尾的斜杠三种 ping 都去掉:Healthchecks 的 ping 路由不认带斜杠的网址
48
+ url.pathname = url.pathname.replace(/\/+$/, "") + suffix;
49
+ url.searchParams.set("rid", rid);
50
+ return url.href;
51
+ };
52
+ // 日志里只写 start/success/fail,不写网址:网址里的 UUID 谁拿到都能替你报平安
53
+ const send = async (kind, url, body) => {
54
+ let lastProblem = "";
55
+ for (let attempt = 1; attempt <= attempts; attempt++) {
56
+ try {
57
+ const res = await fetch(url, {
58
+ method: "POST",
59
+ body,
60
+ headers: {
61
+ "content-type": "text/plain; charset=utf-8",
62
+ "user-agent": `backupdrill/${TOOL_VERSION}`,
63
+ },
64
+ signal: AbortSignal.timeout(timeoutMs),
65
+ });
66
+ const answer = await readStart(res);
67
+ if (res.ok) {
68
+ // Healthchecks.io 对查无此检查、超频的 ping 也回 200,正文是 "OK (not found)" /
69
+ // "OK (rate limited)"(https://healthchecks.io/docs/http_api/);这类 ping 没记上,要提醒
70
+ if (!/^OK \(/.test(answer))
71
+ return;
72
+ lastProblem = `Healthchecks answered "${answer.replace(/[^\x20-\x7e]/g, "").slice(0, 60)}"`;
73
+ break;
74
+ }
75
+ lastProblem = `HTTP ${res.status}`;
76
+ if (res.status < 500)
77
+ break;
78
+ }
79
+ catch (error) {
80
+ // 只报超时或错误码,不报原始报错:有些报错会把整条网址带出来
81
+ const err = error;
82
+ lastProblem =
83
+ err.name === "TimeoutError"
84
+ ? `no response within ${Math.round(timeoutMs / 1000)}s`
85
+ : typeof err.cause?.code === "string"
86
+ ? err.cause.code
87
+ : "network error";
88
+ }
89
+ if (attempt < attempts)
90
+ await new Promise((r) => setTimeout(r, retryDelayMs * attempt));
91
+ }
92
+ log.warn(`Could not send the ${kind} ping (${lastProblem}). The run carries on.`);
93
+ };
94
+ return {
95
+ start: () => send("start", endpoint("/start")),
96
+ success: (summary) => send("success", endpoint(""), truncate(summary)),
97
+ fail: (reason, sensitive = []) => send("fail", endpoint("/fail"), truncate(scrubForPing(reason, sensitive))),
98
+ };
99
+ }
100
+ /** 只读响应开头一小段:Healthchecks 的回答就几个字节,网址填错指到大页面时也不整页下载 */
101
+ async function readStart(res, limit = 256) {
102
+ if (!res.body)
103
+ return "";
104
+ const reader = res.body.getReader();
105
+ const chunks = [];
106
+ let size = 0;
107
+ try {
108
+ while (size < limit) {
109
+ const { done, value } = await reader.read();
110
+ if (done)
111
+ break;
112
+ chunks.push(value);
113
+ size += value.length;
114
+ }
115
+ }
116
+ finally {
117
+ await reader.cancel().catch(() => { });
118
+ }
119
+ return Buffer.concat(chunks).subarray(0, limit).toString("utf8").trim();
120
+ }
121
+ function truncate(text) {
122
+ return text.length > BODY_LIMIT ? `${text.slice(0, BODY_LIMIT - 1)}…` : text;
123
+ }
124
+ /** backup 成功时发给监控的一行。快照名取对象键里的目录名(和 drill --snapshot 认的是同一个) */
125
+ export function backupSummary(manifest) {
126
+ return (`backupdrill ${TOOL_VERSION}: backup complete, ${manifest.database.tableCount} tables` +
127
+ (manifest.storage ? ` + ${manifest.storage.fileCount} Storage files` : " (database only)") +
128
+ `, snapshot ${manifest.dump.key.split("/").at(-2) ?? manifest.createdAt}`);
129
+ }
130
+ /** drill 跑完(没抛错)后发哪种 ping、带什么:通过 = 成功;没通过 = 失败,列出没过的检查 */
131
+ export function drillOutcome(report) {
132
+ const outcome = `snapshot ${report.snapshot}, ${report.restoredTableCount} tables / ` +
133
+ `${report.restoredRowTotal.toLocaleString()} rows restored in ${report.restoreSeconds}s`;
134
+ if (report.pass)
135
+ return { pass: true, text: `backupdrill ${TOOL_VERSION}: drill passed, ${outcome}` };
136
+ const failed = report.checks.filter((c) => !c.pass).map((c) => `${c.name}: ${c.detail}`);
137
+ return {
138
+ pass: false,
139
+ text: `backupdrill ${TOOL_VERSION}: drill failed, ${outcome}. Failed checks:\n${failed.join("\n")}`,
140
+ };
141
+ }
142
+ /** 报错可以自带一句发给监控用的说法(pingMessage),比如不能复述配置文件原文的那种 */
143
+ export function pingMessageOf(error) {
144
+ const err = error;
145
+ return typeof err?.pingMessage === "string" ? err.pingMessage : String(err?.message ?? error);
146
+ }
147
+ /**
148
+ * 失败原因发出去之前抹掉连接信息。先按值抹(这次运行确实用到的连接串、密码、主机、密钥),
149
+ * 再按形状兜底:pg / S3 的报错会带主机名、IP、整条连接串,配置文件解析失败时 JSON.parse
150
+ * 的报错还会带一截文件原文,这些都不是事先知道的值。
151
+ */
152
+ export function scrubForPing(text, sensitive) {
153
+ let out = text;
154
+ // 长的先换:密码可能是连接串的一部分,先换短的会把长的打散,长的就匹配不上了
155
+ for (const value of [...new Set(sensitive)].sort((a, b) => b.length - a.length)) {
156
+ if (value.length >= 4)
157
+ out = out.split(value).join("[redacted]");
158
+ }
159
+ return out
160
+ // Postgres 把具体的值放在 DETAIL / CONTEXT 这类附加字段里(唯一键冲突的键值、COPY 出错的整行),
161
+ // pg_restore 还会回显出错的 SQL。从第一个这类字段起把这一行剩下的全部去掉:值里什么字符都可能有,
162
+ // 包括多条报错之间的拼接符 " | ",所以不能指望找到字段的结尾。引擎上报的恢复错误块已经压成一行
163
+ .replace(/\b(DETAIL|CONTEXT|HINT|QUERY|STATEMENT|LINE \d+|Command was):[^\n]*/g, "$1: [removed]")
164
+ // 主报错里偶尔也带值:键值对 (col)=(value),以及 invalid input syntax for type x: "value"
165
+ .replace(/\(([^()]*)\)=\((?:[^()]|\([^()]*\))*\)/g, "($1)=([removed])")
166
+ .replace(/(invalid input (?:syntax|value) for [^:"\n]*: )"[^"\n]*"/gi, '$1"[removed]"')
167
+ .replace(/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>`]+/gi, "[url]")
168
+ .replace(/[^\s"'<>`/@]*:[^\s"'<>`/@]*@/g, "[redacted]@")
169
+ // Supabase pooler 的用户名是 postgres.<项目 ref>
170
+ .replace(/\bpostgres\.[a-z0-9]{16,}\b/gi, "postgres.[ref]")
171
+ .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[ip]")
172
+ .replace(/\b[0-9a-f]{1,4}(?::[0-9a-f]{0,4}){3,7}\b/gi, "[ip]")
173
+ // 至少两个点的主机名;常见文件扩展名放过,免得把 backupdrill.config.json 也抹了
174
+ .replace(/\b(?:[a-z0-9-]+\.){2,}(?!(?:json|md|ya?ml|js|ts|txt|log|sql|pgcustom)\b)[a-z]{2,}\b/gi, "[host]");
175
+ }
176
+ /** 这次运行里不该出现在 ping 里的值:连接串、密码、主机、项目 ref、桶和 Storage 的密钥。 */
177
+ export function sensitiveValuesOf(config, extraDatabaseUrl) {
178
+ const env = process.env;
179
+ const values = [
180
+ // Storage 端点里有项目 ref(<ref>.storage.supabase.co),库与 Storage 不是同一项目的报错会带出它
181
+ ...[config?.supabaseStorage?.endpoint, env.BACKUPDRILL_SUPABASE_STORAGE_ENDPOINT].flatMap((endpoint) => endpoint ? [refFromStorageEndpoint(endpoint) ?? undefined, hostOf(endpoint)] : []),
182
+ ...[config?.storage?.endpoint, env.BACKUPDRILL_S3_ENDPOINT].map(hostOf),
183
+ env.BACKUPDRILL_S3_ACCESS_KEY_ID,
184
+ env.BACKUPDRILL_S3_SECRET_ACCESS_KEY,
185
+ env.BACKUPDRILL_SUPABASE_STORAGE_ACCESS_KEY_ID,
186
+ env.BACKUPDRILL_SUPABASE_STORAGE_SECRET_ACCESS_KEY,
187
+ config?.storage?.accessKeyId,
188
+ config?.storage?.secretAccessKey,
189
+ config?.supabaseStorage?.accessKeyId,
190
+ config?.supabaseStorage?.secretAccessKey,
191
+ ];
192
+ for (const conn of [config?.databaseUrl, extraDatabaseUrl, env.BACKUPDRILL_DATABASE_URL, env.DATABASE_URL]) {
193
+ if (!conn)
194
+ continue;
195
+ values.push(conn, projectRefOf(conn) ?? undefined);
196
+ // 用户名不进列表:常见的就是 postgres,按值抹会把报错里每个 postgres 都换掉;
197
+ // pooler 用户名里真正要藏的项目 ref 上面已经单独抹了
198
+ try {
199
+ const url = new URL(conn);
200
+ values.push(url.hostname, url.password, decodeURIComponent(url.password));
201
+ }
202
+ catch {
203
+ // 解析不了的连接串整条已经在列表里了
204
+ }
205
+ }
206
+ return values.filter((v) => Boolean(v));
207
+ }
208
+ function hostOf(endpoint) {
209
+ if (!endpoint)
210
+ return undefined;
211
+ try {
212
+ return new URL(endpoint).hostname;
213
+ }
214
+ catch {
215
+ return undefined;
216
+ }
217
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backupdrill",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Open-source CLI to back up a Supabase project — Postgres database and Storage files — to your own S3/R2/B2 bucket, with a checksummed manifest.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,9 +17,15 @@
17
17
  },
18
18
  "files": [
19
19
  "dist",
20
+ "plugin/skills",
20
21
  "README.md",
21
22
  "LICENSE"
22
23
  ],
24
+ "pi": {
25
+ "skills": [
26
+ "./plugin/skills"
27
+ ]
28
+ },
23
29
  "scripts": {
24
30
  "build": "tsc",
25
31
  "prepublishOnly": "npm run build && npm test",
@@ -43,7 +49,9 @@
43
49
  "s3",
44
50
  "cloudflare-r2",
45
51
  "backblaze-b2",
46
- "disaster-recovery"
52
+ "disaster-recovery",
53
+ "healthchecks",
54
+ "pi-package"
47
55
  ],
48
56
  "license": "MIT",
49
57
  "author": "BackupDrill <support@backupdrill.com> (https://backupdrill.com)",
package/plugin/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BackupDrill
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,79 @@
1
+ # BackupDrill plugin
2
+
3
+ Back up a Supabase project to a bucket you own and prove the backup restores, by asking your coding agent. The plugin is one skill that teaches the agent to use [backupdrill](https://github.com/backupdrill/cli), the open-source (MIT) CLI behind backupdrill.com:
4
+
5
+ - `estimate` measures the database and Storage files and projects the Supabase egress cost of backing them up.
6
+ - `backup` dumps the Postgres database with `pg_dump`, plus Storage files once you add their S3 keys, into your S3, R2 or B2 bucket, with a checksummed `manifest.json` and a `RECOVERY.md` in every snapshot.
7
+ - `drill` restores a snapshot into a throwaway Postgres in Docker, checks that the tables and their data came back, and checksums a sample of the Storage files.
8
+ - `restore` puts a snapshot into a new, empty Supabase project, after a dry run and only once you confirm the target.
9
+
10
+ The agent runs every command as `npx -y backupdrill@2.5.0`, so the CLI version stays fixed.
11
+
12
+ ## What you need
13
+
14
+ - A shell the agent can use, such as Claude Code. In a chat with no terminal, the agent can explain the commands but not run them.
15
+ - Node.js 20 or newer.
16
+ - `pg_dump` and `pg_restore` version 17, which handle Supabase's Postgres 15 to 17. On macOS: `brew install libpq@17` (plain `libpq` is now 18, whose dumps can contain SQL that Postgres 17 rejects).
17
+ - Docker, for `drill`.
18
+ - A bucket on AWS S3, Cloudflare R2 or Backblaze B2, with a key that can list, read and write objects in it.
19
+
20
+ ## Install
21
+
22
+ In Claude Code:
23
+
24
+ ```
25
+ /plugin marketplace add backupdrill/cli
26
+ /plugin install backupdrill@backupdrill
27
+ ```
28
+
29
+ Or from a terminal:
30
+
31
+ ```sh
32
+ claude plugin install backupdrill --marketplace backupdrill/cli
33
+ ```
34
+
35
+ In Pi, the same skill comes with the npm package:
36
+
37
+ ```sh
38
+ pi install npm:backupdrill
39
+ ```
40
+
41
+ ## Things to ask
42
+
43
+ - "Back up my Supabase project to my R2 bucket."
44
+ - "How much egress would a daily backup of this project add on the Pro plan?"
45
+ - "Run a restore drill on the latest backup and tell me whether it passed."
46
+ - "Set up a weekly backup in GitHub Actions."
47
+ - "Restore last night's snapshot into my new staging project. Dry run first."
48
+
49
+ ## Your credentials
50
+
51
+ The skill keeps your connection string and bucket keys in `.env.backupdrill` in your project root and adds that file to `.gitignore`. It creates the file with placeholders and asks you to fill it in your editor. The agent is told never to open or print the file, never to ask for keys in the chat, and to load the file only for the command that needs it.
52
+
53
+ ## What runs and where your data goes
54
+
55
+ Everything runs on your machine:
56
+
57
+ - `npx` downloads the `backupdrill` package from the npm registry the first time.
58
+ - The CLI connects to your Supabase project's database, to its Storage if you add Storage S3 keys, and to your bucket. `restore` also connects to the target project you name.
59
+ - `drill` starts a temporary Postgres container in Docker. The first time, Docker downloads the Postgres image from Docker Hub.
60
+ - Nothing goes to BackupDrill. There is no account, telemetry or license check.
61
+
62
+ What the agent reads while it works, such as command output with table names, row counts and file names, goes to your model provider like the rest of your conversation. The CLI itself never deletes objects in your bucket.
63
+
64
+ ## Troubleshooting
65
+
66
+ - `Missing required config`: the message names the missing setting. Fill that line in `.env.backupdrill`.
67
+ - A `pg_dump` version error: install the version 17 PostgreSQL client and set `BACKUPDRILL_PG_DUMP` to its `pg_dump`.
68
+ - Can't connect to the database: use the Session pooler string from the Connect button in the Supabase dashboard, not the Direct one. Resume the project if it's paused.
69
+ - `drill` can't start a container: start Docker.
70
+
71
+ The CLI's [README](https://github.com/backupdrill/cli#readme) covers every option, and [backupdrill.com/docs/cli](https://backupdrill.com/docs/cli) walks through the same steps.
72
+
73
+ ## Support
74
+
75
+ Open an issue at [github.com/backupdrill/cli/issues](https://github.com/backupdrill/cli/issues) or email support@backupdrill.com. Privacy policy: [backupdrill.com/privacy](https://backupdrill.com/privacy).
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,137 @@
1
+ ---
2
+ name: supabase-backup
3
+ description: Use when the user wants to back up a Supabase project to their own S3, R2 or B2 bucket with the open-source backupdrill CLI, estimate what those backups add to their Supabase egress bill, check that a backup actually restores (a restore drill in Docker), schedule backups in GitHub Actions, or restore a backupdrill snapshot into a new Supabase project.
4
+ ---
5
+
6
+ # Back up and restore-drill a Supabase project with backupdrill
7
+
8
+ backupdrill is an open-source (MIT) command-line tool. `backup` dumps the project's Postgres database with `pg_dump`, copies Storage files too once their S3 keys are set, and writes everything to a bucket the user owns, with a checksummed `manifest.json` and a `RECOVERY.md` in every snapshot. `drill` restores a snapshot into a throwaway Postgres in Docker and checks that it came back. The CLI runs on the user's machine. It talks to their Supabase project, their bucket and the local Docker daemon, and sends nothing to BackupDrill.
9
+
10
+ Always run it as `npx -y backupdrill@2.5.0 <command>`, so every run uses the same version.
11
+
12
+ You need a shell on the user's machine for this. If you don't have one, walk the user through the same commands instead of running them.
13
+
14
+ ## Keep credentials out of the conversation
15
+
16
+ The CLI needs a database connection string and bucket keys. They must never appear in the chat, on a command line, or in your output. The CLI keeps its side of this too: from 2.2.4 it hands the database password to `pg_dump` and `pg_restore` through `PGPASSWORD`, not on their command lines.
17
+
18
+ 1. Keep them in `.env.backupdrill` in the project root. Before creating it, make sure Git ignores it and isn't already tracking it: `git check-ignore -q .env.backupdrill || printf '\n.env.backupdrill\n' >> .gitignore; git ls-files --error-unmatch .env.backupdrill >/dev/null 2>&1 && echo 'already tracked by Git'`. An ignore rule doesn't stop Git from committing a file it already tracks. If that prints `already tracked by Git`, stop: ask the user to run `git rm --cached .env.backupdrill`, commit that, and rotate any key that was ever committed in the file.
19
+ 2. If the file doesn't exist yet, create it private from the start with `(umask 077 && touch .env.backupdrill); chmod -N .env.backupdrill 2>/dev/null; true`, then write the placeholders below into it and ask the user to fill it in their editor. If it exists, leave its contents alone, but make sure only the user can read it: `chmod 600 .env.backupdrill; chmod -N .env.backupdrill 2>/dev/null; true`. On macOS `chmod -N` drops ACL entries, which `chmod 600` leaves in place and which can still let other users read the file; elsewhere it fails quietly. Don't ask for keys in the chat. If the user pastes one anyway, don't repeat it, and suggest rotating it.
20
+ 3. Never open, cat or print the file, and never echo a variable loaded from it. The commands in this skill are the only way to touch it. To add a non-secret line, append it with `printf '\n%s\n' "NAME='value'" >> .env.backupdrill` (the leading newline keeps it off the user's last line).
21
+ 4. Run every command exactly as written below. Each one starts a subshell that turns off shell tracing, drops any `BACKUPDRILL_*` or `DATABASE_URL` values inherited from the user's shell, so a broken line can't fall back to some other database, then loads the file and stops if it can't be read. The `2>/dev/null` keeps a malformed line from printing its value.
22
+ 5. The CLI also reads `backupdrill.config.json` from the project root if there is one, and `.env.backupdrill` wins value by value. If the check below reports that file, tell the user and settle on one place: move its settings into `.env.backupdrill` and delete it, or keep it on purpose.
23
+
24
+ ```sh
25
+ # Supabase dashboard → Connect (top of the page) → Session pooler, not Direct
26
+ BACKUPDRILL_DATABASE_URL='postgresql://postgres.<ref>:<password>@aws-0-<region>.pooler.supabase.com:5432/postgres'
27
+ BACKUPDRILL_PROJECT_NAME='my-app'
28
+ # Your bucket. Leave the endpoint empty for AWS S3 and set the bucket's region instead of auto.
29
+ BACKUPDRILL_S3_ENDPOINT='https://<account-id>.r2.cloudflarestorage.com'
30
+ BACKUPDRILL_S3_REGION='auto'
31
+ BACKUPDRILL_S3_BUCKET='my-backups'
32
+ BACKUPDRILL_S3_ACCESS_KEY_ID=''
33
+ BACKUPDRILL_S3_SECRET_ACCESS_KEY=''
34
+ # Optional: Storage files. Supabase → Storage → Settings → S3 Access Keys. Set all four or none.
35
+ # BACKUPDRILL_SUPABASE_STORAGE_ENDPOINT='https://<ref>.storage.supabase.co/storage/v1/s3'
36
+ # BACKUPDRILL_SUPABASE_STORAGE_REGION='<project-region>'
37
+ # BACKUPDRILL_SUPABASE_STORAGE_ACCESS_KEY_ID=''
38
+ # BACKUPDRILL_SUPABASE_STORAGE_SECRET_ACCESS_KEY=''
39
+ ```
40
+
41
+ To see what's filled in without showing any value (it prints the numbers of malformed lines, then which settings are set):
42
+
43
+ ```sh
44
+ ( set +x
45
+ [ ! -f backupdrill.config.json ] || echo 'backupdrill.config.json: the CLI reads this file too'
46
+ [ -z "$(find .env.backupdrill \( -perm -040 -o -perm -004 \) 2>/dev/null)" ] || echo '.env.backupdrill: other users on this machine can read it'
47
+ ! ls -le .env.backupdrill 2>/dev/null | grep -q '^ *[0-9][0-9]*: ' || echo '.env.backupdrill: has ACL entries that may let others read it'
48
+ grep -n -v -E '^[[:space:]]*(#|$)|^(export[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*=($|[^[:space:]])' .env.backupdrill | cut -d: -f1 | sed 's/^/malformed line /'
49
+ unset DATABASE_URL $(env | sed -n 's/^\(BACKUPDRILL_[A-Za-z0-9_]*\)=.*/\1/p')
50
+ set -a; . ./.env.backupdrill 2>/dev/null || { echo ".env.backupdrill could not be loaded" >&2; exit 1; }; set +a
51
+ for name in BACKUPDRILL_DATABASE_URL BACKUPDRILL_S3_BUCKET BACKUPDRILL_S3_ACCESS_KEY_ID BACKUPDRILL_S3_SECRET_ACCESS_KEY; do
52
+ if [ -n "$(printenv "$name")" ]; then echo "$name: set"; else echo "$name: missing"; fi
53
+ done )
54
+ ```
55
+
56
+ Run it before the first command and whenever the CLI reports missing config. If it says other users can read the file or that it has ACL entries, run `chmod 600 .env.backupdrill; chmod -N .env.backupdrill 2>/dev/null; true`. If it lists a malformed line, or a setting the user filled in shows as missing, ask them to make that line read `NAME='value'` with no spaces around `=`.
57
+
58
+ ## Before the first backup
59
+
60
+ - Node.js 20 or newer.
61
+ - `pg_dump` and `pg_restore` version 17. They handle Supabase's Postgres 15 to 17, and the CLI refuses a `pg_dump` older than the project's server. Don't use 18: its dumps can contain SQL that Postgres 17 rejects, and a drill run with 18 tools can pass anyway. If the project runs a newer Postgres than 17, use that major instead. Check with `pg_dump --version`. On macOS, `brew install libpq@17` (plain `libpq` is now 18), then point the CLI at it: `printf '\n%s\n' "BACKUPDRILL_PG_DUMP='$(brew --prefix libpq@17)/bin/pg_dump'" >> .env.backupdrill`. On Debian or Ubuntu, install `postgresql-client-17` from the PostgreSQL apt repository.
62
+ - For drills and restores: `pg_restore` from the same install as `pg_dump`. The CLI looks for it next to `BACKUPDRILL_PG_DUMP`; otherwise set `BACKUPDRILL_PG_RESTORE`.
63
+ - For drills: Docker running (`docker info`).
64
+
65
+ ## Estimate the egress cost first
66
+
67
+ Every backup reads the whole database, and every configured Storage file, out of Supabase again. Nothing is incremental. That is egress on the user's Supabase bill, each run.
68
+
69
+ ```sh
70
+ ( set +x; unset DATABASE_URL $(env | sed -n 's/^\(BACKUPDRILL_[A-Za-z0-9_]*\)=.*/\1/p')
71
+ set -a; . ./.env.backupdrill 2>/dev/null || { echo ".env.backupdrill could not be loaded" >&2; exit 1; }; set +a
72
+ npx -y backupdrill@2.5.0 estimate --plan pro )
73
+ ```
74
+
75
+ Use `--plan free`, `pro` or `team` to match their Supabase plan, and read the projection back before suggesting a daily schedule. On a large database, suggest weekly.
76
+
77
+ ## Back up
78
+
79
+ ```sh
80
+ report="$(mktemp "${TMPDIR:-/tmp}/backupdrill-backup.XXXXXX")" || exit 1; echo "stdout → $report"
81
+ ( set +x; unset DATABASE_URL $(env | sed -n 's/^\(BACKUPDRILL_[A-Za-z0-9_]*\)=.*/\1/p')
82
+ set -a; . ./.env.backupdrill 2>/dev/null || { echo ".env.backupdrill could not be loaded" >&2; exit 1; }; set +a
83
+ npx -y backupdrill@2.5.0 backup ) > "$report"
84
+ ```
85
+
86
+ - The progress lines on stderr show where everything went (`Dumping → s3://…`, `Manifest written → s3://…`) and end with `Backup complete — N tables + M Storage files`. stdout goes to the file named on the first line, a new one each run that only the user can read: one line of JSON with the whole manifest, which lists every table and Storage file, so read only the fields you need from it.
87
+ - Report the table count, the Storage file count (or that it was database only), and where the snapshot landed: `<prefix>/<project>/<timestamp>/` in their bucket, holding `dump.pgcustom`, `manifest.json`, `RECOVERY.md` and, with Storage keys set, `storage/`.
88
+ - By default only the `public` schema is dumped (add their own schemas with `--schema`), so Supabase-managed schemas such as `auth` and `storage` aren't in it. Grants aren't either.
89
+ - The CLI never deletes anything in the bucket. Old snapshots are cleaned up by the bucket's own lifecycle rules, if the user sets any.
90
+
91
+ ## Prove the backup restores (drill)
92
+
93
+ ```sh
94
+ ( set +x; unset DATABASE_URL $(env | sed -n 's/^\(BACKUPDRILL_[A-Za-z0-9_]*\)=.*/\1/p')
95
+ set -a; . ./.env.backupdrill 2>/dev/null || { echo ".env.backupdrill could not be loaded" >&2; exit 1; }; set +a
96
+ npx -y backupdrill@2.5.0 drill )
97
+ ```
98
+
99
+ - It reads the latest snapshot from the bucket, restores it into a throwaway Postgres container, runs its checks and removes the container. It doesn't connect to the Supabase project. The first run pulls a Postgres image from Docker Hub.
100
+ - `--snapshot <timestamp>` picks an older snapshot. `--verify-all-files` checksums every Storage file instead of a sample.
101
+ - `--check-cmd "<command>"` runs the user's own test against the restored copy, with its connection string in `BACKUPDRILL_SANDBOX_URL`. It connects as a superuser, so it can check data and business rules but not row-level security.
102
+ - stdout ends with `{"ok":...,"report":{...}}`. Go through the check lines. If the drill fails, quote the failing check as printed and don't guess at a cause it doesn't state.
103
+
104
+ ## Restore into a new project
105
+
106
+ A restore writes data, so go step by step and let the user drive.
107
+
108
+ 1. The target must be a new, empty Supabase project. The CLI refuses the project the backup came from and any target whose schemas already contain objects.
109
+ 2. Ask the user to add the target's credentials to `.env.backupdrill` themselves: `BACKUPDRILL_TARGET_DATABASE_URL` (the new project's Session pooler string) and, to upload Storage files back, `BACKUPDRILL_TARGET_SERVICE_ROLE_KEY`. The CLI can't prompt for them here.
110
+ 3. Do a dry run. It checks everything and writes nothing:
111
+
112
+ ```sh
113
+ ( set +x; unset DATABASE_URL $(env | sed -n 's/^\(BACKUPDRILL_[A-Za-z0-9_]*\)=.*/\1/p')
114
+ set -a; . ./.env.backupdrill 2>/dev/null || { echo ".env.backupdrill could not be loaded" >&2; exit 1; }; set +a
115
+ npx -y backupdrill@2.5.0 restore --database --target-supabase-url https://<target-ref>.supabase.co --dry-run )
116
+ ```
117
+
118
+ Leave out `--target-supabase-url` when the snapshot is database only. To write Storage files to a local folder instead of the new project, use `--storage-dir ./recovered`.
119
+ 4. Show the dry-run result. It starts with `Dry run for snapshot <timestamp>`; note that timestamp. Run the real restore only after the user asks for it and gives you the target ref. Then replace `--dry-run` with `--snapshot <timestamp> --confirm-target <target-ref>`, using the timestamp from the dry run and exactly the ref they gave, so a backup that lands in between can't change what gets restored. Send stdout to a new `mktemp` file the same way as `backup` (with `backupdrill-restore.XXXXXX` as the name): the JSON result can be long. If the user wants an older snapshot, pass `--snapshot` to both runs.
120
+ 5. Tell them what a restore doesn't bring back: Auth users, secret values, Edge Functions and grants. They need to set those up again. The snapshot's `RECOVERY.md` has a "Not covered" section on this.
121
+ 6. Once they're done, suggest they delete the two `TARGET` lines from `.env.backupdrill`.
122
+
123
+ ## Run it on a schedule
124
+
125
+ [`assets/scheduled-backup.yml`](assets/scheduled-backup.yml) is a GitHub Actions workflow that runs `backup` daily and can be started by hand. Copy it to `.github/workflows/scheduled-backup.yml` in the user's repository. The user adds the secrets listed at the top of the file under Settings → Secrets and variables → Actions, or with `gh secret set <NAME>` in their own terminal. Don't set secrets for them. If they set `BACKUPDRILL_SCHEMAS`, `BACKUPDRILL_S3_PREFIX` or `BACKUPDRILL_SUPABASE_STORAGE_BUCKETS` in `.env.backupdrill`, they need the same values as secrets, or the scheduled backups dump other schemas or land under another prefix. Mention the egress estimate before they keep it daily.
126
+
127
+ If the user wants to hear when a scheduled backup fails or stops running, they can create a check on Healthchecks.io and add its ping URL as the `BACKUPDRILL_PING_URL` secret. `backup` and `drill` ping it when they start, finish and fail. A ping that can't be sent only prints a warning; it never fails the run.
128
+
129
+ If the user asks for drills that run on their own, with alerts, and no CI to maintain, the hosted service at backupdrill.com runs this same engine. Don't bring it up otherwise.
130
+
131
+ ## When something fails
132
+
133
+ - `Missing required config`: the message names the missing setting. Run the check above and ask the user to fill that line.
134
+ - `pg_dump` version errors: install the version 17 client as described above and point `BACKUPDRILL_PG_DUMP` at it.
135
+ - Can't connect to the database: use the Session pooler string from the Connect button, not the Direct one, which resolves to IPv6 only. A paused project has to be resumed in the Supabase dashboard first.
136
+ - Access denied on the bucket: the key needs to list, read and write objects in that bucket.
137
+ - `drill` can't start a container: Docker isn't running.
@@ -0,0 +1,119 @@
1
+ # Example: scheduled Supabase backup with backupdrill.
2
+ #
3
+ # HOW TO USE
4
+ # 1. Copy this file into your OWN repo at .github/workflows/scheduled-backup.yml
5
+ # 2. Add the repo secrets listed below (Settings → Secrets and variables → Actions).
6
+ # 3. That's it — it runs daily, and you can also trigger it by hand from the
7
+ # Actions tab ("Run workflow").
8
+ #
9
+ # REQUIRED SECRETS (database backup)
10
+ # BACKUPDRILL_DATABASE_URL Supabase Session Pooler connection string
11
+ # (Project Settings → Database → Session pooler).
12
+ # The default postgres user works as-is; a
13
+ # read-only role usually fails under RLS —
14
+ # see the README's connection-string section.
15
+ # BACKUPDRILL_S3_BUCKET Destination bucket you own (S3 / R2 / B2).
16
+ # BACKUPDRILL_S3_ACCESS_KEY_ID Credentials for that destination bucket.
17
+ # BACKUPDRILL_S3_SECRET_ACCESS_KEY
18
+ #
19
+ # OPTIONAL SECRETS
20
+ # BACKUPDRILL_S3_ENDPOINT Set for R2/B2/self-hosted; omit for AWS S3.
21
+ # BACKUPDRILL_S3_REGION Bucket region ("auto" works for R2).
22
+ # BACKUPDRILL_PROJECT_NAME Readable name used in object keys/manifest.
23
+ # BACKUPDRILL_PING_URL A Healthchecks.io check's ping URL. The backup
24
+ # pings it when it starts, finishes or fails, so
25
+ # you hear about a failed or skipped run.
26
+ # BACKUPDRILL_SCHEMAS Comma-separated schemas to dump, e.g. public,audit
27
+ # (default: public). Use the same value as your
28
+ # local runs, or scheduled backups miss schemas.
29
+ # BACKUPDRILL_S3_PREFIX Key prefix in the bucket (default: backupdrill).
30
+ # Keep it the same as local runs so drills and
31
+ # restores find these snapshots.
32
+ # BACKUPDRILL_S3_FORCE_PATH_STYLE "true" for S3-compatible stores that need
33
+ # path-style URLs (e.g. MinIO).
34
+ #
35
+ # To also back up Supabase Storage FILES (not just the DB), set all four:
36
+ # BACKUPDRILL_SUPABASE_STORAGE_ENDPOINT
37
+ # BACKUPDRILL_SUPABASE_STORAGE_REGION
38
+ # BACKUPDRILL_SUPABASE_STORAGE_ACCESS_KEY_ID
39
+ # BACKUPDRILL_SUPABASE_STORAGE_SECRET_ACCESS_KEY
40
+ # (generate the keys in Dashboard → Storage → Settings → S3 Access Keys)
41
+ # BACKUPDRILL_SUPABASE_STORAGE_BUCKETS Optional comma-separated list of Storage
42
+ # buckets to copy (default: all of them).
43
+ #
44
+ # Unset optional secrets resolve to an empty string, which backupdrill treats
45
+ # as "not provided" — so leaving them out simply backs up the database only.
46
+ #
47
+ # COST NOTE: a backup is egress on YOUR Supabase bill. Read the README's
48
+ # "Egress & cost" section before switching this to a daily schedule on a large DB.
49
+
50
+ name: scheduled-backup
51
+
52
+ on:
53
+ schedule:
54
+ # Daily at 03:00 UTC. Adjust or switch to weekly (e.g. '0 3 * * 0') as your
55
+ # data grows — see the README on egress cost.
56
+ - cron: "0 3 * * *"
57
+ workflow_dispatch: {} # allow manual runs from the Actions tab
58
+
59
+ # Read-only checkout is all this job needs.
60
+ permissions:
61
+ contents: read
62
+
63
+ # Never let two backups overlap (avoids double egress if one run is slow).
64
+ concurrency:
65
+ group: backupdrill-scheduled
66
+ cancel-in-progress: false
67
+
68
+ jobs:
69
+ backup:
70
+ runs-on: ubuntu-latest
71
+ steps:
72
+ # pg_dump's major version must be >= your Supabase Postgres version.
73
+ # Supabase runs PG 17, so install postgresql-client-17 from the official
74
+ # PostgreSQL apt repo (PGDG); Ubuntu's default repo lags behind.
75
+ - name: Install postgresql-client-17 (PGDG)
76
+ run: |
77
+ sudo install -d /usr/share/postgresql-common/pgdg
78
+ curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
79
+ | sudo gpg --dearmor -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg
80
+ echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg] \
81
+ https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \
82
+ | sudo tee /etc/apt/sources.list.d/pgdg.list
83
+ sudo apt-get update
84
+ sudo apt-get install -y postgresql-client-17
85
+
86
+ # Point the CLI straight at the v17 binary so it can't pick up an older
87
+ # pg_dump that may already be on the runner's PATH.
88
+ - name: Tell backupdrill where pg_dump 17 lives
89
+ run: echo "BACKUPDRILL_PG_DUMP=/usr/lib/postgresql/17/bin/pg_dump" >> "$GITHUB_ENV"
90
+
91
+ - name: Set up Node 20
92
+ uses: actions/setup-node@v4
93
+ with:
94
+ node-version: "20"
95
+
96
+ - name: Run backupdrill
97
+ # Pinned so every run uses the same CLI. Raise it on purpose when you upgrade.
98
+ run: npx -y backupdrill@2.5.0 backup
99
+ env:
100
+ # --- required ---
101
+ BACKUPDRILL_DATABASE_URL: ${{ secrets.BACKUPDRILL_DATABASE_URL }}
102
+ BACKUPDRILL_S3_BUCKET: ${{ secrets.BACKUPDRILL_S3_BUCKET }}
103
+ BACKUPDRILL_S3_ACCESS_KEY_ID: ${{ secrets.BACKUPDRILL_S3_ACCESS_KEY_ID }}
104
+ BACKUPDRILL_S3_SECRET_ACCESS_KEY: ${{ secrets.BACKUPDRILL_S3_SECRET_ACCESS_KEY }}
105
+ # --- optional: destination bucket ---
106
+ BACKUPDRILL_S3_ENDPOINT: ${{ secrets.BACKUPDRILL_S3_ENDPOINT }}
107
+ BACKUPDRILL_S3_REGION: ${{ secrets.BACKUPDRILL_S3_REGION }}
108
+ BACKUPDRILL_PROJECT_NAME: ${{ secrets.BACKUPDRILL_PROJECT_NAME }}
109
+ BACKUPDRILL_SCHEMAS: ${{ secrets.BACKUPDRILL_SCHEMAS }}
110
+ BACKUPDRILL_S3_PREFIX: ${{ secrets.BACKUPDRILL_S3_PREFIX }}
111
+ BACKUPDRILL_S3_FORCE_PATH_STYLE: ${{ secrets.BACKUPDRILL_S3_FORCE_PATH_STYLE }}
112
+ # --- optional: also back up Supabase Storage files (set all four) ---
113
+ BACKUPDRILL_SUPABASE_STORAGE_ENDPOINT: ${{ secrets.BACKUPDRILL_SUPABASE_STORAGE_ENDPOINT }}
114
+ BACKUPDRILL_SUPABASE_STORAGE_REGION: ${{ secrets.BACKUPDRILL_SUPABASE_STORAGE_REGION }}
115
+ BACKUPDRILL_SUPABASE_STORAGE_ACCESS_KEY_ID: ${{ secrets.BACKUPDRILL_SUPABASE_STORAGE_ACCESS_KEY_ID }}
116
+ BACKUPDRILL_SUPABASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.BACKUPDRILL_SUPABASE_STORAGE_SECRET_ACCESS_KEY }}
117
+ BACKUPDRILL_SUPABASE_STORAGE_BUCKETS: ${{ secrets.BACKUPDRILL_SUPABASE_STORAGE_BUCKETS }}
118
+ # --- optional: Healthchecks.io monitoring ---
119
+ BACKUPDRILL_PING_URL: ${{ secrets.BACKUPDRILL_PING_URL }}