Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Only package.json, pnpm-lock.yaml, tsconfig.json, src/ and docker/ are copied,
# but keep local credentials and build output out of the build context anyway.
.git
.github
.review
node_modules
dist
.env
.env.*
backupdrill.config.json
supabase
examples
plugin
.claude-plugin
.cursor-plugin
*.log
.DS_Store
104 changes: 104 additions & 0 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Builds the container image on every PR that touches it, smoke-tests it, and on
# main publishes it to ghcr.io/backupdrill/backupdrill (latest + the package.json
# version + the commit SHA). Credentials never enter the image; the only secret
# used here is the workflow's own GITHUB_TOKEN, to push to GHCR.
name: docker-image

on:
pull_request:
paths:
- Dockerfile
- .dockerignore
- docker/**
- src/**
- package.json
- pnpm-lock.yaml
- tsconfig.json
- .github/workflows/docker-image.yml
push:
branches: [main]
paths:
- Dockerfile
- .dockerignore
- docker/**
- src/**
- package.json
- pnpm-lock.yaml
- tsconfig.json
- .github/workflows/docker-image.yml
workflow_dispatch: {}

permissions:
contents: read

concurrency:
group: docker-image-${{ github.ref }}
cancel-in-progress: true

env:
IMAGE: ghcr.io/backupdrill/backupdrill

jobs:
image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4

- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3

- name: Build for the smoke test (amd64, local only)
uses: docker/build-push-action@v6
with:
context: .
load: true
tags: backupdrill:smoke

- name: Smoke test
run: |
set -eu
expected="$(node -p 'require("./package.json").version')"
actual="$(docker run --rm backupdrill:smoke --version)"
test "$actual" = "$expected" || { echo "image says $actual, package.json says $expected"; exit 1; }
docker run --rm --entrypoint pg_dump backupdrill:smoke --version | grep -q 'PostgreSQL) 17\.'
# an invalid schedule (including a comment or VAR=value line) must stop the container instead of idling
for schedule in "not a cron" "# off" "FOO=bar" "0 3 * *" "@daily extra" "0 3 * * 0 #off" "0 3 * * * foo"; do
status=0; docker run --rm -e BACKUPDRILL_CRON="$schedule" backupdrill:smoke || status=$?
test "$status" = 64 || { echo "schedule '$schedule' exited $status, expected 64"; exit 1; }
done
# with no config it must fail fast with the missing-config message
status=0; docker run --rm backupdrill:smoke 2>err.txt || status=$?
test "$status" != 0 && grep -q BACKUPDRILL_DATABASE_URL err.txt

- name: Image tags
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
id: tags
run: |
version="$(node -p 'require("./package.json").version')"
{
echo "list<<EOF"
echo "$IMAGE:latest"
echo "$IMAGE:$version"
echo "$IMAGE:sha-${GITHUB_SHA::7}"
echo "EOF"
} >> "$GITHUB_OUTPUT"

- name: Log in to GHCR
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push (amd64 + arm64)
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.tags.outputs.list }}
37 changes: 37 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1
# Container image for running `backupdrill backup` on a schedule (Unraid, a NAS,
# any Docker host). Built from this repo's source, so the image always matches
# the commit it was built from. No credentials are baked in: every setting comes
# from environment variables at run time.

FROM node:24-alpine3.24 AS build
WORKDIR /app
# pnpm 9 matches lockfileVersion 9.0; pinned so a pnpm release can't change the install
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY tsconfig.json ./
COPY src ./src
RUN pnpm run build && pnpm prune --prod && chmod +x dist/index.js

FROM node:24-alpine3.24
# postgresql17-client: pg_dump/pg_restore/psql 17, the same major version the
# GitHub Action installs. Supabase runs PG 15–17, and a dump made by pg_dump 18
# can't be read by a pg_restore 17 someone may restore with later.
# supercronic runs the schedule in the foreground and passes the container's
# environment to the job; tini forwards SIGTERM so `docker stop` is clean.
RUN apk add --no-cache postgresql17-client supercronic tini tzdata
WORKDIR /app
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY docker/entrypoint.sh /usr/local/bin/backupdrill-entrypoint
RUN ln -s /app/dist/index.js /usr/local/bin/backupdrill \
&& chmod 755 /usr/local/bin/backupdrill-entrypoint

LABEL org.opencontainers.image.source="https://github.com/backupdrill/cli" \
org.opencontainers.image.description="Back up a Supabase project's Postgres database and Storage files to your own S3/R2/B2 bucket on a schedule." \
org.opencontainers.image.licenses="MIT"

USER node
ENTRYPOINT ["/sbin/tini", "--", "backupdrill-entrypoint"]
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,32 @@ Prefer a plain workflow file? [`examples/scheduled-backup.yml`](examples/schedul

Before switching to a **daily** schedule, read the egress-cost section above — every run is egress on your Supabase bill.

## Docker / scheduled backups on your own server

If you'd rather run backups on a machine you already have (a home server, a NAS, Unraid, any Docker host), use the image at `ghcr.io/backupdrill/backupdrill`. The image includes the CLI and `pg_dump` 17. It contains no credentials; configure it with the `BACKUPDRILL_*` environment variables listed above.

Set `BACKUPDRILL_CRON` and the container stays up and runs `backupdrill backup` on that schedule. The time zone comes from `TZ` (UTC if unset). If a backup is still running when the next one is due, that next run is skipped, so two backups never overlap.

```bash
docker run -d --name backupdrill --restart unless-stopped \
-e BACKUPDRILL_CRON="0 3 * * 0" \
-e TZ="Europe/Berlin" \
-e BACKUPDRILL_DATABASE_URL="postgresql://postgres.<ref>:<pw>@<pooler-host>:5432/postgres" \
-e BACKUPDRILL_PROJECT_NAME="my-app" \
-e BACKUPDRILL_S3_ENDPOINT="https://<account>.r2.cloudflarestorage.com" \
-e BACKUPDRILL_S3_BUCKET="my-backups" \
-e BACKUPDRILL_S3_ACCESS_KEY_ID="…" \
-e BACKUPDRILL_S3_SECRET_ACCESS_KEY="…" \
-e BACKUPDRILL_PING_URL="https://hc-ping.com/<uuid>" \
ghcr.io/backupdrill/backupdrill:latest
```

- Without `BACKUPDRILL_CRON` the container runs one backup and exits with the CLI's exit code, which suits a host cron job or a scheduler you already use.
- Arguments go straight to the CLI: `docker run --rm -e … ghcr.io/backupdrill/backupdrill estimate` or `… --help`. To start a backup now in a running scheduled container: `docker exec backupdrill backupdrill backup`.
- `BACKUPDRILL_CRON` takes a standard 5-field expression or a shortcut such as `@daily`. Anything else stops the container at start (exit code 64) instead of leaving it idle.
- `drill` needs Docker to start its throwaway Postgres, and the image doesn't get access to your Docker daemon, so run drills where Docker is available (your machine, or the GitHub Action with `drill: "true"`).
- Tags: `latest`, the CLI version (e.g. `2.10.0`), and `sha-<commit>`. Images are built for amd64 and arm64.

## Alerts with Healthchecks.io

A scheduled backup can fail quietly, or not run at all: a cron entry that never fires, a runner that dies, a workflow GitHub disabled. `backup` and `drill` can report every run to a [Healthchecks.io](https://healthchecks.io) check (a self-hosted Healthchecks works the same), and Healthchecks alerts you when a run fails or a success ping doesn't arrive on time.
Expand Down
64 changes: 64 additions & 0 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/bin/sh
# Three ways to run the image:
# docker run IMAGE drill ... arguments are passed straight to the CLI
# BACKUPDRILL_CRON="0 3 * * 0" run `backupdrill backup` on that schedule, forever
# neither run one backup and exit with its exit code
set -eu

if [ "$#" -gt 0 ]; then
exec backupdrill "$@"
fi

if [ -z "${BACKUPDRILL_CRON:-}" ]; then
exec backupdrill backup
fi

# One line only: a newline in the variable would otherwise add extra cron entries.
case "$BACKUPDRILL_CRON" in
*"
"*)
echo "BACKUPDRILL_CRON must be a single cron expression, e.g. \"0 3 * * 0\"." >&2
exit 64
;;
esac

# supercronic -test alone would accept a comment ("# off"), a VAR=value line, or
# "0 3 * * * foo" (it reads the 6th word as the start of the command), and the
# container would then run something other than the backup, or nothing. So only
# standard cron is allowed: a descriptor like @daily, or exactly 5 fields.
invalid_schedule() {
echo "BACKUPDRILL_CRON is not a valid cron expression: \"$BACKUPDRILL_CRON\"" >&2
exit 64
}
case "$BACKUPDRILL_CRON" in
@* | [0-9*]*) ;;
*) invalid_schedule ;;
esac
case "$BACKUPDRILL_CRON" in
*[!0-9A-Za-z*/,?@#\ -]*) invalid_schedule ;;
esac
# Word splitting is the point here (count the fields); set -f stops "*" from
# expanding to file names in the working directory.
set -f
# shellcheck disable=SC2086
set -- $BACKUPDRILL_CRON
set +f
# "#" is valid inside a field (0#2 = second Sunday) but a field that starts with it
# would turn the rest of the crontab line, the backup command, into a comment.
for field in "$@"; do
case "$field" in \#*) invalid_schedule ;; esac
done
case "$1" in
@*) [ "$#" -eq 1 ] || invalid_schedule ;;
*) [ "$#" -eq 5 ] || invalid_schedule ;;
esac

crontab_file="$(mktemp)"
printf '%s backupdrill backup\n' "$BACKUPDRILL_CRON" > "$crontab_file"
# Check the expression up front so a typo fails at container start, not silently.
supercronic -test "$crontab_file" >/dev/null 2>&1 || invalid_schedule

echo "backupdrill: scheduled \`backupdrill backup\` at \"$BACKUPDRILL_CRON\" (TZ=${TZ:-UTC})"
# supercronic skips a run while the previous one is still going, so two backups
# never overlap. -passthrough-logs keeps the CLI's own output as-is in the log.
exec supercronic -passthrough-logs "$crontab_file"
Loading