File Storage Strategy (Local vs S3)
The essential playbook for implementing file storage strategy (local vs s3) in your SaaS.
Choose storage based on environment and failure mode, not convenience. Local disk is fast and simple for development and small single-server deployments. S3 or S3-compatible storage is the safer default for production because files survive redeploys, scale across multiple app instances, and work better with CDNs and background processing.
Quick Fix / Quick Setup
Recommended default:
- local storage in development
- S3-compatible storage in production
# development
STORAGE_BACKEND=local
MEDIA_ROOT=./uploads
MEDIA_URL=/media/
# production
STORAGE_BACKEND=s3
S3_BUCKET=your-bucket
S3_REGION=us-east-1
S3_ENDPOINT_URL=
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_PUBLIC_BASE_URL=https://your-bucket.s3.amazonaws.comPython storage abstraction:
import os
STORAGE_BACKEND = os.getenv("STORAGE_BACKEND", "local")
class LocalStorage:
def save(self, path, content):
full_path = os.path.join(os.getenv("MEDIA_ROOT", "./uploads"), path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "wb") as f:
f.write(content)
return f"{os.getenv('MEDIA_URL', '/media/')}{path}"
class S3Storage:
def __init__(self):
import boto3
self.bucket = os.environ["S3_BUCKET"]
self.public_base = os.environ["S3_PUBLIC_BASE_URL"]
self.client = boto3.client(
"s3",
region_name=os.getenv("S3_REGION"),
endpoint_url=os.getenv("S3_ENDPOINT_URL") or None,
aws_access_key_id=os.environ["S3_ACCESS_KEY_ID"],
aws_secret_access_key=os.environ["S3_SECRET_ACCESS_KEY"],
)
def save(self, path, content, content_type="application/octet-stream"):
self.client.put_object(
Bucket=self.bucket,
Key=path,
Body=content,
ContentType=content_type,
)
return f"{self.public_base}/{path}"
def get_storage():
return LocalStorage() if STORAGE_BACKEND == "local" else S3Storage()
# usage
# file_url = get_storage().save("user-uploads/avatar.png", file_bytes)Do not hardcode absolute filesystem paths into app logic. Store only the object key or relative path in the database, and let the storage backend generate the final URL.
Quick decision rules:
- Use local storage for development, test environments, internal tools, and very small single-server MVPs.
- Use S3 or S3-compatible storage for production if you run Docker, autoscaling, multiple instances, worker queues, or frequent deploys.
- If users upload files that must persist after deploys, local storage on ephemeral disks is a bad production default.
- If you expect image delivery, downloads, backups, or signed private access, prefer object storage early.
What’s happening
Local storage writes files to the app server filesystem. That works on one machine, but breaks when you redeploy, replace containers, or run multiple instances. One app instance may write a file that another instance cannot read. A new deploy may replace the filesystem entirely.
S3-style object storage separates file persistence from app servers. Your app uploads bytes to a bucket and keeps only a stable object key such as:
user/42/files/550e8400-e29b-41d4-a716-446655440000-avatar.pngThe app should treat storage as an abstraction:
- save bytes
- store key
- request URL later
- delete by key when needed
Comparison:
| Factor | Local disk | S3-compatible storage |
|---|---|---|
| Setup complexity | Low | Medium |
| Dev speed | High | Medium |
| Persistent across redeploys | Usually no unless mounted | Yes |
| Multi-instance support | Poor | Good |
| CDN integration | Weak | Strong |
| Background job compatibility | Weak if path-local | Strong |
| Private file access | Custom app logic | Signed URLs supported |
| Best use | Dev, single server MVP | Production |
| Feature | Local Disk | Object Storage (S3) |
|---|---|---|
| Durability | Low (single point of failure) | High (distributed) |
| Scaling | Limited by disk size | Virtually infinite |
| CDN Support | Complex / Manual | Native / Easy |
| Cost | Fixed per GB (higher) | Pay-as-you-go (lower) |
comparison table for local disk vs S3 across durability, cost, scaling, CDN support, and operational complexity.
Step-by-step implementation
1. Define a storage interface
Do not scatter file writes across controllers, routes, or models.
class Storage:
def save(self, key: str, content: bytes, content_type: str = "application/octet-stream") -> str:
raise NotImplementedError
def delete(self, key: str) -> None:
raise NotImplementedError
def exists(self, key: str) -> bool:
raise NotImplementedError
def url(self, key: str) -> str:
raise NotImplementedError2. Keep configuration in environment variables
Use environment-driven config so development and production use the same app code.
STORAGE_BACKEND=local
MEDIA_ROOT=./uploads
MEDIA_URL=/media/
# or
STORAGE_BACKEND=s3
S3_BUCKET=your-bucket
S3_REGION=us-east-1
S3_ENDPOINT_URL=
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_PUBLIC_BASE_URL=https://your-bucket.s3.amazonaws.comIf you need a consistent pattern for env management, see Environment Variables and Secrets Management.
3. Store only keys in the database
Good:
{
"file_key": "user/42/files/550e8400-avatar.png"
}Bad:
{
"file_path": "/var/www/app/uploads/user/42/files/avatar.png"
}Bad:
{
"file_url": "https://your-bucket.s3.amazonaws.com/user/42/files/avatar.png"
}Store provider-neutral keys or relative paths. Generate URLs at read time.
4. Use deterministic unique object keys
Avoid collisions and make cleanup easier.
import os
import uuid
import re
def safe_filename(name: str) -> str:
name = name.lower().strip()
name = re.sub(r"[^a-z0-9._-]+", "-", name)
return name[:120]
def build_file_key(user_id: int, original_name: str) -> str:
return f"user/{user_id}/files/{uuid.uuid4()}-{safe_filename(original_name)}"5. Validate uploads before writing
Validate:
- maximum size
- extension allowlist
- MIME type
- image dimensions if needed
- ownership and authorization
Example:
ALLOWED_TYPES = {"image/png", "image/jpeg", "application/pdf"}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
def validate_upload(content: bytes, content_type: str):
if len(content) > MAX_FILE_SIZE:
raise ValueError("file too large")
if content_type not in ALLOWED_TYPES:
raise ValueError("unsupported file type")6. Local storage setup for development
Create and mount a writable uploads directory.
mkdir -p ./uploads
chmod 775 ./uploadsServe it explicitly from your app or reverse proxy.
Example Nginx:
location /media/ {
alias /var/www/app/uploads/;
autoindex off;
add_header Cache-Control "public, max-age=3600";
}Example Flask:
from flask import Flask, send_from_directory
import os
app = Flask(__name__)
MEDIA_ROOT = os.getenv("MEDIA_ROOT", "./uploads")
MEDIA_URL = os.getenv("MEDIA_URL", "/media/")
@app.route("/media/<path:filename>")
def media(filename):
return send_from_directory(MEDIA_ROOT, filename)For more on serving static and media assets, see Static and Media File Handling.
7. Production setup with S3-compatible storage
Basic boto3 implementation:
import os
import boto3
class S3Storage:
def __init__(self):
self.bucket = os.environ["S3_BUCKET"]
self.public_base = os.environ["S3_PUBLIC_BASE_URL"].rstrip("/")
self.client = boto3.client(
"s3",
region_name=os.getenv("S3_REGION"),
endpoint_url=os.getenv("S3_ENDPOINT_URL") or None,
aws_access_key_id=os.environ["S3_ACCESS_KEY_ID"],
aws_secret_access_key=os.environ["S3_SECRET_ACCESS_KEY"],
)
def save(self, key, content, content_type="application/octet-stream"):
self.client.put_object(
Bucket=self.bucket,
Key=key,
Body=content,
ContentType=content_type,
)
return self.url(key)
def delete(self, key):
self.client.delete_object(Bucket=self.bucket, Key=key)
def exists(self, key):
try:
self.client.head_object(Bucket=self.bucket, Key=key)
return True
except Exception:
return False
def url(self, key):
return f"{self.public_base}/{key}"For private files, do not return public URLs. Generate signed URLs:
def signed_url(client, bucket, key, expires=300):
return client.generate_presigned_url(
"get_object",
Params={"Bucket": bucket, "Key": key},
ExpiresIn=expires,
)8. Handle uploads in containers correctly
If you use local storage in Docker, files disappear unless you mount a volume.
services:
app:
image: your-app
environment:
STORAGE_BACKEND: local
MEDIA_ROOT: /app/uploads
MEDIA_URL: /media/
volumes:
- app_uploads:/app/uploads
volumes:
app_uploads:For production Docker deployments, object storage is usually simpler and safer than depending on container volumes. See Docker Production Setup for SaaS.
9. Pass object keys to background jobs
Do not pass local temp file paths to workers if production uses distributed instances.
Good:
job_payload = {
"file_key": "user/42/files/uuid-report.pdf"
}Bad:
job_payload = {
"local_path": "/tmp/upload_1234.pdf"
}10. Add cleanup logic
When a record is deleted or a file is replaced, delete the old object.
def replace_user_avatar(storage, old_key, new_key, content, content_type):
file_url = storage.save(new_key, content, content_type)
if old_key and old_key != new_key:
storage.delete(old_key)
return file_urlAlso define lifecycle rules for stale temp uploads and abandoned exports.
11. Framework-level notes
- Flask: configure an upload folder only for local development; use boto3 or a storage wrapper in production.
- FastAPI: stream uploads when possible instead of reading large files fully into memory.
- Nginx should not be responsible for storing user media.
- Containers need mounted volumes for local media storage.
- For S3-compatible vendors like Cloudflare R2, Backblaze B2, or MinIO, verify:
- endpoint URL
- path-style support
- public URL format
- signed URL behavior
Common causes
Most bad storage setups fail for predictable reasons:
- Using container filesystem for persistent uploads in production.
- Saving absolute file paths in database records.
- Missing mounted volume for local media storage.
- Incorrect bucket name, region, endpoint, or credentials.
- Bucket policy, IAM, ACL, or signed URL misconfiguration.
- Nginx or proxy
client_max_body_sizetoo small for uploads. - App user lacks write permissions to media directory.
- Multiple app instances writing to separate local disks.
- Filename collisions due to non-unique object keys.
- No cleanup process for deleted files or replaced uploads.
Additional failure patterns:
- Uploads work locally but disappear after deployment because the filesystem was ephemeral.
- App serves media from one instance, but uploads were written to another instance.
- Wrong
Content-TypeorContent-Dispositioncauses browser display and download issues. - CORS rules block browser direct-upload flows.
- Large uploads fail due to reverse proxy body size limits or app timeout settings.
Debugging tips
Start by verifying whether the file exists in the configured backend before debugging routing, URLs, or CDN behavior.
Useful checks:
echo $STORAGE_BACKEND && echo $MEDIA_ROOT && echo $S3_BUCKET
ls -lah ./uploads
find ./uploads -maxdepth 3 -type f | head
stat ./uploads
whoami && id
df -h
du -sh ./uploads
curl -I http://localhost:8000/media/test.png
python -c "import os; print(os.getenv('STORAGE_BACKEND')); print(os.getenv('MEDIA_ROOT'))"S3 checks:
python -c "import boto3, os; s3=boto3.client('s3', region_name=os.getenv('S3_REGION'), endpoint_url=os.getenv('S3_ENDPOINT_URL') or None, aws_access_key_id=os.getenv('S3_ACCESS_KEY_ID'), aws_secret_access_key=os.getenv('S3_SECRET_ACCESS_KEY')); print(s3.list_objects_v2(Bucket=os.environ['S3_BUCKET'], MaxKeys=5).get('Contents', []))"
aws s3 ls s3://$S3_BUCKET
aws s3 cp ./test-file.png s3://$S3_BUCKET/debug/test-file.png
aws s3api head-object --bucket $S3_BUCKET --key debug/test-file.png
curl -I https://your-public-media-url/debug/test-file.pngWhat to verify:
- The database stores object keys or relative paths, not environment-specific absolute paths.
- For local storage, the process user can write to the configured media directory.
- For S3, test list/read/write with the same credentials used by the app.
- Inspect generated URLs separately from upload logic.
- If uploads fail only in production, compare:
- request body size limits
- temp disk space
- worker timeout settings
- If files upload but do not render, inspect:
- response headers
- object ACL or bucket policy
- signed URL generation
- content type metadata
If uploads are failing end-to-end, use Media Uploads Not Working.
upload request flowchart from browser to app to storage backend to returned URL.
Checklist
- ✓ Storage backend selected per environment.
- ✓ App uses a storage abstraction, not direct scattered file writes.
- ✓ Database stores object keys or relative paths only.
- ✓ Upload directory is writable in development if using local disk.
- ✓ Production storage is persistent across deploys.
- ✓ Unique object naming strategy is implemented.
- ✓ Private/public file access rules are defined.
- ✓ File validation, size limits, and MIME checks are implemented.
- ✓ Reverse proxy upload size limit is configured.
- ✓ Cleanup and retention policy exists.
- ✓ Backups or replication requirements are defined for critical files.
Pre-launch validation:
# local
test -d ./uploads && test -w ./uploads && echo "local media writable"
# s3
aws s3 cp ./test-file.png s3://$S3_BUCKET/health/test-file.png
aws s3api head-object --bucket $S3_BUCKET --key health/test-file.pngFor a full production review, use SaaS Production Checklist.
Related guides
- Environment Variables and Secrets Management
- Static and Media File Handling
- Docker Production Setup for SaaS
- Media Uploads Not Working
- SaaS Production Checklist
FAQ
Should I use local storage or S3 for production?
Use S3 or S3-compatible object storage for most production SaaS deployments. Local storage is only reasonable if you run a single durable server and accept operational limits.
What should I store in the database for uploaded files?
Store the object key or relative path, plus metadata if needed. Avoid storing absolute local paths or hardcoded provider URLs.
How do I handle private user files?
Keep the objects private and return signed URLs or serve them through an authenticated application endpoint.
Can I migrate from local storage to S3 later?
Yes. Write a migration script that uploads existing files to object storage, updates stored keys if needed, and verifies URL generation before switching traffic.
Example migration outline:
for record in records:
local_path = os.path.join("./uploads", record.file_key)
with open(local_path, "rb") as f:
s3.put_object(Bucket=bucket, Key=record.file_key, Body=f.read())Then verify:
- object exists
- generated URL works
- app reads from new backend
- old local files are retained until rollback window ends
Why do uploads disappear after Docker redeploys?
Because container filesystems are often ephemeral. Without a mounted volume or object storage, uploaded files are lost when the container is replaced.
Final takeaway
Local disk is fine for development and simple single-server setups. Object storage is the default production choice for durability and scaling.
Core design rule:
- abstract storage in code
- store only stable file keys in the database
- keep storage config environment-driven
- avoid server-specific paths in app logic
If you do that, development stays simple and production uploads survive redeploys, multi-instance deployments, and infrastructure changes.