GitHub Actions looks like an ideal backup scheduler: cron built in, secrets management, logs, notifications on failure, no server to maintain. Plenty of teams move their backup cron job into a workflow and never think about it again.
There are two reasons that goes wrong, and both are worth knowing before you build it, because one of them is a security decision rather than an ops one.
Problem 1: scheduled workflows turn themselves off
GitHub disables scheduled workflows in public repositories after 60 days without repository activity.
No commits, no PRs, no issues for 60 days — your backups stop. GitHub emails the repo owner, that email goes to a filter, and you find out when you need a backup that does not exist.
This bites exactly the repositories you would put backups in: small infrastructure repos that are stable by design and therefore quiet by design.
Mitigations, in order of reliability:
- Put the workflow in a repo with real activity — your main application repo.
- Add an external monitor. A dead-man's-switch service that alerts when a nightly ping stops. This is the one that actually protects you, because it detects every failure mode, not just this one.
- Avoid keep-alive commit bots. They work and they make the repo history noise, and they fail silently in their own ways.
Problem 2: production credentials in CI expand your blast radius
This is the serious one, and most articles on this topic skip it.
To back up your production database from GitHub Actions, the credentials must be available to GitHub Actions. Which means:
- Anyone with write access to the repository can read them. Not directly — but they can open a branch, add a workflow step that prints the secret to an external endpoint, and run it. Secret masking hides values in logs; it does not stop exfiltration.
- Any compromised action in your workflow runs with access to those secrets. Supply-chain compromise of a third-party action is a well-established attack path.
- You have moved production database access from "a server" to "a CI system a dozen people can push to."
For a solo project, acceptable. For a team, you have quietly widened who can reach production data, and that is a decision to make on purpose rather than by accident.
The split that works
Run production backups from the server, on cron. The credentials stay where the database already is. Nothing new can reach them.
# /etc/cron.d/db-backup
0 2 * * * backup /opt/backup/run.sh >> /var/log/db-backup.log 2>&1
Use GitHub Actions for restore drills. This is where CI is genuinely better than cron: scheduled, logged, notifies on failure, and needs read-only access rather than production database credentials.
name: Restore drill
on:
schedule:
- cron: "0 4 * * 1" # Mondays 04:00 UTC
workflow_dispatch: # so you can run it by hand
jobs:
drill:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: drill
options: >-
--health-cmd pg_isready --health-interval 10s
--health-timeout 5s --health-retries 5
ports: ["5432:5432"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm install @lighthouse-web3/baas-js-sdk
- run: sudo apt-get update && sudo apt-get install -y postgresql-client
- name: Restore the newest snapshot and check it
env:
# Read-only key. Cannot write backups, cannot delete them.
LH_API_KEY: ${{ secrets.LH_READONLY_API_KEY }}
LH_WORKSPACE_ID: ${{ secrets.LH_WORKSPACE_ID }}
LH_KEYFILE_PASSPHRASE: ${{ secrets.LH_KEYFILE_PASSPHRASE }}
run: node scripts/restore-drill.mjs
// scripts/restore-drill.mjs
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execSync } from "node:child_process";
import { BackupClient } from "@lighthouse-web3/baas-js-sdk";
const client = new BackupClient({
apiKey: process.env.LH_API_KEY,
workspaceId: process.env.LH_WORKSPACE_ID,
});
const started = Date.now();
const target = mkdtempSync(join(tmpdir(), "drill-"));
// listSnapshots takes (cursor, limit) and returns { snapshots }.
const { snapshots } = await client.listSnapshots("", 1);
const [latest] = snapshots;
if (!latest) throw new Error("No snapshots found — backups are not running");
await client.restore(latest.snapshotId, target);
execSync(`createdb -h localhost -U postgres drill`, { env: { ...process.env, PGPASSWORD: "drill" } });
execSync(`pg_restore -h localhost -U postgres -d drill --no-owner "${target}/db-dumps/app.dump"`,
{ env: { ...process.env, PGPASSWORD: "drill" } });
const rows = Number(
execSync(`psql -h localhost -U postgres -tA -d drill -c "SELECT count(*) FROM users;"`,
{ env: { ...process.env, PGPASSWORD: "drill" } }).toString(),
);
// A floor, not just "no error" — an empty dump is what this exists to catch.
if (rows < 1000) throw new Error(`Only ${rows} users restored, expected 1000+`);
console.log(`PASS: ${rows} users, snapshot ${latest.snapshotId}, ${(Date.now() - started) / 1000}s`);
The drill fails loudly, on a schedule, with logs — and if backups stopped running entirely, No snapshots found catches that too. That single check covers the 60-day disable problem for the backup job it is drilling.
When CI is the right place for the backup itself
Three cases where the credential concern does not apply:
Backing up things already in CI's reach. Repository content, build artifacts, generated documentation, release binaries. The secrets are already there.
Non-production databases. Staging and dev credentials in CI expand nothing that matters.
Providers with scoped, rotatable, restricted tokens. A managed database that issues a read-only token limited to specific IPs is a much smaller exposure than a superuser password — see Supabase, Neon and PlanetScale.
If you do back up production from CI anyway, at minimum:
- ✅ Use environment secrets with required reviewers, not repo-wide secrets
- ✅ Give the database user the least privilege that can dump, not superuser
- ✅ Pin third-party actions to a commit SHA, never a tag
- ✅ Restrict who can push to the default branch
- ✅ Rotate credentials on a schedule and after anyone leaves
Cron syntax, and what to expect from it
on:
schedule:
- cron: "0 2 * * *" # 02:00 UTC daily
- cron: "0 */6 * * *" # every 6 hours
- cron: "0 3 * * 0" # Sundays 03:00 UTC
Three practical notes: schedules are always UTC — no local time, no DST adjustment. The shortest interval is five minutes. And scheduled runs are queued, not guaranteed on time — delays of several minutes to over an hour are normal at peak, so never build anything that assumes a workflow fired at exactly the top of the hour.
Always add workflow_dispatch so you can trigger the job manually while testing. Waiting for a cron to fire in order to debug it is a bad afternoon.
Reaching a private database
If the database is not publicly reachable — and it should not be — a GitHub-hosted runner cannot connect to it. Options, worst to best:
IP allowlisting GitHub's ranges. Technically possible via GitHub's meta API. The ranges are large and change, so you end up allowlisting a very wide slice of the internet. Not recommended.
A self-hosted runner inside your network. Works, and now you are maintaining a runner — the server you moved to CI to avoid. If you are doing that, cron on the database host is simpler.
Don't. Run the backup where the database is, and use Actions for the drill against the backup service, which is a public endpoint. This is the split described above, and the reason it is the recommendation.
What good looks like
Server (cron, 02:00) → dump → upload snapshot ← production credentials, on the server
GitHub Actions (Mon 04:00) → restore drill + assert rows ← read-only key, in CI
External monitor → alerts if either goes quiet ← catches silent failure
Each part is where its credentials belong, and the third line is what turns the first two from a hope into a system.
Start with the free tier: backupdata.io has 5 GB free, no card, and the 10-minute quickstart gets you to a first verified snapshot today.