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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/native-s3-backup-target.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@trieb.work/payload-plugin-backup-mongodb': minor
---

Add native AWS S3 as a selectable backup target alongside Vercel Blob.

Set `BACKUP_STORAGE=s3` and configure `BACKUP_S3_*` env vars to store backups in
S3 — or any S3-compatible store (Cloudflare R2, MinIO) via `BACKUP_S3_ENDPOINT`.
Vercel Blob remains the default, so existing deployments are unaffected. The AWS
SDK is an optional peer dependency, loaded only when the S3 target is used.
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@ yarn-debug.log*
yarn-error.log*

# local env files
.env
.env*.local
dev/.env
# keep the committed templates
!.env.example
!dev/.env.example

# vercel
.vercel
Expand Down
183 changes: 121 additions & 62 deletions README.md

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions dev/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,28 @@ PAYLOAD_SECRET=YOUR_SECRET_HERE
# Leave unset to fall back to the default (admin role, or all users when the
# users collection has no `roles` field).
# PAYLOAD_BACKUP_ALLOWED_ROLES=admin,superadmin

# ── Backup target ─────────────────────────────────────────────────────────
# Where backup archives are stored. `vercel-blob` (default) or `s3`.
# BACKUP_STORAGE=vercel-blob

# Vercel Blob target (used when BACKUP_STORAGE=vercel-blob). The default store
# for backups and media; can be overridden in the admin Backup settings.
# BLOB_READ_WRITE_TOKEN=vercel_blob_rw_xxxxxxxx

# AWS S3 / S3-compatible target (used when BACKUP_STORAGE=s3). Works with AWS S3,
# Cloudflare R2, MinIO, etc. — same code, only the config differs.
# BACKUP_S3_BUCKET=my-payload-backups
# BACKUP_S3_REGION=us-east-1
# Custom endpoint for R2/MinIO (omit for AWS). forcePathStyle defaults true when set.
# BACKUP_S3_ENDPOINT=http://127.0.0.1:9000
# BACKUP_S3_FORCE_PATH_STYLE=true
# Optional key namespace when sharing a bucket.
# BACKUP_S3_PREFIX=team-a
# Credentials are optional: omit to use the AWS default chain (env/role). Otherwise:
# BACKUP_S3_ACCESS_KEY_ID=your-access-key-id
# BACKUP_S3_SECRET_ACCESS_KEY=your-secret-access-key
# BACKUP_S3_SESSION_TOKEN=optional-session-token

# Required for cron endpoints (/api/backup-mongodb/cron/*).
# CRON_SECRET=your-cron-secret
21 changes: 19 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@trieb.work/payload-plugin-backup-mongodb",
"version": "0.1.7",
"description": "Payload CMS plugin for MongoDB backup and recovery via Vercel Blob Storage",
"description": "Payload CMS plugin for MongoDB backup and recovery via Vercel Blob or AWS S3 (S3-compatible: R2, MinIO)",
"keywords": [
"payload",
"payloadcms",
Expand Down Expand Up @@ -111,6 +111,9 @@
"tar-stream": "^3.1.7"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/lib-storage": "^3.700.0",
"@aws-sdk/s3-request-presigner": "^3.700.0",
"@changesets/cli": "^2.29.8",
"@eslint/eslintrc": "^3.2.0",
"@payloadcms/db-mongodb": "3.82.1",
Expand All @@ -119,7 +122,7 @@
"@payloadcms/richtext-lexical": "3.82.1",
"@payloadcms/translations": "3.82.1",
"@payloadcms/ui": "3.82.1",
"@playwright/test": "1.58.2",
"@playwright/test": "1.61.0",
"@swc-node/register": "1.10.9",
"@swc/cli": "0.6.0",
"@types/node": "22.19.9",
Expand Down Expand Up @@ -153,12 +156,26 @@
"vitest": "4.1.5"
},
"peerDependencies": {
"@aws-sdk/client-s3": "^3.0.0",
"@aws-sdk/lib-storage": "^3.0.0",
"@aws-sdk/s3-request-presigner": "^3.0.0",
"@payloadcms/db-mongodb": "^3.0.0",
"@payloadcms/ui": "^3.0.0",
"next": "^15.0.0",
"payload": "^3.0.0",
"react": "^19.0.0"
},
"peerDependenciesMeta": {
"@aws-sdk/client-s3": {
"optional": true
},
"@aws-sdk/lib-storage": {
"optional": true
},
"@aws-sdk/s3-request-presigner": {
"optional": true
}
},
"packageManager": "pnpm@9.15.9+sha512.68046141893c66fad01c079231128e9afb89ef87e2691d69e4d40eee228988295fd4682181bae55b58418c3a253bde65a505ec7c5f9403ece5cc3cd37dcf2531",
"engines": {
"node": "^18.20.2 || >=20.9.0",
Expand Down
594 changes: 567 additions & 27 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/collections/BackupTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const BackupTasksCollection: CollectionConfig = {
{
name: 'kind',
type: 'select',
options: ['backup', 'restore', 'seed', 'delete', 'blobTransfer'],
options: ['backup', 'restore', 'delete', 'blobTransfer'],
required: true,
},
{
Expand Down
242 changes: 148 additions & 94 deletions src/components/BackupDashboard/BackupSettingsModal.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ interface BackupSettingsApiResponse {
includeMediaForCron?: boolean
pluginBackupsToKeepOverride?: boolean
skipMongoCollections?: string[]
storage?: {
kind: 's3' | 'vercel-blob'
s3?: {
bucket: string
endpoint?: string
prefix?: string
region: string
} | null
}
transfer?: {
deferred?: boolean
failed: number
Expand Down Expand Up @@ -160,6 +169,7 @@ export const BackupSettingsModal: FC = () => {
}>({ status: 'idle' })
const [pluginOverridesRetention, setPluginOverridesRetention] = useState(false)
const [cronInfo, setCronInfo] = useState<BackupSettingsApiResponse['cron'] | undefined>(undefined)
const [storage, setStorage] = useState<BackupSettingsApiResponse['storage']>(undefined)
const [transferSummary, setTransferSummary] = useState<
BackupSettingsApiResponse['transfer'] | null
>(null)
Expand Down Expand Up @@ -258,6 +268,7 @@ export const BackupSettingsModal: FC = () => {
setTokenCheck({ status: 'idle' })
setPluginOverridesRetention(settingsJson.pluginBackupsToKeepOverride === true)
setCronInfo(settingsJson.cron ?? null)
setStorage(settingsJson.storage)
setIncludeMediaBlobs(settingsJson.includeMediaForCron === true)

const skip =
Expand Down Expand Up @@ -641,6 +652,44 @@ export const BackupSettingsModal: FC = () => {
</p>

<div className="backup-confirm-dialog__body restore-preview">
{storage ?
<>
<p className="restore-preview__sticky-heading">Storage target</p>
{storage.kind === 's3' ?
<>
<p className="restore-preview__intro">
Backups use <strong>AWS S3</strong> (or S3-compatible). Selected via the{' '}
<code>BACKUP_STORAGE</code> environment variable.
</p>
{storage.s3 ?
<p className="restore-preview__intro">
Bucket <code>{storage.s3.bucket}</code> · region{' '}
<code>{storage.s3.region}</code>
{storage.s3.endpoint ?
<>
{' '}
· endpoint <code>{storage.s3.endpoint}</code>
</>
: null}
{storage.s3.prefix ?
<>
{' '}
· prefix <code>{storage.s3.prefix}</code>
</>
: null}
</p>
: <p className="restore-preview__intro">
Set <code>BACKUP_S3_BUCKET</code> (and credentials) to finish configuring S3.
</p>
}
</>
: <p className="restore-preview__intro">
Backups use <strong>Vercel Blob</strong> (default). Set{' '}
<code>BACKUP_STORAGE=s3</code> in the environment to use AWS S3 instead.
</p>
}
</>
: null}
{phase !== 'idle' ?
<>
<p className="restore-preview__sticky-heading">Schedule</p>
Expand Down Expand Up @@ -728,106 +777,111 @@ export const BackupSettingsModal: FC = () => {
/>
</div>

<p className="restore-preview__sticky-heading">
Dedicated backup storage{' '}
<span className="restore-preview__sticky-heading-hint">(optional)</span>
</p>
<p className="restore-preview__intro">
Separate Vercel Blob token for <code>backups/</code>. Without one, archives share the
default media store.
</p>
<div className="restore-preview__settings-fields">
<label className="restore-preview__settings-label" htmlFor="backup-blob-token">
Backup Blob read/write token
</label>
<div className="restore-preview__blob-token-row">
<input
aria-label="Backup Blob read/write token"
autoComplete="off"
className="restore-preview__settings-input restore-preview__settings-input--wide"
id="backup-blob-token"
onChange={(e) => onChangeBackupBlobToken(e.target.value)}
placeholder="vercel_blob_rw_..."
spellCheck={false}
type="text"
value={backupBlobReadWriteToken}
/>
<span className="restore-preview__blob-token-pill-slot">{tokenStatusPill}</span>
</div>
{tokenCheck.status === 'invalid' && tokenCheck.message ?
<p aria-live="polite" className="restore-preview__blob-token-error">
{tokenCheck.message}
{storage?.kind === 's3' ? null : (
<>
<p className="restore-preview__sticky-heading">
Dedicated backup storage{' '}
<span className="restore-preview__sticky-heading-hint">(optional)</span>
</p>
: null}
{savePhase === 'transferring' && transferLive && transferLive.total > 0 ?
<div aria-live="polite" className="restore-preview__transfer-panel">
<div className="restore-preview__transfer-bar-track">
<div
className="restore-preview__transfer-bar-fill"
style={{
width: `${Math.min(
100,
((transferLive.transferred + transferLive.failed) / transferLive.total) *
100,
)}%`,
}}
/>
</div>
<p className="restore-preview__transfer-meta">
Copying backup files: {transferLive.transferred + transferLive.failed} /{' '}
{transferLive.total}
{transferLive.failed > 0 ? ` (${transferLive.failed} failed)` : ''}
{transferLive.pathname ?
<>
<br />
<span
className="restore-preview__transfer-path"
title={transferLive.pathname}
>
{transferLive.pathname.length > 56 ?
`${transferLive.pathname.slice(0, 28)}…${transferLive.pathname.slice(-24)}`
: transferLive.pathname}
</span>
</>
: null}
</p>
</div>
: null}
{willSaveNewRawToken ?
<>
<div className="field-type checkbox backup-dashboard__collapsible-checkbox restore-preview__blob-transfer-row restore-preview__blob-transfer-row--with-help-tip">
<p className="restore-preview__intro">
Separate Vercel Blob token for <code>backups/</code>. Without one, archives share
the default media store.
</p>
<div className="restore-preview__settings-fields">
<label className="restore-preview__settings-label" htmlFor="backup-blob-token">
Backup Blob read/write token
</label>
<div className="restore-preview__blob-token-row">
<input
aria-label="Copy existing backup files into this storage when saving"
checked={transferBackupBlobs}
className="checkbox-input__input"
id={transferOptId}
onChange={(e) => setTransferBackupBlobs(e.target.checked)}
type="checkbox"
aria-label="Backup Blob read/write token"
autoComplete="off"
className="restore-preview__settings-input restore-preview__settings-input--wide"
id="backup-blob-token"
onChange={(e) => onChangeBackupBlobToken(e.target.value)}
placeholder="vercel_blob_rw_..."
spellCheck={false}
type="text"
value={backupBlobReadWriteToken}
/>
<label className="field-label" htmlFor={transferOptId}>
Copy existing backup files into this storage when saving
</label>
<SettingsHelpTip multiline tip={TIP_TRANSFER_COPY} />
<span className="restore-preview__blob-token-pill-slot">{tokenStatusPill}</span>
</div>
{transferBackupBlobs ?
<div className="field-type checkbox backup-dashboard__collapsible-checkbox restore-preview__blob-transfer-row restore-preview__blob-transfer-row--with-help-tip">
<input
aria-label="Delete from previous storage after successful copy"
checked={deleteBackupBlobsFromSource}
className="checkbox-input__input"
id={deleteSourceOptId}
onChange={(e) => setDeleteBackupBlobsFromSource(e.target.checked)}
type="checkbox"
/>
<label className="field-label" htmlFor={deleteSourceOptId}>
Delete from previous storage after successful copy
</label>
<SettingsHelpTip multiline tip={TIP_TRANSFER_DELETE} />
{tokenCheck.status === 'invalid' && tokenCheck.message ?
<p aria-live="polite" className="restore-preview__blob-token-error">
{tokenCheck.message}
</p>
: null}
{savePhase === 'transferring' && transferLive && transferLive.total > 0 ?
<div aria-live="polite" className="restore-preview__transfer-panel">
<div className="restore-preview__transfer-bar-track">
<div
className="restore-preview__transfer-bar-fill"
style={{
width: `${Math.min(
100,
((transferLive.transferred + transferLive.failed) /
transferLive.total) *
100,
)}%`,
}}
/>
</div>
<p className="restore-preview__transfer-meta">
Copying backup files: {transferLive.transferred + transferLive.failed} /{' '}
{transferLive.total}
{transferLive.failed > 0 ? ` (${transferLive.failed} failed)` : ''}
{transferLive.pathname ?
<>
<br />
<span
className="restore-preview__transfer-path"
title={transferLive.pathname}
>
{transferLive.pathname.length > 56 ?
`${transferLive.pathname.slice(0, 28)}…${transferLive.pathname.slice(-24)}`
: transferLive.pathname}
</span>
</>
: null}
</p>
</div>
: null}
</>
: null}
</div>
{willSaveNewRawToken ?
<>
<div className="field-type checkbox backup-dashboard__collapsible-checkbox restore-preview__blob-transfer-row restore-preview__blob-transfer-row--with-help-tip">
<input
aria-label="Copy existing backup files into this storage when saving"
checked={transferBackupBlobs}
className="checkbox-input__input"
id={transferOptId}
onChange={(e) => setTransferBackupBlobs(e.target.checked)}
type="checkbox"
/>
<label className="field-label" htmlFor={transferOptId}>
Copy existing backup files into this storage when saving
</label>
<SettingsHelpTip multiline tip={TIP_TRANSFER_COPY} />
</div>
{transferBackupBlobs ?
<div className="field-type checkbox backup-dashboard__collapsible-checkbox restore-preview__blob-transfer-row restore-preview__blob-transfer-row--with-help-tip">
<input
aria-label="Delete from previous storage after successful copy"
checked={deleteBackupBlobsFromSource}
className="checkbox-input__input"
id={deleteSourceOptId}
onChange={(e) => setDeleteBackupBlobsFromSource(e.target.checked)}
type="checkbox"
/>
<label className="field-label" htmlFor={deleteSourceOptId}>
Delete from previous storage after successful copy
</label>
<SettingsHelpTip multiline tip={TIP_TRANSFER_DELETE} />
</div>
: null}
</>
: null}
</div>
</>
)}

<CollectionBackupPreviewBody
embedded
Expand Down
Loading
Loading