Configure Dokploy Backups with Cloudflare R2 - Complete Guide
Set up Dokploy backups to Cloudflare R2: S3 destination, scheduled system, database and volume backups, retention, and a tested restore you can actually trust.

If you self-host with Dokploy, your backups are the only thing standing between a bad docker compose edit and a very long weekend. This guide wires Dokploy’s backup system to Cloudflare R2: the S3 destination, the three backup types, retention that cleans up after itself, and restore steps you run before you need them.
Cloudflare R2 works well here because it is S3-compatible, has a usable free tier, and charges nothing for egress, which matters the day you actually pull a 20 GB volume backup back down. It also lives on a different provider than your VPS, which is the whole point.
Everything below was re-checked against the Dokploy docs (backups, database backups, restore, volume backups, S3 destinations and the API reference) in September 2026. Where a behaviour is version-dependent I say so instead of pretending it is stable.
What changed in this 2026 update
- Volume backups now matter for more than apps: the API accepts
postgres,mysql,mariadb,mongo,redis,composeandlibsqlservice types. Redis and libSQL volumes are backed up like any other named volume. - Retention is built in. Backup and volume backup records carry a
keepLatestCount, so pruning old objects is Dokploy’s job now, not your monthly chore. - The hard limit most people miss: volume backups only work on Docker named volumes. Anything mounted as
../files/…(a bind mount) is not backed up at all. If your stack uses bind mounts, migrate before you trust the schedule. - The system backup does not contain your application data. It is Dokploy’s own Postgres database plus
/etc/dokploy. Your app databases and volumes need their own backup entries. - Everything in the UI has an API endpoint (
/api/destination.create,/api/backup.create,/api/volumeBackups.create), so destinations and schedules can live in your config repo instead of your memory.
Why Backups Matter
Here’s the short list of ways self-hosted data disappears:
- Hardware and provider failures - dead disks, a hypervisor that eats the VM, a data center incident
- Human error - dropped tables, a compose file that points the database at an empty volume,
docker volume rmon the wrong name - Security incidents - a leaked panel password or a compromised container with a write path to your data
- Account problems - suspended billing, a locked cloud account, an expired card you forgot about
- Software bugs - a botched migration, an app update that rewrites data in place
- Compliance - some work requires retention and a documented recovery plan, not a shrug
The rule I follow: never keep the only copy with the provider hosting the primary data. If your VPS provider suspends the account or loses a rack, you need data that was never in that account.
Why Use a Different Provider
Hetzner, where my Dokploy hosts run, sells cheap S3-compatible storage, and their server snapshots are fine for rolling back a bad package upgrade. Neither is a backup:
- Snapshots live in the same account and usually the same region as the server, so provider-level problems take both copies.
- A snapshot of a running database is not a consistent dump. You get whatever bytes were on disk mid-write.
- An account suspension is not a hardware failure. It locks you out of the whole provider, snapshots included.
Keeping backups in a separate provider’s account gives you:
- Blast radius separation - a suspended VPS account still leaves your backups readable
- Geographic separation - different data centers, different failure domains
- Billing separation - a failed payment on one provider does not freeze recovery on the other
- Provider independence - S3-compatible storage moves between vendors with a config change
Critical Backup Rule
Store backups in a different provider account than your primary infrastructure. Same-provider snapshots are convenience, not disaster recovery.
Dokploy Backup Options
Dokploy has three separate backup mechanisms, and they do not overlap. You configure each one, and nothing runs until you do. There is no default schedule.
1. Dokploy System Backups
Backs up the Dokploy installation itself.
What’s inside:
- The
dokploy-postgresPostgreSQL database with your applications, services, environment variables, destinations and deployment history - The Dokploy file system at
/etc/dokploywith settings, Traefik configs and certificates - Your users, git provider connections and deployment keys
Both pieces are combined and compressed into a single .zip and uploaded to your S3 destination. The exact folder layout inside the archive has changed between Dokploy versions, so treat it as a black box and verify by restoring, not by unzipping and guessing.
When to use it: before Dokploy upgrades, after big configuration changes, and as the first step of any server migration.
Reality check: this archive does not contain your application databases or Docker volumes. Restoring it gives you your Dokploy configuration back, not your app data.
2. Database Backups
Per-database dumps, run by Dokploy on a cron schedule.
Supported types: PostgreSQL (pg_dump), MySQL (mysqldump), MariaDB (mariadb-dump), MongoDB (mongodump), libSQL, plus a web-server type for the Dokploy system backup above.
What’s inside: schema, data, and for the SQL engines the permissions you asked the dump to keep. For Postgres the default command deliberately drops ACLs and ownership (--no-acl --no-owner), which makes restores into a fresh cluster far less painful.
When to use it: every database that holds something you would miss, before schema migrations, and when cloning production into staging.
Reality check: a database backup restores into a database. It will not bring back uploaded files, and it will not bring back the container’s own volume.
3. Volume Backups
Backs up Docker named volumes to an S3 destination.
Good for: SQLite-backed apps (n8n, Memos, Uptime Kuma), file uploads, application state that lives in a volume, Redis and libSQL data directories.
Supported services: single-container Applications and Docker Compose services, with service types application, postgres, mysql, mariadb, mongo, redis, compose and libsql.
When to use it: anything storing state outside a dumpable database.
Reality check: bind mounts are not covered. If your compose file mounts ../files/my-data:/app/data, Dokploy cannot back that up, not with a warning. Just not at all. Details in Step 5.

Why Cloudflare R2 for Backups
Cloudflare R2 is S3-compatible object storage with a genuinely usable free tier.
Free tier
- 10 GB of storage free every month
- Zero egress fees - you pay nothing to download your backups, which is when you least want a bill
- S3-compatible API - works with the AWS CLI, rclone, restic, and Dokploy’s destination form
- Encryption at rest enabled by default
- No minimum storage duration or retrieval fees - unlike the cheap S3 tiers that bill you for deleting young objects
Cost comparison
Prices move, so treat this as order-of-magnitude planning and confirm on each provider’s pricing page before you commit.
| Provider | Storage (per GB/month) | Egress | Free tier |
|---|---|---|---|
| Cloudflare R2 | $0.015 | $0.00 | 10 GB storage |
| Backblaze B2 | ~$0.006 | charged, with a free daily allowance | 10 GB storage |
| AWS S3 Standard | $0.023 | $0.09 per GB | 5 GB, new accounts, first 12 months |
| Hetzner Storage Box | flat monthly, roughly €4 for the smallest box | included | none |
R2’s advantage is not the cheapest storage line, it is the egress line. A 200 GB restore from S3 Standard costs about $18 in bandwidth. From R2 it costs nothing, and that is the moment you do not want to be optimizing.
R2 also bills operations separately (Class A for writes and listing, Class B for reads). For backup workloads those numbers are rounding errors relative to your storage line, but if you point a chatty sync job at the bucket, check them.
Beyond the free tier
Above 10 GB you pay the standard storage rate, so 100 GB of backups is around $1.50/month. That is the entire cost of a sane backup strategy for a self-hoster, which is why I stop arguing with people who say backups are expensive.
Alternatives worth knowing
R2 is my default here because it is the cheapest egress story and it is a different provider from my VPS. If you want options: Backblaze B2 if you want the lowest storage rate, Hetzner Storage Box if you want a flat monthly bill and don’t care about provider separation, and Bunny Storage vs S3 vs Backblaze if you are doing the comparison properly. If you would rather keep backups on hardware you own, RustFS with Dokploy gives you an S3 endpoint you control, just remember that a second copy on the same server is not offsite.
Step 1: Configure the R2 Bucket and API Token
This takes about five minutes in the Cloudflare dashboard.
1.1 Create the bucket
- Log into the Cloudflare dashboard and open R2 Object Storage.
- Click Create bucket.
- Name it something explicit:
dokploy-backups, or one bucket per purpose if you want clean lifecycle rules later. - Pick a location hint close to your VPS: WEUR (Western Europe), ENAM (Eastern North America), WNAM (Western North America), APAC (Asia Pacific). Dokploy’s Region field has to match this.
- Create it.
Bucket naming rules: 3-63 characters, lowercase letters, numbers and hyphens, and it cannot start or end with a hyphen.
1.2 Create an API token scoped to that bucket
- Go back to R2 Object Storage and choose Manage API tokens from the dropdown.
- Click Create API token.
- Name it
Dokploy Backup Access. - Permissions: Object Read & Write.
- Apply to specific buckets only and select
dokploy-backups. Do not give Dokploy account-wide access because it was easier. - Create it and copy both values immediately:
Access Key ID: f3811c6d27415a9s6cv943b6743ad784
Secret Access Key: aa55ee40b4049e93b7252bf698408cc22a3c2856d2530s7c1cb7670e318f15e58
These are the example values from the Dokploy docs. Yours will differ, and the secret is shown once. Put both in a password manager before you close the tab.
Token scoping is the whole hardening story here
A token with account-wide R2 access can read and delete every bucket you own if it leaks through a Dokploy backup archive or a misconfigured API call. Scope it to one bucket, give it an expiry date if your R2 plan supports one, and rotate it if you ever paste it somewhere you regret.
1.3 Get the account endpoint
On the R2 overview page you will see your account endpoint, formatted like this:
https://8ah554705io7842d54c499fbee1156c1c.r2.cloudflarestorage.com
No bucket name in the URL, https:// included, ends in .r2.cloudflarestorage.com. That is the value Dokploy wants.
1.4 Verify the credentials before touching Dokploy
Failing inside Dokploy tells you nothing useful. Test the key with the AWS CLI first:
# requires aws cli v2 -> aws --version
export AWS_ACCESS_KEY_ID="<R2 access key id>"
export AWS_SECRET_ACCESS_KEY="<R2 secret access key>"
export AWS_DEFAULT_REGION="auto" # R2 ignores the value but the CLI wants one
aws s3 ls "s3://dokploy-backups/" \
--endpoint-url "https://<account-id>.r2.cloudflarestorage.com" \
--recursive --human-readable --summarize
An empty listing and exit code 0 means the key, endpoint and bucket name are all correct. What broken looks like:
InvalidAccessKeyIdorSignatureDoesNotMatch- wrong key, or you pasted the secret with a trailing spaceNoSuchBucket- bucket name typo, and it is case-sensitiveAccessDeniedon a listing - the token is scoped to a different bucket than the one you are asking for- region complaints - on some CLI versions set
AWS_DEFAULT_REGION=us-east-1instead ofauto
If you prefer rclone, it has a built-in Cloudflare provider:
rclone config create r2 s3 provider Cloudflare \
access_key_id "$AWS_ACCESS_KEY_ID" \
secret_access_key "$AWS_SECRET_ACCESS_KEY" \
endpoint "https://<account-id>.r2.cloudflarestorage.com"
rclone lsl r2:dokploy-backups --max-depth 2
Step 2: Add the R2 Destination in Dokploy
Destinations are global: you configure one, then reuse it for every backup entry.
- In Dokploy, open Settings → Destinations (the route is
/dashboard/settings/destinations). - Click New Destination and pick S3 Compatible.
- Fill in the form:
| Dokploy field | Value | Notes |
|---|---|---|
| Destination Name | Cloudflare R2 Backups |
Any label; local to Dokploy |
| Access Key ID | R2 access key | From step 1.2, no extra spaces |
| Secret Access Key | R2 secret | Shown once in Cloudflare |
| Region | WEUR, ENAM, WNAM or APAC |
Must match the bucket’s location hint |
| Endpoint | https://<account-id>.r2.cloudflarestorage.com |
No bucket name appended |
| Bucket | dokploy-backups |
Case-sensitive, no slashes |
| Force Path Style | Enabled | Required for R2 |
Why Force Path Style matters: without it, the S3 client builds virtual-hosted URLs like bucket.endpoint/key. R2 wants path-style URLs, endpoint/bucket/key. Leave this off and the connection test may pass while uploads fail, which is the worst kind of failure.
Where the credentials live: Dokploy stores them in its own Postgres database (the destination table is plain columns: access key, secret, bucket, region, endpoint). Two consequences: your destinations come back with a system restore, and anyone who can read the Dokploy database can read your R2 keys. Scope the token accordingly.
- Click Test Connection. A success message means the endpoint, region, bucket and key all line up.
Verify it for real rather than trusting the toast: after saving the destination, create any small backup (or run a manual test from a database’s Backup tab) and list the bucket from your laptop:
aws s3 ls "s3://dokploy-backups/" \
--endpoint-url "https://<account-id>.r2.cloudflarestorage.com" \
--recursive --human-readable --summarize
You should see an object appear with a timestamp within a minute or two. No object plus a green test result usually means the destination test only validated the API call, not the write path, so check the Dokploy logs, and see the troubleshooting section at the end.
Step 3: Back Up the Dokploy System Itself
This is the backup that saves your configuration when the panel is broken or you are moving servers.
Configure it
- Go to Web Server → Backups.
- Click Create Backup.
- Fill in the settings:
| Setting | Recommended value | Why |
|---|---|---|
| Destination | Cloudflare R2 Backups | The destination from Step 2 |
| Prefix | dokploy-system |
Folder inside the bucket |
| Schedule (cron) | 0 2 * * * |
Daily at 02:00 server time |
| Keep latest count | 7 |
Dokploy deletes older objects in the prefix |
| Enabled | On | Nothing runs without this |
Cron syntax is standard five-field: minute hour day month weekday.
0 2 * * * # daily at 02:00
0 */6 * * * # every 6 hours
0 2 * * 0 # weekly, Sunday 02:00
0 3 1 * * # monthly, 1st at 03:00
Keep latest count is the retention setting. Set it and Dokploy prunes the old files in that prefix for you, so retention is no longer a script you forget to write. Leave it empty and the objects accumulate until you hit the R2 free tier and start debugging quota errors.
The schedule is evaluated by the Dokploy service itself, so if Dokploy is down or the VPS is off, the backup does not run. Budget for that: pre-update manual backups, and an external check that the newest object is fresh (Step 6).
Run one manually first
Before you trust the cron, trigger a backup and watch it. Then confirm three things:
- An object exists in the bucket under the prefix you set.
- Its size is plausible (a Dokploy system archive is typically tens of MB, not 0 bytes and not 5 GB).
- It stopped changing. A file still growing a few minutes later means the upload is still in flight or stalled.
What the archive does and does not contain
Contains: the dokploy-postgres dump and a copy of /etc/dokploy (Traefik config, certificates, metadata), compressed into one .zip.
Does not contain: your application databases, your Docker volumes, or your application source (which lives in Git, presumably).
Step 4: Back Up Application Databases
Do this for every database that holds something you cannot regenerate.
- Open Databases, pick the database, then the Backup tab.
- Configure:
| Setting | Example | Notes |
|---|---|---|
| Destination S3 Bucket | Cloudflare R2 Backups | Reuse the same destination |
| Database Name | prefilled | The database this entry belongs to |
| Schedule Cron | 0 */6 * * * |
Every six hours for production |
| Prefix | prod-db |
Folder inside the bucket |
| Keep latest count | 7 |
Keeps the last 7 objects in the prefix |
| Enabled | On |
- Click Test. Dokploy runs a real backup to the destination; check the bucket for the result. This is the one button in the flow that proves the whole path works.
Schedules I actually use:
0 */6 * * * # production databases with real writes
0 2 * * * # staging, once a day
0 2 * * 0 # dev or scratch databases, weekly
0 */3 * * * # e-commerce or anything transactional
File layout in the bucket:
dokploy-backups/
└── prod-db/
├── postgres-backup-2026-09-14-00-00.sql.gz
├── postgres-backup-2026-09-14-06-00.sql.gz
├── postgres-backup-2026-09-14-12-00.sql.gz
└── postgres-backup-2026-09-14-18-00.sql.gz
The commands Dokploy runs
You do not need to write these, but you should know what your backup actually is, because it decides how you restore and what you can trust.
PostgreSQL:
pg_dump -Fc --no-acl --no-owner -h localhost -U ${databaseUser} \
--no-password '${database}' | gzip
-Fc produces the custom archive format, --no-acl --no-owner strips privileges and ownership so restores into a fresh cluster work, and the pipe through gzip compresses the result. So the file is a gzipped custom-format archive, so restore it with gunzip -c file.gz | pg_restore, not with psql.
MySQL:
mysqldump --default-character-set=utf8mb4 -u 'root' \
--password='${databaseRootPassword}' \
--single-transaction --no-tablespaces \
--quick '${database}' | gzip
--single-transaction gives you a consistent snapshot on InnoDB without locking tables, --quick streams rows instead of buffering them in memory, and --no-tablespaces avoids privilege errors on restore.
MariaDB:
mariadb-dump --user='${databaseUser}' \
--password='${databasePassword}' \
--databases ${database} | gzip
MongoDB:
mongodump -d '${database}' -u '${databaseUser}' \
-p '${databasePassword}' \
--archive --authenticationDatabase=admin --gzip
Verify the dump, not just the upload
A file in the bucket proves the upload worked. It does not prove the dump is usable. For Postgres backups, this one-liner lists the archive table of contents without restoring anything:
gunzip -c postgres-backup-2026-09-14-06-00.sql.gz \
| docker run --rm -i postgres:16 pg_restore -l | head -20If that prints a TOC with your tables, the dump is readable. If it errors with “did not find magic string”, you gzipped a text dump and are running the wrong restore path, or the upload was truncated.
Multiple databases
Best practices for multiple databases
Use a prefix per service, not per environment guesswork:
prod-web-app/ # main app database
prod-analytics/ # analytics
prod-auth/ # auth service
dev-testing/ # throwawayStagger the crons. Five databases all dumping at 0 2 * * * will fight for disk I/O and CPU on a small VPS. Spread them:
0 2 * * * # database 1
15 2 * * * # database 2
30 2 * * * # database 3Set retention by criticality: production 7+ objects, staging 3, dev 1.
Check the sizes weekly. A dump that suddenly drops from 400 MB to 40 MB is either great news about your cleanup job or a broken dump. You will not know which unless you look.
Test a restore monthly for the databases that matter. See Step 7.
Step 5: Back Up Docker Volumes
Volume backups cover the state that lives outside a database: SQLite files, uploads, Redis dumps, libSQL data.
Named volumes only - this is the footgun
Volume Backups work with Docker named volumes only. Bind mounts such as ../files/my-data:/app/data are not covered, cannot be selected, and produce no warning. If your service uses bind mounts, it has no volume backup. Migrate to a named volume first.
Migrating a bind mount to a named volume:
# Before: ../files/... is a bind mount, invisible to Volume Backups
services:
app:
image: dokploy/dokploy:latest
volumes:
- ../files/my-data:/app/data
# After: a named volume Dokploy can back up
services:
app:
image: dokploy/dokploy:latest
volumes:
- my-volume:/app/data
volumes:
my-volume:
Do the migration with the container stopped, and move the files yourself (docker run --rm -v my-volume:/data -v "$PWD/files/my-data":/src alpine cp -a /src/. /data/). This is a real data move, not a config edit they cover for you.
Practical example: n8n
n8n stores its SQLite database, credentials and workflows inside the n8n_data volume, so database backups cannot help you.
version: "3.8"
services:
n8n:
image: docker.n8n.io/n8nio/n8n:1.83.2
restart: always
environment:
- N8N_HOST=${N8N_HOST}
- N8N_PORT=${N8N_PORT}
- N8N_PROTOCOL=http
- NODE_ENV=production
- WEBHOOK_URL=https://${N8N_HOST}/
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
- N8N_SECURE_COOKIE=false
volumes:
- n8n_data:/home/node/.n8n
volumes:
n8n_data:
Dokploy’s own n8n template uses exactly this volume. There is a full walkthrough in How to self-host n8n with Docker, Traefik and Dokploy if you are setting it up now.
Configure the volume backup
- Open the application or compose service in Dokploy.
- Go to the Volume Backups section and click Create Volume Backup.
- Fill in:
| Setting | Value | Notes |
|---|---|---|
| Name | n8n-daily-backup |
Label for this entry |
| Schedule | 0 3 * * * |
Daily at 03:00 |
| Destination | Cloudflare R2 Backups | Your S3 destination |
| Service Name | n8n |
Autocompletes from the compose services |
| Volume Name | n8n_data |
Autofilled after picking the service |
| Backup Prefix | n8n-volumes |
Optional folder in the bucket |
| Turn off Container | On | Recommended, see below |
| Keep latest count | 7 |
Prunes old objects |
| Enabled | On |
Container off or on during backup
Turn off container during backup (recommended)
What it gives you: consistency. Dokploy stops the container gracefully, copies the volume while nothing is writing, uploads, then starts the container again.
What it costs: downtime while the archive uploads: seconds for a small volume, a couple of minutes for a multi-GB one, and the upload speed of your VPS to R2 decides most of it. Schedule it at 03:00 and nobody notices.
Use it for: SQLite-backed apps, anything with embedded databases, production data you would rather not gamble on. SQLite backed up while a container is writing can produce a file that opens and then fails mid-query, which is worse than having no backup because you will trust it.
Keep the container running during backup
What it gives you: no downtime, faster runs, no restart side effects.
What it risks: files captured mid-write, embedded databases captured in an inconsistent state, and a restore that fails at the worst possible moment. Large files being written are the classic offender: you get a truncated blob that only fails when the app tries to read it.
Use it for: static content that rarely changes, dev environments, and apps whose storage is genuinely append-only.
Mitigations if you must: schedule into the quietest hour you have, use the app’s own export feature, and test restores on a schedule rather than assuming.
Finding the right volume name
For Docker Compose services, Dokploy names volumes with the pattern {appName}_{volumeName}.
App: n8n-kqlble Volume: n8n_data -> n8n-kqlble_n8n_data
App: memos-prod Volume: memos_data -> memos-prod_memos_data
Check with Docker rather than guessing:
# on the VPS
docker volume ls | grep n8n
For single-container applications the volume name is usually the one you set in Advanced → Mounts.
Apps that belong on volume backups
| Application | Volume contents | Priority |
|---|---|---|
| n8n | SQLite database, credentials, workflows | High, daily |
| Memos | SQLite database, attachments | High, daily |
| Plausible Analytics | ClickHouse data | High, daily |
| Umami | SQLite database | Medium, daily |
| FileBrowser | Uploads and its own database | High, daily |
| Uptime Kuma | SQLite database (all your monitor config) | High, daily |
| Docmost | Uploads and database | High, daily |
| Stirling PDF | Scratch files | Low, weekly |
| Redis / Valkey | Dump file if persistence is on | High if you rely on it |
| libSQL / Turso-style databases | Data directory | High, daily |
If it holds state and it is not in a dumpable database engine, it belongs on this list.
Step 6: Verify Backups Are Actually Running
An unverified backup is a hypothesis. Three checks, cheapest first.
6.1 Look in the bucket
aws s3 ls "s3://dokploy-backups/" \
--endpoint-url "https://<account-id>.r2.cloudflarestorage.com" \
--recursive --human-readable --summarize
You want: one object per backup entry per schedule tick, timestamps that match the cron you set, and file sizes that stopped growing. Zero-byte objects are failures wearing a success costume.
6.2 Check Dokploy’s own logs
# Dokploy runs as a Swarm service on current versions
docker service logs dokploy --tail 200 | grep -i -E "backup|s3|error"
# its Postgres
docker service logs dokploy-postgres --tail 100
What broken looks like: ENOENT, AccessDenied, SignatureDoesNotMatch, ETIMEDOUT, or a backup job that starts and never logs a completion. If the containers are not even named as expected, check docker ps: a current install shows dokploy, dokploy-postgres and dokploy-traefik. Redis is gone from self-hosted installs since v0.29.9, so a dokploy-redis container means an old version.
6.3 Alert on a stale backup
The failure mode that hurts is silent: cron stops running, nobody notices for two months, then you need the file. This script checks that the newest object is younger than your longest expected interval and pushes the result to Uptime Kuma.
#!/usr/bin/env bash
# /usr/local/bin/check-dokploy-backups.sh
set -euo pipefail
BUCKET="dokploy-backups"
ENDPOINT="https://<account-id>.r2.cloudflarestorage.com"
MAX_AGE_HOURS=30
PUSH_URL="https://uptime.example.com/api/push/<monitor-token>"
newest=$(aws s3api list-objects-v2 \
--bucket "$BUCKET" \
--endpoint-url "$ENDPOINT" \
--query 'reverse(sort_by(Contents,&LastModified))[0].LastModified' \
--output text)
if [ -z "$newest" ] || [ "$newest" = "None" ]; then
curl -fsS "$PUSH_URL?status=down&msg=no-backup-objects" >/dev/null || true
exit 1
fi
age_hours=$(( ( $(date +%s) - $(date -d "$newest" +%s) ) / 3600 ))
if [ "$age_hours" -gt "$MAX_AGE_HOURS" ]; then
curl -fsS "$PUSH_URL?status=down&msg=newest-backup-${age_hours}h-old" >/dev/null || true
exit 1
fi
curl -fsS "$PUSH_URL?status=up&msg=newest-backup-${age_hours}h-old" >/dev/null
Run it hourly:
0 * * * * /usr/local/bin/check-dokploy-backups.sh >> /var/log/dokploy-backup-check.log 2>&1
Notes before you deploy it: it needs the aws CLI and the R2 credentials in the environment (date -d is GNU; macOS needs gdate). If you would rather not install the AWS CLI on the host, run the same script from a container. Then add a push monitor in Uptime Kuma and let it alert you. The setup is covered in Monitor your server with Beszel and Uptime Kuma, and you can check disk and container trends at the same time with server and Docker resource monitoring.
Test restores, not just backups
Put a recurring calendar entry: once a month, restore one database backup into a scratch database and check a couple of row counts. Once a quarter, restore a full Dokploy system backup onto a throwaway VPS. A backup you have never restored is a file, not a recovery plan.
Step 7: Restore Backups
Restore the Dokploy system
For disaster recovery or a server migration.
System restore is destructive
Restoring replaces current state with backup state: the existing /etc/dokploy directory is cleared and replaced, the dokploy-postgres database is dropped and recreated from the archive, and connected users are disconnected. Anything created after the backup you restore is gone. Pick the timestamp deliberately.
- Go to Web Server → Backups and click Restore Backup.
- Choose the S3 destination holding the backup.
- Browse or search for the backup file you want.
- Review the summary of what will be restored.
- Confirm and wait. Expect a few minutes.
- After it completes you may be signed out; log back in and check that your applications, environment variables and destinations are all present. If routing looks wrong, restart Traefik:
docker restart dokploy-traefik.
Migrating to a different server:
Migration checklist
- Update the server IP in Web Server → Server → Update IP.
- Fix DNS so your A records point at the new IP, then wait for propagation.
- Recreate Traefik.me domains if you used them; they follow the server IP and need to be recreated in the app settings.
- Reconfigure Git providers if any webhook or integration was configured with a raw IP instead of a hostname. Domain-based providers need nothing.
- Wait for certificates. Traefik provisions new Let’s Encrypt certificates on the new host; give it a few minutes before deciding HTTPS is broken.
- Test database connections from each application, and update any connection strings that referenced the old host.
- Hit every app URL and check logs for errors. A restored stack with one broken container is the normal outcome of a rushed migration.
Restore a database
- Open Databases, select the database, go to the Backup tab.
- Click Restore.
- Choose the source S3 bucket, then start typing the filename, and the field autocompletes from what is in the bucket.
- If you use prefixes, type the prefix to narrow it: typing
prod-db/autocompletes toprod-db/postgres-backup-2026-09-14-06-00.sql.gz. - Confirm the database name, then run the restore.
What happens: the current database is dropped, the backup is downloaded from R2 and restored, and permissions are reapplied. Applications connected to it will error briefly and usually reconnect on their own; if yours does not, restart the app container.
Restores are only guaranteed for Dokploy's own dumps
Dokploy picks the restore command based on how it created the backup. A .sql.gz you produced by hand, or a dump from a different version of pg_dump, may not work through the automated path. When in doubt, download the file and restore manually:
gunzip -c prod-db-backup.sql.gz | docker exec -i <postgres-container> \
psql -U postgres -d appdbRestore a volume
- Open the application or compose service, find Volume Backups, click Restore Volume.
- Choose the destination and the specific backup file.
- Enter the target volume name. For compose services this is the full
{appName}_{volumeName}, for examplen8n-kqlble_n8n_data. A baren8n_datawill fail.
Volume restore preconditions
The target volume must not exist and must not be in use. Stop the containers using it, remove the volume, then restore. Back up the current volume first if there is any chance you are restoring the wrong file.
# 1. stop the stack (or use the Dokploy UI)
docker compose -f /opt/stacks/n8n-kqlble/docker-compose.yml down
# 2. confirm nothing still references the volume
docker volume ls | grep n8n
# 3. remove the old volume - this deletes its data
docker volume rm n8n-kqlble_n8n_data
# 4. restore from the Dokploy UI (Volume Backups -> Restore Volume)
# 5. bring the stack back up
docker compose -f /opt/stacks/n8n-kqlble/docker-compose.yml up -d
Then verify: container logs clean, app loads, and the data you restored is actually there (open a workflow in n8n, check an uploaded file exists). “The container started” is not verification.
Step 8: Automate Destinations and Schedules With the API
Everything you can click, you can call. That matters when you run more than one Dokploy host and want the backup configuration in version control instead of in your memory.
Get an API key from your Dokploy account settings and send it in the x-api-key header. Treat it like a root credential: it can create destinations, which means it can hand out write access to your backup bucket.
export DOKPLOY_URL="https://app.example.com"
export DOKPLOY_API_KEY="<your api key>"
# what destinations already exist?
curl -sS "$DOKPLOY_URL/api/destination.all" \
-H "x-api-key: $DOKPLOY_API_KEY" | jq
Create the R2 destination. The values are the same ones you would type in the UI. Note that the Force Path Style toggle is stored as an entry in the additionalFlags array, and Dokploy does not document the exact string, so the reliable way to learn it is to create the destination once in the UI and read it back:
# 1. create the destination in the UI, then read back exactly what Dokploy stored
curl -sS "$DOKPLOY_URL/api/destination.all" \
-H "x-api-key: $DOKPLOY_API_KEY" | jq '.[] | select(.name=="Cloudflare R2 Backups")'
With that value in hand, provisioning a second host looks like this:
export R2_ACCESS_KEY_ID="<r2 access key id>"
export R2_SECRET_ACCESS_KEY="<r2 secret access key>"
curl -sS -X POST "$DOKPLOY_URL/api/destination.create" \
-H "x-api-key: $DOKPLOY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Cloudflare R2 Backups",
"provider": "cloudflare",
"accessKey": "'"$R2_ACCESS_KEY_ID"'",
"secretAccessKey": "'"$R2_SECRET_ACCESS_KEY"'",
"bucket": "dokploy-backups",
"region": "WEUR",
"endpoint": "https://<account-id>.r2.cloudflarestorage.com",
"additionalFlags": ["<flag string your instance uses>"]
}'
Test it from the API as well. destination.testConnection takes the same body as destination.create:
curl -sS -X POST "$DOKPLOY_URL/api/destination.testConnection" \
-H "x-api-key: $DOKPLOY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Cloudflare R2 Backups",
"provider": "cloudflare",
"accessKey": "'"$R2_ACCESS_KEY_ID"'",
"secretAccessKey": "'"$R2_SECRET_ACCESS_KEY"'",
"bucket": "dokploy-backups",
"region": "WEUR",
"endpoint": "https://<account-id>.r2.cloudflarestorage.com",
"additionalFlags": ["<flag string your instance uses>"]
}'
Create a database backup schedule:
curl -sS -X POST "$DOKPLOY_URL/api/backup.create" \
-H "x-api-key: $DOKPLOY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"schedule": "0 */6 * * *",
"enabled": true,
"prefix": "prod-db",
"destinationId": "<destinationId>",
"keepLatestCount": 7,
"database": "appdb",
"databaseType": "postgres",
"postgresId": "<postgresId>"
}'
databaseType accepts postgres, mysql, mariadb, mongo, libsql and web-server (the system backup). backupType can be database or compose, the latter with a serviceName when the backup belongs to a compose service. The API also exposes includeEncryptionKey for the Dokploy system backup. If that copy is your disaster recovery for a brand-new server, keep it on, otherwise environment variables Dokploy stored encrypted may not be readable on the restored install.
Create a volume backup entry (turnOff is the container-stop switch):
curl -sS -X POST "$DOKPLOY_URL/api/volumeBackups.create" \
-H "x-api-key: $DOKPLOY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "n8n-daily-backup",
"volumeName": "n8n-kqlble_n8n_data",
"prefix": "n8n-volumes",
"serviceType": "compose",
"appName": "n8n-kqlble",
"serviceName": "n8n",
"turnOff": true,
"cronExpression": "0 3 * * *",
"keepLatestCount": 7,
"enabled": true,
"destinationId": "<destinationId>"
}'
serviceType accepts application, postgres, mysql, mariadb, mongo, redis, compose and libsql.
Run things now instead of waiting for cron, which is useful right before a migration:
# force a Postgres backup
curl -sS -X POST "$DOKPLOY_URL/api/backup.manualBackupPostgres" \
-H "x-api-key: $DOKPLOY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"backupId": "<backupId>"}'
# force a volume backup
curl -sS -X POST "$DOKPLOY_URL/api/volumeBackups.runManually" \
-H "x-api-key: $DOKPLOY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"volumeBackupId": "<volumeBackupId>"}'
There are matching manual endpoints for MySQL, MariaDB, MongoDB and the web server, plus backup.listBackupFiles, which is the same listing the restore picker reads:
curl -sS -G "$DOKPLOY_URL/api/backup.listBackupFiles" \
-H "x-api-key: $DOKPLOY_API_KEY" \
--data-urlencode "destinationId=<destinationId>" \
--data-urlencode "search=prod-db/"
Two operator notes. First, create is not idempotent: run your provisioning script twice and you get two destinations and two schedules. Check destination.all first, or keep the script to a one-shot bootstrap. Second, API keys are account-scoped and long-lived, so keep them in a secrets manager or an env file with chmod 600, never in the repo next to the compose files.
Retention, Storage Math and the 3-2-1 Rule
The 3-2-1 rule still holds:
- 3 copies of the data - production plus two backups
- 2 different media or platforms - for example the VPS disk plus S3-compatible object storage
- 1 offsite, on a different provider - Cloudflare R2 in this setup
A retention plan that fits the free tier:
| Backup type | Frequency | Retention | Objects kept |
|---|---|---|---|
| Database (production) | every 6 hours | 7 days | about 28 |
| Dokploy system | daily | 7 days | 7 |
| Volume (n8n, Kuma, Memos) | daily | 7 days | 7 each |
| Weekly archive | Sunday | 4 weeks | 4 |
Set keepLatestCount on each entry so Dokploy prunes for you, and consider an R2 lifecycle rule on the bucket as a second net (for example, expire objects older than 30 days in a specific prefix). Two independent pruners are better than one forgotten script.
Rough storage math for a small self-hosted stack:
Dokploy system backup: 10 MB x 7 days = 70 MB
Database backups: 50 MB x 4/day x 7 = 1400 MB
n8n volume: 200 MB x 7 days = 1400 MB
Other volumes: 100 MB x 7 days = 700 MB
--------------------------------------------------------
Total: ~3.5 GB
That fits inside R2’s free tier with room to spare. If your volumes are big, the pressure comes from volume backups, not databases. A photo library mounted as a named volume will blow past 10 GB long before your Postgres dumps do. Compress or exclude what you can, and remember that pulling the data back is free.
Security and Encryption
- One token per purpose. A token for Dokploy backups, scoped to one bucket, that can be deleted without touching anything else.
- No public access. R2 buckets are private by default; do not enable public buckets or a public dev URL on your backup bucket “just to check something”.
- Rotate on a schedule. Tokens in a password manager with a calendar reminder beat tokens nobody ever revisits.
- Know where the keys live. Dokploy keeps destination credentials in its own Postgres database, in plain columns. Anyone with database access has your R2 keys, which is another reason the token is bucket-scoped and read/write only on that bucket.
- Encrypt what leaves the box, if the data justifies it. R2 encrypts at rest, but Cloudflare can technically read your objects. If that matters:
# encrypt before upload, store the passphrase in your password manager
gpg --symmetric --cipher-algo AES256 backup-file.zip
# produces backup-file.zip.gpg
Symmetrical encryption means you must keep the passphrase somewhere you will still have access to during an outage. A passphrase only in the same VPS’s .bash_history is not a plan.
- TLS everywhere. R2 endpoints are HTTPS-only and Dokploy’s destination uses them as-is. Do not insert a proxy that downgrades.
- Audit access occasionally. Cloudflare shows R2 token usage; Dokploy keeps its own logs. If you did not run a restore, nobody should be reading that bucket.
If you are hardening the host side too, securing a Docker server after a BSI report covers the firewall and exposure work that should come before any of this.
Troubleshooting Common Issues
Backup completes but nothing appears in R2
- Re-run the destination Test Connection, then check the write path with the AWS CLI listing from Step 1.4.
- Confirm the bucket name is exactly right: it is case-sensitive.
- Confirm the region matches the bucket’s location hint (
WEUR,ENAM,WNAM,APAC). - Confirm the endpoint is your account endpoint with no bucket path appended.
- Confirm Force Path Style is enabled. Without it the test can pass and uploads still fail.
- Check the token permissions: Object Read & Write, scoped to this bucket.
docker service logs dokploy --tail 300 | grep -i -E "backup|s3|denied|error"
Backups ran yesterday and not today
Check the cron expression first, because a * * * * *-style typo in the wrong field is the classic. Then check that the entry is still Enabled and that the Dokploy service is actually up (docker ps). If backups are supposed to run while the host is off, they will not: the scheduler lives inside Dokploy.
Backup takes too long or times out
# how big is the volume, really?
docker system df -v | grep <volume-name>
Large volumes plus a slow uplink is the usual answer, and there is no clever fix: split the volume, stop backing up scratch data, or accept the window. Uploading 50 GB over a 100 Mbit link takes over an hour regardless of what the dashboard implies.
Restore fails
df -h: no free disk space is the most common cause, and the least obvious error message.- Confirm nothing is using the target volume or database (
docker ps,docker volume ls). - Confirm the file is intact: download it and run the gzip/pg_restore check from Step 4.
- For databases, confirm Dokploy can drop and create databases on that instance:
docker exec -it dokploy-postgres psql -U postgres -l
The Dokploy UI is down because the disk filled up
A full disk puts the Dokploy Postgres database into recovery mode and the panel stops responding. Backups you have been accumulating locally, plus dangling images, are the usual culprits:
docker system prune -a
docker builder prune -a
docker image prune -a
Then confirm the containers are all up:
docker ps # expect dokploy, dokploy-postgres, dokploy-traefik
If the Dokploy service is up but cannot reach its database (getaddrinfo ENOTFOUND dokploy-postgres in the logs), restart it:
docker service scale dokploy=0
docker service scale dokploy=1
The UI will not come back after a restore
docker service logs dokploy --tail 200
docker service logs dokploy-postgres --tail 100
docker logs dokploy-traefik --tail 100
Traefik errors usually point at a config file under /etc/dokploy/traefik/dynamic/, which the restore just replaced. A single invalid key (the docs use a missing passHostHeader as the example) takes down all routing. Fix the file, then docker restart dokploy-traefik.
R2 quota errors once you pass 10 GB
- Check current usage in the Cloudflare dashboard.
- Set
keepLatestCounton every entry that does not have it. - Add a lifecycle rule to expire objects in stale prefixes.
- Reduce frequency where the data does not justify every-6-hours.
- If you still need more, the paid tier is around $1.50 per 100 GB per month, with no egress charges. Buy the storage instead of degrading the backup.
FAQ
Does a Dokploy system backup include my application data?
No. The system backup contains the dokploy-postgres database and /etc/dokploy: your Dokploy configuration, users, destinations and deployment history. Application databases need their own database backup entries, and volumes need volume backups. Treat the three as three separate jobs and you will not discover the gap during a restore.
Can I back up a bind mount like ../files/my-data?
Not with Volume Backups. Dokploy only backs up Docker named volumes. If your compose file mounts ../files/..., that data is outside the backup system entirely, with no warning in the UI. Migrate the service to a named volume, then configure the backup. See Step 5 for the migration shape.
What happens to old backups when retain count is set?
Dokploy prunes objects in that backup’s prefix so only the newest keepLatestCount remain. It is per backup entry, so a production database keeping 7 and a dev database keeping 1 can share one bucket without stepping on each other. Bucket prefixes are the boundary, so do not point two entries at the same prefix with different retention and then wonder which one won.
Do backups still run if Dokploy or the server is down?
No. The schedule is executed by the Dokploy service on your host. Server off, Dokploy unhealthy, disk full, no backup, and no alert unless you add one. That is exactly why Step 6 adds an external staleness check with Uptime Kuma. Also take a manual backup before upgrades instead of trusting that the next cron tick will cover you.
Can I restore a backup to a different server?
Yes, that is the migration path. Restore the system backup on the new host, then update the IP in Web Server → Server → Update IP, fix DNS, recreate any Traefik.me domains, update Git providers that used raw IPs, and give Traefik a few minutes to issue certificates. The full checklist is in Step 7.
Is the R2 free tier enough for a small stack?
Usually yes. Dokploy system archives are tens of MB, database dumps compress well, and a handful of small volumes fit comfortably in 10 GB with a week of retention. Watch volume backups if you store media, because that is where the gigabytes come from. Above the free tier you are paying cents per gigabyte with free egress, so this is a cheap problem to have.
Should I use R2 lifecycle rules as well as Dokploy's retain count?
Yes, as a backstop. Dokploy prunes per backup entry based on keepLatestCount; an R2 lifecycle rule on the bucket catches anything left behind: a deleted backup entry, a prefix you stopped using, a failed prune. Two independent cleanup mechanisms cost nothing and prevent the disk/quota surprise.
Conclusion
You now have a working backup chain for a self-hosted Dokploy box: a bucket in a different provider, a tested S3 destination, schedules with built-in retention for the Dokploy instance itself, every database, and every named volume, plus restore procedures and an external check that keeps the whole thing honest.
Key takeaways
- Three separate backup types: Dokploy system, databases, volumes. None of them cover the others.
- Volume backups need Docker named volumes. Bind mounts are invisible to the feature.
- Set
keepLatestCounton every entry, then add an R2 lifecycle rule as a second net. - Store backups with a different provider than your VPS, which is the entire point of using R2 here.
- Nothing runs until you enable a schedule, and the scheduler lives inside Dokploy, so alert on stale backups externally.
- Test restores on a calendar, not on the day you need one. A monthly database restore and a quarterly system restore drill is enough.
Next steps
- Turn on the staleness check from Step 6 and let it run for a week before you trust it.
- Do one monthly database restore into a scratch database and record how long it took.
- Do a quarterly full system restore on a throwaway VPS. A cheap 2 vCPU box is enough for the drill.
- Write down the restore order for your stack (Dokploy config first, then databases, then volumes) and keep it where you can find it when your panel is down.
Related resources
If you are building out the rest of the stack, these are the guides that pair with this one:
- Install Dokploy on a VPS - the base setup, hardening and update routine
- Deploy a Docker Compose app in Dokploy - named volumes, mounts and services
- Update Docker Compose stacks in Dokploy - the operation that makes backups matter
- Deploy TanStack Start on Dokploy - a real app with a real database
- Multiple PostgreSQL databases in one service - when one instance holds several apps
- Pluton: self-hosted backups with restic and rclone - a second, independent backup path
- Zerobyte: Restic GUI for backup automation - if you want retention and dedupe handled for you
- Mount an S3 bucket as a filesystem - useful when you need to inspect backups as files
- Monitor with Beszel and Uptime Kuma - the alerting half of this setup
- Coolify vs Dokploy vs Kamal 2 - if you are still choosing a panel
Configure the backup, then break something on purpose and restore it. That is the only test that counts.


