Skip to content

Shipping Labels Storage with DigitalOcean Spaces

Last Updated: March 3, 2026

This document explains the DigitalOcean Spaces integration for persistent, scalable shipping label storage.


Why DigitalOcean Spaces?

Previous approach: Shipping labels stored in local Docker volumes (./media/shipping_labels/)

Problems: - ❌ Labels lost on container rebuilds (before media volume added) - ❌ Volume mounts required for persistence - ❌ Difficult to scale across multiple servers - ❌ No CDN for fast global delivery - ❌ Manual backup management

DigitalOcean Spaces solution: - ✅ Persistent - survives all container operations - ✅ CDN-enabled - fast global delivery via lon1.cdn.digitaloceanspaces.com - ✅ Scalable - no disk space concerns - ✅ Multi-server ready - deploy multiple containers without shared volumes - ✅ S3-compatible - industry standard API (boto3) - ✅ Zero rebuild deployments - no volume configuration needed


Architecture

Components

  1. DigitalOcean Spaces (S3-compatible object storage)
  2. Bucket: sensational-shipping-labels
  3. Region: London (lon1)
  4. CDN: Enabled for fast delivery
  5. Access: Private (authentication required)

  6. django-storages + boto3

  7. S3-compatible storage backend for Django
  8. Handles upload/download/streaming

  9. Custom Storage Backend (uplink/storage_backends.py)

  10. ShippingLabelStorage class
  11. Configured for shipping labels specifically
  12. Uses private ACL (secure by default)

File Flow

Creating a Shipment: 1. Order shipped via FedEx/Royal Mail/etc 2. API returns label URL (temporary, typically 24-72 hour expiry) 3. Label PDF downloaded from carrier API 4. Uploaded to Spaces ({tracking_number}.pdf) 5. Tracking number stored in database

Accessing a Label: 1. User clicks label link (/shipping-label/{tracking_number}/) 2. Django checks authentication 3. Fetches PDF from Spaces via boto3 4. Streams to user's browser 5. If missing: attempt regeneration from carrier API → upload to Spaces


Setup Instructions

1. Create Spaces Access Key

  1. Go to: https://cloud.digitalocean.com/spaces/sensational-shipping-labels
  2. Click Settings tab
  3. Scroll to Access Keys section
  4. Click Create Access Key
  5. Name it: uplink-production (or similar)
  6. Save the Key ID and Secret (secret only shown once!)

2. Add Credentials to .env

Add these variables to your production .env file:

# DigitalOcean Spaces Configuration
SPACES_ACCESS_KEY_ID=your-spaces-access-key-id-here
SPACES_SECRET_ACCESS_KEY=your-spaces-secret-key-here
SPACES_BUCKET_NAME=sensational-shipping-labels
SPACES_REGION=lon1
SPACES_ENDPOINT_URL=https://lon1.digitaloceanspaces.com
SPACES_CDN_DOMAIN=sensational-shipping-labels.lon1.cdn.digitaloceanspaces.com

Template: See .env.spaces.example for reference

3. Update Dependencies

The required packages are already added to pyproject.toml: - boto3 - AWS SDK for Python (S3-compatible) - django-storages - Django storage backends

Install them:

# Local development
docker compose build web

# Production
./deploy.sh deploy-rebuild

4. Test Configuration

# Test Spaces connection
docker compose exec web python manage.py shell

from django.conf import settings
from django.utils.module_loading import import_string

# Initialize storage
storage_class = import_string(settings.SHIPPING_LABEL_STORAGE)
storage = storage_class()

# Test connection (should not raise error)
print(f"Bucket: {storage.bucket_name}")
print(f"Region: {storage.client.meta.region_name}")

5. Migrate Existing Labels (Optional)

If you have existing labels in /media/shipping_labels/, migrate them to Spaces:

# Dry run first (see what would be uploaded)
docker compose exec web python migrate_labels_to_spaces.py --dry-run

# Actually migrate
docker compose exec web python migrate_labels_to_spaces.py

Note: The migration script: - Skips files already in Spaces - Preserves original filenames - Reports upload errors - Safe to run multiple times


How It Works

Label Upload (Ship View)

orders/views.py ~Line 2125:

# Step 4: Download & Upload FedEx Shipping Label to DigitalOcean Spaces
if fedex_label_url:
    try:
        response = requests.get(fedex_label_url)
        response.raise_for_status()

        if response.status_code == 200:
            # Upload to DigitalOcean Spaces
            storage_class = import_string(settings.SHIPPING_LABEL_STORAGE)
            storage = storage_class()
            storage_path = f"{shipment.tracking_number}.pdf"
            storage.save(storage_path, ContentFile(response.content))
            logger.info(f"FedEx Shipping Label uploaded to Spaces: {storage_path}")

Label Serving (ServeShippingLabel View)

orders/views.py ~Line 3100:

class ServeShippingLabel(View):
    """
    Serve shipping label PDFs from DigitalOcean Spaces with authentication.
    """
    def get(self, request, tracking_number, *args, **kwargs):
        # Authentication required
        if not request.user.is_authenticated:
            raise Http404("Not found")

        # Initialize Spaces storage
        storage_class = import_string(settings.SHIPPING_LABEL_STORAGE)
        storage = storage_class()

        # Check if exists
        storage_path = f'{tracking_number}.pdf'
        if not storage.exists(storage_path):
            # Attempt regeneration from carrier API
            # ...

        # Stream from Spaces to user
        file_obj = storage.open(storage_path, 'rb')
        response = HttpResponse(file_obj.read(), content_type='application/pdf')
        return response

Automatic Regeneration

If a label is missing from Spaces, the system attempts to recover it:

  1. FedEx shipments: Calls Document Retrieval API (works 30-90 days)
  2. Other carriers: Tries cached URL from api_create_shipment_response (typically 24-72 hours)
  3. If successful: uploads to Spaces for future requests
  4. If fails: shows error with tracking link

Configuration Reference

Settings (uplink/settings.py)

# DigitalOcean Spaces Configuration
AWS_ACCESS_KEY_ID = env("SPACES_ACCESS_KEY_ID", default="")
AWS_SECRET_ACCESS_KEY = env("SPACES_SECRET_ACCESS_KEY", default="")
AWS_STORAGE_BUCKET_NAME = env("SPACES_BUCKET_NAME", default="sensational-shipping-labels")
AWS_S3_ENDPOINT_URL = env("SPACES_ENDPOINT_URL", default="https://lon1.digitaloceanspaces.com")
AWS_S3_REGION_NAME = env("SPACES_REGION", default="lon1")
AWS_S3_CUSTOM_DOMAIN = env("SPACES_CDN_DOMAIN", default=f"{AWS_STORAGE_BUCKET_NAME}.lon1.cdn.digitaloceanspaces.com")
AWS_S3_OBJECT_PARAMETERS = {
    'CacheControl': 'max-age=86400',  # Cache for 24 hours
}
AWS_DEFAULT_ACL = 'private'  # Keep files private by default
AWS_QUERYSTRING_AUTH = True  # Use pre-signed URLs for private files
AWS_QUERYSTRING_EXPIRE = 3600  # Pre-signed URLs valid for 1 hour

# Shipping Label Storage Backend
SHIPPING_LABEL_STORAGE = 'uplink.storage_backends.ShippingLabelStorage'

Storage Backend (uplink/storage_backends.py)

from storages.backends.s3boto3 import S3Boto3Storage

class ShippingLabelStorage(S3Boto3Storage):
    """
    Storage backend for shipping label PDFs in DigitalOcean Spaces.
    """
    bucket_name = None  # Set from settings.AWS_STORAGE_BUCKET_NAME
    location = 'labels'  # Subfolder within bucket
    default_acl = 'private'  # Private by default
    file_overwrite = False  # Never overwrite existing files

Deployment Workflow

Local Development

  1. Add Spaces credentials to local .env
  2. Rebuild containers: docker compose build web
  3. Restart: docker compose up -d
  4. Create test shipment → label uploads to Spaces
  5. Access label → streams from Spaces

Production

  1. Add Spaces credentials to production .env
  2. Deploy: ./deploy.sh deploy-rebuild (first time only for dependencies)
  3. Future deploys: ./deploy.sh deploy-main (no rebuild needed!)
  4. Optionally migrate existing labels: migrate_labels_to_spaces.py

Important: After adding boto3/django-storages, you need ONE rebuild. After that, normal deploy-main works since code changes don't affect dependencies.


Benefits

Before (Local Media Volumes)

# docker-compose.prod.yml
web:
  volumes:
    - ./media:/app/media  # Required for persistence

Issues: - Must mount volume on every container - Labels lost if volume not mounted - Can't scale horizontally easily - Manual backup required

After (DigitalOcean Spaces)

# docker-compose.prod.yml (simplified)
web:
  # No volume mount needed for shipping labels!
  environment:
    - SPACES_ACCESS_KEY_ID=${SPACES_ACCESS_KEY_ID}
    - SPACES_SECRET_ACCESS_KEY=${SPACES_SECRET_ACCESS_KEY}

Benefits: - ✅ No volume mounts for labels - ✅ Labels persist automatically
- ✅ Can run multiple web containers - ✅ CDN delivers files globally - ✅ DigitalOcean handles backups


Troubleshooting

"NoCredentialsError: Unable to locate credentials"

Cause: Spaces access keys not configured

Solution:

# Check .env has credentials
cat .env | grep SPACES_

# Restart containers to load new env vars
docker compose down
docker compose up -d

"botocore.exceptions.EndpointConnectionError"

Cause: Wrong endpoint URL or region

Solution:

# Verify endpoint matches your Spaces region
SPACES_ENDPOINT_URL=https://lon1.digitaloceanspaces.com  # London
SPACES_REGION=lon1

Labels not uploading

Check logs:

docker compose logs -f web | grep -i "shipping label\|spaces"

Verify storage:

from django.conf import settings
from django.utils.module_loading import import_string

storage_class = import_string(settings.SHIPPING_LABEL_STORAGE)
storage = storage_class()

# Test upload
from django.core.files.base import ContentFile
storage.save('test.txt', ContentFile(b'Hello Spaces!'))

# Check if exists
print(storage.exists('test.txt'))  # Should print True

# Clean up
storage.delete('test.txt')

Old labels missing after Spaces migration

Expected: Labels created before Spaces was configured won't exist in Spaces

Solution: Run migration script:

docker compose exec web python migrate_labels_to_spaces.py


Security

Access Control

  • Bucket: Private (file listing restricted to access keys)
  • Files: Private by default (AWS_DEFAULT_ACL = 'private')
  • Access: Django authentication required
  • URLs: Pre-signed (temporary, expire after 1 hour)

Best Practices

  1. Never commit access keys - use .env only
  2. Rotate keys periodically - create new keys, update .env, delete old
  3. Use granular keys - create bucket-specific keys (not account-wide)
  4. Monitor usage - check Spaces dashboard for unusual activity
  5. CORS configured - only allow your domain if serving directly

Cost Estimation

DigitalOcean Spaces Pricing (as of March 2026): - Storage: $5/month for 250 GB - Bandwidth: $0.01/GB for outbound transfer - First 1 TB outbound: Included

Estimated costs: - Average label: ~50 KB - 1000 labels: ~50 MB storage - 1000 downloads/month: ~50 MB bandwidth

Monthly cost: ~$5 base (easily handles thousands of labels)

Compared to volume storage: Essentially free (already paying for droplet)

Value: CDN, persistence, scalability, backups included!


Maintenance

Regular Tasks

  1. Monitor Spaces usage - Check dashboard monthly
  2. Review access logs - If enabled, audit who's accessing labels
  3. Rotate access keys - Every 6-12 months
  4. Clean up old labels - Optional: delete labels for closed/delivered orders

Backup

DigitalOcean automatically backs up Spaces data, but you can also:

# Download all labels for local backup
aws s3 sync s3://sensational-shipping-labels ./backup/labels/ \
  --endpoint-url https://lon1.digitaloceanspaces.com \
  --profile digitalocean

Additional Resources


Questions or issues? Contact: hannah@sensational.systems