From 375fa72757888df3b30efea0a92da4ec16c946e0 Mon Sep 17 00:00:00 2001 From: Super <20788648+supertj@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:20:33 +0800 Subject: [PATCH 1/3] feat: Docker image on GHCR for scheduled backups on your own server - Dockerfile builds the CLI from this repo's source on node 24 alpine, with pg_dump/pg_restore/psql 17 (same major as the GitHub Action), runs as the non-root node user, and has no credentials in it: config is env-only - BACKUPDRILL_CRON runs `backupdrill backup` on that schedule via supercronic (TZ honoured, no overlapping runs); without it the container runs one backup and exits; arguments go straight to the CLI. An invalid schedule exits 64 at start - workflow builds and smoke-tests the image on PRs, and on main pushes ghcr.io/backupdrill/backupdrill:latest, :, :sha- for amd64 and arm64 - README: Docker section --- .dockerignore | 17 +++++ .github/workflows/docker-image.yml | 102 +++++++++++++++++++++++++++++ Dockerfile | 37 +++++++++++ README.md | 26 ++++++++ docker/entrypoint.sh | 36 ++++++++++ 5 files changed, 218 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker-image.yml create mode 100644 Dockerfile create mode 100755 docker/entrypoint.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..01541a1 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..0e9a6fd --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,102 @@ +# 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 must stop the container instead of idling + status=0; docker run --rm -e BACKUPDRILL_CRON="not a cron" backupdrill:smoke || status=$? + test "$status" = 64 + # 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' + id: tags + run: | + version="$(node -p 'require("./package.json").version')" + { + echo "list<> "$GITHUB_OUTPUT" + + - name: Log in to GHCR + if: github.event_name != 'pull_request' + 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' + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.tags.outputs.list }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cad788f --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 4bf6fa2..1f6e527 100644 --- a/README.md +++ b/README.md @@ -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`. It has the CLI and `pg_dump` 17, and nothing else: no credentials are in the image, every setting comes from the same `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). A run that's still going when the next one is due makes the next one wait its turn rather than 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.:@:5432/postgres" \ + -e BACKUPDRILL_PROJECT_NAME="my-app" \ + -e BACKUPDRILL_S3_ENDPOINT="https://.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/" \ + 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`. +- An invalid `BACKUPDRILL_CRON` 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-`. 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. diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..06b57a7 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,36 @@ +#!/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 + +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. +if ! supercronic -test "$crontab_file" >/dev/null 2>&1; then + echo "BACKUPDRILL_CRON is not a valid cron expression: \"$BACKUPDRILL_CRON\"" >&2 + exit 64 +fi + +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" From 2d656a5dd02715081ebd0d3844696b24ef6989c3 Mon Sep 17 00:00:00 2001 From: Super <20788648+supertj@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:28:27 +0800 Subject: [PATCH 2/3] fix: reject cron values that would leave the container with no job; publish only from main --- .github/workflows/docker-image.yml | 14 +++++++----- README.md | 2 +- docker/entrypoint.sh | 35 ++++++++++++++++++++++++++---- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 0e9a6fd..f382bb4 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -64,15 +64,17 @@ jobs: 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 must stop the container instead of idling - status=0; docker run --rm -e BACKUPDRILL_CRON="not a cron" backupdrill:smoke || status=$? - test "$status" = 64 + # 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"; 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' + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' id: tags run: | version="$(node -p 'require("./package.json").version')" @@ -85,7 +87,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Log in to GHCR - if: github.event_name != 'pull_request' + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' uses: docker/login-action@v3 with: registry: ghcr.io @@ -93,7 +95,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push (amd64 + arm64) - if: github.event_name != 'pull_request' + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' uses: docker/build-push-action@v6 with: context: . diff --git a/README.md b/README.md index 1f6e527..8d2cd11 100644 --- a/README.md +++ b/README.md @@ -338,7 +338,7 @@ Before switching to a **daily** schedule, read the egress-cost section above — ## 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`. It has the CLI and `pg_dump` 17, and nothing else: no credentials are in the image, every setting comes from the same `BACKUPDRILL_*` environment variables listed above. +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). A run that's still going when the next one is due makes the next one wait its turn rather than overlap. diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 06b57a7..80e1391 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -22,13 +22,40 @@ case "$BACKUPDRILL_CRON" in ;; esac +# supercronic -test alone would accept a comment ("# off") or a VAR=value line and +# then sit there with no job, so backups would silently never run. Only allow cron +# syntax: a descriptor like @daily, or 5 to 7 fields made of cron characters. +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 ;; + *) { [ "$#" -ge 5 ] && [ "$#" -le 7 ]; } || 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. -if ! supercronic -test "$crontab_file" >/dev/null 2>&1; then - echo "BACKUPDRILL_CRON is not a valid cron expression: \"$BACKUPDRILL_CRON\"" >&2 - exit 64 -fi +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 From 05731fd9763d30a0ab87601de33f1aa0aaeccddc Mon Sep 17 00:00:00 2001 From: Super <20788648+supertj@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:34:49 +0800 Subject: [PATCH 3/3] fix: BACKUPDRILL_CRON takes exactly 5 fields or a @ shortcut; README says overlapping runs are skipped --- .github/workflows/docker-image.yml | 2 +- README.md | 4 ++-- docker/entrypoint.sh | 9 +++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index f382bb4..076feb6 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -65,7 +65,7 @@ jobs: 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"; do + 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 diff --git a/README.md b/README.md index 8d2cd11..20f9386 100644 --- a/README.md +++ b/README.md @@ -340,7 +340,7 @@ Before switching to a **daily** schedule, read the egress-cost section above — 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). A run that's still going when the next one is due makes the next one wait its turn rather than overlap. +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 \ @@ -358,7 +358,7 @@ docker run -d --name backupdrill --restart unless-stopped \ - 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`. -- An invalid `BACKUPDRILL_CRON` stops the container at start (exit code 64) instead of leaving it idle. +- `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-`. Images are built for amd64 and arm64. diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 80e1391..826371b 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -22,9 +22,10 @@ case "$BACKUPDRILL_CRON" in ;; esac -# supercronic -test alone would accept a comment ("# off") or a VAR=value line and -# then sit there with no job, so backups would silently never run. Only allow cron -# syntax: a descriptor like @daily, or 5 to 7 fields made of cron characters. +# 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 @@ -49,7 +50,7 @@ for field in "$@"; do done case "$1" in @*) [ "$#" -eq 1 ] || invalid_schedule ;; - *) { [ "$#" -ge 5 ] && [ "$#" -le 7 ]; } || invalid_schedule ;; + *) [ "$#" -eq 5 ] || invalid_schedule ;; esac crontab_file="$(mktemp)"