THE VAULT / RUN IT LIKE A BUSINESS / YAML + GUIDE
GitHub Actions Runbooks
DevOps for people who never did DevOps: five complete GitHub Actions workflows that deploy safely, back up your database nightly, watch your uptime, patrol your dependencies, and give every PR a live preview URL. Every file is copy-paste complete, annotated line by line, and current as of July 2026. Set the secrets, commit, done.
LAST VERIFIED 2026-07-08
NOTE
Use this page as a prompt reference. Every file below is built to be handed straight to an AI. Download it, drop it into Claude, ChatGPT, Cursor, or Lovable, and say "use this as my playbook." The prompts on this page are copy-paste ready.
Five workflows cover 90% of indie SaaS ops
You shipped a product with AI agents. Cool. Now the boring part decides whether it survives: deploys that don't clobber each other, a database backup that actually exists, something that tells you the site is down before a customer does. That's DevOps, and for an indie SaaS it fits in five YAML files.
Every runbook here is a complete file, not a snippet. Copy it into .github/workflows/ in your repo, set the secrets listed at the top of the file, change the two or three placeholder values, commit. GitHub runs it from there. No servers, no CI product, no monthly bill (public repos get unlimited Actions minutes; private repos get 2,000 free minutes a month, and these five together use a fraction of that).
| Runbook | Trigger | What it saves you from |
|---|---|---|
| deploy-with-migrations.yml | push to main | two deploys racing, code shipping before the DB schema |
| nightly-backup.yml | daily 03:17 UTC | the database deletion you find out about too late |
| uptime-and-errors.yml | every ~10 min | a customer tweeting your outage before you know about it |
| dependency-patrol.yml | Mondays 06:23 UTC | the critical CVE sitting in package-lock.json for 8 months |
| preview-envs.yml | every PR | "works on my machine" arguments with yourself |
NOTE
Action versions in these files are current as of July 2026: actions/checkout@v7, actions/setup-node@v6, actions/github-script@v8. First-party actions/* are fine on floating major tags; anything third-party (like peter-evans/create-pull-request) should be pinned to a full commit SHA. More in the secrets section.
GitHub Actions in 3 minutes (skip if you know it)
A workflow is a YAML file in .github/workflows/. It has three parts: `on:` (when to run: a push, a schedule, a PR), `jobs:` (what to run, each job gets a fresh Ubuntu virtual machine), and inside each job, `steps:` (shell commands via run:, or prebuilt building blocks via uses:, which are called actions).
Three more concepts and you can read every file on this page:
| Concept | What it is | Where you'll see it |
|---|---|---|
secrets.X | Encrypted values you set in repo settings; never printed in logs | every runbook, e.g. ${{ secrets.VERCEL_TOKEN }} |
concurrency | A named lock; two runs in the same group can't execute at once | deploy (queue) and preview (cancel) |
permissions | What the job's built-in GITHUB_TOKEN may touch; default is too generous, we declare the minimum | every runbook |
One mental model that prevents most confusion: each job is a throwaway computer. It boots, checks out your code (only if you tell it to), runs your steps, and evaporates. Nothing persists between runs unless you upload it somewhere (R2, an artifact, a git commit).
Secrets first: 10 minutes, least privilege
All five runbooks read credentials from repo secrets. Set them once. The rule that keeps you safe when (not if) a token leaks: every token gets the minimum scope that still works. A deploy token that can only deploy one project is an annoyance to rotate. An account-wide token is a breach.
- 1
Open the secrets page
Repo -> Settings -> Secrets and variables -> Actions -> New repository secret. Name goes in caps with underscores (
VERCEL_TOKEN), value is the raw token. Secrets are write-only: you can overwrite one but never view it again, so keep the originals in your password manager. - 2
Create the Vercel token (deploy + preview runbooks)
vercel.com -> Account Settings -> Tokens -> Create. On a team plan, scope it to the one project. Then run
vercel linkin your repo locally and copyorgIdandprojectIdout of the generated.vercel/project.jsonintoVERCEL_ORG_IDandVERCEL_PROJECT_ID. - 3
Create the R2 token (backup runbook)
Cloudflare dashboard -> R2 -> Manage API Tokens -> Create. Permission: Object Read & Write, limited to your one backup bucket. Copy the Access Key ID and Secret Access Key into
R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY, and your account ID intoR2_ACCOUNT_ID. - 4
Grab the database URL (deploy + backup runbooks)
From your Supabase/Neon dashboard. Use the direct connection string (port 5432), not the transaction pooler:
pg_dumpfails through a pooler in fun ways, andprisma migratedoes too. Store asDATABASE_URL. - 5
Create the alert webhook (deploy, backup, uptime runbooks)
Discord: channel -> Settings -> Integrations -> Webhooks -> New, copy URL. Slack: create an Incoming Webhook app for a channel. Either URL goes in
ALERT_WEBHOOK_URL. The runbooks send a JSON body with bothtext(Slack) andcontent(Discord) keys, so one format works for both. - 6
Test the webhook right now, from your terminal
curl -X POST -H 'Content-Type: application/json' -d '{"text":"test","content":"test"}' YOUR_WEBHOOK_URL. If nothing appears in the channel, fix it now, not during your first outage.
Least-privilege cheat sheet
| Secret | Scope it to | Never |
|---|---|---|
VERCEL_TOKEN | one project (team plans) | a token from a Vercel account that owns client projects too |
R2_ACCESS_KEY_ID/SECRET | one bucket, Object Read & Write | account-level Admin R2 token |
DATABASE_URL | a dedicated backup role if your provider supports it | the same superuser URL your app uses, if avoidable |
GITHUB_TOKEN (built-in) | declared per job via permissions: | leaving permissions undeclared on a repo with default write access |
WATCH OUT
The built-in GITHUB_TOKEN defaults matter. Go to Settings -> Actions -> General -> Workflow permissions and set the default to Read repository contents. Every runbook here declares the exact permissions it needs on top of that (issues: write, pull-requests: write, etc.), so nothing breaks, and a compromised dependency in one workflow can't push commits.
Deep dive: why pin third-party actions to a SHA
uses: someone/action@v3 means "whatever commit the v3 tag points at today." Tags are movable. In several real supply-chain attacks, a maintainer account got compromised and the tag was repointed at malicious code that exfiltrated CI secrets; every repo using the floating tag ran it on the next trigger.
The fix costs one line of diligence: uses: someone/action@a1b2c3d4... # v3.1.2. A full 40-character commit SHA is immutable. First-party actions/* and github/* are generally considered safe on major tags; everything else, pin. In these runbooks the only third-party action is peter-evans/create-pull-request in dependency-patrol, and the file tells you to pin it.
Also worth knowing: as of checkout v7 (June 2026), fork-PR checkouts are blocked by default under the dangerous pull_request_target and workflow_run triggers, and GitHub backports that enforcement to all supported older majors (including floating @v4/@v5/@v6 tags) on July 16, 2026. Good default, and one more reason to stay on current versions.
Runbook 1: deploy with migrations (the one that earns money)
Push to main -> tests run -> database migrates -> Vercel prod deploy -> Slack/Discord ping if anything failed. The two things this buys you over Vercel's built-in Git deploys: migrations run in the right order (schema before code), and deploys queue instead of overlapping when you push twice in a row.
- 1
on: push to main + workflow_dispatch
Runs on every push to
main.workflow_dispatchadds a manual "Run workflow" button in the Actions tab, which is your redeploy button when you need to ship the same commit again. - 2
concurrency: the no-overlap lock
group: production-deploywithcancel-in-progress: falsemeans a second push while a deploy is running waits in line instead of killing the running deploy mid-migration. GitHub keeps at most one run queued; if you push five times fast, only the newest queued one survives, which is exactly right. - 3
permissions + environment
contents: readis all this job needs from GitHub. Theenvironment: productionblock is optional but worth it: create a "production" environment in Settings -> Environments, moveDATABASE_URLinto it, and you can later add a required-reviewer gate in front of prod without touching YAML. (Environments on private repos need a paid GitHub plan; free on public repos.) - 4
checkout, setup-node, npm ci, npm test
Fresh VM, so: get the code (
checkout@v7), install Node 22 with npm caching (setup-node@v6; note v6 only auto-caches npm, declare pnpm/yarn explicitly), install exact lockfile versions (npm ci), run tests. A red test stops everything before the database is touched. - 5
the migration step
npx prisma migrate deployapplies pending migrations to prod. Swap in your tool (the file lists drizzle and raw-psql variants). It runs before the deploy so new code never meets an old schema. The contract this creates: migrations must be backward-compatible, because old code serves traffic for the ~60 seconds until the new deploy is live. - 6
vercel pull, build, deploy --prebuilt
pullfetches project settings and prod env vars,build --prodbuilds on the runner,deploy --prebuilt --produploads the finished output. Vercel never rebuilds and never sees your source. The deploy URL is captured as a step output and shows as a "View deployment" link on the run. - 7
the failure alert
if: failure()makes the last step run only when any earlier step failed. It curls your webhook with the repo, branch, and a direct link to the failed run. Silence means success.
WATCH OUT
The thing that bites people: Vercel deploys twice. If Vercel's Git integration is still connected, Vercel auto-deploys your push AND this workflow deploys it, racing each other and occasionally shipping the version that skipped your tests. Disable auto-deploy (disconnect the Git integration, or set "git": {"deploymentEnabled": false} in vercel.json) so Actions is the only thing that ships.
Deep dive: writing migrations that don't break the 60-second window
Because the migration lands before the code, the old app runs against the new schema briefly. Safe changes: new tables, new nullable columns, new indexes. Dangerous changes: renaming a column, dropping a column, adding a NOT NULL column without a default. The old code still selects the old column name and crashes.
| You want to | Do it as |
|---|---|
| rename a column | deploy 1: add new column, write to both, backfill. deploy 2: switch reads. deploy 3: drop old column |
| add a required column | add it nullable with a default, backfill, then add the NOT NULL constraint in a later deploy |
| drop a column | stop reading it in a deploy first, drop it in the next |
This is called expand/contract. You don't need to memorize it; you need to remember that renames and drops are never one deploy, and let your agent work out the sequence when the case comes up.
Runbook 2: nightly backup (the one that saves your company)
Every night at 03:17 UTC: pg_dump your Postgres, gzip it, verify it isn't empty, upload to Cloudflare R2, delete anything older than 30 days, alert if any of that failed. R2 because egress is free and 30 days of nightly dumps for a typical indie SaaS costs pennies (R2 storage runs $0.015/GB-month as of July 2026, and your first 10 GB are free).
Yes, Supabase and Neon have their own backups. Paid tiers, mostly, with provider-controlled retention, living in the same account that a bad supabase db reset or a billing lapse can take out. An off-provider copy you control is the whole point.
- 1
schedule at an odd minute + workflow_dispatch
17 3 * * *is 03:17 UTC daily. The :17 is deliberate: top-of-the-hour crons queue behind everyone else's and get delayed or skipped.workflow_dispatchstays alongside so you can fire a backup by hand before anything scary (big migration, plan change). - 2
install the Postgres 17 client
pg_dumprefuses to dump a server newer than itself, and the runner's default client is older than what Supabase/Neon run. Installing the v17 client from the official Postgres apt repo dumps any server up to 17. Costs ~20 seconds per run. - 3
dump and gzip
pg_dump "$DATABASE_URL" --no-owner --no-privileges | gzipwrites a timestamped.sql.gz. The two flags strip role ownership so the dump restores cleanly into a different project or provider, which is exactly the disaster you're insuring against. Use the direct (port 5432) connection string, not the pooled one. - 4
the not-empty check
A wrong URL can produce a tiny "empty" dump and still exit 0, and you'd discover it during a restore, i.e. the worst possible moment. This step fails the run if the file is under 1KB, which turns a silent non-backup into a loud alert.
- 5
upload to R2 with the AWS CLI
R2 speaks the S3 API, and the AWS CLI is preinstalled on the runner. The two
AWS_*_CHECKSUM_*env vars are load-bearing: AWS CLI v2.23+ sends CRC32 checksums by default and R2 rejects them. Without those two lines the upload fails with a cryptic error, and this is the single most-Googled R2+CI problem. - 6
30-day retention with rclone
rclone delete r2:bucket/prefix --min-age 30dprunes everything older than 30 days in one line. rclone'sprovider: Cloudflaremode auto-handles R2's quirks. 30 nightly copies is enough to recover from "we corrupted data two weeks ago and just noticed." - 7
failure alert + optional dead-man's switch
Failure pings your webhook. But the sneakier risk is the backup silently not running (skipped cron, auto-disabled schedule), which no failure alert can catch. The commented healthchecks.io ping fixes that: an external service emails you when the expected daily ping stops arriving. Free tier, two minutes to set up, genuinely do it.
WATCH OUT
The thing that bites people: never testing a restore. An untested backup is a hope, not a backup. Once a quarter: download the newest dump, spin up a throwaway Postgres (Neon branch or local Docker), gunzip -c backup.sql.gz | psql $THROWAWAY_URL, and row-count 2-3 important tables against prod. The restore procedure is written at the bottom of the workflow file so it's there when you're panicking.
Deep dive: why not GitHub artifacts, and what a month of this costs
Tempting shortcut: upload-artifact and skip the R2 setup. Don't. Artifacts cap at 90 days retention, count against your storage billing on private repos, live inside the same GitHub account as your code (one compromised account = code and backups gone together), and are annoying to fetch in a crisis.
Cost math for a 500 MB compressed dump, 30 copies retained: ~15 GB in R2. First 10 GB free, remainder at $0.015/GB-month means roughly $0.08/month. The Actions run itself is ~2-3 minutes/night, about 75 minutes/month against your 2,000 free private-repo minutes. This is the cheapest insurance you will ever buy.
If your DB is big enough that pg_dump takes over ~30 minutes, this pattern stops fitting a CI runner; look at your provider's PITR (point-in-time recovery) plus a weekly full dump instead.
Runbook 3: uptime checks (free, with one honest caveat)
Every ~10 minutes, curl your homepage and one health API route; anything but a 200 fires the webhook. The health route matters more than the homepage: point it at an endpoint that runs a real DB query (SELECT 1 minimum), because "site renders fine, database is on fire" is the classic silent outage a homepage ping never catches.
- 1
the schedule line
7,17,27,37,47,57 * * * *is every 10 minutes, offset to :x7 to dodge the cron rush. GitHub's floor is 5 minutes; anything more frequent is silently ignored. Times are always UTC. - 2
timeout-minutes: 3
Without this, a hung check could occupy a runner for the 6-hour default job timeout, eating your free minutes. Three minutes is generous for two curls; the next scheduled run is minutes away anyway.
- 3
the curl incantation, decoded
-s -o /dev/null -w "%{http_code}"prints only the status code.--max-time 20treats a 20-second page as down, because to a user it is.--retry 2 --retry-delay 5absorbs single blips so one dropped packet doesn't ping you at 3am.|| echo "000"catches DNS failures and refused connections, which produce no status code at all. - 4
if: always() on the API check
Normally a failed step skips everything after it.
always()makes the API check run even when the homepage check failed, so the alert can report both codes and you immediately know whether it's the whole site or just the API. - 5
the alert
Reports both status codes (000 = timeout/DNS/connection refused) plus a link to the run. At 10-minute intervals, a 2-hour outage means ~12 pings. For an indie project, that's a feature: you cannot sleep through it.
WATCH OUT
The thing that bites people: trusting GitHub's cron. Scheduled workflows are best-effort. 5-30 minute delays are routine at peak hours, runs get silently skipped when queues saturate, and schedules are auto-disabled after 60 days without a commit to the repo (as of July 2026, on public and private repos; only commits reset the timer). Treat this runbook as a free second opinion. If you have paying customers, put UptimeRobot or Better Stack (both have free tiers) in front as the primary pager and keep this one because it checks your API for real, which dumb pingers don't.
Deep dive: a health route worth pinging
A /api/health that returns {"ok": true} without touching anything only proves your host is up, which Vercel already guarantees. A useful one checks the things that actually fail:
Keep it fast (one trivial query), uncached, and unauthenticated. If you're worried about strangers hitting it, rate-limit it or require a static query param; don't put it behind auth or your monitor needs credentials.
Runbook 4: dependency patrol (the Monday morning issue)
Every Monday: npm audit + npm outdated, posted into one recurring GitHub issue that gets updated in place (not 52 new issues a year). Plus an optional second job that opens a PR with minor/patch updates already applied, so "update deps" becomes "read the diff, merge if green."
Why not just Dependabot? Dependabot is fine and you can run both. The difference: Dependabot opens a PR per package (10 packages = 10 PRs to babysit), this gives you one weekly digest and one batched PR. For a solo builder, the batched version is the one that actually gets read.
- 1
job 1: collect the reports
npm audit --omit=dev(prod dependencies only; dev-dep vulnerabilities in your build tooling are usually noise) andnpm outdated, both piped into a markdown file. The|| truematters: both commands exit non-zero when they find anything, which would kill the job before posting. - 2
job 1: post or update the issue
actions/github-script@v8gives you an authenticated GitHub API client inline. It looks for an open issue labeleddependency-patrol; updates it if found, creates it if not. Your issue tracker stays clean and the issue's edit history is your audit trail. Note the job's permissions:contents: read, issues: writeand nothing else. - 3
job 2 (optional): npm update + auto-PR
npm updaterespects the semver ranges already in your package.json, so with default caret ranges it applies minor and patch bumps only, never majors.peter-evans/create-pull-requestopens a PR only if the lockfile actually changed. Delete this whole job if you prefer updating by hand. - 4
the triage rule baked into the report
The report footer carries the rule so future-you follows it: criticals this week, highs this month, park the rest.
npm auditcries wolf constantly; a severity-based rule keeps you patching what matters without burning Mondays on moderate-severity noise in a transitive dev dependency.
WATCH OUT
The thing that bites people: the auto-PR shows no CI checks. PRs created with the built-in GITHUB_TOKEN deliberately don't trigger other workflows (GitHub's anti-recursion rule). So your test workflow won't run on the patrol PR. Fixes, easiest first: close and reopen the PR by hand (kicks the checks), or create a fine-grained PAT and pass it to the create-pull-request step's token: input. Don't merge a deps PR that never ran your tests.
Deep dive: reading an npm audit report without panicking
Three questions per finding, in order: (1) Is it in a prod dependency? --omit=dev already filters most noise, but check the dependency path. (2) Is the vulnerable code path reachable? A ReDoS in a URL parser you never feed user input is theory, not threat. (3) Is there a fix? npm audit fix handles anything solvable within your semver ranges; npm audit fix --force can jump majors and break you, so treat it as a last resort with a git branch under it.
The one that's always real: a critical in something that touches auth, parsing user uploads, or your payment flow. Those you patch the day you see them, even if it means a major-version bump and an afternoon of fixing types.
Runbook 5: preview environments (every PR gets a URL)
Open a PR, get a live URL commented on it two minutes later. Push more commits, the same comment updates (sticky comment, no comment spam). Lint and tests gate the deploy, so broken PRs never get a URL. This is how you test agent-written changes on a real device before they touch main.
- 1
on: pull_request + preview concurrency
Runs on every PR targeting
main. The concurrency group ispreview-${{ github.ref }}, one lock per PR, withcancel-in-progress: true: pushing a new commit cancels the now-stale preview build. Opposite setting from prod, where cancelling mid-deploy is the thing we're preventing. - 2
permissions: pull-requests: write
Needed for exactly one thing: posting the comment. Contents stays read-only.
- 3
lint and test before deploy
--if-presentmeans the workflow won't explode if you have no lint script yet. The ordering is the point: a preview URL is a promise the branch basically works. - 4
the same vercel pull/build/deploy dance
Identical machinery to the prod runbook, minus
--prodand with--environment=preview, so Vercel injects your preview env vars (point these at a staging database, never prod). Previews and production going through the same pipeline means a green preview actually predicts a green deploy. - 5
the sticky comment
github-script lists the PR's comments, finds the one containing the hidden
<!-- preview-envs -->marker, and edits it (or creates it the first time). Includes the short commit SHA so you know which push you're looking at.
WATCH OUT
The thing that bites people: fork PRs and `pull_request_target`. On the pull_request trigger used here, PRs from forks run without access to your secrets, so fork previews simply fail to deploy. Safe, if annoying. The "fix" people Google their way into is switching to pull_request_target, which runs with your secrets against a stranger's code: that's the pwn-request attack, and it's how CI secrets get stolen. Solo repo where every PR is yours? Non-issue, ignore fork support. Public repo with outside contributors? Live without fork previews.
Deep dive: preview env vars and the staging database
vercel pull --environment=preview pulls the env vars you set for the Preview environment in the Vercel dashboard. The one rule: preview `DATABASE_URL` never points at production. An agent-written PR with a stray migration or a destructive seed script should only ever be able to hurt a staging DB.
Cheapest staging DB setups: a Neon branch (copy-on-write clone of prod, resets in seconds and the free tier covers it), a second free-tier Supabase project, or a single shared "staging" database all previews point at. Solo builders: the shared staging DB is fine, don't overbuild per-PR databases until concurrent PRs actually collide.
The cron fine print (applies to runbooks 2, 3, and 4)
Three of these five runbooks run on schedule:. GitHub's scheduler is a shared queue, not a promise, and the failure modes are all silent. The full list, as of July 2026:
| Behavior | Reality | Your move |
|---|---|---|
| Timing | best-effort; 5-30 min delays routine, 60+ min at peak; UTC only | schedule on odd minutes (:17, :23), never :00 |
| Skipped runs | under load, runs are dropped with no error, no log, no email | external dead-man's switch (healthchecks.io ping as the last step) |
| 60-day auto-disable | no commits for 60 days = schedule silently disabled; tags, issues, releases don't reset the timer | commit occasionally, watch for the Actions-tab banner, or add a keepalive commit job |
| Minimum interval | 5 minutes; tighter crons silently not honored | don't build anything that needs sub-5-min checks on Actions |
| Which file runs | the workflow file on the default branch only | schedule changes on a feature branch do nothing until merged |
NOTE
The pattern all three scheduled runbooks share: schedule: for the automation, workflow_dispatch: for the manual button, odd-minute cron, and an alert path that doesn't depend on the schedule itself firing. Copy that shape into any scheduled workflow you write later.
Adapting these to your stack: the prompt
These files assume Node + npm + Vercel + Postgres because that's the modal vibe-coder stack. If yours differs (pnpm, Railway, Fly.io, MySQL), don't hand-edit YAML you can't read yet. Paste the runbook plus this prompt into your agent and make it do the translation while showing its work.
Then verify the agent's work the cheap way: commit the workflow to a branch, open a throwaway PR, and watch it run in the Actions tab. YAML errors and permission problems all surface on the first run. Never debug a workflow by pushing to main.
Download all five
Each file is complete and commented, including its secrets list at the top. Drop them into .github/workflows/ in your repo.
Go-live checklist
Thirty minutes, in this order. The order matters: backups before deploys, because the first thing a new deploy pipeline does is find new ways to need a backup.
- [ ]Set default workflow permissions to read-only (Settings -> Actions -> General -> Workflow permissions)
- [ ]Create all secrets from the secrets section; test the alert webhook with a curl from your terminal
- [ ]Disable Vercel Git auto-deploy so Actions owns deploys (no double builds)
- [ ]Ship
nightly-backup.yml, run it once via workflow_dispatch, confirm the file landed in R2 with a sane size - [ ]Do one restore test into a throwaway database this week, then put a quarterly repeat on your calendar
- [ ]Ship
uptime-and-errors.yml, then break it on purpose (pointAPI_URLat a 404 path for one run) to prove the alert fires - [ ]Ship
deploy-with-migrations.ymlon a branch first; watch a full run in the Actions tab before merging to main - [ ]Push twice in quick succession once, and watch the second deploy queue instead of overlapping (Actions tab shows it pending)
- [ ]Ship
preview-envs.yml, open a test PR, click the preview URL on a phone - [ ]Ship
dependency-patrol.yml, trigger it manually, read your first Monday report - [ ]Optional but smart: healthchecks.io dead-man's switch on the backup workflow
- [ ]Put a calendar note for ~every 6 months: check for new major versions of actions/checkout and actions/setup-node
PAYOFF
That's the whole ops story for an indie SaaS: five files, ~30 minutes of setup, under $0.10/month in R2 storage, and every failure mode reports to the same channel. The first time the deploy alert fires before a customer notices anything, this page paid for itself.
Get the next drop
Every new Vault system ships to the list first. Twice a week, free.