robots on payroll.

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

RunbookTriggerWhat it saves you from
deploy-with-migrations.ymlpush to maintwo deploys racing, code shipping before the DB schema
nightly-backup.ymldaily 03:17 UTCthe database deletion you find out about too late
uptime-and-errors.ymlevery ~10 mina customer tweeting your outage before you know about it
dependency-patrol.ymlMondays 06:23 UTCthe critical CVE sitting in package-lock.json for 8 months
preview-envs.ymlevery 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:

ConceptWhat it isWhere you'll see it
secrets.XEncrypted values you set in repo settings; never printed in logsevery runbook, e.g. ${{ secrets.VERCEL_TOKEN }}
concurrencyA named lock; two runs in the same group can't execute at oncedeploy (queue) and preview (cancel)
permissionsWhat the job's built-in GITHUB_TOKEN may touch; default is too generous, we declare the minimumevery 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. 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. 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 link in your repo locally and copy orgId and projectId out of the generated .vercel/project.json into VERCEL_ORG_ID and VERCEL_PROJECT_ID.

  3. 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 into R2_ACCOUNT_ID.

  4. 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_dump fails through a pooler in fun ways, and prisma migrate does too. Store as DATABASE_URL.

  5. 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 both text (Slack) and content (Discord) keys, so one format works for both.

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

SecretScope it toNever
VERCEL_TOKENone project (team plans)a token from a Vercel account that owns client projects too
R2_ACCESS_KEY_ID/SECRETone bucket, Object Read & Writeaccount-level Admin R2 token
DATABASE_URLa dedicated backup role if your provider supports itthe 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.

deploy-with-migrations.yml
# deploy-with-migrations.yml
# Production deploy: test -> migrate DB -> deploy to Vercel -> alert on failure.
# Action versions current as of July 2026 (checkout v7, setup-node v6).
#
# SECRETS REQUIRED (Settings -> Secrets and variables -> Actions):
#   VERCEL_TOKEN        - Vercel access token (vercel.com/account/tokens, scope to one project if on a team)
#   VERCEL_ORG_ID       - from .vercel/project.json after running `vercel link` locally
#   VERCEL_PROJECT_ID   - same file
#   DATABASE_URL        - production Postgres connection string. Use the DIRECT
#                         (or session-mode) string, not the transaction pooler:
#                         prisma migrate fails through transaction pooling.
#   ALERT_WEBHOOK_URL   - Slack or Discord incoming webhook URL
#
# ONE-TIME SETUP:
#   1. Run `vercel link` locally, copy the two IDs from .vercel/project.json into secrets.
#   2. Disable Vercel's Git auto-deploy for this project (or set "git": { "deploymentEnabled": false }
#      in vercel.json). Otherwise Vercel and this workflow will both deploy every push and race.
#   3. Swap the migration command for your tool (prisma / drizzle / whatever you use).

name: Deploy to production

on:
  push:
    branches: [main]
  workflow_dispatch: {} # manual "Run workflow" button, useful for redeploys

# Deploys must never overlap. Same group name for every run of this workflow,
# cancel-in-progress: false means a second push QUEUES behind the running deploy
# instead of killing it mid-migration. GitHub keeps at most one run queued;
# older queued runs get superseded, which is what you want.
concurrency:
  group: production-deploy
  cancel-in-progress: false

env:
  VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
  VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

jobs:
  deploy:
    runs-on: ubuntu-latest
    # Least privilege: this job only needs to read the repo.
    permissions:
      contents: read
    # Optional but recommended: create a "production" environment in
    # Settings -> Environments and move DATABASE_URL there. You can then add
    # required reviewers or a wait timer in front of prod deploys.
    environment:
      name: production
      url: ${{ steps.deploy.outputs.url }}

    steps:
      - name: Check out code
        uses: actions/checkout@v7

      - name: Set up Node
        uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: npm # v6 only auto-caches npm; set 'pnpm' or 'yarn' explicitly if you use those

      - name: Install dependencies
        run: npm ci # ci, not install: exact lockfile versions, fails loudly on drift

      # Gate: broken code should never reach the migration step, let alone prod.
      - name: Run tests
        run: npm test

      # ---- DATABASE MIGRATION ----
      # Runs BEFORE the deploy so new code never talks to an old schema.
      # This ordering means migrations must be backward-compatible:
      # old code (still serving traffic) must survive the new schema for
      # the ~60s until the deploy finishes. Additive changes (new tables,
      # new nullable columns) are safe. Renames and drops are two-deploy jobs.
      - name: Run database migrations
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
        run: npx prisma migrate deploy
        # drizzle:  npx drizzle-kit migrate
        # raw SQL:  psql "$DATABASE_URL" -f migrations/latest.sql
        # none yet: delete this step, add it back when you have a DB

      # ---- VERCEL DEPLOY (build here, ship prebuilt output) ----
      # vercel pull  = fetch project settings + prod env vars into .vercel/
      # vercel build = build locally on this runner using those settings
      # deploy --prebuilt = upload the finished build; Vercel never rebuilds
      - name: Install Vercel CLI
        run: npm install --global vercel@latest

      - name: Pull Vercel production settings
        run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}

      - name: Build
        run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}

      - name: Deploy to production
        id: deploy
        run: |
          url=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }})
          echo "url=$url" >> "$GITHUB_OUTPUT"
          echo "Deployed: $url"

      # ---- FAILURE ALERT ----
      # if: failure() makes this run ONLY when an earlier step failed.
      # Works with Slack and Discord; both accept a JSON body on their
      # incoming webhook URL (Discord wants "content", Slack wants "text",
      # sending both keys works for either).
      - name: Alert on failure
        if: failure()
        env:
          WEBHOOK: ${{ secrets.ALERT_WEBHOOK_URL }}
        run: |
          msg="Production deploy FAILED on ${GITHUB_REPOSITORY}@${GITHUB_REF_NAME}. Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
          curl -sS -X POST -H 'Content-Type: application/json' \
            -d "{\"text\": \"$msg\", \"content\": \"$msg\"}" \
            "$WEBHOOK"
  1. 1

    on: push to main + workflow_dispatch

    Runs on every push to main. workflow_dispatch adds a manual "Run workflow" button in the Actions tab, which is your redeploy button when you need to ship the same commit again.

  2. 2

    concurrency: the no-overlap lock

    group: production-deploy with cancel-in-progress: false means 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. 3

    permissions + environment

    contents: read is all this job needs from GitHub. The environment: production block is optional but worth it: create a "production" environment in Settings -> Environments, move DATABASE_URL into 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. 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. 5

    the migration step

    npx prisma migrate deploy applies 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. 6

    vercel pull, build, deploy --prebuilt

    pull fetches project settings and prod env vars, build --prod builds on the runner, deploy --prebuilt --prod uploads 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. 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 toDo it as
rename a columndeploy 1: add new column, write to both, backfill. deploy 2: switch reads. deploy 3: drop old column
add a required columnadd it nullable with a default, backfill, then add the NOT NULL constraint in a later deploy
drop a columnstop 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.

nightly-backup.yml
# nightly-backup.yml
# Nightly pg_dump of your Postgres (Supabase / Neon / any Postgres) to
# Cloudflare R2, with 30-day retention pruning and a failure alert.
#
# SECRETS REQUIRED:
#   DATABASE_URL         - Postgres connection string. For Supabase use the
#                          DIRECT (non-pooled, port 5432) connection string;
#                          pg_dump through a transaction pooler can fail.
#   R2_ACCOUNT_ID        - Cloudflare account ID (dashboard -> R2 -> right sidebar)
#   R2_ACCESS_KEY_ID     - from an R2 API token, scoped to ONE bucket, Object Read & Write only
#   R2_SECRET_ACCESS_KEY - same token
#   ALERT_WEBHOOK_URL    - Slack/Discord incoming webhook
#
# ONE-TIME SETUP:
#   1. Create an R2 bucket (example name below: "db-backups"). R2 has zero
#      egress fees, which is why it beats S3 for backups you hope to never read.
#   2. Create an R2 API token: Object Read & Write, limited to that one bucket.
#      R2 does not accept GitHub OIDC, so these are real keys; scope them hard.
#   3. Change BUCKET below to your bucket name.

name: Nightly database backup

on:
  schedule:
    # 03:17 UTC. Odd minute on purpose: :00 crons queue behind everyone
    # else's :00 crons and get delayed 5-30 min, sometimes skipped entirely.
    - cron: "17 3 * * *"
  workflow_dispatch: {} # always keep a manual trigger next to schedule

env:
  BUCKET: db-backups # <- your R2 bucket name
  PREFIX: postgres # folder inside the bucket

jobs:
  backup:
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      # Match pg_dump's major version to your server. Supabase and Neon run
      # Postgres 15-17 depending on project age; ubuntu-latest ships an older
      # client, and pg_dump refuses to dump a server NEWER than itself.
      # Installing the v17 client dumps any server up to 17.
      - name: Install Postgres 17 client
        run: |
          sudo apt-get -qq update
          sudo apt-get -qq install -y postgresql-common
          sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
          sudo apt-get -qq install -y postgresql-client-17

      - name: Dump database
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
        run: |
          STAMP=$(date -u +%Y%m%dT%H%M%SZ)
          echo "STAMP=$STAMP" >> "$GITHUB_ENV"
          # --no-owner --no-privileges: restores cleanly to a different
          # project/role, which is exactly the disaster scenario.
          pg_dump "$DATABASE_URL" --no-owner --no-privileges \
            | gzip > "backup-${STAMP}.sql.gz"
          ls -lh backup-*.sql.gz

      # Sanity check: an "empty" dump usually means a wrong URL or a pooler
      # issue, and it exits 0 anyway. Fail if the file is suspiciously small.
      - name: Verify dump is not empty
        run: |
          SIZE=$(stat -c%s backup-${STAMP}.sql.gz)
          echo "Dump size: $SIZE bytes"
          if [ "$SIZE" -lt 1024 ]; then
            echo "Dump under 1KB, something is wrong"; exit 1
          fi

      # AWS CLI is preinstalled on ubuntu-latest and speaks R2's S3 API.
      # The two CHECKSUM env vars are REQUIRED: AWS CLI v2.23+ sends CRC32
      # integrity checksums by default and R2 rejects them.
      - name: Upload to Cloudflare R2
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
          AWS_DEFAULT_REGION: auto
          AWS_REQUEST_CHECKSUM_CALCULATION: when_required
          AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
          ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
        run: |
          aws s3 cp "backup-${STAMP}.sql.gz" \
            "s3://${BUCKET}/${PREFIX}/backup-${STAMP}.sql.gz" \
            --endpoint-url "$ENDPOINT"

      # ---- 30-DAY RETENTION ----
      # Delete objects older than 30 days. rclone does this in one line and
      # its Cloudflare provider mode auto-handles R2 quirks.
      - name: Prune backups older than 30 days
        env:
          RCLONE_CONFIG_R2_TYPE: s3
          RCLONE_CONFIG_R2_PROVIDER: Cloudflare
          RCLONE_CONFIG_R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
          RCLONE_CONFIG_R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
          RCLONE_CONFIG_R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
        run: |
          curl -fsSL https://rclone.org/install.sh | sudo bash
          rclone delete "r2:${BUCKET}/${PREFIX}/" --min-age 30d --s3-no-check-bucket
          echo "Current backups:"
          rclone ls "r2:${BUCKET}/${PREFIX}/"

      # Optional dead-man's-switch: GitHub silently skips crons under load and
      # auto-disables schedules after 60 days of repo inactivity. Pinging an
      # external monitor (healthchecks.io free tier) on success means you get
      # an email when the backup STOPS happening, not just when it fails.
      # - name: Ping healthcheck
      #   run: curl -fsS https://hc-ping.com/YOUR-UUID-HERE

      - name: Alert on failure
        if: failure()
        env:
          WEBHOOK: ${{ secrets.ALERT_WEBHOOK_URL }}
        run: |
          msg="Nightly DB backup FAILED for ${GITHUB_REPOSITORY}. Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
          curl -sS -X POST -H 'Content-Type: application/json' \
            -d "{\"text\": \"$msg\", \"content\": \"$msg\"}" \
            "$WEBHOOK"

# RESTORE TEST (do this once a quarter, on the calendar, for real):
#   1. Download the latest backup from R2.
#   2. Create a throwaway Postgres (new Neon branch or local Docker).
#   3. gunzip -c backup-XXXX.sql.gz | psql "$THROWAWAY_URL"
#   4. Row-count 2-3 important tables against prod.
# A backup you have never restored is a hope, not a backup.
  1. 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_dispatch stays alongside so you can fire a backup by hand before anything scary (big migration, plan change).

  2. 2

    install the Postgres 17 client

    pg_dump refuses 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. 3

    dump and gzip

    pg_dump "$DATABASE_URL" --no-owner --no-privileges | gzip writes 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. 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. 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. 6

    30-day retention with rclone

    rclone delete r2:bucket/prefix --min-age 30d prunes everything older than 30 days in one line. rclone's provider: Cloudflare mode auto-handles R2's quirks. 30 nightly copies is enough to recover from "we corrupted data two weeks ago and just noticed."

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

uptime-and-errors.yml
# uptime-and-errors.yml
# Poor man's uptime monitor: every 10-ish minutes, hit your homepage and one
# key API route. Anything but a 200 fires your Slack/Discord webhook.
#
# HONEST FRAMING: GitHub's cron is best-effort. Expect 5-30 min delays at
# busy hours and occasional silently skipped runs. This is a free safety net,
# not a pager. If your SaaS has paying users, add a real external monitor
# (UptimeRobot / Better Stack free tiers) and keep this as the second opinion
# that also hits a health route backed by a real DB query, which dumb pingers don't.
#
# SECRETS REQUIRED:
#   ALERT_WEBHOOK_URL - Slack/Discord incoming webhook
# (URLs below are plain env vars, they are not secret.)

name: Uptime and error check

on:
  schedule:
    # every 10 min, offset to :07 to dodge the top-of-the-hour cron rush.
    # 5 min is GitHub's minimum interval; more frequent is silently ignored.
    - cron: "7,17,27,37,47,57 * * * *"
  workflow_dispatch: {}

env:
  PROD_URL: https://yourapp.com # <- your production URL
  API_URL: https://yourapp.com/api/health # <- a route that touches your DB

jobs:
  check:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    # If a check hangs, don't burn 6 hours of Actions minutes (the default
    # job timeout). Kill it fast; the next scheduled run is minutes away.
    timeout-minutes: 3

    steps:
      # No checkout needed: this job never touches your code.

      # curl flags, decoded:
      #   -s silent  -o /dev/null throw away the body
      #   -w "%{http_code}" print only the status code
      #   --max-time 20  a "slow" site is a down site for users
      #   --retry 2 --retry-delay 5  one blip should not page you at 3am;
      #     retries happen inside this step, 5s apart
      - name: Ping homepage
        id: home
        run: |
          code=$(curl -s -o /dev/null -w "%{http_code}" \
            --max-time 20 --retry 2 --retry-delay 5 "$PROD_URL" || echo "000")
          echo "code=$code" >> "$GITHUB_OUTPUT"
          echo "Homepage: $code"
          [ "$code" = "200" ] || exit 1

      # A health route should hit the DB (e.g. SELECT 1) so this catches
      # "site renders but database is on fire", the classic silent outage.
      - name: Ping API health route
        id: api
        # Run even if the homepage check failed, so the alert names both.
        if: always()
        run: |
          code=$(curl -s -o /dev/null -w "%{http_code}" \
            --max-time 20 --retry 2 --retry-delay 5 "$API_URL" || echo "000")
          echo "code=$code" >> "$GITHUB_OUTPUT"
          echo "API: $code"
          [ "$code" = "200" ] || exit 1

      - name: Alert on failure
        if: failure()
        env:
          WEBHOOK: ${{ secrets.ALERT_WEBHOOK_URL }}
          HOME_CODE: ${{ steps.home.outputs.code }}
          API_CODE: ${{ steps.api.outputs.code }}
        run: |
          msg="DOWN ALERT: ${PROD_URL} returned ${HOME_CODE:-?}, API returned ${API_CODE:-?} (000 = timeout/DNS/conn refused). Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
          curl -sS -X POST -H 'Content-Type: application/json' \
            -d "{\"text\": \"$msg\", \"content\": \"$msg\"}" \
            "$WEBHOOK"

# CAVEATS THAT BITE:
# - 60-day auto-disable: GitHub disables scheduled workflows after 60 days
#   with no commits to the repo. If your project is "done", your monitor
#   dies quietly two months later. Commit occasionally or re-enable manually
#   (Actions tab shows a banner), or run this from an active repo.
# - This alerts on EVERY failed run. At 10-min intervals a 2-hour outage is
#   ~12 pings. For an indie project that is a feature (you cannot miss it).
  1. 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. 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. 3

    the curl incantation, decoded

    -s -o /dev/null -w "%{http_code}" prints only the status code. --max-time 20 treats a 20-second page as down, because to a user it is. --retry 2 --retry-delay 5 absorbs 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. 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. 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:

app/api/health/route.ts
// Next.js App Router health route: proves DB connectivity, not just uptime
import { NextResponse } from "next/server";
import { sql } from "@vercel/postgres"; // or your prisma/drizzle client

export const dynamic = "force-dynamic"; // never cache a health check

export async function GET() {
  try {
    await sql`SELECT 1`; // prisma: await prisma.$queryRaw`SELECT 1`
    return NextResponse.json({ ok: true, ts: Date.now() });
  } catch (e) {
    return NextResponse.json(
      { ok: false, error: "db_unreachable" },
      { status: 503 } // non-200 is what trips the monitor
    );
  }
}

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.

dependency-patrol.yml
# dependency-patrol.yml
# Weekly dependency report: npm audit + npm outdated, posted as a single
# GitHub issue every Monday. One recurring issue, updated in place, so your
# tracker doesn't fill up with 52 stale reports a year.
#
# Optional add-on at the bottom: auto-PR for patch/minor updates.
#
# SECRETS REQUIRED: none. Uses the built-in GITHUB_TOKEN with
# least-privilege permissions declared per job.

name: Dependency patrol

on:
  schedule:
    - cron: "23 6 * * 1" # Mondays 06:23 UTC (odd minute dodges cron rush)
  workflow_dispatch: {}

jobs:
  report:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      issues: write # only what this job needs: read code, write one issue

    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: npm

      - run: npm ci

      # Both commands exit non-zero when they find anything, which would
      # fail the job before we can post. `|| true` captures output either way.
      - name: Collect audit and outdated reports
        run: |
          {
            echo "## npm audit (prod deps)"
            echo '```'
            npm audit --omit=dev 2>&1 || true
            echo '```'
            echo ""
            echo "## npm outdated"
            echo '```'
            npm outdated 2>&1 || true
            echo '```'
            echo ""
            echo "_Generated $(date -u +%Y-%m-%d) by dependency-patrol. Triage rule: fix criticals this week, highs this month, park the rest._"
          } > report.md

      # github-script gives you an authenticated Octokit client inline.
      # Find the existing report issue by label; update it, or create it.
      - name: Post or update the report issue
        uses: actions/github-script@v8
        with:
          script: |
            const fs = require('fs');
            const body = fs.readFileSync('report.md', 'utf8');
            const title = 'Weekly dependency report';
            const label = 'dependency-patrol';

            // Ensure the label exists (ignore "already exists" errors)
            await github.rest.issues.createLabel({
              ...context.repo, name: label, color: '1d76db',
            }).catch(() => {});

            const { data: issues } = await github.rest.issues.listForRepo({
              ...context.repo, labels: label, state: 'open', per_page: 1,
            });

            if (issues.length > 0) {
              await github.rest.issues.update({
                ...context.repo, issue_number: issues[0].number, title, body,
              });
              core.info(`Updated issue #${issues[0].number}`);
            } else {
              const { data: issue } = await github.rest.issues.create({
                ...context.repo, title, body, labels: [label],
              });
              core.info(`Created issue #${issue.number}`);
            }

  # ---- OPTIONAL: auto-PR patch + minor updates ----
  # Semver honesty check: "minor" updates break things too, just less often.
  # This PR runs your tests via your normal PR checks; merge only when green.
  # Delete this whole job if you'd rather update by hand.
  auto-update:
    runs-on: ubuntu-latest
    needs: report
    permissions:
      contents: write # push the update branch
      pull-requests: write # open the PR
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: npm

      - run: npm ci

      # `npm update` respects the semver ranges in package.json (the default
      # caret ^ range = minor + patch only). It will not jump majors.
      - name: Apply minor and patch updates
        run: npm update

      # Only opens a PR if npm update actually changed the lockfile.
      # NOTE: peter-evans/create-pull-request is a third-party action.
      # For supply-chain safety, pin it to a full commit SHA:
      #   uses: peter-evans/create-pull-request@<full-sha> # v7
      # (grab the SHA from the action's releases page).
      - name: Open PR if anything changed
        uses: peter-evans/create-pull-request@v7
        with:
          branch: deps/weekly-minor-updates
          title: "chore(deps): weekly minor and patch updates"
          commit-message: "chore(deps): weekly minor and patch updates"
          body: |
            Automated `npm update` from dependency-patrol.
            Minor + patch bumps only (respects package.json semver ranges).
            Merge when checks are green; close if anything looks off.
          labels: dependencies
          delete-branch: true

# NOTE: PRs opened with the default GITHUB_TOKEN do NOT trigger your other
# workflows (GitHub prevents recursion). If your CI doesn't run on this PR,
# close and reopen it by hand to kick the checks, or use a fine-grained PAT
# stored as a secret and passed via the `token:` input.
  1. 1

    job 1: collect the reports

    npm audit --omit=dev (prod dependencies only; dev-dep vulnerabilities in your build tooling are usually noise) and npm outdated, both piped into a markdown file. The || true matters: both commands exit non-zero when they find anything, which would kill the job before posting.

  2. 2

    job 1: post or update the issue

    actions/github-script@v8 gives you an authenticated GitHub API client inline. It looks for an open issue labeled dependency-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: write and nothing else.

  3. 3

    job 2 (optional): npm update + auto-PR

    npm update respects 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-request opens a PR only if the lockfile actually changed. Delete this whole job if you prefer updating by hand.

  4. 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 audit cries 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.

preview-envs.yml
# preview-envs.yml
# Every PR gets its own live preview URL on Vercel, posted as a PR comment.
# Same build-locally pattern as the prod deploy (pull -> build -> deploy
# --prebuilt), so previews and prod go through identical machinery.
#
# SECRETS REQUIRED:
#   VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID (same three as the deploy
#   runbook; from `vercel link` -> .vercel/project.json)
#
# NOTE: If Vercel's Git integration is still connected, Vercel already makes
# previews on its own and this duplicates them. Pick one owner. This workflow
# exists for when Actions owns deploys (you disabled Vercel auto-deploy) or
# when you want tests gating previews.

name: PR preview deploy

on:
  pull_request:
    branches: [main]

# One preview per PR. New pushes to the same PR cancel the older in-flight
# preview build (unlike prod, cancelling a preview is free and correct).
concurrency:
  group: preview-${{ github.ref }}
  cancel-in-progress: true

env:
  VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
  VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

jobs:
  preview:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write # needed to comment the URL on the PR

    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: npm

      - run: npm ci

      # Broken PRs should not get preview URLs; fail here, fix, push again.
      - name: Lint and test
        run: |
          npm run lint --if-present
          npm test --if-present

      - name: Install Vercel CLI
        run: npm install --global vercel@latest

      - name: Pull Vercel preview settings
        run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}

      - name: Build
        run: vercel build --token=${{ secrets.VERCEL_TOKEN }}

      - name: Deploy preview
        id: deploy
        run: |
          url=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }})
          echo "url=$url" >> "$GITHUB_OUTPUT"

      # Sticky comment: finds this workflow's earlier comment on the PR and
      # edits it instead of stacking a new comment on every push.
      - name: Comment preview URL on the PR
        uses: actions/github-script@v8
        with:
          script: |
            const marker = '<!-- preview-envs -->';
            const url = '${{ steps.deploy.outputs.url }}';
            const body = `${marker}\n**Preview deployed:** ${url}\n\n_Updated ${new Date().toISOString()} for commit ${context.payload.pull_request.head.sha.slice(0, 7)}._`;

            const { data: comments } = await github.rest.issues.listComments({
              ...context.repo, issue_number: context.issue.number, per_page: 100,
            });
            const existing = comments.find(c => c.body.includes(marker));

            if (existing) {
              await github.rest.issues.updateComment({
                ...context.repo, comment_id: existing.id, body,
              });
            } else {
              await github.rest.issues.createComment({
                ...context.repo, issue_number: context.issue.number, body,
              });
            }

# SECURITY NOTE: `pull_request` (used here) runs the PR's own code WITHOUT
# access to your secrets for forks, which is safe. Never "upgrade" this to
# `pull_request_target` to make fork previews work; that trigger hands your
# secrets to code from strangers (the classic pwn-request attack, and the
# reason checkout v7 now blocks fork checkouts under it by default).
# For a solo/private repo where all PRs are yours, this is a non-issue.
  1. 1

    on: pull_request + preview concurrency

    Runs on every PR targeting main. The concurrency group is preview-${{ github.ref }}, one lock per PR, with cancel-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. 2

    permissions: pull-requests: write

    Needed for exactly one thing: posting the comment. Contents stays read-only.

  3. 3

    lint and test before deploy

    --if-present means 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. 4

    the same vercel pull/build/deploy dance

    Identical machinery to the prod runbook, minus --prod and 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. 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:

BehaviorRealityYour move
Timingbest-effort; 5-30 min delays routine, 60+ min at peak; UTC onlyschedule on odd minutes (:17, :23), never :00
Skipped runsunder load, runs are dropped with no error, no log, no emailexternal dead-man's switch (healthchecks.io ping as the last step)
60-day auto-disableno commits for 60 days = schedule silently disabled; tags, issues, releases don't reset the timercommit occasionally, watch for the Actions-tab banner, or add a keepalive commit job
Minimum interval5 minutes; tighter crons silently not honoreddon't build anything that needs sub-5-min checks on Actions
Which file runsthe workflow file on the default branch onlyschedule 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.

PROMPT: The adaptation prompt
You are adapting a production GitHub Actions workflow to my stack. Precision over creativity: change only what my stack requires, preserve every safety mechanism.

MY STACK:
- Package manager: [npm | pnpm | yarn | bun]
- Framework: [Next.js | SvelteKit | Remix | other]
- Hosting: [Vercel | Railway | Fly.io | Cloudflare | other]
- Database + provider: [Postgres on Supabase | Neon | PlanetScale MySQL | other]
- Migration tool: [prisma | drizzle | none | other]
- Alert channel: [Slack | Discord]

THE WORKFLOW TO ADAPT:
[PASTE THE FULL YML FILE HERE]

RULES:
1. Preserve these mechanisms exactly unless my stack makes one impossible, and if so, say so explicitly: concurrency groups and their cancel-in-progress values, least-privilege permissions blocks, if: failure() alerting, timeout-minutes, odd-minute cron schedules, workflow_dispatch triggers.
2. Keep action major versions as-is (checkout@v7, setup-node@v6, github-script@v8). Do not "upgrade" or downgrade them.
3. If my host has no CLI equivalent of a step (e.g. no --prebuilt deploys), replace it with that host's documented CI pattern and mark the line with a comment: # CHANGED: reason.
4. Mark every line you changed with # CHANGED: and every line you removed with a note at the bottom.
5. List the exact secrets I must create, with where to obtain each value.

OUTPUT FORMAT:
1. The complete adapted YAML in one code block, comments intact.
2. A CHANGES table: line/step, what changed, why.
3. A SECRETS table: name, where to get it, minimal scope.
4. Any safety mechanism you could not preserve, with the closest substitute.

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 (point API_URL at a 404 path for one run) to prove the alert fires
  • [ ]Ship deploy-with-migrations.yml on 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.