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..076feb6 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -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<> "$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 }} 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..20f9386 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`. 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.:@: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`. +- `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. + ## 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..826371b --- /dev/null +++ b/docker/entrypoint.sh @@ -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"