Standalone, self-hostable scheduled backups for MySQL, MariaDB, PostgreSQL and MongoDB, with a multi-user web UI, role-based permissions, email/ntfy/Telegram/Discord notifications, and a scoped REST API so other apps can create, trigger, and manage backup tasks programmatically.
- Backup tasks — each task owns one database connection, a cron schedule, a retention count, and an optional encryption password. Connection details and passwords are encrypted at rest with
BACKUP_MASTER_KEY. - Multi-user accounts with two roles:
admin(manages users and system notifications) anduser. Backup tasks are private to the user who created them — even admins can't see them unless the owner shares them. Passwords are hashed with Argon2id. - Two-factor authentication & passkeys — users can enable TOTP (any authenticator app) with recovery codes, and/or register WebAuthn passkeys (Touch ID, Face ID, Windows Hello, security keys). A passkey login counts as its own second factor. Admins can reset a user's 2FA/passkeys for account recovery.
- Web UI, session-gated with real DB-backed sessions — create/edit tasks, view run history, run a backup on demand, download or delete backup files, manage users, manage notification recipients, change your own password. Every save, add and delete confirms with a short success or error message.
- Dashboard — the home page summarises the last 7 days (successful and failed backups, off-site copy failures, failed sign-ins to your account), lists anything that needs attention (a task whose latest backup failed, couldn't sign in to its database or save location, failed to copy off-site, or failed its check), shows recent activity across all your tasks, and gives each task a strip of its last 14 results.
- Sign-in security — every sign-in attempt is recorded (with IP and browser) and shown to the account owner; repeated failures lock the account for a while (configurable); users can reset a forgotten password by email; and the Account page lists your active sessions so you can sign out any you don't recognise. See Sign-in security.
- Languages — the UI is available in English, Spanish, German, French and Simplified Chinese, picked from the browser's language or a per-user setting on the Account page. See Languages.
- Notifications by email, ntfy, Telegram or Discord — per-task recipients for backup success/failure, plus admin-managed system-wide recipients for logins, user creation/deletion, and task creation/deletion. Notifications are best-effort: if SMTP isn't configured or a push service is unreachable, everything else keeps working and the notification is skipped (and logged).
- Off-site save locations — a task can copy each completed backup to any number of remote destinations (S3-compatible storage, Google Drive, OneDrive, OpenCloud, FTP, FTPS, SFTP) on top of the always-kept local copy. Google Drive and OneDrive connect with a one-click sign-in, and small enough backups can be emailed to any address as an attachment. Pluggable like the DB engines: implement
BackupDestinationinlib/backup/destinations/to add a new provider. - REST API at
/api/v1/*, authenticated with scoped API keys (tasks:read,tasks:write,runs:trigger,runs:download,runs:delete), optionally pinned to a single task. This is separate from user accounts — API keys are their own credential type. - In-process cron scheduler (
node-cron, booted viainstrumentation.ts) — one job per enabled task. - Access checks live in
lib/auth/session.ts(requireUser()/requireAdmin()), which every page, server action and data function calls: they validate the session against the database and redirect expired, revoked or 2FA-pending sessions to sign in.proxy.ts(Next.js 16's replacement formiddleware.ts) only does a quick "is there a session cookie" check so signed-out visitors go straight to/login— it is deliberately not the security boundary, following the Next.js authentication guide.
| Engine (in the app) | Covers | Dump tool |
|---|---|---|
| MySQL / MariaDB | MySQL and MariaDB, including hosted and compatible services such as Amazon RDS / Aurora MySQL, Azure Database for MySQL, Google Cloud SQL for MySQL, DigitalOcean, PlanetScale and TiDB | mysqldump or mariadb-dump |
| PostgreSQL | PostgreSQL, including hosted and compatible services such as Amazon RDS / Aurora PostgreSQL, Azure Database for PostgreSQL, Google Cloud SQL, Supabase, Neon and TimescaleDB | pg_dump |
| MongoDB | Self-hosted MongoDB (standalone or replica set) and MongoDB Atlas | mongodump / mongorestore (MongoDB Database Tools) |
A few notes on compatible services:
- Managed MySQL (RDS, DigitalOcean, PlanetScale…) often has
gtid_mode=ON— Backup Hub already passes--set-gtid-purged=OFF, so a normal schema-scoped backup user works. See the troubleshooting section for the minimal grants. - TimescaleDB backs up with
pg_dumplike any Postgres database, but restoring (including test restores) needs the TimescaleDB extension installed on the target server, at the same version. - Serverless Postgres (Neon, Supabase) may need
?sslmode=require-style TLS — tick the SSL option on the task — and a direct (non-pooled) connection string's host, sincepg_dumpdoesn't work through transaction-mode connection poolers. - Keep the dump tool's version at or below the server's major version; see troubleshooting below.
- What's backed up: one database per task, as a
mongodumparchive, gzipped (.archive.gz, or.archive.gz.encwhen encrypted). Users and roles defined on the server aren't included, same asmongodump --dbon its own. - Connecting: users are authenticated against the Auth database on the new-task form,
adminby default (right for Atlas and most servers). For MongoDB Atlas, enter the cluster host (e.g.cluster0.abcde.mongodb.net): hosts ending in.mongodb.netconnect with an SRV connection string and TLS automatically, and the port is ignored. Elsewhere, tick SSL to encrypt the connection (the certificate isn't verified, as with the other engines). The password is passed to the tools in a private temporary file, never on the command line. - Permissions: backing up needs
readon the database (or the built-inbackuprole). Restoring needsreadWriteon it. - Restoring replaces each collection that's in the backup (
mongorestore --drop); collections that aren't in the backup are left alone. To restore a downloaded file by hand:gunzip -c backup.archive.gz | mongorestore --uri "<connection string>" --archive --drop --nsFrom '$db$.$coll$' --nsTo '<database>.$coll$'(decrypt.encfiles first — see Encrypted backups). - Checks read the archive back and make sure every collection it lists was written out completely. Test restores use
VERIFY_MONGODB_URL(see below). - Consistency:
mongodumpreads collections one after another, so on a busy database the backup isn't a single point-in-time snapshot. That's fine for most apps; for strict consistency on a replica set, back up from a hidden/delayed secondary or pause writes.
Not supported: Microsoft SQL Server, Redis and SQLite don't have a network dump tool that fits the same model. Adding another engine means implementing BackupEngine in lib/backup/engines/ (see below).
cp example.env-file .env
# generate a real secret:
openssl rand -hex 32 # → BACKUP_MASTER_KEY
npm install
npm run db:migrate # applies the checked-in migrations to APP_DATABASE_URL (re-run after pulling updates)
# after editing db/schema.ts: npm run db:generate first
npm run devYou'll also need mysqldump and/or pg_dump + pg_isready installed wherever this runs (already handled in the provided Dockerfile).
Visit http://localhost:3000 — since there are no users yet, you'll land on /setup to create the first admin account interactively. Every user after that is created from the Users page by an admin.
Every task belongs to the user who created it. The owner can do everything with it. Nobody else can see it — admins included — until the owner shares it.
admin |
user |
|
|---|---|---|
| Create tasks, and fully manage the ones they own | Yes | Yes |
| See other users' tasks | No (only a per-user task count) | No |
| Manage their own API keys | Yes | Yes |
See/manage users (/users) |
Yes | No |
Manage system-wide notification recipients (/notifications) |
Yes | No |
On a task's page, the owner can share it with another user by email and pick exactly what they may do. These are the same permissions API keys use:
| Permission | Allows |
|---|---|
| View tasks | See the task and its run history (always included) |
| Manage tasks | Edit settings, enable/disable, manage notification recipients and save locations, delete the task |
| Trigger backups | Start a manual backup |
| Download backups | Download backup files |
| Delete backups | Delete backup files / clear failed runs |
Restoring a backup into the source database is owner-only and can't be shared. Only the owner can share a task or change who it's shared with. To give an admin access to a task, share it with them like any other user.
A key belongs to the user who created it and acts as that user: on any task, it can only do what both its scopes and its owner's own permission on that task allow. GET /api/v1/tasks lists only tasks its owner can see, and tasks created through the API are owned by the key's owner. If a share is removed or reduced, keys made by that user lose the same access immediately. A key pinned to a single task can't create new tasks.
Deleting a task removes it from the app: its run history, task-only notification channels and save locations (saved ones stay in your account), shares, and any API keys pinned to it. Deleting a user does that for every task they own, plus their shares on other people's tasks and their API keys.
Both dialogs ask "Also permanently delete the backup files from disk?", unchecked by default:
- Unchecked (keep files): the files stay in
BACKUP_DIR/<taskId>/on the server, so a mistaken delete can still be recovered by hand. A_deleted-task.jsonis written in that folder listing the task name, engine and which file belongs to which run (never connection details or passwords). Encrypted backups still need the password they were encrypted with. - Checked: the folder is erased too.
Via the API, DELETE /api/v1/tasks/:id keeps the files by default; add ?purgeFiles=true to erase them.
Every completed backup is always kept locally under BACKUP_DIR. On top of that, a task can have any number of destinations (task detail page → Save locations) that each completed run also gets copied to.
Save locations come in two kinds:
- Saved (account-level) — enter the credentials once, on the Save locations page or by ticking "Save to my account" when adding one to a task, then pick it from Use a saved location on any of your tasks. Editing it (e.g. a rotated OpenCloud app token) updates every task that uses it. Each task can add its own subfolder (S3, OpenCloud, FTP/FTPS/SFTP, OneDrive), defaulting to the task name, so tasks sharing a location don't mix files.
- Task-only (the default when adding from a task) — tied to that one task; its credentials can never be picked for another task. Removing it from the task deletes it. On the Save locations page you can make one reusable later (one-way).
Save locations belong to the task owner and are private to them — admins can't see them. People a task is shared with only ever see a location's name and type, never the owner's other saved locations. A shared user with "Manage tasks" can add a task-only location with their own credentials: the owner can remove it but can't edit it or make it reusable, so their credentials can't be pointed elsewhere or reused. Credentials are never sent back to the browser; when editing, leave a secret field blank to keep it. Managing destinations requires the same tasks:write ("Manage tasks") permission as notification recipients — see the sharing table above. Destinations are independent — one being unreachable never fails the run itself (the local copy already succeeded) or blocks the others. Each upload attempt is recorded per-destination and shown in the run history as uploaded, failed, or skipped.
Built-in destinations:
- S3 — real AWS S3 or any S3-compatible provider (Backblaze B2, Wasabi, Cloudflare R2, DigitalOcean Spaces, MinIO, ...). Set a custom endpoint URL and enable "force path-style addressing" for most non-AWS providers.
- Google Drive and OneDrive — the user clicks Connect, is shown a short code, enters it on Google's/Microsoft's own sign-in page, and approves. No passwords or tokens are ever pasted, and users never touch a cloud console. (Uses the OAuth device flow, which needs no redirect URL, so it works on any self-hosted domain.) Requires the one-time admin setup below.
- Email — emails the backup file as an attachment to any address you choose, if it's under a size limit you set (larger files are recorded as
skipped, not failed). This is separate from notification recipients on purpose: notifications say "it worked/failed", this delivers the data, and it can go to a different mailbox. Sent through the server'sSMTP_*settings — unlike notifications, missing SMTP config is a real failure here, so a backup is never reported as delivered when it wasn't. - OpenCloud — Enter the WebDAV URL of the folder, the users name and App token to upload to your OpenCloud server. Remeber that OpenCloud App tokens have an expiry date, so either use one that has a long expiry date or remeber to delete the old destination and then add a new destination with the new App token.
- FTP / FTPS — host, port, username and password, plus an optional remote folder (created if missing; start it with
/for an absolute path, otherwise it's relative to the login folder). Plain FTP sends your password and backup unencrypted, so prefer FTPS or SFTP — or only use FTP with encrypted backups on a trusted network. FTPS uses explicit TLS (AUTH TLS, port 21) by default; tick Use implicit TLS for older servers that expect TLS from the start (usually port 990). Servers with a self-signed certificate need Allow self-signed / untrusted certificates. - SFTP — file transfer over SSH. Log in with a password, or paste a private key (OpenSSH or PEM format, with an optional passphrase). Click Test connection to see the server's host key fingerprint and paste it into the fingerprint field: once set, Backup Hub refuses to upload to a server presenting a different key. The remote folder works the same as FTP, but relative paths start from the user's home folder.
Google and Microsoft only hand out access to registered apps, so someone has to register Backup Hub once. That's you, the person running the server — not each user. Both registrations are free. Put the resulting IDs in .env, restart, and the Connect button appears for everyone.
Google Drive
- In Google Cloud Console create a project and enable the Google Drive API.
- OAuth consent screen: user type External. Add the scopes
drive.file,openidandemail(all non-sensitive, so no Google verification review is needed). Publish the app to "In production" — while it's in "Testing", Google expires refresh tokens after 7 days and backups would silently stop working. - Credentials → Create credentials → OAuth client ID → Application type: "TVs and Limited Input devices". That's the type that supports the device flow.
- Set
GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRET. (Google doesn't treat a device-flow client secret as confidential.)
The app only requests the drive.file scope: access to files Backup Hub itself creates, never the rest of the user's Drive.
OneDrive / Microsoft
- In the Microsoft Entra admin center → App registrations → New registration. A free tenant is enough; no paid Azure subscription. Supported account types: "Accounts in any organizational directory and personal Microsoft accounts".
- Authentication → Advanced settings → Allow public client flows: Yes.
- API permissions: Microsoft Graph, delegated:
Files.ReadWrite(plusopenid,email,offline_access). - Set
MICROSOFT_CLIENT_IDto the Application (client) ID. It's a public client, so there is no secret.
Some locked-down work/school tenants block users from consenting to third-party apps; their admin will need to approve Backup Hub once. Personal Microsoft accounts are unaffected.
Token lifetime. Google refresh tokens last until revoked (or unused for 6 months). Microsoft refresh tokens expire 90 days after issue but are reissued on every use, and Backup Hub saves each new one — so any task that runs at least once every 90 days stays connected.
Prefer not to use the shared app? Under Advanced: use my own credentials when adding a Google Drive/OneDrive destination, you can paste your own OAuth client ID/secret and refresh token instead. Those destinations don't depend on the .env values at all.
OpenCloud
- In OpenCloud, click on My Account → Preferences → Preferences.
- In the Preferences table section you will see an option called View options under theme, enable the Show WebDAV information in details view.
- Now in the side panel of any folder or space, you will see a WebDAV URL. Copy and use the url for the desired location.
- To create a new App Token click on: My Account → Preferences → App Tokens. Remeber to set a long expiry date if you don't want to have to always recreate the destination in Backup-Hub.
Implement BackupDestination in lib/backup/destinations/ and register it in lib/backup/destinations/index.ts. The add-destination form is generated from your implementation's configFields, so no UI code changes. If the provider has an OAuth device flow, also implement the optional connect (start() / poll()) and the Connect button appears automatically. If the provider rotates refresh tokens, persist the new one with updateConfig() (see onedrive.ts).
Two independent notification surfaces:
- Per-task (task detail page → Notifications section): pick a channel and choose whether it gets notified on success, failure, or both. Different tasks can have entirely different recipients.
- System-wide (Admin menu → System notifications,
/notifications, admin-only): recipients for account/task lifecycle events —user_login,user_created,user_deleted,task_created,task_deleted,security_reset(an admin reset someone's 2FA/passkeys or unlocked their account, or a user reset their password by email).
Per-task channels work like save locations: saved channels (on the Notifications page (top menu), or "Save to my account" when adding one to a task) are set up once and picked from any of your tasks, and editing one updates every task using it; task-only channels (the default when adding from a task) belong to that one task and can be made reusable later. The same privacy rules apply: only the task owner sees where a channel sends to, and a shared user's channel can be removed by the owner but not edited or reused. System-wide recipients are separate and admin-managed.
Each channel picks how it's reached. Use Send test on the form to check the settings before saving. Tokens and webhook URLs are stored encrypted with BACKUP_MASTER_KEY, and are never shown again after saving.
- Email — set the
SMTP_*variables in.env. If SMTP isn't configured,sendMail()logs a warning once and no-ops. - ntfy — server URL (
https://ntfy.shor your own server) and a topic, then subscribe to that topic in the ntfy app. On the public ntfy.sh server anyone who knows a topic name can read it, so choose a hard-to-guess one. For protected topics, add an access token (tk_..., created withntfy token addor in the web app under Account → Access tokens). Failures are sent with max priority. - Telegram — message @BotFather, send
/newbot, and copy the bot token. Then send your new bot a message (or add it to a group) — bots can't start conversations. To find the chat ID, openhttps://api.telegram.org/bot<token>/getUpdatesand look for"chat":{"id":...}(group IDs are negative). - Discord — in the channel's settings → Integrations → Webhooks → New Webhook → Copy Webhook URL.
Push notifications are best-effort like email: a failed send is logged and never breaks a backup run, a login, or a task creation.
A backup you can't restore is worse than none, because you only find out when you need it. Backup Hub checks backup files at three levels:
- Fingerprint — a SHA-256 of each file is recorded when it's made. A check recomputes it, so a file that has changed on disk since (damaged, truncated, tampered with) is caught.
- Read-back — the whole file is read back the way a restore would read it: decrypted (AES-GCM's integrity check fails on any damaged byte), decompressed (gzip checks its own checksum), and checked to be complete: SQL dumps must end with the dump tool's "dump completed" line, and MongoDB archives must close every collection they list. A dump that was cut short fails.
- Test restore — the backup is restored into a brand-new throwaway database on a separate scratch server, Backup Hub checks that tables came back, then drops the database again. This is the only way to be sure a backup really restores.
Automatically: every new backup gets levels 1 and 2 before it's marked completed. If it fails, the run is marked failed, the broken file is removed, and the task's failure recipients are alerted, so you find out now and not at restore time. Each task can also opt in to Test-restore each new backup (task settings). It runs in the background after the backup and alerts on failure.
By hand: in a task's backup history, open a backup's ⋯ menu (a bottom drawer on phones) and choose Verify file (levels 1+2) or Test restore (all three). Verify all checks every stored backup of the task, and Test-restore all test-restores each of them in turn. Checks are queued and run one at a time, and the page updates as they finish. Running a check needs the Trigger backups permission. Via the API: POST /api/v1/runs/:runId/verify (add ?level=restore for a test restore), then read verifyStatus / verifyMessage from the run list.
Encrypted backups are checked with the task's saved backup password. If that password has changed since a backup was made, the check says so instead of calling the file damaged. Restore it with its original password.
Test restores need a database server to restore into. Point these at a separate scratch server, never production: a test restore loads the whole backup into it.
VERIFY_MYSQL_URL="mysql://verify:secret@scratch-mysql:3306" # add ?ssl=true to require TLS
VERIFY_POSTGRES_URL="postgres://verify:secret@scratch-postgres:5432/postgres" # the path is the database to connect to; add ?sslmode=require for TLS
VERIFY_MONGODB_URL="mongodb://verify:secret@scratch-mongo:27017" # add ?tls=true for TLS, ?authSource=<db> if the user isn't in "admin"(In Docker, set BH_VERIFY_MYSQL_URL / BH_VERIFY_POSTGRES_URL / BH_VERIFY_MONGODB_URL in docker/.env.) Leave one unset and test restores are simply not offered for that engine. For MongoDB, the account needs to create and drop databases (for example the root role, or readWriteAnyDatabase plus dbAdminAnyDatabase, which also covers dropping and listing databases).
- The account needs permission to create and drop the test databases. For MySQL/MariaDB,
GRANT ALL ON `bh\_verify\_%`.* TO 'verify'@'%';limits it to just those. For Postgres, use a role withCREATEDB. - Each test creates a database named
bh_verify_<time>_<random>, restores into it, and always drops it afterwards, even if the test fails. Databases left behind by a server that stopped mid-test are dropped at the next start once they're over 6 hours old. - Ownership and permission statements from the original server (Postgres
OWNER TO/GRANT/REVOKE, MySQLDEFINER=clauses) are skipped in test restores, since those users won't exist on the scratch server. Real restores are unchanged. - Use a scratch server running the same database type and a similar version to production. Restoring a newer server's dump into an older one can fail for reasons that have nothing to do with the backup.
- If the scratch server itself has a problem (can't connect, can't create the database), users see a generic message and the details go to the server log, so the scratch server's address and login errors aren't shown to every user.
The scheduler is an in-process cron job — it only fires reliably on a long-running Node server (Docker, a VM, PM2, etc., as in the provided docker-compose.yml). On serverless platforms like Vercel, the process doesn't stay warm between requests, so node-cron won't reliably fire. If you deploy serverless, point an external cron (e.g. Vercel Cron, or a system crontab hitting a protected endpoint) at each due task instead of relying on instrumentation.ts.
argon2 ships prebuilt native binaries for common platforms — no build toolchain needed in the container.
Behind a reverse proxy, make sure it sets X-Forwarded-For (and strips any value the client sent). Sign-in history, active sessions and the per-IP lockout read the client's IP from it.
Upgrades and open browser tabs. docker/build.sh gives every image build its own Next.js deployment ID (NEXT_DEPLOYMENT_ID, set in the Dockerfile). After you upgrade, a tab still open on the old version reloads itself on its next request instead of failing with "Server Action unavailable". If you build another way, set NEXT_DEPLOYMENT_ID to something unique per build.
- In the UI, go to API Keys → create a key, choose scopes, optionally pin it to one task. Copy the raw key — it's shown once.
- Call the API with
Authorization: Bearer <key>:
# List tasks
curl -H "Authorization: Bearer bkp_..." https://your-host/api/v1/tasks
# Trigger a backup
curl -X POST -H "Authorization: Bearer bkp_..." https://your-host/api/v1/tasks/<taskId>/runs
# Check a backup file (add ?level=restore for a test restore); results appear in the run list
curl -X POST -H "Authorization: Bearer bkp_..." https://your-host/api/v1/runs/<runId>/verify
# Download the most recent backup file
curl -H "Authorization: Bearer bkp_..." https://your-host/api/v1/runs/<runId>/download -o backup.sql.gz.enc
# Create a task
curl -X POST -H "Authorization: Bearer bkp_..." -H "Content-Type: application/json" \
https://your-host/api/v1/tasks \
-d '{
"name": "Orders DB",
"engine": "postgres",
"connection": { "host": "db.internal", "port": 5432, "user": "app", "password": "...", "database": "orders" },
"cronExpression": "0 4 * * *",
"retentionCount": 14,
"encryptionEnabled": true,
"backupPassword": "a-strong-password"
}'Full route list is in app/api/v1/. API keys are unrelated to user accounts — they're a separate credential type for machine-to-machine access, still admin/user-creatable via the UI.
Backup Hub shells out to your system's mysqldump/pg_dump, so its reliability depends on which version is installed, and on the DB account having the right (minimal) privileges. If you're setting up a dedicated backup user, save yourself the trial-and-error and grant this up front:
-- Minimal privileges mysqldump needs for a standalone logical backup
-- (not replication setup) of one schema, with the flags Backup Hub passes:
CREATE USER 'backup_user'@'%' IDENTIFIED BY 'a-strong-password';
GRANT SELECT, SHOW VIEW, TRIGGER, LOCK TABLES ON your_database.* TO 'backup_user'@'%';
-- Only needed if the server has gtid_mode=ON (common on managed MySQL —
-- RDS, DigitalOcean, PlanetScale, etc.) and you'd rather grant this than
-- rely on --set-gtid-purged=OFF (already passed by default, see below):
-- GRANT RELOAD ON *.* TO 'backup_user'@'%';
FLUSH PRIVILEGES;With that grant, none of the three issues below should come up at all. They're documented here for anyone working with an account they don't control:
Couldn't execute 'FLUSH ... TABLES': Access denied ... RELOAD or FLUSH_TABLES privilege(s) — if the server has gtid_mode=ON, MySQL's own docs state that --single-transaction requires the RELOAD or FLUSH_TABLES privilege, purely to record the GTID position in the dump. That position is only useful for setting up replication from the dump — irrelevant for a standalone backup/restore. Already fixed: the mysql engine always passes --set-gtid-purged=OFF, which skips recording it and avoids needing that privilege at all.
Access denied for user '...'@'localhost' on the manual/scheduled run, even though "Test connection" passed — this is a MySQL command-line client quirk, not a Backup Hub bug: when the host is the literal string localhost, mysqldump (and mysql) silently switch from TCP to a Unix socket and ignore the port entirely. So a task pointed at localhost:3307 can end up talking to a completely different local MySQL instance over the socket. "Test connection" doesn't hit this because it uses the mysql2 Node driver, which always uses real TCP. Already fixed: the mysql engine always passes --protocol=TCP, which forces real TCP to the host/port you configured regardless of the hostname string. If you still see this after upgrading, double-check the task's host/port actually match what you tested.
Access denied ... PROCESS privilege(s) ... when trying to dump tablespaces — mysqldump 8.0.20+ tries to dump tablespace metadata by default, which needs a privilege a normal, schema-scoped backup user shouldn't need. Already fixed: the mysql engine always passes --no-tablespaces.
Unknown table 'LIBRARIES' in information_schema — mysqldump 9.3 added an unconditional check for a MySQL 9.x-only feature (JS/WASM "libraries") that doesn't exist on most currently-deployed servers (including MySQL 8.4). There's no flag to disable this probe — it's a client bug when a 9.3+ client talks to an older server. If you hit this:
- The Docker image is unaffected (
default-mysql-clienton Debian resolves tomysql-client-8.0, which predates this probe). - Locally (e.g. macOS via Homebrew, which installs the newest client by default), install an older client and point
MYSQLDUMP_PATHat it:
brew install mysql-client@8.4
echo 'MYSQLDUMP_PATH="/opt/homebrew/opt/mysql-client@8.4/bin/mysqldump"' >> .env- General rule of thumb: keep your
mysqldump/pg_dumpclient version at or below your target server's major version. A newer client talking to an older server is the risky direction;PG_DUMP_PATH/PG_ISREADY_PATHexist for the same reason on the Postgres side.
The error message itself will tell you which of these two you're hitting and what to do — both engines now detect these specific failure signatures and append a hint.
Both are opt-in, per-user, and set up from /account:
- TOTP (authenticator app): scan the QR code with any TOTP app (1Password, Google Authenticator, Authy, etc.), confirm with a 6-digit code to enable, and save the 10 single-use recovery codes shown once at that point. From then on, password login is followed by a
/login/2fastep. Recovery codes can be regenerated any time (invalidates the old batch); disabling 2FA requires re-entering your password. - Passkeys: "Add a passkey" registers a WebAuthn credential (Touch ID, Face ID, Windows Hello, a security key, or a password manager's synced passkey) via the browser's own UI. The login page's "Sign in with a passkey" button uses usernameless/discoverable authentication — no email typed first, the OS picker handles it. A successful passkey login is treated as satisfying 2FA on its own (it's phishing-resistant by construction), even if the account also has TOTP enabled.
- Admin recovery: if a user loses their authenticator app and their passkey device, an admin can hit "Reset 2FA & passkeys" on
/users/[id]— this wipes all their second factors and revokes their sessions, dropping them back to password-only login so they can re-enroll. A password reset by email does not skip 2FA: the next sign-in still asks for the code.
Passkeys require correct WEBAUTHN_* configuration in production. WEBAUTHN_RP_ID must be exactly your domain (no scheme, no port — e.g. backup.yourdomain.com) and WEBAUTHN_ORIGIN must be the exact origin the browser sees (e.g. https://backup.yourdomain.com). Both default to localhost/http://localhost:3000 for local dev, where WebAuthn is allowed over plain HTTP as a special case — everywhere else, WebAuthn requires HTTPS outright. Changing WEBAUTHN_RP_ID after people have registered passkeys will make their existing passkeys stop being offered (the browser scopes passkeys to the RP ID they were created under), so treat it like you would a domain migration.
TOTP secrets are encrypted at rest with BACKUP_MASTER_KEY (same mechanism as DB connection credentials); recovery codes and passkey public keys are stored hashed/as-is respectively, following the same "never store what you can verify instead" pattern as API keys and sessions.
Every sign-in attempt is recorded in login_attempt: success, wrong password, unknown account, wrong 2FA code, rejected passkey, blocked by a lockout, unlocked by an admin, and password reset by email — with the IP address and browser. Records are kept for 90 days.
- Everyone sees their own history under Account → Recent sign-in activity, and a summary on the dashboard (previous sign-in, recent failures). If someone entered your correct password but the wrong 2FA code, the dashboard shows a red warning, since that means your password may be known.
- Admins also see failures across the whole server on the dashboard: totals, attempts on emails that have no account, and which IPs and accounts have the most failures.
- Deleting a user deletes their history.
Too many failed sign-ins block password sign-in for a while. Set the limits in .env (BH_-prefixed in docker/.env):
| Variable | Default | Meaning |
|---|---|---|
LOGIN_MAX_FAILED_ATTEMPTS |
2 |
Failed password / 2FA attempts for one account, within the lockout window, before it locks. 0 turns account lockouts off. |
LOGIN_MAX_FAILED_ATTEMPTS_PER_IP |
20 |
Failed attempts of any kind (all accounts) from one IP address before that address is blocked. 0 turns it off. |
LOGIN_LOCKOUT_MINUTES |
15 |
How long a lock lasts, counted from the last real failure. |
- Retrying while locked doesn't extend the lock, and a successful sign-in, an admin unlock or a password reset clears the count.
- Passkeys skip the account lock, so someone guessing passwords can't keep the real owner out. The per-IP lock still applies to them.
- Unknown emails lock the same way, so the message can't be used to discover which emails have accounts.
- An admin can lift a lock early with Unlock now on the user's page (
/users/[id]). - A low limit makes it easy for anyone who knows an email address to lock that user out for a while — raise it if that's a concern.
- The per-IP limit relies on
X-Forwarded-For, so it only works behind a reverse proxy that sets that header. Without one, clients can send any value. The per-account limit doesn't depend on IPs.
Forgot password? on the sign-in page emails a reset link. It works once and expires after 30 minutes; each account gets at most 3 links an hour, and the page answers the same way whether or not the email has an account. Completing a reset:
- sets the new password and signs the account out everywhere,
- lifts any lockout (so it's also the way back in for a locked-out admin),
- emails the user that their password changed, and sends a
security_resetsystem notification.
It needs the SMTP_* settings, plus APP_URL — the address people open Backup Hub at, used to build the link (it falls back to WEBAUTHN_ORIGIN). Links are never built from the request's own address, so a forged Host header can't redirect them. Without SMTP, the page tells users to ask an admin instead.
Account → Active sessions lists everywhere you're signed in: browser and device, when and from which IP it signed in, and when it was last active. A session whose IP has changed since it signed in is flagged. Sign out ends one session; Sign out everywhere else ends all but this one.
Changing your password signs out all your other sessions (this device stays signed in), and an admin setting a user's password signs that user out everywhere — so a stolen session cookie stops working.
Session cookies are HttpOnly, SameSite=Lax, Secure in production, and only their hash is stored. Sessions last 7 days.
The web UI is available in English, Spanish (es), German (de), French (fr) and Simplified Chinese (zh), using next-intl.
How the language is chosen, first match wins:
- The language set under Account → Profile & password → Language. It's saved on the user, so it follows them to any device.
- The
bh_localecookie, which saving that setting also writes. The cookie means the sign-in pages appear in the same language before anyone signs in. - The browser's
Accept-Languageheader, sode-ATgets German andzh-CNgets Chinese. - English.
Choosing Automatic on the Account page clears the setting and the cookie, so the browser's language is used again.
What gets translated: every page, form, button, success/error message and the dashboard. Dates, times, relative times ("3 hours ago"), numbers and cron schedule descriptions are formatted for the chosen language. Password-reset emails go out in the recipient's language.
What stays in English:
- error output from the database tools themselves (e.g. a
pg_dumpconnection error), which is stored and shown exactly as the tool printed it; - backup and system notifications (email, ntfy, Telegram, Discord), because one channel can have several readers;
- the REST API's error messages;
- names people type in, such as task, location and channel names.
Each language is a single JSON file in translations/ (en.json, es.json, …), grouped by area (dashboard, task, account, …). Messages use ICU syntax for plurals and placeholders, e.g. "{count, plural, one {# backup} other {# backups}}", and tags like <link>…</link> for embedded links. translations/en.json is the source of truth: the TypeScript types are generated from it (global.d.ts), so using a key that doesn't exist is a type error.
Labels and help text for save-location providers and notification channels live in their code definitions (lib/backup/destinations/, lib/notifications/channels/definitions.ts), and the server uses the English there too. The translation files override them under destinations.<provider>.fields.<field>.label / helpText / placeholder and channels.<type>…. Anything without a translation falls back to the definition's English, so a new provider shows up in English until it's translated.
The non-English translations were machine-assisted and would benefit from a native speaker's review — corrections are welcome.
- Add its code and name to
LOCALESandLOCALE_NAMESinlib/i18n/locales.ts. - Copy
translations/en.jsontotranslations/<code>.jsonand translate the values. Keep the keys, the{placeholders}, the plural keywords (one,other, …) and the<tags>as they are. - Run
npm run build(ornpx tsc --noEmit) and open a few pages with the browser set to that language.
If a task has encryption enabled, downloaded files end in .enc (.sql.gz.enc, or .archive.gz.enc for MongoDB) — Backup Hub never decrypts server-side on download, so the backup password is only ever held in memory during the dump itself. To decrypt locally, the file layout is [salt(16 bytes)][iv(12 bytes)][ciphertext][authTag(16 bytes)], AES-256-GCM, key derived via scrypt from your password + the salt. A small decrypt script would look like:
// see lib/backup/crypto.ts:decryptFile for the reference implementation- Permissions: two flat roles (
admin/user), not per-task ACLs. See the table above. - Key rotation: no built-in
BACKUP_MASTER_KEYversioning — rotating it means decrypting all stored secrets with the old key and re-encrypting with the new one in a one-off script. - Task connection edits: the UI doesn't yet support editing a task's DB connection after creation (only schedule/retention/encryption) — delete and recreate, or use
PATCH /api/v1/tasks/:id. - Engines: MySQL/MariaDB, PostgreSQL and MongoDB (plus compatible services — see Supported databases). Adding another engine means implementing
BackupEngineinlib/backup/engines/(including itscreateScanner()completeness check), registering it inlib/backup/engines/index.ts, and adding its label and default port inlib/backup/engines/labels.ts. - Destinations: S3-compatible, Google Drive, OneDrive, and email only. Same pluggable pattern as engines — see
lib/backup/destinations/. - Destination secrets aren't re-validated on save: "Test connection" is a manual step in the add-destination form, not an automatic check before saving — a destination with bad credentials will save successfully and only surface the failure on the next real upload (visible per-run in the backup history, or via the "Test" button next to it afterwards).
- Large encrypted files: decryption reads the whole ciphertext into memory (fine for typical dumps; swap for chunked authenticated decryption if you expect multi-GB encrypted files).
- Password reset needs SMTP — without
SMTP_*configured, "Forgot password?" tells users to ask an admin. A server with no SMTP and a single admin who forgets their password has no way back in short of editing the database. - Sessions aren't tied to an IP address — on purpose: IPs change constantly (mobile networks, VPNs, IPv6 privacy addresses) and
X-Forwarded-Forcan be faked without a trusted reverse proxy. Instead, Account → Active sessions flags a session whose IP has changed so you can sign it out. - Notifications aren't translated — backup and system notifications go out in English, since a shared channel can have readers who use different languages. Only the UI and password-reset emails follow each user's language.
- 2FA enforcement: enabling TOTP/passkeys is opt-in per user, not something an admin can mandate account-wide yet.
- REST API has no 2FA equivalent — API keys are a separate credential type from user accounts and aren't affected by a user's 2FA/passkey settings (this is intentional: API keys are meant for machine-to-machine use where an interactive second factor doesn't apply).
db/ schema (users, sessions, tasks, shares, runs, destinations, API keys, notification recipients, 2FA/passkeys, sign-in attempts, reset links) + drizzle client
lib/backup/crypto.ts secret wrapping, file encryption, API key hashing
lib/backup/engines/ per-database dump implementations (mysql, postgres, mongodb) + their completeness checks
lib/backup/destinations/ pluggable off-site upload implementations (s3, google_drive, onedrive, email)
lib/backup/purge.ts removes a task (and everything hanging off it) from the DB, optionally its files too
lib/backup/runner.ts runs a backup end-to-end: dump → encrypt → check → record → prune → upload to destinations → notify
lib/backup/verify.ts backup file checks (fingerprint, read-back, test restore) and their background queue
lib/backup/scheduler.ts node-cron job management
lib/backup/failureKind.ts groups backup/upload errors (sign-in problem, can't connect, other) for the dashboard
lib/auth/api-key.ts REST API scoped-key authentication
lib/auth/password.ts Argon2id password hashing
lib/auth/session.ts DB-backed sessions + the access checks (requireUser/requireAdmin), active-session tracking
lib/auth/token-hash.ts session-token hashing
lib/auth/login-attempts.ts records every sign-in attempt (and reads the client IP / browser)
lib/auth/lockout.ts sign-in lockouts, worked out from the sign-in history
lib/auth/password-reset.ts "forgot password" email links
lib/action-result.ts fail("…") — how server actions report errors the user should see
lib/i18n/locales.ts supported languages + browser-language matching
i18n/request.ts picks each request's language (account setting → cookie → browser → English)
translations/ UI text, one JSON file per language (en.json is the source of truth)
lib/auth/totp.ts RFC 6238 TOTP implementation (secret gen, code gen/verify, otpauth URI)
lib/auth/recovery-codes.ts 2FA recovery code generation/hashing
lib/auth/webauthn.ts WebAuthn RP config + ephemeral challenge-cookie helpers
lib/notifications/mailer.ts nodemailer transport, best-effort sends
lib/notifications/notify.ts task and system notification dispatch
lib/notifications/channels/ email / ntfy / Telegram / Discord senders + form field definitions
app/api/v1/ the public REST API
app/setup/ first-run admin account creation
app/login/ email/password login, logout, passkey login
app/login/2fa/ TOTP/recovery-code verification step
app/login/forgot/, reset/ forgot-password request and reset pages
app/account/ self-service profile, password, 2FA, passkeys, sign-in history, active sessions
app/users/ admin-only user management, incl. 2FA/passkey reset
app/notifications/ admin-only system notification recipients
app/tasks/, app/api-keys/ the first-party UI + its server actions
app/page.tsx the dashboard
components/ui/ UI components, one folder per area (standard, common, modal, tasks, runs,
locations, notifications, auth, api-keys, users, dashboard)
proxy.ts quick session-cookie check that sends signed-out visitors to /login
instrumentation.ts boots the scheduler on server start