Pick one. All three run the same script; they differ in what happens when it fails and who finds out.
Everything below assumes the repository is checked out at /opt/render-backup
and that credentials live in /opt/render-backup/.env, which is chmod 600
and not committed.
.env:
DATABASE_URL='postgresql://user:password@host.oregon-postgres.render.com/dbname'
BACKUP_DIR='/var/backups/render'
KEEP=14
DB_CREATED_AT='2026-08-01'
ALERT_WEBHOOK='https://hooks.example.com/your-endpoint'DB_CREATED_AT is the date the Render instance was created. Setting it turns
on the countdown: the script warns at 7 days, escalates at 3, and exits 3 once
either threshold is crossed so your scheduler can treat it as an alert rather
than a success.
# m h dom mon dow command
17 3 * * * set -a; . /opt/render-backup/.env; set +a; /opt/render-backup/examples/render-backup.sh >> /var/log/render-backup.log 2>&1
# during the last week before expiry, twice a day
17 3,15 * * * set -a; . /opt/render-backup/.env; set +a; /opt/render-backup/examples/render-backup.sh >> /var/log/render-backup.log 2>&1Use one or the other, not both. 17 rather than 0 because everybody's cron
fires on the hour and your backup does not need to queue behind the rest of the
internet.
cron mails output to the crontab owner if MAILTO is set and a mail transfer
agent exists. On most containers neither is true, which is why the script also
supports ALERT_WEBHOOK.
More setup, better failure reporting: systemctl list-timers shows you the
next run, and a failed unit is visible to anything watching systemd state.
/etc/systemd/system/render-backup.service:
[Unit]
Description=Logical backup of the Render Postgres instance
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=backup
EnvironmentFile=/opt/render-backup/.env
ExecStart=/opt/render-backup/examples/render-backup.sh
# exit 3 means "backup succeeded, expiry is close" — surface it, do not hide it
SuccessExitStatus=0
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/var/backups/render/etc/systemd/system/render-backup.timer:
[Unit]
Description=Daily Render Postgres backup
[Timer]
OnCalendar=*-*-* 03:17:00
RandomizedDelaySec=600
Persistent=true
[Install]
WantedBy=timers.targetsudo systemctl daemon-reload
sudo systemctl enable --now render-backup.timer
sudo systemctl start render-backup.service # run once now
journalctl -u render-backup.service -n 50Persistent=true matters: if the machine was asleep at 03:17 the run happens
at next boot instead of being skipped.
For when there is no always-on machine. The runner is ephemeral, so the dump has to go somewhere — object storage, or an artifact if it is small and you accept the retention window.
name: render-backup
on:
schedule:
- cron: '17 3 * * *'
workflow_dispatch:
jobs:
dump:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install a matching client
run: |
sudo sh -c 'echo "deb https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/pgdg.gpg
sudo apt-get update && sudo apt-get install -y postgresql-client-16
- name: Back up
env:
DATABASE_URL: ${{ secrets.RENDER_DATABASE_URL }}
DB_CREATED_AT: ${{ vars.RENDER_DB_CREATED_AT }}
BACKUP_DIR: ${{ runner.temp }}/backups
KEEP: 1
run: ./examples/render-backup.sh
- uses: actions/upload-artifact@v4
with:
name: render-dump-${{ github.run_number }}
path: ${{ runner.temp }}/backups
retention-days: 30Two things to be honest about. Scheduled workflows on GitHub are best-effort
and can be delayed under load, so this is not a precise clock. And GitHub
disables scheduled workflows in repositories with no activity for a period — if
this is your only backup and the repository is otherwise idle, it will
eventually stop running and not tell you. Use workflow_dispatch occasionally,
or use one of the other two options.
A backup you have never restored is a hypothesis.
docker run -d --name pgtest -e POSTGRES_PASSWORD=test -p 5433:5432 postgres:16
export TARGET_DSN='postgresql://postgres:test@localhost:5433/postgres'
export DUMP_PATH=/var/backups/render/20260818-031700.dump
./restore-into.sh
docker rm -f pgtestPut a reminder in your calendar to do this monthly. It takes four minutes and it is the only thing that turns a directory of files into a backup.