Bitdoze Logo

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.

DragosDragos50 min read
Dokploy backup configuration writing database and volume archives to a Cloudflare R2 bucket

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, compose and libsql service 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 rm on 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.

Get a Hetzner VPS

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-postgres PostgreSQL database with your applications, services, environment variables, destinations and deployment history
  • The Dokploy file system at /etc/dokploy with 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.

Dokploy volume backups interface showing schedule, destination and volume name

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

  1. Log into the Cloudflare dashboard and open R2 Object Storage.
  2. Click Create bucket.
  3. Name it something explicit: dokploy-backups, or one bucket per purpose if you want clean lifecycle rules later.
  4. 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.
  5. 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

  1. Go back to R2 Object Storage and choose Manage API tokens from the dropdown.
  2. Click Create API token.
  3. Name it Dokploy Backup Access.
  4. Permissions: Object Read & Write.
  5. Apply to specific buckets only and select dokploy-backups. Do not give Dokploy account-wide access because it was easier.
  6. 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:

  • InvalidAccessKeyId or SignatureDoesNotMatch - wrong key, or you pasted the secret with a trailing space
  • NoSuchBucket - bucket name typo, and it is case-sensitive
  • AccessDenied on 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-1 instead of auto

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.

  1. In Dokploy, open Settings → Destinations (the route is /dashboard/settings/destinations).
  2. Click New Destination and pick S3 Compatible.
  3. 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.

  1. 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

  1. Go to Web Server → Backups.
  2. Click Create Backup.
  3. 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:

  1. An object exists in the bucket under the prefix you set.
  2. Its size is plausible (a Dokploy system archive is typically tens of MB, not 0 bytes and not 5 GB).
  3. 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.

  1. Open Databases, pick the database, then the Backup tab.
  2. 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
  1. 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 -20

If 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/      # throwaway

Stagger 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 3

Set 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

  1. Open the application or compose service in Dokploy.
  2. Go to the Volume Backups section and click Create Volume Backup.
  3. 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.

  1. Go to Web Server → Backups and click Restore Backup.
  2. Choose the S3 destination holding the backup.
  3. Browse or search for the backup file you want.
  4. Review the summary of what will be restored.
  5. Confirm and wait. Expect a few minutes.
  6. 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
  1. Update the server IP in Web Server → Server → Update IP.
  2. Fix DNS so your A records point at the new IP, then wait for propagation.
  3. Recreate Traefik.me domains if you used them; they follow the server IP and need to be recreated in the app settings.
  4. Reconfigure Git providers if any webhook or integration was configured with a raw IP instead of a hostname. Domain-based providers need nothing.
  5. Wait for certificates. Traefik provisions new Let’s Encrypt certificates on the new host; give it a few minutes before deciding HTTPS is broken.
  6. Test database connections from each application, and update any connection strings that referenced the old host.
  7. 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

  1. Open Databases, select the database, go to the Backup tab.
  2. Click Restore.
  3. Choose the source S3 bucket, then start typing the filename, and the field autocompletes from what is in the bucket.
  4. If you use prefixes, type the prefix to narrow it: typing prod-db/ autocompletes to prod-db/postgres-backup-2026-09-14-06-00.sql.gz.
  5. 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 appdb

Restore a volume

  1. Open the application or compose service, find Volume Backups, click Restore Volume.
  2. Choose the destination and the specific backup file.
  3. Enter the target volume name. For compose services this is the full {appName}_{volumeName}, for example n8n-kqlble_n8n_data. A bare n8n_data will 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

  1. Re-run the destination Test Connection, then check the write path with the AWS CLI listing from Step 1.4.
  2. Confirm the bucket name is exactly right: it is case-sensitive.
  3. Confirm the region matches the bucket’s location hint (WEUR, ENAM, WNAM, APAC).
  4. Confirm the endpoint is your account endpoint with no bucket path appended.
  5. Confirm Force Path Style is enabled. Without it the test can pass and uploads still fail.
  6. 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

  1. df -h: no free disk space is the most common cause, and the least obvious error message.
  2. Confirm nothing is using the target volume or database (docker ps, docker volume ls).
  3. Confirm the file is intact: download it and run the gzip/pg_restore check from Step 4.
  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

  1. Check current usage in the Cloudflare dashboard.
  2. Set keepLatestCount on every entry that does not have it.
  3. Add a lifecycle rule to expire objects in stale prefixes.
  4. Reduce frequency where the data does not justify every-6-hours.
  5. 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 keepLatestCount on 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

  1. Turn on the staleness check from Step 6 and let it run for a week before you trust it.
  2. Do one monthly database restore into a scratch database and record how long it took.
  3. Do a quarterly full system restore on a throwaway VPS. A cheap 2 vCPU box is enough for the drill.
  4. 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.

If you are building out the rest of the stack, these are the guides that pair with this one:

Install Dokploy Guide Deploy Apps on Dokploy Update Docker Compose Apps

Configure the backup, then break something on purpose and restore it. That is the only test that counts.