Back Up Django Media Files

Back up Django uploads reliably: MEDIA_ROOT with restic, S3 versioning and replication, consistency with database backups, retention, and restores.

Problem statement

Many Django projects back up the database carefully and forget uploaded files. After a disk failure, an accidental rm -rf, a compromised server, or a bug that deletes objects in a bucket, the database still references avatars/2026/09/photo.jpg, invoices/1042.pdf, and thousands of other files that no longer exist. Restoring only the database leaves broken images and missing documents everywhere.

Media backups have their own challenges:

  • files change continuously and can be large in total
  • database rows and files must correspond, but they are backed up at different times
  • files in object storage feel durable, but durability protects against hardware failure, not against deletion by your own code or an attacker with credentials
  • a backup nobody has restored is an assumption, not a backup

This guide covers backups for media stored on local disk and in S3-compatible object storage, how to keep them consistent with database backups, and how to test restores.

Protecting user uploadsUploadsdisk or bucketCopyversioned, off-siteRetentionand lifecycleRestore testfiles usableMedia is often the only data with no second copy in the database dump.

Quick answer

  1. Identify where media lives: MEDIA_ROOT on disk, or a bucket through django-storages.
  2. Local disk: back up MEDIA_ROOT with an incremental, encrypted, deduplicating tool such as restic to storage in a different location, on a schedule.
  3. Object storage: enable bucket versioning, add lifecycle rules for old versions, and replicate to a second bucket (different region or account) with restricted delete permissions.
  4. Take media backups right after database backups, and retain both for the same period.
  5. Monitor backup jobs with heartbeats, and check backup integrity regularly.
  6. Test restores: restore files and a matching database into a staging environment and verify pages load.

Step-by-step solution

1. Inventory media storage

python manage.py shell -c "
from django.conf import settings
from django.core.files.storage import default_storage
print(settings.STORAGES['default']['BACKEND'])
print(getattr(settings, 'MEDIA_ROOT', None))
print(type(default_storage).__name__)
"

Also check models with their own storage= arguments; private files often use a separate bucket or directory. List every location so nothing is forgotten.

Estimate size and change rate:

du -sh /srv/myapp/media
find /srv/myapp/media -type f -mtime -1 | wc -l
the backup depends on where MEDIA livesWhere do this app's uploads live?MEDIA_ROOT on diskrestic snapshots off-siteencrypted, deduplicatedS3-compatible bucketVersioning + second copyreplication or rclone copybothBack up each locationlist every storage=Models with their own storage= argument are the ones most often forgottendjango-deployment.com
Start from the inventory: every storage location needs its own backup path.

2. Local MEDIA_ROOT: back up with restic

restic creates encrypted, deduplicated snapshots. Only changed data is uploaded after the first run, which suits media directories that grow gradually.

Initialize a repository in object storage in another location or provider:

export RESTIC_REPOSITORY="s3:s3.eu-central-1.amazonaws.com/myapp-media-backups"
export RESTIC_PASSWORD_FILE=/etc/myapp/restic-password
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...

restic init

Store the repository password in a password manager too. Without it, backups cannot be restored.

Create the backup script /srv/myapp/bin/backup-media:

#!/bin/bash
set -euo pipefail

set -a
. /etc/myapp/backup.env
set +a

restic backup /srv/myapp/media --tag media --host "$(hostname)"
restic forget --tag media --keep-daily 14 --keep-weekly 8 --keep-monthly 12 --prune
curl -fsS -m 10 --retry 3 "$MEDIA_BACKUP_HEARTBEAT_URL" > /dev/null
  • forget --prune applies retention and removes unreferenced data.
  • The heartbeat ping at the end only runs if backup and retention succeeded.

Schedule it with a systemd timer after the nightly database backup, and run an integrity check weekly:

restic check --read-data-subset=5%

--read-data-subset verifies a sample of stored data, which catches storage corruption without downloading the whole repository each time.

3. Object storage: versioning

If media lives in S3, enable versioning on the bucket. Overwrites and deletes then keep previous versions instead of destroying data:

aws s3api put-bucket-versioning --bucket myapp-media \
  --versioning-configuration Status=Enabled

Add a lifecycle rule so noncurrent versions do not accumulate forever:

{
  "Rules": [
    {
      "ID": "expire-old-versions",
      "Status": "Enabled",
      "Filter": {},
      "NoncurrentVersionExpiration": { "NoncurrentDays": 90 },
      "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
    }
  ]
}
aws s3api put-bucket-lifecycle-configuration --bucket myapp-media \
  --lifecycle-configuration file://lifecycle.json

Versioning protects against accidental deletion by application code, as long as the application's credentials cannot also delete old versions. Grant the app s3:DeleteObject (which creates delete markers) but not s3:DeleteObjectVersion.

4. Object storage: replication or copies elsewhere

Versioning within one bucket does not protect against losing the bucket, account compromise, or region-wide problems. Add a second copy:

  • S3 Replication to a bucket in another region, ideally in another AWS account with separate credentials. Replication requires versioning on both buckets.
  • Scheduled copies with rclone to another provider:
rclone copy s3-primary:myapp-media backup-provider:myapp-media-backup \
  --checksum --transfers 16 --log-level INFO

Use copy, not sync, for backups: sync deletes files in the destination that were deleted at the source, which propagates accidental deletions into the backup. Combine with versioning or object lock on the destination for protection against tampering.

the same command, one word apartrclone sync (mirror)✕deletes destination files missing atsource✕an accidental delete is copied to thebackup✕a bug that wipes uploads wipes bothrclone copy (backup)✓only adds and updates files✓deleted uploads stay recoverableprune separately, on your scheduleA mirror protects against disk failure, not against your own deletesdjango-deployment.com
Use copy for backups, and apply retention as a separate, deliberate step.

For compliance or ransomware protection, S3 Object Lock in governance or compliance mode prevents deletion of object versions for a retention period, even by administrators in compliance mode.

5. Keep database and media consistent

A database backup taken at 02:00 and a media backup at 04:00 do not describe exactly the same moment:

  • files uploaded between 02:00 and 04:00 exist in the media backup but not in the database (orphans, harmless)
  • files deleted between 02:00 and 04:00 exist in the database backup but not in the media backup (broken references)
one night, two backupsDatabase dump02:00User uploads a file02:40User deletesreport.pdf03:10Media snapshot04:00Restore: row, no filebroken referenceMedia after the database, plus delayed physical deletes, closes the gapdjango-deployment.com
Orphaned files are harmless; missing files are not, which is why deletes should be delayed past the backup gap.

Practical approach:

  • run the media backup after the database backup, so the media snapshot contains every file the database backup could reference, plus a few extra
  • avoid deleting media files immediately when records are deleted; soft-delete or delay physical deletion by a period longer than the gap between backups
  • with S3 versioning, you can restore object versions as of the database backup time if needed

6. Restore procedures

Write down and test the steps.

a media restore, step by step1Pick the restore pointmatch the database backup2Restore to a new locationnever over live files3Spot-check filesopen a few recent uploads4Point MEDIA_ROOT or the bucketor sync missing files back5Check broken linksrows without filesMedia and database restored to different moments give broken linksdjango-deployment.com
Time a full restore once; it is usually slower than expected.

restic restore to a staging path:

restic snapshots --tag media
restic restore latest --tag media --target /srv/restore-test
ls /srv/restore-test/srv/myapp/media | head

S3 restore of a deleted object: list versions and copy the previous version back:

aws s3api list-object-versions --bucket myapp-media --prefix invoices/1042.pdf
aws s3api copy-object --bucket myapp-media --key invoices/1042.pdf \
  --copy-source "myapp-media/invoices/1042.pdf?versionId=EXAMPLEVERSIONID"

Full restore test: restore the database backup and media into staging, then check that pages with images and downloads work, and run a script comparing file references with storage:

# core/management/commands/check_media_references.py
from django.apps import apps
from django.core.files.storage import default_storage
from django.core.management.base import BaseCommand
from django.db.models import FileField


class Command(BaseCommand):
    help = "Report FileField values that do not exist in storage."

    def handle(self, *args, **options):
        missing = 0
        for model in apps.get_models():
            fields = [f for f in model._meta.get_fields() if isinstance(f, FileField)]
            for field in fields:
                names = (
                    model._default_manager.exclude(**{field.name: ""})
                    .values_list(field.name, flat=True)
                    .iterator()
                )
                for name in names:
                    if name and not default_storage.exists(name):
                        missing += 1
                        self.stdout.write(f"{model._meta.label}.{field.name}: {name}")
        self.stdout.write(f"missing files: {missing}")

For large media collections, run this on a sample or in batches, because each exists() call is a filesystem check or storage API request. Fields with custom storage= arguments need their own storage instance instead of default_storage.

7. Monitor

  • heartbeat monitors for each backup job
  • alerts on failed restic check runs
  • replication status metrics for S3 replication
  • periodic restore tests on a calendar, with results recorded

Verification check:

  • a snapshot or replicated copy of media exists outside the primary server or bucket
  • application credentials cannot delete backups or old object versions
  • a restore test into staging shows images and downloads working with the matching database backup
  • retention policies keep backups for at least as long as database backups

Explanation

Uploaded files are part of your application's data, just stored outside the database. A useful media backup lives somewhere a single failure or compromised credential cannot reach, retains history long enough to recover from mistakes discovered late, and can be restored consistently with a database backup. Incremental snapshot tools handle local directories efficiently; for object storage, versioning plus an independent copy provides both undo and disaster protection.

Edge cases / notes

Very large media libraries

For terabytes of media, initial backups take days. Seed the backup from a snapshot or use provider-side replication, and plan restore time as part of your recovery objectives.

Generated files

Thumbnails and derived files can often be regenerated. Excluding them reduces backup size, but regenerating everything after a disaster takes time; decide consciously.

Private and sensitive files

Encrypt backups (restic does by default) and restrict access to backup credentials as tightly as production credentials.

Sizing and scheduling

Media backups behave differently from database dumps because the data set grows monotonically and individual files rarely change.

  • First run transfers everything; plan for it to take hours or days on large libraries, and run it outside peak hours with bandwidth limits (rclone --bwlimit 20M, restic --limit-upload).
  • Later runs transfer only new files, so they are fast and cheap.
  • Frequency can be lower than database backups for slow-growing media (daily is typical), but keep the ordering: database first, then media.
  • Cost is dominated by storage volume and, for object storage, request counts; deduplicating tools such as restic reduce storage but make many small requests.

Track the backup size trend. A sudden jump can mean a bug generating files (for example thumbnails regenerated on every request), and a sudden drop can mean files were deleted.

Restore time matters as much as backup time

A backup that takes three days to restore does not meet a one-day recovery objective. Measure restore time on a realistic subset:

time restic restore latest --tag media --target /srv/restore-test --include "/srv/myapp/media/avatars"

Extrapolate from the subset, and if the full restore exceeds your target, plan a faster path: provider-side replication for immediate availability, restoring critical prefixes first, or serving from the backup bucket temporarily while the main one is rebuilt.

Common mistakes

  • Backing up the database but not uploads.
  • Using rclone sync or aws s3 sync --delete as a backup, propagating deletions.
  • Giving the application permission to delete object versions or backup data.
  • Never restoring a backup to check it works.

Next steps

FAQ

How should I back up Django media files?

Back up local MEDIA_ROOT with an incremental, encrypted tool such as restic to storage in another location. For S3, enable versioning and replicate or copy to a separate bucket or account.

Is S3 storage already a backup for Django uploads?

No. S3 durability protects against hardware failure, not against deletion by your code, compromised credentials, or misconfiguration. Enable versioning and keep an independent copy.

Should media backups run before or after database backups?

After. The media snapshot then contains every file the database backup can reference, and extra files are harmless orphans.

How do I check that restored media matches the database?

Restore both into staging, browse pages with images and downloads, and run a script that checks each FileField value exists in storage.

Keep going