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¶
- DigitalOcean Spaces (S3-compatible object storage)
- Bucket:
sensational-shipping-labels - Region: London (
lon1) - CDN: Enabled for fast delivery
-
Access: Private (authentication required)
-
django-storages + boto3
- S3-compatible storage backend for Django
-
Handles upload/download/streaming
-
Custom Storage Backend (
uplink/storage_backends.py) ShippingLabelStorageclass- Configured for shipping labels specifically
- 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¶
- Go to: https://cloud.digitalocean.com/spaces/sensational-shipping-labels
- Click Settings tab
- Scroll to Access Keys section
- Click Create Access Key
- Name it:
uplink-production(or similar) - 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:
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)¶
# 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)¶
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:
- FedEx shipments: Calls Document Retrieval API (works 30-90 days)
- Other carriers: Tries cached URL from
api_create_shipment_response(typically 24-72 hours) - If successful: uploads to Spaces for future requests
- 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¶
- Add Spaces credentials to local
.env - Rebuild containers:
docker compose build web - Restart:
docker compose up -d - Create test shipment → label uploads to Spaces
- Access label → streams from Spaces
Production¶
- Add Spaces credentials to production
.env - Deploy:
./deploy.sh deploy-rebuild(first time only for dependencies) - Future deploys:
./deploy.sh deploy-main(no rebuild needed!) - 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)¶
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:
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:
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¶
- ✅ Never commit access keys - use
.envonly - ✅ Rotate keys periodically - create new keys, update
.env, delete old - ✅ Use granular keys - create bucket-specific keys (not account-wide)
- ✅ Monitor usage - check Spaces dashboard for unusual activity
- ✅ 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¶
- Monitor Spaces usage - Check dashboard monthly
- Review access logs - If enabled, audit who's accessing labels
- Rotate access keys - Every 6-12 months
- 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¶
- DigitalOcean Spaces Documentation
- django-storages Documentation
- boto3 Documentation
- Migration Script
- Storage Backend
Questions or issues? Contact: hannah@sensational.systems