From 4c46b6dbfa943dffd94f51c8ff7e36e10f203d71 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 02:07:02 +0000 Subject: [PATCH 1/2] Provider shorthands for destinations, and a Dokploy recipe - type: alarik / rustfs / minio / garage / hetzner / aws / r2 / b2 / digitalocean / wasabi expand to S3 with that provider's defaults (endpoint from region, path-style for self-hosted stores); explicit settings still win. path_style is now unset unless configured. 'nobackups list' shows the provider. - Recipe for Dokploy: /etc/dokploy plus a pg_dump of dokploy-postgres (same approach as Dokploy's own web-server backup), and app volumes. - Whole-server recipe warns that it skips app data on Docker hosts. - Example config, README and docs use the shorthands. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TXNwZBkaVRn9LobyrUJcGA --- README.md | 78 +++++++++++++++++---- cmd/nobackups/example.yaml | 41 +++++++---- cmd/nobackups/main.go | 6 +- docs/configuration/destinations.mdx | 89 +++++++++++++++--------- docs/docs.json | 40 +++++++++-- docs/index.mdx | 4 +- docs/quickstart.mdx | 3 +- docs/recipes/dokploy.mdx | 104 ++++++++++++++++++++++++++++ docs/recipes/overview.mdx | 1 + docs/recipes/whole-server.mdx | 4 ++ docs/reference/config.mdx | 41 +++++++---- internal/config/config.go | 64 +++++++++++++++-- internal/config/config_test.go | 51 ++++++++++++++ internal/storage/s3.go | 2 +- internal/storage/s3_test.go | 2 +- 15 files changed, 434 insertions(+), 96 deletions(-) create mode 100644 docs/recipes/dokploy.mdx diff --git a/README.md b/README.md index bd9d000..db3d4de 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ NoBackups is named after the situation it gets you out of. It's one small static **What's in the box** -- **Any S3-compatible storage**: Hetzner Object Storage, RustFS, MinIO, Garage, Alarik, AWS S3, Backblaze B2, Cloudflare R2, … plus local directories (second disk, NFS). Your data, your bucket list. +- **Any S3-compatible storage**: Alarik, Hetzner Object Storage, RustFS, MinIO, Garage, AWS S3, Backblaze B2, Cloudflare R2, … plus local directories (second disk, NFS). Your data, your bucket list. - **Multiple destinations per job**: the archive is built once and streamed to every destination in parallel. If one destination fails, the others still finish, so 3-2-1 takes one line of config. - **Streaming**: nothing is staged on local disk. Memory use is about `part_size_mb` per S3 destination. Light enough for even the most *byte*-sized VPS. - **zstd / gzip compression** and **age encryption** (passphrase or public keys). Squeezed, sealed, delivered. @@ -21,7 +21,7 @@ NoBackups is named after the situation it gets you out of. It's one small static 📚 **Full documentation** lives in [`docs/`](docs/) (a [Mintlify](https://mintlify.com) site: run `make docs` to preview it locally). -Hungry for examples? The [Recipes](#recipes-the-backup-cookbook) cover Docker, PostgreSQL, MySQL/MariaDB, MongoDB, Redis, SQLite, VirtFusion and whole-server setups. +Hungry for examples? The [Recipes](#recipes-the-backup-cookbook) cover Docker, Dokploy, PostgreSQL, MySQL/MariaDB, MongoDB, Redis, SQLite, VirtFusion and whole-server setups. ## Install: back it up, then back it in @@ -87,8 +87,7 @@ A minimal config: ```yaml destinations: hetzner: - type: s3 - endpoint: fsn1.your-objectstorage.com + type: hetzner region: fsn1 bucket: my-backups access_key_id: ${HETZNER_ACCESS_KEY} @@ -117,12 +116,12 @@ Run `nobackups init -c ./config.yaml` to get the fully commented example, or rea | Field | Applies to | Notes | |---|---|---| -| `type` | all | `s3` or `local` | +| `type` | all | `local`, `s3`, or a provider shorthand (`alarik`, `rustfs`, `minio`, `garage`, `hetzner`, `aws`, `r2`, `b2`, `digitalocean`, `wasabi`) that fills in that provider's defaults | | `prefix` | all | Key prefix / subdirectory. `{hostname}` is replaced by the server's hostname, so one bucket can serve a whole fleet. | -| `endpoint` | s3 | Host, optionally with port. Prefix with `http://` for plain HTTP. | +| `endpoint` | s3 | Host, optionally with port. Prefix with `http://` for plain HTTP. Derived from `region` for `hetzner`, `aws`, `b2`, `digitalocean` and `wasabi`. | | `bucket`, `region` | s3 | `region` defaults to `us-east-1`. Most self-hosted stores ignore it. | | `access_key_id`, `secret_access_key`, `session_token` | s3 | | -| `path_style` | s3 | Set to `true` for most self-hosted stores (RustFS, MinIO, Garage) | +| `path_style` | s3 | Defaults to `true` for `alarik`, `rustfs`, `minio` and `garage`; otherwise picked automatically | | `storage_class` | s3 | e.g. `STANDARD_IA` on AWS | | `part_size_mb` | s3 | Multipart chunk size, default 64. Max object size is 10,000 × this. | | `ca_file` / `insecure_skip_verify` | s3 | For private CAs / self-signed certs | @@ -131,11 +130,13 @@ Run `nobackups init -c ./config.yaml` to get the fully commented example, or rea Provider examples: ```yaml -hetzner: { type: s3, endpoint: nbg1.your-objectstorage.com, region: nbg1, bucket: b, ... } -rustfs: { type: s3, endpoint: "http://10.0.0.5:9000", path_style: true, bucket: b, ... } -aws: { type: s3, endpoint: s3.eu-central-1.amazonaws.com, region: eu-central-1, bucket: b, ... } -r2: { type: s3, endpoint: .r2.cloudflarestorage.com, region: auto, bucket: b, ... } -b2: { type: s3, endpoint: s3.eu-central-003.backblazeb2.com, region: eu-central-003, bucket: b, ... } +alarik: { type: alarik, endpoint: s3.example.com, bucket: b, ... } +hetzner: { type: hetzner, region: nbg1, bucket: b, ... } +rustfs: { type: rustfs, endpoint: "http://10.0.0.5:9000", bucket: b, ... } +aws: { type: aws, region: eu-central-1, bucket: b, ... } +r2: { type: r2, endpoint: .r2.cloudflarestorage.com, bucket: b, ... } +b2: { type: b2, region: eu-central-003, bucket: b, ... } +other: { type: s3, endpoint: s3.example.net, region: us-east-1, bucket: b, ... } ``` ### Jobs: what to save, and when @@ -310,6 +311,57 @@ docker compose -f /opt/myapp/compose.yaml up -d --- +### Dokploy: deploy with confidence, restore with even more + +On a Dokploy server nearly everything lives in Docker, so the whole-server job below (which skips `/var/lib/docker`) backs up **none of your apps** on its own. Add these two jobs. + +**The panel:** `/etc/dokploy` plus a consistent dump of Dokploy's own Postgres, done the same way Dokploy's web-server backup does it: + +```yaml +- name: dokploy + sources: + - /etc/dokploy + - /var/backups/nobackups/dokploy + exclude: + - /etc/dokploy/volume-backups + - /etc/dokploy/logs + destinations: [offsite] + schedule: "0 3 * * *" + encryption: + passphrase: ${BACKUP_PASSPHRASE} + hooks: + before: + - | + set -eu + umask 077 + mkdir -p /var/backups/nobackups/dokploy + c=$(docker ps --filter name=dokploy-postgres --filter status=running -q | head -n 1) + [ -n "$c" ] || { echo "dokploy-postgres is not running"; exit 1; } + docker exec "$c" pg_dump -Fc -U dokploy -d dokploy > /var/backups/nobackups/dokploy/dokploy.dump + after: + - rm -f /var/backups/nobackups/dokploy/dokploy.dump + retention: { keep_last: 14 } +``` + +**Your apps:** named Docker volumes, minus the panel database's raw files (the dump above covers it): + +```yaml +- name: dokploy-volumes + sources: [/var/lib/docker/volumes] + exclude: + - /var/lib/docker/volumes/dokploy-postgres + - /var/lib/docker/volumes/backingFsBlockDev + destinations: [offsite] + schedule: "30 3 * * *" + encryption: + passphrase: ${BACKUP_PASSPHRASE} + retention: { keep_last: 7, keep_days: 30 } +``` + +Volumes are copied live. For databases you created *in* Dokploy, turn on Dokploy's own scheduled database backups (they can upload to the same bucket) or dump them with a hook as in the recipes below. Restore steps are in the [Dokploy docs page](docs/recipes/dokploy.mdx). + +--- + ### PostgreSQL: dump it like it's hot **Installed on the host:** `pg_dumpall` takes a consistent snapshot of every database, plus roles and permissions, without locking writers. @@ -584,6 +636,8 @@ A "disaster recovery" job for a server without special workloads. `one_file_syst retention: { keep_last: 4 } ``` +On Docker hosts (Dokploy, Coolify, Portainer…) this job skips all of your apps' data. Pair it with the Docker or Dokploy recipe above. + If `/home` or `/var` are separate filesystems, list them as extra sources, because `one_file_system` won't cross into them from `/`. --- diff --git a/cmd/nobackups/example.yaml b/cmd/nobackups/example.yaml index a39d530..cbc2920 100644 --- a/cmd/nobackups/example.yaml +++ b/cmd/nobackups/example.yaml @@ -16,39 +16,50 @@ log_level: info # from jobs. Every S3-compatible store uses type: s3. # --------------------------------------------------------------------------- destinations: - # Hetzner Object Storage (endpoints: fsn1 / nbg1 / hel1 .your-objectstorage.com) + # type is s3, local, or a provider shorthand that fills in the provider's + # defaults: alarik, rustfs, minio, garage, hetzner, aws, r2, b2, + # digitalocean, wasabi. Anything you set yourself overrides the defaults. + + # Hetzner Object Storage: region is fsn1, nbg1 or hel1; the endpoint is + # derived from it. hetzner: - type: s3 - endpoint: fsn1.your-objectstorage.com + type: hetzner region: fsn1 bucket: my-backups access_key_id: ${HETZNER_ACCESS_KEY} secret_access_key: ${HETZNER_SECRET_KEY} prefix: servers/{hostname} - # Self-hosted RustFS / MinIO / Garage / Alarik etc. Self-hosted stores - # usually need path_style: true. Use http:// for plain HTTP. - # rustfs: - # type: s3 - # endpoint: http://rustfs.internal:9000 + # Self-hosted Alarik / RustFS / MinIO / Garage (path-style by default). + # Use http:// for plain HTTP. + # alarik: + # type: alarik + # endpoint: s3.example.com # bucket: backups - # access_key_id: ${RUSTFS_ACCESS_KEY} - # secret_access_key: ${RUSTFS_SECRET_KEY} - # path_style: true + # access_key_id: ${ALARIK_ACCESS_KEY} + # secret_access_key: ${ALARIK_SECRET_KEY} # prefix: "{hostname}" - # # ca_file: /etc/nobackups/rustfs-ca.pem # private CA for HTTPS + # # ca_file: /etc/nobackups/alarik-ca.pem # private CA for HTTPS # # insecure_skip_verify: false # last resort for self-signed certs - # AWS S3 + # AWS S3 (endpoint derived from region) # aws: - # type: s3 - # endpoint: s3.eu-central-1.amazonaws.com + # type: aws # region: eu-central-1 # bucket: my-backups # access_key_id: ${AWS_ACCESS_KEY_ID} # secret_access_key: ${AWS_SECRET_ACCESS_KEY} # storage_class: STANDARD_IA + # Any other S3-compatible service: type s3 with an explicit endpoint. + # other: + # type: s3 + # endpoint: s3.example.net + # region: us-east-1 + # bucket: my-backups + # access_key_id: ${S3_ACCESS_KEY} + # secret_access_key: ${S3_SECRET_KEY} + # A local directory, e.g. a second disk or NFS mount. # local: # type: local diff --git a/cmd/nobackups/main.go b/cmd/nobackups/main.go index 8ea5b9b..0970e8b 100644 --- a/cmd/nobackups/main.go +++ b/cmd/nobackups/main.go @@ -311,7 +311,11 @@ func cmdList(cfg *config.Config) error { } else if d.Prefix != "" { loc = filepath.Join(d.Path, d.Prefix) } - fmt.Fprintf(w, "%s\t%s\t%s\n", n, d.Type, loc) + kind := d.Type + if d.Provider != "" { + kind = d.Provider + } + fmt.Fprintf(w, "%s\t%s\t%s\n", n, kind, loc) } fmt.Fprintln(w) fmt.Fprintln(w, "JOB\tSCHEDULE\tNEXT RUN\tDESTINATIONS\tSOURCES") diff --git a/docs/configuration/destinations.mdx b/docs/configuration/destinations.mdx index da8154e..7e99d62 100644 --- a/docs/configuration/destinations.mdx +++ b/docs/configuration/destinations.mdx @@ -25,7 +25,7 @@ Names may contain letters, digits, `.`, `_` and `-`. ## Common options - `s3` for any S3-compatible object store, or `local` for a directory. + `local` for a directory, `s3` for any S3-compatible object store, or a [provider shorthand](#providers) such as `alarik` or `hetzner` that fills in that provider's defaults. @@ -44,8 +44,8 @@ Names may contain letters, digits, `.`, `_` and `-`. ## S3 options - - Host, with an optional port, e.g. `fsn1.your-objectstorage.com` or `10.0.0.5:9000`. Prefix with `http://` to use plain HTTP; `https://` is the default. + + Host, with an optional port, e.g. `cdn.example.com` or `10.0.0.5:9000`. Required unless the provider derives it from `region`. Prefix with `http://` to use plain HTTP; `https://` is the default. @@ -63,8 +63,8 @@ Names may contain letters, digits, `.`, `_` and `-`. Most self-hosted stores ignore this. - - Use `https://endpoint/bucket/key` addressing instead of `https://bucket.endpoint/key`. Most self-hosted stores (RustFS, MinIO, Garage) need `true`. + + Use `https://endpoint/bucket/key` addressing instead of `https://bucket.endpoint/key`. Most self-hosted stores need `true`, which is the default for the `alarik`, `rustfs`, `minio` and `garage` types. With plain `type: s3`, NoBackups picks automatically. @@ -93,54 +93,66 @@ Names may contain letters, digits, `.`, `_` and `-`. Absolute path to a directory, e.g. a second disk or an NFS mount. Snapshots are written to a `.partial` file first, then renamed, so a half-written file never looks like a snapshot. -## Provider examples +## Providers + +Instead of `type: s3`, you can name the provider. It's still S3 underneath, but NoBackups fills in that provider's defaults, and `nobackups list` shows which provider each destination uses. Anything you set yourself (`endpoint`, `region`, `path_style`) overrides the defaults. + +| `type` | Defaults | +|---|---| +| `alarik`, `rustfs`, `minio` | `path_style: true`. Set `endpoint` to your server. | +| `garage` | `path_style: true`, `region: garage` | +| `hetzner` | Endpoint from `region` (`fsn1`, `nbg1`, `hel1`) → `fsn1.your-objectstorage.com` | +| `aws` | Endpoint from `region` → `s3.eu-central-1.amazonaws.com`. Region defaults to `us-east-1`. | +| `b2` | Endpoint from `region` → `s3.eu-central-003.backblazeb2.com` | +| `digitalocean` | Endpoint from `region` → `ams3.digitaloceanspaces.com` | +| `wasabi` | Endpoint from `region` → `s3.eu-central-1.wasabisys.com` | +| `r2` | `region: auto`. Set `endpoint` to `.r2.cloudflarestorage.com`. | +| `s3` | Nothing filled in: set `endpoint` for any other S3-compatible service | + + [Alarik](https://github.com/achtungsoftware/alarik) is a self-hosted, S3-compatible store. + + ```yaml + alarik: + type: alarik + endpoint: s3.example.com # or http://10.0.0.5:8080 for plain HTTP + bucket: backups + access_key_id: ${ALARIK_ACCESS_KEY} + secret_access_key: ${ALARIK_SECRET_KEY} + prefix: "{hostname}" + ``` + + If the endpoint is behind Cloudflare, see [Troubleshooting](#troubleshooting). + - Endpoints: `fsn1`, `nbg1` and `hel1` `.your-objectstorage.com`. Create credentials under **Object Storage → Security credentials** in the Hetzner Console. + Create credentials under **Object Storage → Security credentials** in the Hetzner Console. ```yaml hetzner: - type: s3 - endpoint: fsn1.your-objectstorage.com - region: fsn1 + type: hetzner + region: fsn1 # fsn1, nbg1 or hel1 bucket: my-backups access_key_id: ${HETZNER_ACCESS_KEY} secret_access_key: ${HETZNER_SECRET_KEY} prefix: servers/{hostname} ``` - + ```yaml rustfs: - type: s3 - endpoint: http://rustfs.internal:9000 # or https:// with ca_file + type: rustfs # or minio / garage + endpoint: http://rustfs.internal:9000 bucket: backups access_key_id: ${RUSTFS_ACCESS_KEY} secret_access_key: ${RUSTFS_SECRET_KEY} - path_style: true prefix: "{hostname}" ``` - - [Alarik](https://github.com/achtungsoftware/alarik) and other self-hosted S3-compatible stores work the same way as RustFS. If you put the endpoint behind Cloudflare, see [Troubleshooting](#troubleshooting). Use the endpoint and region your server is configured with, and `path_style: true` unless you've set up virtual-host-style bucket DNS. - - ```yaml - alarik: - type: s3 - endpoint: https://s3.example.internal - region: us-east-1 # match the region your server is configured with - bucket: backups - access_key_id: ${ALARIK_ACCESS_KEY} - secret_access_key: ${ALARIK_SECRET_KEY} - path_style: true - ``` - ```yaml aws: - type: s3 - endpoint: s3.eu-central-1.amazonaws.com + type: aws region: eu-central-1 bucket: my-backups access_key_id: ${AWS_ACCESS_KEY_ID} @@ -151,9 +163,8 @@ Names may contain letters, digits, `.`, `_` and `-`. ```yaml r2: - type: s3 + type: r2 endpoint: .r2.cloudflarestorage.com - region: auto bucket: my-backups access_key_id: ${R2_ACCESS_KEY} secret_access_key: ${R2_SECRET_KEY} @@ -162,14 +173,24 @@ Names may contain letters, digits, `.`, `_` and `-`. ```yaml b2: - type: s3 - endpoint: s3.eu-central-003.backblazeb2.com + type: b2 region: eu-central-003 bucket: my-backups access_key_id: ${B2_KEY_ID} secret_access_key: ${B2_APP_KEY} ``` + + ```yaml + other: + type: s3 + endpoint: s3.example.net + region: us-east-1 + bucket: my-backups + access_key_id: ${S3_ACCESS_KEY} + secret_access_key: ${S3_SECRET_KEY} + ``` + ```yaml nas: diff --git a/docs/docs.json b/docs/docs.json index ee1914b..9f763c8 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -20,7 +20,12 @@ "groups": [ { "group": "Get started", - "pages": ["index", "quickstart", "installation", "how-it-works"] + "pages": [ + "index", + "quickstart", + "installation", + "how-it-works" + ] }, { "group": "Configuration", @@ -36,7 +41,10 @@ }, { "group": "Operating", - "pages": ["guides/restoring", "guides/operations"] + "pages": [ + "guides/restoring", + "guides/operations" + ] } ] }, @@ -45,11 +53,16 @@ "groups": [ { "group": "Before you start", - "pages": ["recipes/overview"] + "pages": [ + "recipes/overview" + ] }, { "group": "Containers", - "pages": ["recipes/docker"] + "pages": [ + "recipes/docker", + "recipes/dokploy" + ] }, { "group": "Databases", @@ -63,7 +76,11 @@ }, { "group": "Servers & platforms", - "pages": ["recipes/virtfusion", "recipes/whole-server", "recipes/web-and-mail"] + "pages": [ + "recipes/virtfusion", + "recipes/whole-server", + "recipes/web-and-mail" + ] } ] }, @@ -72,7 +89,11 @@ "groups": [ { "group": "Reference", - "pages": ["reference/cli", "reference/config", "reference/development"] + "pages": [ + "reference/cli", + "reference/config", + "reference/development" + ] } ] } @@ -85,7 +106,12 @@ } }, "contextual": { - "options": ["copy", "view", "chatgpt", "claude"] + "options": [ + "copy", + "view", + "chatgpt", + "claude" + ] }, "footer": { "socials": { diff --git a/docs/index.mdx b/docs/index.mdx index 4207200..5a5a404 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -13,8 +13,8 @@ NoBackups is named after the situation it gets you out of. It's one small static ```yaml /etc/nobackups/config.yaml destinations: hetzner: - type: s3 - endpoint: fsn1.your-objectstorage.com + type: hetzner + region: fsn1 bucket: my-backups access_key_id: ${HETZNER_ACCESS_KEY} secret_access_key: ${HETZNER_SECRET_KEY} diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 3fdad8e..e60d7b3 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -42,8 +42,7 @@ icon: "rocket" ```yaml /etc/nobackups/config.yaml destinations: hetzner: - type: s3 - endpoint: fsn1.your-objectstorage.com + type: hetzner region: fsn1 bucket: my-backups access_key_id: ${HETZNER_ACCESS_KEY} diff --git a/docs/recipes/dokploy.mdx b/docs/recipes/dokploy.mdx new file mode 100644 index 0000000..5712be0 --- /dev/null +++ b/docs/recipes/dokploy.mdx @@ -0,0 +1,104 @@ +--- +title: "Dokploy" +description: "Back up the Dokploy panel, its database, and your apps' Docker volumes." +icon: "rocket" +--- + +*Deploy with confidence, restore with even more.* + +On a Dokploy server nearly everything that matters lives in Docker: your apps, their volumes, and Dokploy's own database. The [whole-server](/recipes/whole-server) job deliberately skips `/var/lib/docker`, so on its own it backs up the OS and **none of your apps**. Add the two jobs below next to it. + +## The panel: config and database + +Dokploy keeps its configuration under `/etc/dokploy` (applications, compose files, Traefik config and certificates, SSH keys) and its state in a Postgres service called `dokploy-postgres`. This job dumps the database the same way Dokploy's own web-server backup does, then archives both. + +```yaml +- name: dokploy + sources: + - /etc/dokploy + - /var/backups/nobackups/dokploy + exclude: + - /etc/dokploy/volume-backups + - /etc/dokploy/logs + destinations: [offsite] + schedule: "0 3 * * *" + encryption: + passphrase: ${BACKUP_PASSPHRASE} + hooks: + before: + - | + set -eu + umask 077 + mkdir -p /var/backups/nobackups/dokploy + c=$(docker ps --filter name=dokploy-postgres --filter status=running -q | head -n 1) + [ -n "$c" ] || { echo "dokploy-postgres is not running"; exit 1; } + docker exec "$c" pg_dump -Fc -U dokploy -d dokploy > /var/backups/nobackups/dokploy/dokploy.dump + after: + - rm -f /var/backups/nobackups/dokploy/dokploy.dump + retention: { keep_last: 14 } +``` + + + Always encrypt this job. `/etc/dokploy` contains SSH keys, TLS certificates and app environment variables. + + +## Your apps: Docker volumes + +Named volumes hold your apps' data (uploads, files, and the data directories of databases you created in Dokploy). + +```yaml +- name: dokploy-volumes + sources: [/var/lib/docker/volumes] + exclude: + - /var/lib/docker/volumes/dokploy-postgres + - /var/lib/docker/volumes/backingFsBlockDev + destinations: [offsite] + schedule: "30 3 * * *" + encryption: + passphrase: ${BACKUP_PASSPHRASE} + retention: { keep_last: 7, keep_days: 30 } +``` + +To see what's in there and how big each volume is: + +```bash +docker volume ls +du -sh /var/lib/docker/volumes/* | sort -h +``` + + + Volumes are copied while the containers run. That's fine for files, but a database's raw data directory can be caught mid-write. For each database you created **in Dokploy**, either: + + - turn on Dokploy's built-in scheduled backups for it (**Database → Backups**). It can upload straight to the same S3 bucket, Alarik included. Or + - dump it with a hook as in the [PostgreSQL](/recipes/postgresql) and [MySQL](/recipes/mysql) recipes (`docker exec pg_dumpall ...`), and add its volume to `exclude`. + + +## Restoring + + + + Install Dokploy on the new server as usual and install NoBackups with the same config. Don't create any projects yet; they'd be overwritten. + + + ```bash + nobackups restore dokploy --target / + c=$(docker ps --filter name=dokploy-postgres -q | head -n 1) + docker exec -i "$c" pg_restore -U dokploy -d dokploy --clean --if-exists \ + < /var/backups/nobackups/dokploy/dokploy.dump + docker service update --force dokploy + ``` + + + Stop the app in Dokploy first, then: + + ```bash + nobackups restore dokploy-volumes --target / --path var/lib/docker/volumes/ + ``` + + Start the app again in Dokploy. + + + + + Rehearse this on a throwaway VPS once. Dokploy restores have a few moving parts, and you want to discover them on a quiet afternoon rather than during an outage. + diff --git a/docs/recipes/overview.mdx b/docs/recipes/overview.mdx index d34cd73..266d638 100644 --- a/docs/recipes/overview.mdx +++ b/docs/recipes/overview.mdx @@ -8,6 +8,7 @@ Each recipe is a job to paste under `jobs:` in your config. They assume a destin + diff --git a/docs/recipes/whole-server.mdx b/docs/recipes/whole-server.mdx index 83a23c2..91bdbb1 100644 --- a/docs/recipes/whole-server.mdx +++ b/docs/recipes/whole-server.mdx @@ -31,4 +31,8 @@ A "disaster recovery" job for a server without special workloads. `one_file_syst retention: { keep_last: 4 } ``` + + On Docker hosts (Dokploy, Coolify, Portainer…) this job skips **all of your apps' data**, because it excludes `/var/lib/docker`. Pair it with the [Docker](/recipes/docker) or [Dokploy](/recipes/dokploy) recipe. + + If `/home` or `/var` are separate filesystems, list them as extra sources, because `one_file_system` won't cross into them from `/`. diff --git a/docs/reference/config.mdx b/docs/reference/config.mdx index 4541c87..9317872 100644 --- a/docs/reference/config.mdx +++ b/docs/reference/config.mdx @@ -31,39 +31,50 @@ log_level: info # from jobs. Every S3-compatible store uses type: s3. # --------------------------------------------------------------------------- destinations: - # Hetzner Object Storage (endpoints: fsn1 / nbg1 / hel1 .your-objectstorage.com) + # type is s3, local, or a provider shorthand that fills in the provider's + # defaults: alarik, rustfs, minio, garage, hetzner, aws, r2, b2, + # digitalocean, wasabi. Anything you set yourself overrides the defaults. + + # Hetzner Object Storage: region is fsn1, nbg1 or hel1; the endpoint is + # derived from it. hetzner: - type: s3 - endpoint: fsn1.your-objectstorage.com + type: hetzner region: fsn1 bucket: my-backups access_key_id: ${HETZNER_ACCESS_KEY} secret_access_key: ${HETZNER_SECRET_KEY} prefix: servers/{hostname} - # Self-hosted RustFS / MinIO / Garage / Alarik etc. Self-hosted stores - # usually need path_style: true. Use http:// for plain HTTP. - # rustfs: - # type: s3 - # endpoint: http://rustfs.internal:9000 + # Self-hosted Alarik / RustFS / MinIO / Garage (path-style by default). + # Use http:// for plain HTTP. + # alarik: + # type: alarik + # endpoint: s3.example.com # bucket: backups - # access_key_id: ${RUSTFS_ACCESS_KEY} - # secret_access_key: ${RUSTFS_SECRET_KEY} - # path_style: true + # access_key_id: ${ALARIK_ACCESS_KEY} + # secret_access_key: ${ALARIK_SECRET_KEY} # prefix: "{hostname}" - # # ca_file: /etc/nobackups/rustfs-ca.pem # private CA for HTTPS + # # ca_file: /etc/nobackups/alarik-ca.pem # private CA for HTTPS # # insecure_skip_verify: false # last resort for self-signed certs - # AWS S3 + # AWS S3 (endpoint derived from region) # aws: - # type: s3 - # endpoint: s3.eu-central-1.amazonaws.com + # type: aws # region: eu-central-1 # bucket: my-backups # access_key_id: ${AWS_ACCESS_KEY_ID} # secret_access_key: ${AWS_SECRET_ACCESS_KEY} # storage_class: STANDARD_IA + # Any other S3-compatible service: type s3 with an explicit endpoint. + # other: + # type: s3 + # endpoint: s3.example.net + # region: us-east-1 + # bucket: my-backups + # access_key_id: ${S3_ACCESS_KEY} + # secret_access_key: ${S3_SECRET_KEY} + # A local directory, e.g. a second disk or NFS mount. # local: # type: local diff --git a/internal/config/config.go b/internal/config/config.go index 112e861..1f052e4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -34,8 +34,9 @@ type Config struct { } type Destination struct { - Name string `yaml:"-"` - Type string `yaml:"type"` + Name string `yaml:"-"` + Type string `yaml:"type"` + Provider string `yaml:"-"` // s3 Endpoint string `yaml:"endpoint"` @@ -45,7 +46,7 @@ type Destination struct { SecretAccessKey string `yaml:"secret_access_key"` SessionToken string `yaml:"session_token"` UseSSL *bool `yaml:"use_ssl"` - PathStyle bool `yaml:"path_style"` + PathStyle *bool `yaml:"path_style"` StorageClass string `yaml:"storage_class"` PartSizeMB int `yaml:"part_size_mb"` CAFile string `yaml:"ca_file"` @@ -206,6 +207,54 @@ func expandNode(n *yaml.Node, missing *[]string) { } } +type provider struct { + endpoint string + region string + pathStyle bool + needRegion bool + example string +} + +var providers = map[string]provider{ + "aws": {endpoint: "s3.%s.amazonaws.com", region: "us-east-1"}, + "hetzner": {endpoint: "%s.your-objectstorage.com", needRegion: true, example: "fsn1"}, + "b2": {endpoint: "s3.%s.backblazeb2.com", needRegion: true, example: "eu-central-003"}, + "digitalocean": {endpoint: "%s.digitaloceanspaces.com", needRegion: true, example: "ams3"}, + "wasabi": {endpoint: "s3.%s.wasabisys.com", needRegion: true, example: "eu-central-1"}, + "r2": {region: "auto"}, + "alarik": {pathStyle: true}, + "rustfs": {pathStyle: true}, + "minio": {pathStyle: true}, + "garage": {pathStyle: true, region: "garage"}, +} + +func ProviderNames() []string { + names := make([]string, 0, len(providers)) + for n := range providers { + names = append(names, n) + } + sort.Strings(names) + return names +} + +func (d *Destination) applyProvider() { + p, ok := providers[d.Type] + if !ok { + return + } + d.Provider, d.Type = d.Type, "s3" + if d.Region == "" { + d.Region = p.region + } + if d.Endpoint == "" && p.endpoint != "" && d.Region != "" { + d.Endpoint = fmt.Sprintf(p.endpoint, d.Region) + } + if d.PathStyle == nil { + ps := p.pathStyle + d.PathStyle = &ps + } +} + func (c *Config) applyDefaults() { if c.StateDir == "" { c.StateDir = DefaultStateDir @@ -222,6 +271,7 @@ func (c *Config) applyDefaults() { } d.Name = name d.Prefix = strings.Trim(strings.ReplaceAll(d.Prefix, "{hostname}", c.Hostname), "/") + d.applyProvider() if d.Type == "s3" { if strings.HasPrefix(d.Endpoint, "http://") { d.Endpoint = strings.TrimPrefix(d.Endpoint, "http://") @@ -270,7 +320,9 @@ func (c *Config) Validate() error { } switch d.Type { case "s3": - if d.Endpoint == "" { + if p := providers[d.Provider]; d.Endpoint == "" && p.needRegion { + add("destination %q: region is required for %s (e.g. %s), or set endpoint", name, d.Provider, p.example) + } else if d.Endpoint == "" { add("destination %q: endpoint is required", name) } if d.Bucket == "" { @@ -289,9 +341,9 @@ func (c *Config) Validate() error { add("destination %q: path must be absolute", name) } case "": - add("destination %q: type is required (s3 or local)", name) + add("destination %q: type is required (s3, local, or a provider: %s)", name, strings.Join(ProviderNames(), ", ")) default: - add("destination %q: unknown type %q (expected s3 or local)", name, d.Type) + add("destination %q: unknown type %q (expected s3, local, or a provider: %s)", name, d.Type, strings.Join(ProviderNames(), ", ")) } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 94a3f94..45fba6c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -100,3 +100,54 @@ func TestUnquotedBraceValueHint(t *testing.T) { t.Fatalf("quoted prefix: %v %+v", err, c) } } + +func TestProviderShorthands(t *testing.T) { + cfg := ` +destinations: + alarik: {type: alarik, endpoint: cdn.example.com, bucket: b, access_key_id: k, secret_access_key: s} + hetzner: {type: hetzner, region: nbg1, bucket: b, access_key_id: k, secret_access_key: s} + aws: {type: aws, region: eu-west-1, bucket: b, access_key_id: k, secret_access_key: s} + minio: {type: minio, endpoint: "http://10.0.0.5:9000", path_style: false, bucket: b, access_key_id: k, secret_access_key: s} + custom: {type: hetzner, region: fsn1, endpoint: s3.internal, bucket: b, access_key_id: k, secret_access_key: s} +jobs: + - name: j + sources: [/etc] + destinations: [alarik] +` + c, err := Parse([]byte(cfg), "test.yaml") + if err != nil { + t.Fatal(err) + } + want := map[string]struct { + endpoint, region string + pathStyle bool + }{ + "alarik": {"cdn.example.com", "us-east-1", true}, + "hetzner": {"nbg1.your-objectstorage.com", "nbg1", false}, + "aws": {"s3.eu-west-1.amazonaws.com", "eu-west-1", false}, + "minio": {"10.0.0.5:9000", "us-east-1", false}, + "custom": {"s3.internal", "fsn1", false}, + } + for name, w := range want { + d := c.Destinations[name] + if d.Type != "s3" || d.Provider == "" || d.Endpoint != w.endpoint || d.Region != w.region || *d.PathStyle != w.pathStyle { + t.Errorf("%s: type=%s provider=%s endpoint=%s region=%s path_style=%v", name, d.Type, d.Provider, d.Endpoint, d.Region, *d.PathStyle) + } + } + if *c.Destinations["minio"].UseSSL { + t.Error("http:// endpoint should disable TLS for provider types too") + } +} + +func TestProviderNeedsRegion(t *testing.T) { + cfg := ` +destinations: + h: {type: hetzner, bucket: b, access_key_id: k, secret_access_key: s} + x: {type: dropbox} +jobs: [{name: j, sources: [/etc], destinations: [h]}] +` + _, err := Parse([]byte(cfg), "test.yaml") + if err == nil || !strings.Contains(err.Error(), "region is required for hetzner (e.g. fsn1)") || !strings.Contains(err.Error(), "alarik, aws, b2") { + t.Fatalf("got %v", err) + } +} diff --git a/internal/storage/s3.go b/internal/storage/s3.go index eaafeda..e64b9cc 100644 --- a/internal/storage/s3.go +++ b/internal/storage/s3.go @@ -33,7 +33,7 @@ func NewS3(d *config.Destination) (*S3, error) { Secure: d.UseSSL == nil || *d.UseSSL, Region: d.Region, } - if d.PathStyle { + if d.PathStyle != nil && *d.PathStyle { opts.BucketLookup = minio.BucketLookupPath } else { opts.BucketLookup = minio.BucketLookupAuto diff --git a/internal/storage/s3_test.go b/internal/storage/s3_test.go index a8e1154..7b26a42 100644 --- a/internal/storage/s3_test.go +++ b/internal/storage/s3_test.go @@ -91,7 +91,7 @@ func testS3(t *testing.T, srv *httptest.Server) *S3 { tr := true s, err := NewS3(&config.Destination{ Name: "alarik", Type: "s3", Endpoint: strings.TrimPrefix(srv.URL, "https://"), UseSSL: &tr, InsecureSkipVerify: true, - Bucket: "bkt", AccessKeyID: "k", SecretAccessKey: "s", Region: "us-east-1", PathStyle: true, PartSizeMB: 5, + Bucket: "bkt", AccessKeyID: "k", SecretAccessKey: "s", Region: "us-east-1", PathStyle: &tr, PartSizeMB: 5, }) if err != nil { t.Fatal(err) From 2d2b8f89222529a2c1289927281341926e2b29f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 02:22:04 +0000 Subject: [PATCH 2/2] Built-in database dumps Jobs can list databases (postgres, mysql, mariadb, mongodb, redis, sqlite). NoBackups runs the native dump tool on the host or inside a Docker container (exact name or unique prefix, so Swarm and Compose names work), stores the dumps under nobackups-databases/ in the snapshot and removes the temporary files. - Passwords never go on the command line: PGPASSWORD, MYSQL_PWD and REDISCLI_AUTH are passed by name through docker exec; MongoDB gets a private temp config file via stdin - mariadb type prefers mariadb-dump (MariaDB 11 images lack mysqldump) - A failed dump fails the run but still uploads everything else, and skips retention so older good dumps are kept - Per-database results in status JSON, webhooks and Discord embeds - Tests: command construction, container resolution, real PostgreSQL, MariaDB, Redis and SQLite (opt-in via env), runner integration - Docs: new Databases page; database, Dokploy and VirtFusion recipes use databases: instead of hook scripts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TXNwZBkaVRn9LobyrUJcGA --- README.md | 299 +++++++++++---------------- cmd/nobackups/example.yaml | 41 +++- docs/configuration/databases.mdx | 166 +++++++++++++++ docs/configuration/hooks.mdx | 2 +- docs/configuration/jobs.mdx | 8 +- docs/configuration/notifications.mdx | 5 +- docs/docs.json | 1 + docs/how-it-works.mdx | 5 +- docs/recipes/docker.mdx | 2 +- docs/recipes/dokploy.mdx | 52 +++-- docs/recipes/mongodb.mdx | 32 +-- docs/recipes/mysql.mdx | 91 ++++---- docs/recipes/overview.mdx | 13 +- docs/recipes/postgresql.mdx | 100 ++++----- docs/recipes/redis.mdx | 32 +-- docs/recipes/sqlite.mdx | 37 ++-- docs/recipes/virtfusion.mdx | 21 +- docs/reference/config.mdx | 41 +++- internal/archive/archive.go | 36 +++- internal/backup/backup_test.go | 71 +++++++ internal/backup/runner.go | 63 +++++- internal/config/config.go | 64 +++++- internal/config/config_test.go | 51 +++++ internal/dbdump/dbdump.go | 281 +++++++++++++++++++++++++ internal/dbdump/dbdump_test.go | 226 ++++++++++++++++++++ internal/notify/notify.go | 11 + 26 files changed, 1347 insertions(+), 404 deletions(-) create mode 100644 docs/configuration/databases.mdx create mode 100644 internal/dbdump/dbdump.go create mode 100644 internal/dbdump/dbdump_test.go diff --git a/README.md b/README.md index db3d4de..3e8f62a 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,8 @@ other: { type: s3, endpoint: s3.example.net, region: us-east-1, bucket: b, . | Field | Notes | |---|---| | `name` | Letters, digits, `.`, `_`, `-` | -| `sources` | Absolute paths (files or directories) | +| `sources` | Absolute paths (files or directories). A job needs `sources`, `databases`, or both. | +| `databases` | Databases to dump into the snapshot; see [Databases](#databases-dump-dont-copy) | | `exclude` | Patterns without `/` match a name anywhere (`*.log`, `node_modules`). Patterns with `/` match full paths and everything under them (`/var/lib/docker`, `/home/*/.cache`). | | `destinations` | Names from `destinations:` | | `schedule` | Cron (`m h dom mon dow`), `@daily`, `@hourly`, `@every 6h`. Leave it out for manual-only jobs. | @@ -156,6 +157,42 @@ other: { type: s3, endpoint: s3.example.net, region: us-east-1, bucket: b, . | `one_file_system` | Don't cross into other mounts | | `notify` | Per-job `discord` / `webhooks` targets, sent in addition to the global ones (see [Notifications](#notifications-no-news-is-suspicious-news)) | +### Databases: dump, don't copy + +List databases on a job and NoBackups dumps each one with its own tool, on the host or inside a Docker container, and puts the dumps in the snapshot under `nobackups-databases/`. No hook scripts needed. + +```yaml +jobs: + - name: databases + databases: + - type: postgres # postgres | mysql | mariadb | mongodb | redis | sqlite + container: my-postgres # dump inside this container; leave out to use the host's tools + user: postgres + password: ${PG_PASSWORD} + database: app # leave out to dump all databases + - type: mariadb + host: 127.0.0.1 + user: backup + password: ${MYSQL_PASSWORD} + - type: sqlite + path: /opt/vaultwarden/data/db.sqlite3 + destinations: [offsite] + schedule: "0 */6 * * *" +``` + +| Type | Tool | Restore with | +|---|---|---| +| `postgres` | `pg_dump -Fc` (one database) / `pg_dumpall` (all) | `pg_restore -d app --clean --if-exists x.dump` / `psql -f x.sql` | +| `mysql`, `mariadb` | `mysqldump --single-transaction` / `mariadb-dump` | `mysql < x.sql` | +| `mongodb` | `mongodump --archive` | `mongorestore --archive=x.archive --drop` | +| `redis` | `redis-cli --rdb` | Replace `dump.rdb` while Redis is stopped | +| `sqlite` | `sqlite3 .backup` (host only) | Copy the file back while the app is stopped | + +- **Containers:** `container` matches the exact name or a unique prefix, so Swarm (`dokploy-postgres.1.abc…`) and Compose (`app-db-1`) names just work. +- **Passwords** never appear on the command line; they're passed through the tool's environment variable (or a private temporary file for MongoDB). +- **Failures:** if a dump fails, everything else is still backed up, but the run is reported as failed and retention is skipped, so old good dumps aren't deleted. +- Other options: `name`, `port`, `auth_database` (MongoDB), `options` (extra arguments for the dump tool). See the [databases docs](docs/configuration/databases.mdx). + ### Notifications: no news is suspicious news Every job run ends in one of three events: **`success`**, **`warning`** (the backup finished, but some files couldn't be read and were skipped), or **`failure`**. Choose which events each target receives with `on`. Subscribing to `success` also delivers warnings. @@ -238,17 +275,7 @@ Copy-paste job configs for common workloads, prepped and ready to serve. Each go ### Rule zero: don't copy a moving target -Copying the files of a running database gives you a backup that may not restore. The pattern used throughout these recipes: - -1. A `before` hook writes a consistent **dump** (or stops/pauses the service) into a staging directory such as `/var/backups/nobackups/`. -2. NoBackups archives that directory. -3. An `after` hook deletes the dump (or restarts the service). `after` hooks **always run**, even if the backup failed, so services never stay stopped. - -Some rules for hooks: - -- Each list item runs as its own `sh -c` command, and any failure aborts the job. For a multi-line script (`- |`), **start it with `set -eu`**, otherwise only the last line's exit code counts. -- Dumps are written to local disk first, so the staging directory needs room for one dump. Compression and encryption happen on the way out. -- Create the staging directory with `umask 077` so dumps (which contain all your data) are readable by root only. +Copying the files of a running database gives you a backup that may not restore. Use [`databases`](#databases-dump-dont-copy) for databases, and hooks for anything else that needs preparing (an app's export command, stopping a service while its files are copied). For multi-line hook scripts, start with `set -eu`, otherwise only the last line's exit code counts. --- @@ -271,7 +298,7 @@ Some rules for hooks: retention: { keep_last: 14 } ``` -**No downtime:** for containers that only hold plain files (uploads, configs, static sites), back up the volumes live. For containers running databases, dump the database out of the container instead (see below) and exclude its raw data directory. +**No downtime:** for containers that only hold plain files (uploads, configs, static sites), back up the volumes live. For containers running databases, dump them from inside their containers with [`databases`](#databases-dump-dont-copy) and exclude their volumes. ```yaml - name: docker-volumes @@ -313,37 +340,30 @@ docker compose -f /opt/myapp/compose.yaml up -d ### Dokploy: deploy with confidence, restore with even more -On a Dokploy server nearly everything lives in Docker, so the whole-server job below (which skips `/var/lib/docker`) backs up **none of your apps** on its own. Add these two jobs. +On a Dokploy server nearly everything lives in Docker, so the whole-server job below (which skips `/var/lib/docker`) backs up **none of your apps** on its own. Add these jobs. -**The panel:** `/etc/dokploy` plus a consistent dump of Dokploy's own Postgres, done the same way Dokploy's web-server backup does it: +**The panel:** `/etc/dokploy` plus a dump of Dokploy's own Postgres, taken inside its container the same way Dokploy's built-in backup does it (no password needed): ```yaml - name: dokploy - sources: - - /etc/dokploy - - /var/backups/nobackups/dokploy + sources: [/etc/dokploy] exclude: - /etc/dokploy/volume-backups - /etc/dokploy/logs + databases: + - name: dokploy + type: postgres + container: dokploy-postgres + user: dokploy + database: dokploy destinations: [offsite] schedule: "0 3 * * *" encryption: passphrase: ${BACKUP_PASSPHRASE} - hooks: - before: - - | - set -eu - umask 077 - mkdir -p /var/backups/nobackups/dokploy - c=$(docker ps --filter name=dokploy-postgres --filter status=running -q | head -n 1) - [ -n "$c" ] || { echo "dokploy-postgres is not running"; exit 1; } - docker exec "$c" pg_dump -Fc -U dokploy -d dokploy > /var/backups/nobackups/dokploy/dokploy.dump - after: - - rm -f /var/backups/nobackups/dokploy/dokploy.dump retention: { keep_last: 14 } ``` -**Your apps:** named Docker volumes, minus the panel database's raw files (the dump above covers it): +**Your apps:** named Docker volumes, minus the panel database's raw files: ```yaml - name: dokploy-volumes @@ -358,118 +378,65 @@ On a Dokploy server nearly everything lives in Docker, so the whole-server job b retention: { keep_last: 7, keep_days: 30 } ``` -Volumes are copied live. For databases you created *in* Dokploy, turn on Dokploy's own scheduled database backups (they can upload to the same bucket) or dump them with a hook as in the recipes below. Restore steps are in the [Dokploy docs page](docs/recipes/dokploy.mdx). - ---- - -### PostgreSQL: dump it like it's hot - -**Installed on the host:** `pg_dumpall` takes a consistent snapshot of every database, plus roles and permissions, without locking writers. +**Databases you created in Dokploy:** dump them using each database's App Name as the container, then add their volumes to the `exclude` list above: ```yaml -- name: postgres - sources: [/var/backups/nobackups/postgres] +- name: dokploy-databases + databases: + - type: postgres + container: myapp-postgres-k1x2y3 # the database's App Name in Dokploy + user: myapp # its Database User in Dokploy + password: ${MYAPP_DB_PASSWORD} + - type: mariadb + container: blog-mariadb-a9b8c7 + password: ${BLOG_DB_ROOT_PASSWORD} destinations: [offsite] schedule: "0 */6 * * *" - compression: zstd - hooks: - before: - - | - set -eu - umask 077 - mkdir -p /var/backups/nobackups/postgres - cd /tmp && sudo -u postgres pg_dumpall --clean --if-exists > /var/backups/nobackups/postgres/all.sql - after: - - rm -f /var/backups/nobackups/postgres/all.sql + encryption: + passphrase: ${BACKUP_PASSPHRASE} retention: { keep_last: 28 } ``` -For big databases, use one custom-format dump per database instead. These dump in parallel, and `pg_restore` can restore a single table: - -```yaml - before: - - | - set -eu - umask 077 - D=/var/backups/nobackups/postgres - install -d -m 700 -o postgres "$D" # pg_dump -Fd writes as the postgres user - cd /tmp - sudo -u postgres pg_dumpall --globals-only > "$D/globals.sql" - for db in $(sudo -u postgres psql -Atc "SELECT datname FROM pg_database WHERE NOT datistemplate"); do - sudo -u postgres pg_dump -Fd -j 4 -Z 0 -f "$D/$db.dir" "$db" - done - after: - - rm -rf /var/backups/nobackups/postgres/* -``` +Restore steps are in the [Dokploy docs page](docs/recipes/dokploy.mdx). -(`-Z 0` turns off pg_dump's own compression, because NoBackups compresses with zstd anyway.) +--- -**In Docker:** +### PostgreSQL: dump it like it's hot ```yaml - before: - - | - set -eu - umask 077 - mkdir -p /var/backups/nobackups/postgres - docker exec my-postgres pg_dumpall -U postgres --clean --if-exists > /var/backups/nobackups/postgres/all.sql -``` + - name: postgres + databases: + - type: postgres + container: my-postgres + user: postgres + password: ${PG_PASSWORD} + destinations: [offsite] + schedule: "0 */6 * * *" + retention: { keep_last: 28 } + ``` -**Restore:** +Leave out `container` to use `pg_dump` on the host (install `postgresql-client`). Without `database`, everything (including roles) is dumped with `pg_dumpall`. -```sh -nobackups restore postgres --target /tmp/r -psql -U postgres -f /tmp/r/var/backups/nobackups/postgres/all.sql -# docker: docker exec -i my-postgres psql -U postgres < /tmp/r/var/backups/nobackups/postgres/all.sql -``` +**Restore:** `pg_restore -U postgres -d app --clean --if-exists postgres-app.dump`, or `psql -f postgres.sql postgres` for a `pg_dumpall` file. --- ### MySQL / MariaDB: single transaction, zero drama -`--single-transaction` gives a consistent dump of InnoDB tables without locking. Keep credentials in `/root/.my.cnf` (`chmod 600`), not on the command line: - -```ini -# /root/.my.cnf -[client] -user=backup -password=secret -``` - ```yaml -- name: mysql - sources: [/var/backups/nobackups/mysql] - destinations: [offsite] - schedule: "0 */6 * * *" - hooks: - before: - - | - set -eu - umask 077 - mkdir -p /var/backups/nobackups/mysql - mysqldump --defaults-extra-file=/root/.my.cnf --all-databases \ - --single-transaction --quick --routines --triggers --events \ - > /var/backups/nobackups/mysql/all.sql - after: - - rm -f /var/backups/nobackups/mysql/all.sql - retention: { keep_last: 28 } -``` + - name: mysql + databases: + - type: mysql # or mariadb + container: my-mysql + password: ${MYSQL_ROOT_PASSWORD} + destinations: [offsite] + schedule: "0 */6 * * *" + retention: { keep_last: 28 } + ``` -On MariaDB 11+ the tool is called `mariadb-dump` (same flags). **In Docker**, the official images keep the root password in the container's environment: +Use `type: mariadb` for MariaDB (it uses `mariadb-dump`, which MariaDB 11 images ship instead of `mysqldump`). A read-only user with `SELECT, SHOW VIEW, TRIGGER, LOCK TABLES, EVENT` is enough. -```yaml - before: - - | - set -eu - umask 077 - mkdir -p /var/backups/nobackups/mysql - docker exec my-mysql sh -c 'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --all-databases --single-transaction --routines --triggers --events' \ - > /var/backups/nobackups/mysql/all.sql -``` - -(Use `$MARIADB_ROOT_PASSWORD` and `mariadb-dump` for the `mariadb` image.) - -**Restore:** `mysql --defaults-extra-file=/root/.my.cnf < /tmp/r/var/backups/nobackups/mysql/all.sql` +**Restore:** `mysql -u root -p < mysql.sql` (the dump recreates its databases). --- @@ -477,79 +444,58 @@ On MariaDB 11+ the tool is called `mariadb-dump` (same flags). **In Docker**, th ```yaml - name: mongodb - sources: [/var/backups/nobackups/mongodb] + databases: + - type: mongodb + container: my-mongo # leave out to use mongodump on the host + user: admin + password: ${MONGO_PASSWORD} + # database: app # leave out to dump everything + # options: ["--oplog"] # replica sets: point-in-time consistent dump destinations: [offsite] schedule: "0 */6 * * *" - compression: none # mongodump --gzip already compresses - hooks: - before: - - | - set -eu - umask 077 - mkdir -p /var/backups/nobackups/mongodb - mongodump --uri="mongodb://backup:${MONGO_BACKUP_PASSWORD}@localhost:27017/?authSource=admin" \ - --archive=/var/backups/nobackups/mongodb/dump.archive.gz --gzip - # on a replica set, add --oplog for a point-in-time consistent dump - after: - - rm -f /var/backups/nobackups/mongodb/dump.archive.gz + retention: { keep_last: 28 } ``` -`${MONGO_BACKUP_PASSWORD}` is filled in from `nobackups.env` when the config loads. In Docker: `docker exec my-mongo mongodump --archive --gzip -u root -p "$PASS" > /var/backups/nobackups/mongodb/dump.archive.gz`. - -**Restore:** `mongorestore --archive=dump.archive.gz --gzip --drop` +**Restore:** `mongorestore -u admin -p --archive=mongodb.archive --drop` --- ### Redis / Valkey: in-memory, not out of mind -`redis-cli --rdb` asks the server for a fresh snapshot and writes it locally: - ```yaml - name: redis - sources: [/var/backups/nobackups/redis] + databases: + - type: redis + container: my-redis # leave out to use redis-cli on the host + password: ${REDIS_PASSWORD} # if requirepass / ACLs are set + # user: backup # ACL user destinations: [offsite] schedule: "0 * * * *" - hooks: - before: - - | - set -eu - umask 077 - mkdir -p /var/backups/nobackups/redis - redis-cli --rdb /var/backups/nobackups/redis/dump.rdb - # with a password: REDISCLI_AUTH="$REDIS_PASSWORD" redis-cli --rdb ... - # docker (Redis 7+): docker exec my-redis redis-cli --rdb - > /var/backups/nobackups/redis/dump.rdb - after: - - rm -f /var/backups/nobackups/redis/dump.rdb retention: { keep_last: 48 } ``` -**Restore:** stop Redis, copy `dump.rdb` into its data directory (`/var/lib/redis`), start Redis. +**Restore:** stop Redis, replace `dump.rdb` in its data directory with the restored `redis.rdb`, then start Redis. --- ### SQLite: small database, big regrets if you lose it -This also covers apps built on it: Vaultwarden, Gitea, Uptime Kuma, Home Assistant… - -Copying a live SQLite file can catch it mid-write. `sqlite3 .backup` makes a safe copy without stopping the app: +Covers Vaultwarden, Gitea, Uptime Kuma, Home Assistant and friends. `sqlite3 .backup` makes a consistent copy while the app keeps running (install `sqlite3` on the host): ```yaml - name: vaultwarden - sources: - - /opt/vaultwarden/data - - /var/backups/nobackups/vaultwarden # the safe copy made by the hook - exclude: ["db.sqlite3*"] # live DB + WAL files from the data dir + sources: [/opt/vaultwarden/data] + exclude: ["db.sqlite3*"] # the live database + WAL files + databases: + - type: sqlite + name: vaultwarden + path: /opt/vaultwarden/data/db.sqlite3 destinations: [offsite] schedule: "0 */4 * * *" - hooks: - before: - - | - set -eu - umask 077 - mkdir -p /var/backups/nobackups/vaultwarden - sqlite3 /opt/vaultwarden/data/db.sqlite3 ".backup '/var/backups/nobackups/vaultwarden/vaultwarden.sqlite3'" ``` +**Restore:** stop the app, copy `nobackups-databases/vaultwarden.sqlite` over the original, delete its `-wal`/`-shm` files, start the app. + --- ### VirtFusion: keep your panel from becoming a panic @@ -560,25 +506,18 @@ Copying a live SQLite file can catch it mid-write. `sqlite3 .backup` makes a saf ```yaml - name: virtfusion-control - sources: - - /opt/virtfusion # app, config, .env - - /var/backups/nobackups/virtfusion + sources: [/opt/virtfusion] # app, config, .env exclude: ["*.log"] + databases: + - name: virtfusion + type: mysql + user: ${VIRTFUSION_DB_USER} # DB_USERNAME in the app's .env + password: ${VIRTFUSION_DB_PASSWORD} # DB_PASSWORD in the app's .env + database: virtfusion # DB_DATABASE in the app's .env destinations: [offsite] schedule: "0 */6 * * *" encryption: passphrase: ${BACKUP_PASSPHRASE} # the .env holds secrets: always encrypt - hooks: - before: - - | - set -eu - umask 077 - mkdir -p /var/backups/nobackups/virtfusion - # DB name/credentials are in the app's .env (look for DB_DATABASE / DB_USERNAME) - mysqldump --defaults-extra-file=/root/.my.cnf --single-transaction --routines --triggers \ - virtfusion > /var/backups/nobackups/virtfusion/virtfusion.sql - after: - - rm -f /var/backups/nobackups/virtfusion/virtfusion.sql retention: { keep_last: 28, keep_days: 30 } ``` diff --git a/cmd/nobackups/example.yaml b/cmd/nobackups/example.yaml index cbc2920..a7aebc5 100644 --- a/cmd/nobackups/example.yaml +++ b/cmd/nobackups/example.yaml @@ -115,17 +115,26 @@ jobs: # on: [failure, warning] # default # mention: "<@&123456789>" # optional role/user ping on failure/warning - # Example: dump a database first, back up the dump, then remove it. - # - name: postgres - # sources: [/var/backups/postgres] - # destinations: [hetzner, local] - # schedule: "@every 6h" - # hooks: - # before: - # - mkdir -p /var/backups/postgres - # - sudo -u postgres pg_dumpall > /var/backups/postgres/all.sql - # after: # always runs; $NOBACKUPS_STATUS is success or failure - # - rm -f /var/backups/postgres/all.sql + # Databases are dumped with their own tools (pg_dump, mysqldump, mongodump, + # redis-cli, sqlite3) and stored in the snapshot under nobackups-databases/. + # Set container to dump from inside a running Docker container (a unique + # name prefix is enough, e.g. Swarm's "name.1.abc"); leave it out to use + # the tools installed on this server. + # - name: databases + # databases: + # - type: postgres # postgres | mysql | mariadb | mongodb | redis | sqlite + # container: my-postgres + # user: postgres + # password: ${PG_PASSWORD} + # database: app # leave out to dump all databases + # - type: mariadb + # host: 127.0.0.1 + # user: backup + # password: ${MYSQL_PASSWORD} + # - type: sqlite + # path: /opt/vaultwarden/data/db.sqlite3 + # destinations: [hetzner] + # schedule: "0 */6 * * *" # encryption: # recipients: # - age1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs3290gq @@ -133,6 +142,16 @@ jobs: # # identity_file: /root/backup-key.txt # retention: # keep_last: 28 + # + # Hooks run shell commands before and after a job, e.g. to stop a service + # while its files are copied. after hooks always run and get + # $NOBACKUPS_STATUS (success or failure). + # - name: app-files + # sources: [/srv/app/uploads] + # destinations: [hetzner] + # hooks: + # before: [systemctl stop app-worker] + # after: [systemctl start app-worker] # --------------------------------------------------------------------------- # Notifications for every job. Jobs can add their own under `notify:` too. diff --git a/docs/configuration/databases.mdx b/docs/configuration/databases.mdx new file mode 100644 index 0000000..6f85987 --- /dev/null +++ b/docs/configuration/databases.mdx @@ -0,0 +1,166 @@ +--- +title: "Databases" +description: "Dump PostgreSQL, MySQL, MariaDB, MongoDB, Redis and SQLite consistently, on the host or inside Docker. No shell scripting required." +icon: "database" +--- + +Copying a running database's files can give you a backup that won't restore. Instead, list your databases on a job and NoBackups dumps each one with the database's own tool before archiving: + +```yaml +jobs: + - name: databases + databases: + - type: postgres + container: my-postgres + user: postgres + password: ${PG_PASSWORD} + database: app + - type: mysql + host: 127.0.0.1 + user: backup + password: ${MYSQL_PASSWORD} + destinations: [offsite] + schedule: "0 */6 * * *" + retention: { keep_last: 28 } +``` + +A job can have `databases`, `sources`, or both. + +## How it works + +1. After the `before` hooks, each database is dumped to a private temporary file in `state_dir/dumps//`. +2. The dumps are added to the snapshot under `nobackups-databases/`, next to any `sources`. +3. The temporary files are deleted, whether the run succeeded or not. + +If a dump fails, NoBackups still backs up everything else, but the run is reported as **failed** (Discord shows which database and why). **Retention is skipped for that run**, so old snapshots with good dumps of that database aren't deleted while it's broken. + +Credentials never appear on the command line, where other users could see them with `ps`. Passwords go to the dump tool through its own environment variable (`PGPASSWORD`, `MYSQL_PWD`, `REDISCLI_AUTH`). MongoDB has no such variable, so its password goes into a private temporary config file that's removed straight after. + +## Options + + + `postgres`, `mysql`, `mariadb`, `mongodb`, `redis` or `sqlite`. + + + + File name inside the snapshot, e.g. `postgres-app` → `nobackups-databases/postgres-app.dump`. Must be unique within the job. + + + + Dump from inside this running Docker container using the tools in its image, so nothing needs installing on the host. Matches the exact container name, or a unique prefix, so Swarm (`dokploy-postgres.1.abc…`) and Compose (`app-db-1`) names work without the suffix. + + + + Server address. Leave it out to use the local socket (also inside a container). + + + + Leave it out for the default port. + + + + For MongoDB, authenticates against `auth_database` (default `admin`). For Redis, sets the ACL user. + + + + Use `${VAR}` so it lives in `nobackups.env`, not the config. + + + + The database to dump. Leave it out to dump **all** databases (not available for Redis, which always dumps the whole server). + + + + SQLite only: absolute path to the database file. + + + + MongoDB only. + + + + Extra arguments passed to the dump tool, e.g. `["--exclude-table=audit_log"]` for `pg_dump`. + + +## What each type runs + +| Type | Tool | In the snapshot | Notes | +|---|---|---|---| +| `postgres` | `pg_dump -Fc` with `database`, `pg_dumpall` without | `.dump` / `.sql` | `pg_dumpall` includes roles | +| `mysql` | `mysqldump --single-transaction` | `.sql` | Consistent for InnoDB without locking | +| `mariadb` | `mariadb-dump` (falls back to `mysqldump`) | `.sql` | Works with MariaDB 11 images, which have no `mysqldump` | +| `mongodb` | `mongodump --archive` | `.archive` | Needs mongodump 100.3+ | +| `redis` | `redis-cli --rdb` | `.rdb` | A point-in-time snapshot of the whole server | +| `sqlite` | `sqlite3 .backup` | `.sqlite` | Safe while the app is writing. Host only. | + +Without `container`, the tool must be installed on the server (e.g. `apt install postgresql-client`), ideally at least the server's version. + +## Examples + + + + ```yaml + databases: + - name: dokploy + type: postgres + container: dokploy-postgres + user: dokploy + password: ${DOKPLOY_DB_PASSWORD} + database: dokploy + - type: mariadb + container: wordpress-db + password: ${WORDPRESS_DB_ROOT_PASSWORD} + - type: redis + container: cache + ``` + + + ```yaml + databases: + - type: postgres + password: ${PG_PASSWORD} + - type: mysql + user: backup + password: ${MYSQL_PASSWORD} + database: shop + - type: mongodb + user: backup + password: ${MONGO_PASSWORD} + - type: sqlite + path: /opt/vaultwarden/data/db.sqlite3 + ``` + + + ```yaml + databases: + - type: postgres + host: db.internal + port: 5432 + user: backup + password: ${PG_PASSWORD} + database: app + ``` + + + + + Give backups a read-only database user where you can. MySQL/MariaDB needs `SELECT, SHOW VIEW, TRIGGER, LOCK TABLES, EVENT`. PostgreSQL needs read access to everything you dump (or a superuser for `pg_dumpall`). + + +## Restoring + +```bash +nobackups restore databases --target /tmp/r --path nobackups-databases +ls /tmp/r/nobackups-databases +``` + +| Type | Restore with | +|---|---| +| `postgres` (`.dump`) | `pg_restore -d app --clean --if-exists postgres-app.dump` | +| `postgres` (`.sql`) | `psql -f postgres.sql postgres` | +| `mysql` / `mariadb` | `mysql < mariadb-shop.sql` (it recreates the database) | +| `mongodb` | `mongorestore --archive=mongodb.archive --drop` | +| `redis` | Stop Redis, replace `dump.rdb` in its data directory, start Redis | +| `sqlite` | Stop the app, copy the `.sqlite` file over the original, start the app | + +For a container, pipe the file in with `docker exec -i ... < file`, e.g. `docker exec -i my-postgres pg_restore -U postgres -d app --clean --if-exists < postgres-app.dump`. diff --git a/docs/configuration/hooks.mdx b/docs/configuration/hooks.mdx index 1946769..d191b8e 100644 --- a/docs/configuration/hooks.mdx +++ b/docs/configuration/hooks.mdx @@ -56,4 +56,4 @@ before: `${VAR}` inside a hook is expanded by NoBackups when the config loads, which is handy for secrets from `nobackups.env`. Plain `$VAR` is left for the shell at run time. -See the [recipes](/recipes/overview) for ready-made hooks for common databases and Docker. +For databases, you usually don't need hooks at all: list them under [`databases`](/configuration/databases) and NoBackups runs the dump for you. Hooks are for everything else, like stopping a service or exporting app data. diff --git a/docs/configuration/jobs.mdx b/docs/configuration/jobs.mdx index 250e581..3c82588 100644 --- a/docs/configuration/jobs.mdx +++ b/docs/configuration/jobs.mdx @@ -29,8 +29,12 @@ jobs: Unique. Letters, digits, `.`, `_` and `-`. Used in snapshot names, so changing it starts a new snapshot series. - - Absolute paths to files or directories. + + Absolute paths to files or directories. A job needs `sources`, `databases`, or both. + + + + Databases to dump into the snapshot (PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQLite), on the host or inside Docker containers. See [Databases](/configuration/databases). diff --git a/docs/configuration/notifications.mdx b/docs/configuration/notifications.mdx index 2c952a3..1044ceb 100644 --- a/docs/configuration/notifications.mdx +++ b/docs/configuration/notifications.mdx @@ -41,7 +41,7 @@ Targets can be set **globally** (top-level `notify:`, applies to every job) and -Messages are colour-coded embeds (green ✅, yellow ⚠️, red ❌). Each one shows the host, duration, file count, original → stored size, and every destination's result, including how many old snapshots were pruned. Failures include the error message. +Messages are colour-coded embeds (green ✅, yellow ⚠️, red ❌). Each one shows the host, duration, file count, original → stored size, every destination's result, including how many old snapshots were pruned, and each [database dump](/configuration/databases)'s result. Failures include the error message. The Discord webhook URL. Must be `https://`. @@ -116,6 +116,9 @@ The payload contains the event, a human-readable `text` (shown as-is by Slack an "destinations": [ { "destination": "hetzner", "error": "connection reset by peer" }, { "destination": "nas", "key": "postgres/postgres-20261004T031500Z.tar.zst", "pruned": ["postgres/postgres-20260927T031500Z.tar.zst"] } + ], + "databases": [ + { "name": "postgres-app", "type": "postgres", "size": 201326592 } ] } ``` diff --git a/docs/docs.json b/docs/docs.json index 9f763c8..3ee5614 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -33,6 +33,7 @@ "configuration/overview", "configuration/destinations", "configuration/jobs", + "configuration/databases", "configuration/encryption", "configuration/retention", "configuration/hooks", diff --git a/docs/how-it-works.mdx b/docs/how-it-works.mdx index c4a285e..748fc99 100644 --- a/docs/how-it-works.mdx +++ b/docs/how-it-works.mdx @@ -15,6 +15,9 @@ Every run of a job goes through the same steps: Your `hooks.before` commands run, e.g. a database dump. If one fails, the run is aborted. + + Each entry in `databases` is dumped with its own tool (`pg_dump`, `mysqldump`, …) to a private temporary file, which goes into the snapshot under `nobackups-databases/` and is deleted afterwards. See [Databases](/configuration/databases). + Sources are walked and written as a tar stream, compressed (zstd by default), encrypted with age if configured, and streamed straight to **every destination in parallel**. Nothing is staged on local disk. @@ -41,7 +44,7 @@ destinations: [hetzner, nas] ## Memory and disk -- **Disk**: none beyond what your hooks write (e.g. a database dump). +- **Disk**: none, except temporary database dumps (one per database, deleted after the run) and whatever your hooks write. - **Memory**: about `part_size_mb` (default 64 MB) per S3 destination, for the multipart upload buffer. - **Speed**: the slowest destination sets the pace for all of them. diff --git a/docs/recipes/docker.mdx b/docs/recipes/docker.mdx index f63f574..c86a9ec 100644 --- a/docs/recipes/docker.mdx +++ b/docs/recipes/docker.mdx @@ -27,7 +27,7 @@ The simplest consistent option: stop the stack, copy everything, start it again. ## Live, without downtime -For containers that only hold plain files (uploads, configs, static sites), back up the volumes live. For containers running databases, dump the database out of the container instead (see the [database recipes](/recipes/overview)) and exclude its raw data directory. +For containers that only hold plain files (uploads, configs, static sites), back up the volumes live. For containers running databases, dump the database from inside its container with [`databases`](/configuration/databases) (`container: my-postgres`) and exclude its volume. ```yaml - name: docker-volumes diff --git a/docs/recipes/dokploy.mdx b/docs/recipes/dokploy.mdx index 5712be0..fb471f3 100644 --- a/docs/recipes/dokploy.mdx +++ b/docs/recipes/dokploy.mdx @@ -10,31 +10,24 @@ On a Dokploy server nearly everything that matters lives in Docker: your apps, t ## The panel: config and database -Dokploy keeps its configuration under `/etc/dokploy` (applications, compose files, Traefik config and certificates, SSH keys) and its state in a Postgres service called `dokploy-postgres`. This job dumps the database the same way Dokploy's own web-server backup does, then archives both. +Dokploy keeps its configuration under `/etc/dokploy` (applications, compose files, Traefik config and certificates, SSH keys) and its state in a Postgres service called `dokploy-postgres`. This job dumps that database from inside its container, the same way Dokploy's own web-server backup does, and archives both. No password is needed: the dump connects through the container's local socket. ```yaml - name: dokploy - sources: - - /etc/dokploy - - /var/backups/nobackups/dokploy + sources: [/etc/dokploy] exclude: - /etc/dokploy/volume-backups - /etc/dokploy/logs + databases: + - name: dokploy + type: postgres + container: dokploy-postgres + user: dokploy + database: dokploy destinations: [offsite] schedule: "0 3 * * *" encryption: passphrase: ${BACKUP_PASSPHRASE} - hooks: - before: - - | - set -eu - umask 077 - mkdir -p /var/backups/nobackups/dokploy - c=$(docker ps --filter name=dokploy-postgres --filter status=running -q | head -n 1) - [ -n "$c" ] || { echo "dokploy-postgres is not running"; exit 1; } - docker exec "$c" pg_dump -Fc -U dokploy -d dokploy > /var/backups/nobackups/dokploy/dokploy.dump - after: - - rm -f /var/backups/nobackups/dokploy/dokploy.dump retention: { keep_last: 14 } ``` @@ -66,12 +59,28 @@ docker volume ls du -sh /var/lib/docker/volumes/* | sort -h ``` - - Volumes are copied while the containers run. That's fine for files, but a database's raw data directory can be caught mid-write. For each database you created **in Dokploy**, either: +## Databases you created in Dokploy - - turn on Dokploy's built-in scheduled backups for it (**Database → Backups**). It can upload straight to the same S3 bucket, Alarik included. Or - - dump it with a hook as in the [PostgreSQL](/recipes/postgresql) and [MySQL](/recipes/mysql) recipes (`docker exec pg_dumpall ...`), and add its volume to `exclude`. - +Volumes are copied while the containers run. That's fine for files, but a database's raw data directory can be caught mid-write. Dump each database you created in Dokploy instead, using the **App Name** from its Dokploy page as the container. Dokploy runs it as a Swarm service, and NoBackups matches the `.1.xyz` container automatically. + +```yaml +- name: dokploy-databases + databases: + - type: postgres + container: myapp-postgres-k1x2y3 # the database's App Name in Dokploy + user: myapp # its Database User in Dokploy + password: ${MYAPP_DB_PASSWORD} + - type: mariadb + container: blog-mariadb-a9b8c7 + password: ${BLOG_DB_ROOT_PASSWORD} + destinations: [offsite] + schedule: "0 */6 * * *" + encryption: + passphrase: ${BACKUP_PASSPHRASE} + retention: { keep_last: 28 } +``` + +Then add those databases' volumes to the `dokploy-volumes` job's `exclude`. The credentials are on each database's page in Dokploy. ## Restoring @@ -84,8 +93,9 @@ du -sh /var/lib/docker/volumes/* | sort -h nobackups restore dokploy --target / c=$(docker ps --filter name=dokploy-postgres -q | head -n 1) docker exec -i "$c" pg_restore -U dokploy -d dokploy --clean --if-exists \ - < /var/backups/nobackups/dokploy/dokploy.dump + < /nobackups-databases/dokploy.dump docker service update --force dokploy + rm -rf /nobackups-databases ``` diff --git a/docs/recipes/mongodb.mdx b/docs/recipes/mongodb.mdx index 3eee8e8..2433a78 100644 --- a/docs/recipes/mongodb.mdx +++ b/docs/recipes/mongodb.mdx @@ -6,27 +6,29 @@ icon: "leaf" *No humongous downtime.* +NoBackups runs `mongodump --archive` and stores the archive in the snapshot. The password is passed through a private temporary config file, never on the command line. + ```yaml - name: mongodb - sources: [/var/backups/nobackups/mongodb] + databases: + - type: mongodb + container: my-mongo # leave out to use mongodump on the host + user: admin + password: ${MONGO_PASSWORD} + # database: app # leave out to dump everything + # options: ["--oplog"] # replica sets: point-in-time consistent dump destinations: [offsite] schedule: "0 */6 * * *" - compression: none # mongodump --gzip already compresses - hooks: - before: - - | - set -eu - umask 077 - mkdir -p /var/backups/nobackups/mongodb - mongodump --uri="mongodb://backup:${MONGO_BACKUP_PASSWORD}@localhost:27017/?authSource=admin" \ - --archive=/var/backups/nobackups/mongodb/dump.archive.gz --gzip - # on a replica set, add --oplog for a point-in-time consistent dump - after: - - rm -f /var/backups/nobackups/mongodb/dump.archive.gz + retention: { keep_last: 28 } ``` -`${MONGO_BACKUP_PASSWORD}` is filled in from `nobackups.env` when the config loads. In Docker: `docker exec my-mongo mongodump --archive --gzip -u root -p "$PASS" > /var/backups/nobackups/mongodb/dump.archive.gz`. +Needs MongoDB Database Tools 100.3 or newer (any `mongo` image from the last few years includes them). ## Restore -`mongorestore --archive=dump.archive.gz --gzip --drop` +```bash +nobackups restore mongodb --target /tmp/r --path nobackups-databases +mongorestore -u admin -p --archive=/tmp/r/nobackups-databases/mongodb.archive --drop +docker exec -i my-mongo mongorestore -u admin -p "$MONGO_PASSWORD" --archive --drop \ + < /tmp/r/nobackups-databases/mongodb.archive # container +``` diff --git a/docs/recipes/mysql.mdx b/docs/recipes/mysql.mdx index 514c44f..cd4b90e 100644 --- a/docs/recipes/mysql.mdx +++ b/docs/recipes/mysql.mdx @@ -6,54 +6,51 @@ icon: "database" *Single transaction, zero drama.* -## On the host +NoBackups runs `mysqldump --single-transaction` (or `mariadb-dump`), which gives a consistent dump of InnoDB tables without locking them. Use `type: mariadb` for MariaDB: it uses `mariadb-dump`, which MariaDB 11 images ship instead of `mysqldump`. + + + + The official images keep the root password in an environment variable you already have, so reuse it in `nobackups.env`: + + ```yaml + - name: mysql + databases: + - type: mysql # or mariadb + container: my-mysql + password: ${MYSQL_ROOT_PASSWORD} + destinations: [offsite] + schedule: "0 */6 * * *" + retention: { keep_last: 28 } + ``` + + + ```yaml + - name: mysql + databases: + - type: mysql + user: backup + password: ${MYSQL_BACKUP_PASSWORD} + database: shop # leave out to dump all databases + destinations: [offsite] + schedule: "0 */6 * * *" + retention: { keep_last: 28 } + ``` + + A read-only backup user is enough: + + ```sql + CREATE USER 'backup'@'localhost' IDENTIFIED BY '...'; + GRANT SELECT, SHOW VIEW, TRIGGER, LOCK TABLES, EVENT ON *.* TO 'backup'@'localhost'; + ``` + + -`--single-transaction` gives a consistent dump of InnoDB tables without locking. Keep credentials in `/root/.my.cnf` (`chmod 600`), not on the command line: - -```ini -# /root/.my.cnf -[client] -user=backup -password=secret -``` - -```yaml -- name: mysql - sources: [/var/backups/nobackups/mysql] - destinations: [offsite] - schedule: "0 */6 * * *" - hooks: - before: - - | - set -eu - umask 077 - mkdir -p /var/backups/nobackups/mysql - mysqldump --defaults-extra-file=/root/.my.cnf --all-databases \ - --single-transaction --quick --routines --triggers --events \ - > /var/backups/nobackups/mysql/all.sql - after: - - rm -f /var/backups/nobackups/mysql/all.sql - retention: { keep_last: 28 } -``` - -On MariaDB 11+ the tool is called `mariadb-dump` (same flags). - -## In Docker - -The official images keep the root password in the container's environment: +## Restore -```yaml - before: - - | - set -eu - umask 077 - mkdir -p /var/backups/nobackups/mysql - docker exec my-mysql sh -c 'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --all-databases --single-transaction --routines --triggers --events' \ - > /var/backups/nobackups/mysql/all.sql +```bash +nobackups restore mysql --target /tmp/r --path nobackups-databases +mysql -u root -p < /tmp/r/nobackups-databases/mysql.sql +docker exec -i my-mysql sh -c 'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD"' < /tmp/r/nobackups-databases/mysql.sql # container ``` -(Use `$MARIADB_ROOT_PASSWORD` and `mariadb-dump` for the `mariadb` image.) - -## Restore - -`mysql --defaults-extra-file=/root/.my.cnf < /tmp/r/var/backups/nobackups/mysql/all.sql` +The dump includes `CREATE DATABASE` statements, so it recreates the databases it contains. diff --git a/docs/recipes/overview.mdx b/docs/recipes/overview.mdx index 266d638..f194aa6 100644 --- a/docs/recipes/overview.mdx +++ b/docs/recipes/overview.mdx @@ -21,16 +21,11 @@ Each recipe is a job to paste under `jobs:` in your config. They assume a destin ## Rule zero: don't copy a moving target -Copying the files of a running database gives you a backup that may not restore. The pattern used throughout these recipes: +Copying the files of a running database gives you a backup that may not restore. Use a consistent dump instead: -1. A `before` hook writes a consistent **dump** (or stops/pauses the service) into a staging directory such as `/var/backups/nobackups/`. -2. NoBackups archives that directory. -3. An `after` hook deletes the dump (or restarts the service). `after` hooks **always run**, even if the backup failed, so services never stay stopped. +- **Databases**: list them under [`databases`](/configuration/databases). NoBackups runs `pg_dump`, `mysqldump`, `mongodump`, `redis-cli --rdb` or `sqlite3 .backup` itself, on the host or inside a Docker container, and puts the dump in the snapshot. No scripting needed. +- **Everything else** (an app's own export command, stopping a service while its files are copied): use [hooks](/configuration/hooks). A `before` hook prepares the data, and an `after` hook, which always runs, cleans up or restarts the service. -Some rules for hooks: - -- Each list item runs as its own `sh -c` command, and any failure aborts the job. For a multi-line script (`- |`), **start it with `set -eu`**, otherwise only the last line's exit code counts. -- Dumps are written to local disk first, so the staging directory needs room for one dump. Compression and encryption happen on the way out. -- Create the staging directory with `umask 077` so dumps (which contain all your data) are readable by root only. +For multi-line hook scripts, start with `set -eu`, otherwise only the last line's exit code counts. See [Hooks](/configuration/hooks) for the full details of how hooks run. diff --git a/docs/recipes/postgresql.mdx b/docs/recipes/postgresql.mdx index 036514b..94bcd65 100644 --- a/docs/recipes/postgresql.mdx +++ b/docs/recipes/postgresql.mdx @@ -1,74 +1,64 @@ --- title: "PostgreSQL" -description: "Consistent dumps with pg_dumpall or pg_dump, on the host or in Docker." +description: "Consistent dumps with pg_dump or pg_dumpall, on the host or in Docker." icon: "database" --- *Dump it like it's hot.* -## Installed on the host +NoBackups runs `pg_dump` / `pg_dumpall` for you. Both take a consistent snapshot without locking writers. See [Databases](/configuration/databases) for every option. -`pg_dumpall` takes a consistent snapshot of every database, plus roles and permissions, without locking writers. + + + Uses the tools inside the container, so nothing needs installing on the host: -```yaml -- name: postgres - sources: [/var/backups/nobackups/postgres] - destinations: [offsite] - schedule: "0 */6 * * *" - compression: zstd - hooks: - before: - - | - set -eu - umask 077 - mkdir -p /var/backups/nobackups/postgres - cd /tmp && sudo -u postgres pg_dumpall --clean --if-exists > /var/backups/nobackups/postgres/all.sql - after: - - rm -f /var/backups/nobackups/postgres/all.sql - retention: { keep_last: 28 } -``` - -### Large databases - -For big databases, use one custom-format dump per database instead. These dump in parallel, and `pg_restore` can restore a single table: - -```yaml - before: - - | - set -eu - umask 077 - D=/var/backups/nobackups/postgres - install -d -m 700 -o postgres "$D" # pg_dump -Fd writes as the postgres user - cd /tmp - sudo -u postgres pg_dumpall --globals-only > "$D/globals.sql" - for db in $(sudo -u postgres psql -Atc "SELECT datname FROM pg_database WHERE NOT datistemplate"); do - sudo -u postgres pg_dump -Fd -j 4 -Z 0 -f "$D/$db.dir" "$db" - done - after: - - rm -rf /var/backups/nobackups/postgres/* -``` + ```yaml + - name: postgres + databases: + - type: postgres + container: my-postgres + user: postgres + password: ${PG_PASSWORD} + destinations: [offsite] + schedule: "0 */6 * * *" + retention: { keep_last: 28 } + ``` + + + Needs `postgresql-client`, ideally the same major version as the server: -(`-Z 0` turns off pg_dump's own compression, because NoBackups compresses with zstd anyway.) + ```yaml + - name: postgres + databases: + - type: postgres + password: ${PG_PASSWORD} + destinations: [offsite] + schedule: "0 */6 * * *" + retention: { keep_last: 28 } + ``` + + -## In Docker - -The same idea, with the dump coming out of the container: +Without `database`, every database plus roles and permissions is dumped with `pg_dumpall` (`postgres.sql`). To dump specific databases in the faster custom format, which can restore single tables, list them separately: ```yaml - before: - - | - set -eu - umask 077 - mkdir -p /var/backups/nobackups/postgres - docker exec my-postgres pg_dumpall -U postgres --clean --if-exists > /var/backups/nobackups/postgres/all.sql +- name: postgres-apps + databases: + - { type: postgres, container: my-postgres, password: "${PG_PASSWORD}", database: app } + - { type: postgres, container: my-postgres, password: "${PG_PASSWORD}", database: analytics, options: ["--exclude-table-data=events_raw"] } + destinations: [offsite] + schedule: "0 */6 * * *" ``` ## Restore +```bash +nobackups restore postgres --target /tmp/r --path nobackups-databases +``` - -```sh -nobackups restore postgres --target /tmp/r -psql -U postgres -f /tmp/r/var/backups/nobackups/postgres/all.sql -# docker: docker exec -i my-postgres psql -U postgres < /tmp/r/var/backups/nobackups/postgres/all.sql +```bash +pg_restore -U postgres -d app --clean --if-exists /tmp/r/nobackups-databases/postgres-app.dump # one database +psql -U postgres -f /tmp/r/nobackups-databases/postgres.sql postgres # pg_dumpall +docker exec -i my-postgres pg_restore -U postgres -d app --clean --if-exists \ + < /tmp/r/nobackups-databases/postgres-app.dump # into a container ``` diff --git a/docs/recipes/redis.mdx b/docs/recipes/redis.mdx index fe9476c..f2beeca 100644 --- a/docs/recipes/redis.mdx +++ b/docs/recipes/redis.mdx @@ -6,27 +6,31 @@ icon: "bolt" *In-memory, not out of mind.* -`redis-cli --rdb` asks the server for a fresh snapshot and writes it locally: +NoBackups asks Redis for a fresh snapshot (`redis-cli --rdb`) and stores the RDB file. This works for Valkey too, as long as the image or host provides `redis-cli`. ```yaml - name: redis - sources: [/var/backups/nobackups/redis] + databases: + - type: redis + container: my-redis # leave out to use redis-cli on the host + password: ${REDIS_PASSWORD} # if requirepass / ACLs are set + # user: backup # ACL user destinations: [offsite] schedule: "0 * * * *" - hooks: - before: - - | - set -eu - umask 077 - mkdir -p /var/backups/nobackups/redis - redis-cli --rdb /var/backups/nobackups/redis/dump.rdb - # with a password: REDISCLI_AUTH="$REDIS_PASSWORD" redis-cli --rdb ... - # docker (Redis 7+): docker exec my-redis redis-cli --rdb - > /var/backups/nobackups/redis/dump.rdb - after: - - rm -f /var/backups/nobackups/redis/dump.rdb retention: { keep_last: 48 } ``` ## Restore -stop Redis, copy `dump.rdb` into its data directory (`/var/lib/redis`), start Redis. +Stop Redis, replace `dump.rdb` in its data directory (usually `/var/lib/redis` or `/data` in containers) with the restored file, then start Redis: + +```bash +nobackups restore redis --target /tmp/r --path nobackups-databases +systemctl stop redis-server +cp /tmp/r/nobackups-databases/redis.rdb /var/lib/redis/dump.rdb && chown redis:redis /var/lib/redis/dump.rdb +systemctl start redis-server +``` + + + If append-only mode (`appendonly yes`) is on, Redis loads the AOF instead of the RDB on start. Turn it off for the first start after a restore, then turn it back on. + diff --git a/docs/recipes/sqlite.mdx b/docs/recipes/sqlite.mdx index 76bcc73..ccb154f 100644 --- a/docs/recipes/sqlite.mdx +++ b/docs/recipes/sqlite.mdx @@ -6,23 +6,32 @@ icon: "feather" *Small database, big regrets if you lose it.* -This also covers apps built on it: Vaultwarden, Gitea, Uptime Kuma, Home Assistant… - -Copying a live SQLite file can catch it mid-write. `sqlite3 .backup` makes a safe copy without stopping the app: +This covers Vaultwarden, Gitea, Uptime Kuma, Home Assistant and many more. Copying a live SQLite file can catch it mid-write. NoBackups uses `sqlite3 .backup` instead, which makes a consistent copy while the app keeps running. `sqlite3` must be installed on the host (`apt install sqlite3`). ```yaml - name: vaultwarden - sources: - - /opt/vaultwarden/data - - /var/backups/nobackups/vaultwarden # the safe copy made by the hook - exclude: ["db.sqlite3*"] # live DB + WAL files from the data dir + sources: [/opt/vaultwarden/data] + exclude: ["db.sqlite3*"] # the live database + WAL files + databases: + - type: sqlite + name: vaultwarden + path: /opt/vaultwarden/data/db.sqlite3 destinations: [offsite] schedule: "0 */4 * * *" - hooks: - before: - - | - set -eu - umask 077 - mkdir -p /var/backups/nobackups/vaultwarden - sqlite3 /opt/vaultwarden/data/db.sqlite3 ".backup '/var/backups/nobackups/vaultwarden/vaultwarden.sqlite3'" +``` + +The data directory (attachments, keys) comes from `sources`, and the database from the safe copy at `nobackups-databases/vaultwarden.sqlite`. + + + The database lives inside a Docker volume? Point `path` at the file under `/var/lib/docker/volumes//_data/`. `sqlite3` runs on the host, so the container doesn't need it. + + +## Restore + +Stop the app, put the copy back under the original name, and start it again: + +```bash +nobackups restore vaultwarden --target /tmp/r +cp /tmp/r/nobackups-databases/vaultwarden.sqlite /opt/vaultwarden/data/db.sqlite3 +rm -f /opt/vaultwarden/data/db.sqlite3-wal /opt/vaultwarden/data/db.sqlite3-shm ``` diff --git a/docs/recipes/virtfusion.mdx b/docs/recipes/virtfusion.mdx index 2e31a75..b74040f 100644 --- a/docs/recipes/virtfusion.mdx +++ b/docs/recipes/virtfusion.mdx @@ -16,25 +16,18 @@ This is the panel itself: its database plus the application directory with its c ```yaml - name: virtfusion-control - sources: - - /opt/virtfusion # app, config, .env - - /var/backups/nobackups/virtfusion + sources: [/opt/virtfusion] # app, config, .env exclude: ["*.log"] + databases: + - name: virtfusion + type: mysql + user: ${VIRTFUSION_DB_USER} # DB_USERNAME in the app's .env + password: ${VIRTFUSION_DB_PASSWORD} # DB_PASSWORD in the app's .env + database: virtfusion # DB_DATABASE in the app's .env destinations: [offsite] schedule: "0 */6 * * *" encryption: passphrase: ${BACKUP_PASSPHRASE} # the .env holds secrets: always encrypt - hooks: - before: - - | - set -eu - umask 077 - mkdir -p /var/backups/nobackups/virtfusion - # DB name/credentials are in the app's .env (look for DB_DATABASE / DB_USERNAME) - mysqldump --defaults-extra-file=/root/.my.cnf --single-transaction --routines --triggers \ - virtfusion > /var/backups/nobackups/virtfusion/virtfusion.sql - after: - - rm -f /var/backups/nobackups/virtfusion/virtfusion.sql retention: { keep_last: 28, keep_days: 30 } ``` diff --git a/docs/reference/config.mdx b/docs/reference/config.mdx index 9317872..ed39ea8 100644 --- a/docs/reference/config.mdx +++ b/docs/reference/config.mdx @@ -130,17 +130,26 @@ jobs: # on: [failure, warning] # default # mention: "<@&123456789>" # optional role/user ping on failure/warning - # Example: dump a database first, back up the dump, then remove it. - # - name: postgres - # sources: [/var/backups/postgres] - # destinations: [hetzner, local] - # schedule: "@every 6h" - # hooks: - # before: - # - mkdir -p /var/backups/postgres - # - sudo -u postgres pg_dumpall > /var/backups/postgres/all.sql - # after: # always runs; $NOBACKUPS_STATUS is success or failure - # - rm -f /var/backups/postgres/all.sql + # Databases are dumped with their own tools (pg_dump, mysqldump, mongodump, + # redis-cli, sqlite3) and stored in the snapshot under nobackups-databases/. + # Set container to dump from inside a running Docker container (a unique + # name prefix is enough, e.g. Swarm's "name.1.abc"); leave it out to use + # the tools installed on this server. + # - name: databases + # databases: + # - type: postgres # postgres | mysql | mariadb | mongodb | redis | sqlite + # container: my-postgres + # user: postgres + # password: ${PG_PASSWORD} + # database: app # leave out to dump all databases + # - type: mariadb + # host: 127.0.0.1 + # user: backup + # password: ${MYSQL_PASSWORD} + # - type: sqlite + # path: /opt/vaultwarden/data/db.sqlite3 + # destinations: [hetzner] + # schedule: "0 */6 * * *" # encryption: # recipients: # - age1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs3290gq @@ -148,6 +157,16 @@ jobs: # # identity_file: /root/backup-key.txt # retention: # keep_last: 28 + # + # Hooks run shell commands before and after a job, e.g. to stop a service + # while its files are copied. after hooks always run and get + # $NOBACKUPS_STATUS (success or failure). + # - name: app-files + # sources: [/srv/app/uploads] + # destinations: [hetzner] + # hooks: + # before: [systemctl stop app-worker] + # after: [systemctl start app-worker] # --------------------------------------------------------------------------- # Notifications for every job. Jobs can add their own under `notify:` too. diff --git a/internal/archive/archive.go b/internal/archive/archive.go index e213ac1..7ab5441 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -29,9 +29,15 @@ type Options struct { Sources []string Exclude []string OneFileSystem bool + Extra []Extra Log *slog.Logger } +type Extra struct { + Path string + Name string +} + func Create(w io.Writer, opts Options) (Stats, error) { log := opts.Log if log == nil { @@ -44,6 +50,9 @@ func Create(w io.Writer, opts Options) (Stats, error) { return a.stats, err } } + if err := a.addExtras(opts.Extra); err != nil { + return a.stats, err + } return a.stats, tw.Close() } @@ -126,8 +135,33 @@ func (a *archiver) addParents(p string) error { return nil } +func (a *archiver) addExtras(extras []Extra) error { + dirs := map[string]bool{} + for _, e := range extras { + if d := path.Dir(e.Name); d != "." && !dirs[d] { + dirs[d] = true + hdr := &tar.Header{Name: d + "/", Typeflag: tar.TypeDir, Mode: 0o700, ModTime: time.Now().Truncate(time.Second), Format: tar.FormatPAX} + if err := a.tw.WriteHeader(hdr); err != nil { + return err + } + a.stats.Dirs++ + } + info, err := os.Lstat(e.Path) + if err != nil { + return err + } + if err := a.addNamed(e.Path, e.Name, info); err != nil { + return err + } + } + return nil +} + func (a *archiver) addEntry(p string, info fs.FileInfo) error { - name := strings.TrimPrefix(filepath.ToSlash(p), "/") + return a.addNamed(p, strings.TrimPrefix(filepath.ToSlash(p), "/"), info) +} + +func (a *archiver) addNamed(p, name string, info fs.FileInfo) error { if name == "" || a.seen[name] { return nil } diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go index 863e951..7994c62 100644 --- a/internal/backup/backup_test.go +++ b/internal/backup/backup_test.go @@ -223,3 +223,74 @@ func TestLockPreventsConcurrentRuns(t *testing.T) { u() } } + +func TestDatabaseDumpsAndFailedDumpSkipsRetention(t *testing.T) { + bin := t.TempDir() + os.WriteFile(filepath.Join(bin, "pg_dump"), []byte(`#!/bin/sh +for a in "$@"; do db=$a; done +[ "$db" = broken ] && { echo "pg_dump: error: database \"broken\" does not exist" >&2; exit 1; } +printf 'dump of %s as %s\n' "$db" "$PGPASSWORD" +`), 0o755) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + src := t.TempDir() + os.WriteFile(filepath.Join(src, "f"), []byte("file"), 0o644) + store := t.TempDir() + yaml := ` +state_dir: ` + t.TempDir() + ` +destinations: + disk: {type: local, path: ` + store + `} +jobs: + - name: j + sources: [` + src + `] + databases: + - {type: postgres, database: app, password: pw} + - {type: postgres, database: broken} + destinations: [disk] + retention: {keep_last: 1} +` + cfg, err := config.Parse([]byte(yaml), "test.yaml") + if err != nil { + t.Fatal(err) + } + job := cfg.Jobs[0] + r := NewRunner(cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) + ctx := context.Background() + + job.Databases = job.Databases[:1] + if _, err := r.Run(ctx, job); err != nil { + t.Fatal(err) + } + time.Sleep(1100 * time.Millisecond) + + cfg2, _ := config.Parse([]byte(yaml), "test.yaml") + job2 := cfg2.Jobs[0] + res, err := r.Run(ctx, job2) + if err == nil || !strings.Contains(err.Error(), `database "broken" does not exist`) { + t.Fatalf("expected the failed dump to fail the job, got %v", err) + } + if len(res.Databases) != 2 || res.Databases[0].Error != "" || res.Databases[1].Error == "" { + t.Fatalf("database results: %+v", res.Databases) + } + + b, _ := r.Backend("disk") + snaps, _ := ListSnapshots(ctx, b, "j") + if len(snaps) != 2 { + t.Fatalf("retention must be skipped after a failed dump; have %d snapshots", len(snaps)) + } + + target := t.TempDir() + if _, _, err := r.Restore(ctx, job2, RestoreOptions{Target: target}); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(target, "nobackups-databases", "postgres-app.dump")) + if err != nil || string(got) != "dump of app as pw\n" { + t.Fatalf("restored dump %q, %v", got, err) + } + if _, err := os.ReadFile(filepath.Join(target, src, "f")); err != nil { + t.Fatal("files should be backed up alongside the dumps") + } + if entries, _ := os.ReadDir(filepath.Join(cfg.StateDir, "dumps")); len(entries) != 0 { + t.Fatalf("dump files left behind: %v", entries) + } +} diff --git a/internal/backup/runner.go b/internal/backup/runner.go index b528569..23dccca 100644 --- a/internal/backup/runner.go +++ b/internal/backup/runner.go @@ -9,6 +9,7 @@ import ( "log/slog" "os" "os/exec" + "path/filepath" "strings" "sync" "time" @@ -17,6 +18,7 @@ import ( "github.com/codemeapixel/nobackups/internal/archive" "github.com/codemeapixel/nobackups/internal/config" + "github.com/codemeapixel/nobackups/internal/dbdump" "github.com/codemeapixel/nobackups/internal/storage" ) @@ -42,6 +44,14 @@ type Result struct { StoredBytes int64 `json:"stored_bytes"` Warnings int64 `json:"warnings"` Destinations []DestResult `json:"destinations"` + Databases []DBResult `json:"databases,omitempty"` +} + +type DBResult struct { + Name string `json:"name"` + Type string `json:"type"` + Size int64 `json:"size"` + Error string `json:"error,omitempty"` } type Runner struct { @@ -150,6 +160,12 @@ func (r *Runner) backup(ctx context.Context, log *slog.Logger, job *config.Job, backends = append(backends, b) } + extras, cleanup, dumpErr := r.dumpDatabases(ctx, log, job, res) + defer cleanup() + if dumpErr != nil && len(extras) == 0 && len(job.Sources) == 0 { + return dumpErr + } + key := snapshotKey(job.Name, res.Started, job.Compression, recips != nil) uploadCtx, cancelUploads := context.WithCancel(ctx) defer cancelUploads() @@ -180,7 +196,7 @@ func (r *Runner) backup(ctx context.Context, log *slog.Logger, job *config.Job, fan := newFanout(pipes) counter := &countingWriter{w: fan} - stats, archErr := writeArchive(counter, job, recips, log) + stats, archErr := writeArchive(counter, job, extras, recips, log) fan.closeAll(archErr) if archErr != nil { cancelUploads() @@ -209,6 +225,10 @@ func (r *Runner) backup(ctx context.Context, log *slog.Logger, job *config.Job, continue } log.Info("uploaded", "destination", results[i].Destination, "key", key) + if dumpErr != nil { + log.Warn("skipping retention because a database dump failed", "destination", results[i].Destination) + continue + } pruned, err := r.prune(ctx, log, backends[i], job) results[i].Pruned = pruned if err != nil { @@ -217,12 +237,46 @@ func (r *Runner) backup(ctx context.Context, log *slog.Logger, job *config.Job, } res.Destinations = results if len(failed) > 0 { - return fmt.Errorf("upload failed for: %s", strings.Join(failed, ", ")) + return errors.Join(dumpErr, fmt.Errorf("upload failed for: %s", strings.Join(failed, ", "))) } - return nil + return dumpErr +} + +func (r *Runner) dumpDatabases(ctx context.Context, log *slog.Logger, job *config.Job, res *Result) ([]archive.Extra, func(), error) { + if len(job.Databases) == 0 { + return nil, func() {}, nil + } + dir := filepath.Join(r.Cfg.StateDir, "dumps", job.Name) + cleanup := func() { + if err := os.RemoveAll(dir); err != nil { + log.Warn("cannot remove database dumps", "dir", dir, "err", err) + } + } + cleanup() + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, func() {}, fmt.Errorf("create dump directory: %w", err) + } + var extras []archive.Extra + var errs []error + for _, db := range job.Databases { + start := time.Now() + log.Info("dumping database", "database", db.Name, "type", db.Type, "container", db.Container) + d, err := dbdump.Dump(ctx, db, dir) + dr := DBResult{Name: db.Name, Type: db.Type, Size: d.Size} + if err != nil { + dr.Error = err.Error() + errs = append(errs, fmt.Errorf("database %s: %w", db.Name, err)) + log.Error("database dump failed", "database", db.Name, "err", err) + } else { + extras = append(extras, archive.Extra{Path: d.File, Name: d.ArchiveName}) + log.Info("database dumped", "database", db.Name, "bytes", d.Size, "duration", time.Since(start).Round(time.Millisecond)) + } + res.Databases = append(res.Databases, dr) + } + return extras, cleanup, errors.Join(errs...) } -func writeArchive(w io.Writer, job *config.Job, recips []age.Recipient, log *slog.Logger) (archive.Stats, error) { +func writeArchive(w io.Writer, job *config.Job, extras []archive.Extra, recips []age.Recipient, log *slog.Logger) (archive.Stats, error) { var out io.WriteCloser = nopWriteCloser{w} if recips != nil { enc, err := age.Encrypt(w, recips...) @@ -239,6 +293,7 @@ func writeArchive(w io.Writer, job *config.Job, recips []age.Recipient, log *slo Sources: job.Sources, Exclude: job.Exclude, OneFileSystem: job.OneFileSystem, + Extra: extras, Log: log, }) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 1f052e4..6ecbcf3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -71,8 +71,25 @@ type Job struct { Timeout Duration `yaml:"timeout"` Notify Notify `yaml:"notify"` OneFileSystem bool `yaml:"one_file_system"` + Databases []*Database `yaml:"databases"` } +type Database struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Container string `yaml:"container"` + Host string `yaml:"host"` + Port int `yaml:"port"` + User string `yaml:"user"` + Password string `yaml:"password"` + Database string `yaml:"database"` + AuthDB string `yaml:"auth_database"` + Path string `yaml:"path"` + Options []string `yaml:"options"` +} + +var DatabaseTypes = []string{"postgres", "mysql", "mariadb", "mongodb", "redis", "sqlite"} + type Encryption struct { Passphrase string `yaml:"passphrase"` PassphraseFile string `yaml:"passphrase_file"` @@ -369,8 +386,51 @@ func (c *Config) Validate() error { add("job %q: duplicate job name", j.Name) } seen[j.Name] = true - if len(j.Sources) == 0 { - add("job %s: at least one source is required", id) + if len(j.Sources) == 0 && len(j.Databases) == 0 { + add("job %s: at least one source or database is required", id) + } + dbNames := map[string]bool{} + for i, db := range j.Databases { + if db == nil { + add("job %s: database #%d is empty", id, i+1) + continue + } + where := fmt.Sprintf("job %s: database #%d", id, i+1) + if !slices.Contains(DatabaseTypes, db.Type) { + add("%s: type must be one of %s", where, strings.Join(DatabaseTypes, ", ")) + continue + } + if db.Name == "" { + db.Name = db.Type + if db.Database != "" { + db.Name += "-" + db.Database + } else if db.Path != "" { + db.Name += "-" + strings.TrimSuffix(filepath.Base(db.Path), filepath.Ext(db.Path)) + } + } + if !nameRe.MatchString(db.Name) { + add("%s: name %q may only contain letters, digits, '.', '_' and '-'", where, db.Name) + } + if dbNames[db.Name] { + add("%s: duplicate name %q; set name: to tell them apart", where, db.Name) + } + dbNames[db.Name] = true + if db.Port < 0 || db.Port > 65535 { + add("%s: invalid port %d", where, db.Port) + } + switch db.Type { + case "sqlite": + if db.Path == "" || !filepath.IsAbs(db.Path) { + add("%s: sqlite needs an absolute path to the database file", where) + } + if db.Container != "" { + add("%s: sqlite is dumped from the host; set path to the file instead of container", where) + } + case "redis": + if db.Database != "" { + add("%s: redis dumps the whole server; remove database", where) + } + } } for _, s := range j.Sources { if !filepath.IsAbs(s) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 45fba6c..d146094 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -151,3 +151,54 @@ jobs: [{name: j, sources: [/etc], destinations: [h]}] t.Fatalf("got %v", err) } } + +func TestDatabaseValidation(t *testing.T) { + good := ` +destinations: {d: {type: local, path: /tmp/x}} +jobs: + - name: j + destinations: [d] + databases: + - {type: postgres, database: app} + - {type: postgres} + - {type: sqlite, path: /srv/data/app.db} + - {type: redis, container: cache} +` + c, err := Parse([]byte(good), "test.yaml") + if err != nil { + t.Fatal(err) + } + var names []string + for _, db := range c.Jobs[0].Databases { + names = append(names, db.Name) + } + if strings.Join(names, ",") != "postgres-app,postgres,sqlite-app,redis" { + t.Errorf("default names: %v", names) + } + + bad := ` +destinations: {d: {type: local, path: /tmp/x}} +jobs: + - name: j + destinations: [d] + databases: + - {type: oracle} + - {type: sqlite, container: x} + - {type: redis, database: "0"} + - {type: mysql, database: shop} + - {type: mysql, database: shop} + - {type: postgres, port: 70000} + - name: empty + destinations: [d] +` + _, err = Parse([]byte(bad), "test.yaml") + if err == nil { + t.Fatal("expected errors") + } + for _, want := range []string{"type must be one of postgres", "sqlite needs an absolute path", "sqlite is dumped from the host", + "redis dumps the whole server", `duplicate name "mysql-shop"`, "invalid port 70000", "at least one source or database"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("missing %q in:\n%v", want, err) + } + } +} diff --git a/internal/dbdump/dbdump.go b/internal/dbdump/dbdump.go new file mode 100644 index 0000000..636c533 --- /dev/null +++ b/internal/dbdump/dbdump.go @@ -0,0 +1,281 @@ +package dbdump + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/codemeapixel/nobackups/internal/config" +) + +const ArchiveDir = "nobackups-databases" + +type Result struct { + Name string + File string + ArchiveName string + Size int64 +} + +type plan struct { + argv []string + env []string + stdin []byte + ext string + writesTo string +} + +const redisScript = `f=$(mktemp) || exit 1 +redis-cli "$@" --rdb "$f" >/dev/null && cat "$f" +rc=$? +rm -f "$f" +exit $rc` + +const mongoScript = `umask 077 +f=$(mktemp) || exit 1 +cat > "$f" +mongodump --config "$f" "$@" +rc=$? +rm -f "$f" +exit $rc` + +const mariadbScript = `if command -v mariadb-dump >/dev/null 2>&1; then exec mariadb-dump "$@"; fi +exec mysqldump "$@"` + +func buildPlan(db *config.Database, out string) (plan, error) { + port := "" + if db.Port > 0 { + port = strconv.Itoa(db.Port) + } + var p plan + switch db.Type { + case "postgres": + user := or(db.User, "postgres") + args := []string{"-U", user} + args = appendIf(args, "-h", db.Host) + args = appendIf(args, "-p", port) + if db.Database == "" { + p.argv = append([]string{"pg_dumpall"}, args...) + p.ext = ".sql" + } else { + p.argv = append(append([]string{"pg_dump"}, args...), "-Fc", "-d", db.Database) + p.ext = ".dump" + } + p.argv = append(p.argv, db.Options...) + if db.Password != "" { + p.env = []string{"PGPASSWORD=" + db.Password} + } + case "mysql", "mariadb": + args := []string{"-u", or(db.User, "root"), "--single-transaction", "--quick", "--routines", "--triggers", "--no-tablespaces"} + args = appendIf(args, "-h", db.Host) + args = appendIf(args, "-P", port) + if db.Database == "" { + args = append(args, "--all-databases", "--events") + } else { + args = append(args, "--databases", db.Database) + } + args = append(args, db.Options...) + if db.Type == "mariadb" { + p.argv = append([]string{"sh", "-c", mariadbScript, "sh"}, args...) + } else { + p.argv = append([]string{"mysqldump"}, args...) + } + p.ext = ".sql" + if db.Password != "" { + p.env = []string{"MYSQL_PWD=" + db.Password} + } + case "mongodb": + args := []string{"--archive"} + args = appendIf(args, "--host", db.Host) + args = appendIf(args, "--port", port) + args = appendIf(args, "--db", db.Database) + if db.User != "" { + args = append(args, "--username", db.User, "--authenticationDatabase", or(db.AuthDB, "admin")) + } + args = append(args, db.Options...) + cfg := map[string]string{} + if db.Password != "" { + cfg["password"] = db.Password + } + stdin, err := yaml.Marshal(cfg) + if err != nil { + return p, err + } + p.argv = append([]string{"sh", "-c", mongoScript, "sh"}, args...) + p.stdin = stdin + p.ext = ".archive" + case "redis": + var args []string + args = appendIf(args, "-h", db.Host) + args = appendIf(args, "-p", port) + args = appendIf(args, "--user", db.User) + args = append(args, db.Options...) + p.argv = append([]string{"sh", "-c", redisScript, "sh"}, args...) + p.ext = ".rdb" + if db.Password != "" { + p.env = []string{"REDISCLI_AUTH=" + db.Password} + } + case "sqlite": + p.ext = ".sqlite" + p.writesTo = out + p.ext + p.argv = []string{"sqlite3", db.Path, ".backup '" + strings.ReplaceAll(p.writesTo, "'", "''") + "'"} + default: + return p, fmt.Errorf("unknown database type %q", db.Type) + } + return p, nil +} + +func wrapDocker(p plan, container string) plan { + argv := []string{"docker", "exec", "-i"} + for _, kv := range p.env { + k, _, _ := strings.Cut(kv, "=") + argv = append(argv, "-e", k) + } + p.argv = append(append(argv, container), p.argv...) + return p +} + +func Dump(ctx context.Context, db *config.Database, dir string) (Result, error) { + res := Result{Name: db.Name} + base := filepath.Join(dir, db.Name) + p, err := buildPlan(db, base) + if err != nil { + return res, err + } + if db.Container != "" { + id, err := ResolveContainer(ctx, db.Container) + if err != nil { + return res, err + } + p = wrapDocker(p, id) + } + res.File = base + p.ext + res.ArchiveName = ArchiveDir + "/" + db.Name + p.ext + + cmd := exec.CommandContext(ctx, p.argv[0], p.argv[1:]...) + cmd.Env = append(os.Environ(), p.env...) + if p.stdin != nil { + cmd.Stdin = bytes.NewReader(p.stdin) + } + stderr := &tailBuffer{max: 4096} + cmd.Stderr = stderr + if p.writesTo == "" { + f, err := os.OpenFile(res.File, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return res, err + } + cmd.Stdout = f + err = cmd.Run() + if cerr := f.Close(); err == nil { + err = cerr + } + if err != nil { + return res, commandError(p.argv[0], err, stderr) + } + } else { + cmd.Stdout = stderr + if err := cmd.Run(); err != nil { + return res, commandError(p.argv[0], err, stderr) + } + if err := os.Chmod(res.File, 0o600); err != nil { + return res, err + } + } + info, err := os.Stat(res.File) + if err != nil { + return res, err + } + if info.Size() == 0 { + return res, fmt.Errorf("dump produced no data%s", stderrSuffix(stderr)) + } + res.Size = info.Size() + return res, nil +} + +func ResolveContainer(ctx context.Context, name string) (string, error) { + out, err := exec.CommandContext(ctx, "docker", "ps", "--format", "{{.ID}}\t{{.Names}}").Output() + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + return "", fmt.Errorf("docker ps: %s", strings.TrimSpace(string(ee.Stderr))) + } + return "", fmt.Errorf("docker ps: %w", err) + } + var matches []string + var ids []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + id, n, ok := strings.Cut(line, "\t") + if !ok { + continue + } + if n == name { + return id, nil + } + for _, sep := range []string{".", "-", "_"} { + if strings.HasPrefix(n, name+sep) { + matches = append(matches, n) + ids = append(ids, id) + break + } + } + } + switch len(matches) { + case 0: + return "", fmt.Errorf("no running container named %q", name) + case 1: + return ids[0], nil + } + return "", fmt.Errorf("container %q matches several running containers (%s); use the full name", name, strings.Join(matches, ", ")) +} + +func commandError(tool string, err error, stderr *tailBuffer) error { + if errors.Is(err, exec.ErrNotFound) { + return fmt.Errorf("%s not found: install the database client tools, or set container to dump from inside a Docker container", tool) + } + return fmt.Errorf("%w%s", err, stderrSuffix(stderr)) +} + +func stderrSuffix(b *tailBuffer) string { + s := strings.TrimSpace(b.String()) + if s == "" { + return "" + } + return ": " + s +} + +func or(v, def string) string { + if v == "" { + return def + } + return v +} + +func appendIf(args []string, flag, v string) []string { + if v == "" { + return args + } + return append(args, flag, v) +} + +type tailBuffer struct { + max int + buf []byte +} + +func (t *tailBuffer) Write(p []byte) (int, error) { + t.buf = append(t.buf, p...) + if len(t.buf) > t.max { + t.buf = t.buf[len(t.buf)-t.max:] + } + return len(p), nil +} + +func (t *tailBuffer) String() string { return string(t.buf) } diff --git a/internal/dbdump/dbdump_test.go b/internal/dbdump/dbdump_test.go new file mode 100644 index 0000000..37afa89 --- /dev/null +++ b/internal/dbdump/dbdump_test.go @@ -0,0 +1,226 @@ +package dbdump + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" + + "github.com/codemeapixel/nobackups/internal/config" +) + +func TestPlansKeepPasswordsOffTheCommandLine(t *testing.T) { + cases := []struct { + db config.Database + tool string + env string + ext string + args []string + noArgs []string + stdinPw bool + }{ + {db: config.Database{Type: "postgres", Password: "pw", Database: "app", Host: "db", Port: 5433, User: "u"}, + tool: "pg_dump", env: "PGPASSWORD=pw", ext: ".dump", args: []string{"-Fc", "-d", "app", "-h", "db", "-p", "5433", "-U", "u"}}, + {db: config.Database{Type: "postgres", Password: "pw"}, + tool: "pg_dumpall", env: "PGPASSWORD=pw", ext: ".sql", args: []string{"-U", "postgres"}}, + {db: config.Database{Type: "mysql", Password: "pw", Database: "shop"}, + tool: "mysqldump", env: "MYSQL_PWD=pw", ext: ".sql", args: []string{"--databases", "shop", "-u", "root", "--single-transaction"}, noArgs: []string{"--all-databases"}}, + {db: config.Database{Type: "mariadb", Password: "pw"}, + tool: "sh", env: "MYSQL_PWD=pw", ext: ".sql", args: []string{"--all-databases", "--events"}}, + {db: config.Database{Type: "mongodb", Password: "pw", User: "admin", Database: "app"}, + tool: "sh", ext: ".archive", args: []string{"--archive", "--username", "admin", "--authenticationDatabase", "admin", "--db", "app"}, stdinPw: true}, + {db: config.Database{Type: "redis", Password: "pw", Port: 6380}, + tool: "sh", env: "REDISCLI_AUTH=pw", ext: ".rdb", args: []string{"-p", "6380"}}, + } + for _, c := range cases { + p, err := buildPlan(&c.db, "/tmp/out") + if err != nil { + t.Fatal(err) + } + if p.argv[0] != c.tool || p.ext != c.ext { + t.Errorf("%s: tool %s ext %s", c.db.Type, p.argv[0], p.ext) + } + for _, a := range p.argv { + if strings.Contains(a, "pw") && !strings.Contains(a, "\n") { + t.Errorf("%s: password on the command line: %q", c.db.Type, p.argv) + } + } + if c.env != "" && !slices.Contains(p.env, c.env) { + t.Errorf("%s: env %v, want %s", c.db.Type, p.env, c.env) + } + for _, a := range c.args { + if !slices.Contains(p.argv, a) { + t.Errorf("%s: missing %q in %q", c.db.Type, a, p.argv) + } + } + for _, a := range c.noArgs { + if slices.Contains(p.argv, a) { + t.Errorf("%s: unexpected %q in %q", c.db.Type, a, p.argv) + } + } + if c.stdinPw != strings.Contains(string(p.stdin), "password: pw") { + t.Errorf("%s: stdin %q", c.db.Type, p.stdin) + } + } +} + +func TestDockerWrapPassesEnvByName(t *testing.T) { + p, _ := buildPlan(&config.Database{Type: "postgres", Password: "s3cret", Database: "app"}, "/tmp/out") + w := wrapDocker(p, "abc123") + want := []string{"docker", "exec", "-i", "-e", "PGPASSWORD", "abc123", "pg_dump"} + if !slices.Equal(w.argv[:len(want)], want) { + t.Fatalf("argv %q", w.argv) + } + if strings.Contains(strings.Join(w.argv, " "), "s3cret") { + t.Fatal("password leaked into docker argv") + } +} + +func fakeDocker(t *testing.T, script string) { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "docker"), []byte("#!/bin/sh\n"+script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func TestResolveContainer(t *testing.T) { + fakeDocker(t, `printf 'aaa\tdokploy-postgres.1.x7k2\nbbb\tapp-db-1\nccc\tapp-web-1\nddd\tapp-web-2\neee\tredis\n'`) + ctx := context.Background() + for name, want := range map[string]string{"dokploy-postgres": "aaa", "app-db": "bbb", "app-db-1": "bbb", "redis": "eee"} { + got, err := ResolveContainer(ctx, name) + if err != nil || got != want { + t.Errorf("%s: got %q, %v; want %q", name, got, err, want) + } + } + if _, err := ResolveContainer(ctx, "app-web"); err == nil || !strings.Contains(err.Error(), "app-web-1, app-web-2") { + t.Errorf("ambiguous name: %v", err) + } + if _, err := ResolveContainer(ctx, "mongo"); err == nil || !strings.Contains(err.Error(), "no running container") { + t.Errorf("missing container: %v", err) + } +} + +func TestDumpThroughDocker(t *testing.T) { + fakeDocker(t, `if [ "$1" = ps ]; then printf 'c1\tdokploy-postgres.1.abc\n'; exit 0; fi +shift 2 +[ "$1" = -e ] && [ "$2" = PGPASSWORD ] || { echo "env not passed by name: $*" >&2; exit 1; } +[ "$3" = c1 ] || { echo "wrong container $3" >&2; exit 1; } +printf 'dump of %s with password %s\n' "$4" "$PGPASSWORD"`) + dir := t.TempDir() + db := &config.Database{Name: "panel", Type: "postgres", Container: "dokploy-postgres", User: "dokploy", Database: "dokploy", Password: "amuk"} + res, err := Dump(context.Background(), db, dir) + if err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(res.File) + if string(b) != "dump of pg_dump with password amuk\n" || res.ArchiveName != "nobackups-databases/panel.dump" { + t.Fatalf("got %q as %s", b, res.ArchiveName) + } + if fi, _ := os.Stat(res.File); fi.Mode().Perm() != 0o600 { + t.Errorf("dump file mode %v", fi.Mode().Perm()) + } +} + +func TestDumpErrorsAreReadable(t *testing.T) { + fakeDocker(t, `if [ "$1" = ps ]; then printf 'c1\tdb\n'; exit 0; fi +echo 'pg_dump: error: connection failed: FATAL: password authentication failed for user "x"' >&2; exit 1`) + _, err := Dump(context.Background(), &config.Database{Name: "x", Type: "postgres", Container: "db", Database: "x"}, t.TempDir()) + if err == nil || !strings.Contains(err.Error(), "password authentication failed") { + t.Fatalf("got %v", err) + } + t.Setenv("PATH", t.TempDir()) + _, err = Dump(context.Background(), &config.Database{Name: "x", Type: "postgres", Database: "x"}, t.TempDir()) + if err == nil || !strings.Contains(err.Error(), "pg_dump not found") { + t.Fatalf("got %v", err) + } +} + +func TestRealDatabases(t *testing.T) { + ctx := context.Background() + run := func(t *testing.T, db *config.Database, check func(file string)) { + t.Helper() + res, err := Dump(ctx, db, t.TempDir()) + if err != nil { + t.Fatal(err) + } + check(res.File) + } + restoreCheck := func(t *testing.T, cmd *exec.Cmd, want string) { + t.Helper() + out, err := cmd.CombinedOutput() + if err != nil || !strings.Contains(string(out), want) { + t.Fatalf("%s: %v\n%s", cmd.Args, err, out) + } + } + + t.Run("postgres", func(t *testing.T) { + port := os.Getenv("NOBACKUPS_TEST_PG_PORT") + if port == "" { + t.Skip("set NOBACKUPS_TEST_PG_PORT (and _PASSWORD) to test against a real PostgreSQL") + } + pw := os.Getenv("NOBACKUPS_TEST_PG_PASSWORD") + db := &config.Database{Name: "pg", Type: "postgres", Host: "127.0.0.1", Port: atoi(port), Password: pw, Database: "app"} + run(t, db, func(f string) { + c := exec.Command("pg_restore", "-l", f) + restoreCheck(t, c, "TABLE public t") + }) + db.Database = "" + run(t, db, func(f string) { + b, _ := os.ReadFile(f) + if !strings.Contains(string(b), "CREATE DATABASE app") || !strings.Contains(string(b), "hello") { + t.Fatalf("pg_dumpall output missing data") + } + }) + }) + t.Run("mariadb", func(t *testing.T) { + port := os.Getenv("NOBACKUPS_TEST_MYSQL_PORT") + if port == "" { + t.Skip("set NOBACKUPS_TEST_MYSQL_PORT, _USER and _PASSWORD to test against a real MySQL/MariaDB") + } + for _, typ := range []string{"mysql", "mariadb"} { + db := &config.Database{Name: typ, Type: typ, Host: "127.0.0.1", Port: atoi(port), User: os.Getenv("NOBACKUPS_TEST_MYSQL_USER"), Password: os.Getenv("NOBACKUPS_TEST_MYSQL_PASSWORD"), Database: "shop"} + run(t, db, func(f string) { + b, _ := os.ReadFile(f) + if !strings.Contains(string(b), "CREATE DATABASE") || !strings.Contains(string(b), "'apple'") { + t.Fatalf("%s dump missing data:\n%.400s", typ, b) + } + }) + } + }) + t.Run("redis", func(t *testing.T) { + port := os.Getenv("NOBACKUPS_TEST_REDIS_PORT") + if port == "" { + t.Skip("set NOBACKUPS_TEST_REDIS_PORT (and _PASSWORD) to test against a real Redis") + } + db := &config.Database{Name: "r", Type: "redis", Host: "127.0.0.1", Port: atoi(port), Password: os.Getenv("NOBACKUPS_TEST_REDIS_PASSWORD")} + run(t, db, func(f string) { + b, _ := os.ReadFile(f) + if !strings.HasPrefix(string(b), "REDIS") || !strings.Contains(string(b), "greeting") { + t.Fatalf("not an RDB file with our key: %.40q", b) + } + }) + }) + t.Run("sqlite", func(t *testing.T) { + if _, err := exec.LookPath("sqlite3"); err != nil { + t.Skip("sqlite3 not installed") + } + src := filepath.Join(t.TempDir(), "app's.db") + if out, err := exec.Command("sqlite3", src, "create table t(x); insert into t values ('kept');").CombinedOutput(); err != nil { + t.Fatal(err, string(out)) + } + run(t, &config.Database{Name: "s", Type: "sqlite", Path: src}, func(f string) { + restoreCheck(t, exec.Command("sqlite3", f, "select x from t"), "kept") + }) + }) +} + +func atoi(s string) int { + n, _ := strconv.Atoi(s) + return n +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go index cf693d4..d9d34fc 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -148,6 +148,17 @@ func discordPayload(d config.Discord, res *backup.Result) discordMessage { } e.Fields = append(e.Fields, discordField{Name: "Destinations", Value: truncate(strings.Join(lines, "\n"), 1024)}) } + if len(res.Databases) > 0 { + var lines []string + for _, db := range res.Databases { + if db.Error != "" { + lines = append(lines, fmt.Sprintf("❌ **%s**: %s", db.Name, truncate(db.Error, 200))) + } else { + lines = append(lines, fmt.Sprintf("✅ **%s** (%s, %s)", db.Name, db.Type, HumanBytes(db.Size))) + } + } + e.Fields = append(e.Fields, discordField{Name: "Databases", Value: truncate(strings.Join(lines, "\n"), 1024)}) + } e.Footer = &struct { Text string `json:"text"` }{"NoBackups"}