Overview
You need to implement file uploads and sharing, but the files must stay private. Anonymous users shouldn't be able to access them at all. This tutorial walks you through using FileField with Django 6.0's Class-based Views to build exactly that, including how to hand off private file delivery to your web server in production.
You'll learn to:
- Handle private uploaded files and organize them by user
- Download & share files from your private uploaded file collection
- Delete the file from the drive when the model instance is deleted
- Serve files with Nginx in production (not a Django view)

Prerequisites
Before starting, ensure you have:
- Python 3.12 or higher is required for Django 6.0
- Reviewed the Django docs for simple public uploads
This tutorial differs from the Django docs by demonstrating how to handle private downloads. Users can choose to share their file collections with others by setting their profiles as public. They can also just store their files by setting their profiles to private. Anonymous users cannot download files at all.
Set Up Filebaby Locally
Clone the project directory and run the app in the usual Django way. The repo has detailed instructions for getting the app set-up: Read the Filebaby Quickstart on Github to get started.
App Operation
The app has two main functions: Uploads and downloads.
Let's try it out. Now that the application is running, add a file.
Add a file

When your user clicks the 'add a file' button on the app it loads the /uploads/create/ page. An empty "dropzone" is displayed. The user can click on the area and use the file picker to choose a file to upload or they can just drag-and-drop files onto the area.
The success message is either a visual check-mark on the uploaded image if Javascript is enabled and if the dropzone is used. If Javascript is not available the standard file widget is used as a fall-back.
The file is saved on the drive at: {SENDFILE_ROOT}/{User.id}/{Filename}
If you uploaded a file named "2026-Q1-QuarterlyPicks.pdf" for a User with an ID value of "1234" the file would be saved at ./user_files/1234/2026-Q1-QuarterlyPicks.pdf.
Download a file

From the list view of File objects, you can click on the 'Download' button to download the file.
The download proceeds as an "attachment" with the Content-Disposition: attachment header set. On Chrome this downloads your file to the Downloads directory. I'll discuss this a bit more when I talk about the FileDownloadView view.
Settings: Protecting Private Uploads in Django
Django lives up to its motto "batteries-included". In the official documentation, it gives an example of how to set up a FileField to use the public media directory in settings.MEDIA_ROOT.
But, those file are public and they are available to anyone who can guess the URL. If you want to control who accesses user files you need a different approach. The privacy I’ve described relies on moving user files out of the public eye and into a dedicated storage root managed by "django-sendfile2".
We are using django-sendfile2 for our production deployment
Now that we’ve seen how the app handles files, let’s look under the hood. The privacy we’ve described relies on moving user files out of the public eye and into a dedicated storage root.
You need to tell Django where to store the uploaded files. This app uses a private folder that is protected from anonymous access. To implement this feature, a custom storage object was created. Django stores uploads in the MEDIA_ROOT by default but that is a public folder. This app uses a different setting SENDFILE_ROOT provided by django-sendfile2 that holds an absolute path to private storage. In production, this private location is served by your web server (eg. X-Accel-Redirect in Nginx or X-Sendfile in Apache).
SENDFILE_ROOTmust be an absolute path (not a relative path)
For development purposes, if SENDFILE_ROOT is not set, the private files are stored in a folder named user_files in the project root.
# filebaby/settings.py
# User files are not shared publicly, so we can't leave them in the MEDIA_ROOT
#
# The SENDFILE_BACKEND is set to development for simplicity. In production,
# you would use a more robust backend like X-Sendfile or Nginx
# See https://django-sendfile2.readthedocs.io/en/latest/backends.html
#
SENDFILE_BACKEND = env.str("SENDFILE_BACKEND", "django_sendfile.backends.development")
SENDFILE_ROOT = env.str("SENDFILE_ROOT", "")
if not SENDFILE_ROOT:
SENDFILE_ROOT = BASE_DIR / "user_files"
SENDFILE_CHECK_FILE_EXISTS = env.bool("SENDFILE_CHECK_FILE_EXISTS", True)
SENDFILE_URL = env.str("SENDFILE_URL", "/protected")
FILEBABY_AS_ATTACHMENT = env.bool("FILEBABY_AS_ATTACHMENT", True)
The downloads can occur as an attachment or inline. If FILEBABY_AS_ATTACHMENT is unset then downloads occur with the header Content-Disposition: attachment which means that the file is placed into your "Downloads" folder. If you distribute PDFs you might want to open the PDF in the current window. Set FILEBABY_AS_ATTACHMENT=0 in your .env file and the header Content-Disposition: inline will be set to allow opening of the downloaded file in the current window.
Now that your settings.py is configured, any user uploaded files will reside in this directory: user_files/
This is the directory tree structure relative to your project root (the _README.txt file marks the user uploads folder):
filebaby/
├── filebaby/
├── files/
└── user_files/
└── _README.txt
Now let's look at the model for the files.
Model: FileField on the model sets the upload directory format
Our files.File data model has a property named file. For your own application the name should be more descriptive, but for this tutorial a simple 'file' is good enough. Find the FileField model field in files/models.py:
# files/models.py
class File(Timestamped):
"""This holds a single user uploaded file"""
file = models.FileField(upload_to=uploads_path, storage=get_user_files_storage)
The FileField model field has one required attribute named 'upload_to'. You must set this. You have three choices for this attribute: a string containing a period or a path, a 'strftime' format string or a custom callable (usually a function but it can be any callable). We are using a custom callable (uploads_path) to place any uploads into a directory structure organized by user ID. Earlier in models.py file the callable was instantiated so let's look at it:
# files/models.py
@deconstructible
class Uploads:
def __call__(self, instance, filename):
# Return path relative to the storage root (e.g. 1/myfile.txt)
return os.path.join(str(instance.owner.id), filename)
uploads_path = Uploads()
The FileField model field has an optional storage attribute that we are overriding. Let's look at it.
# files/models.py
class DynamicUserFilesStorage(FileSystemStorage):
def __init__(self, location=None, base_url=None):
# We ignore the passed location to force it to use our settings
super().__init__(location=None, base_url=base_url)
self.default_location = os.path.join(settings.BASE_DIR, "user_files")
@property
def location(self):
# This property is accessed whenever the storage needs to write/read
return getattr(settings, "SENDFILE_ROOT", self.default_location)
def get_user_files_storage():
return DynamicUserFilesStorage()
SENDFILE_ROOTmust be an absolute path to a folder
The DynamicUserFilesStorage allows us to set the drive path for reading and writing by accessing the value in SENDFILE_ROOT each time it is needed. In the test suite, we set up a temp folder for our file manipulation tests and this capability allows us to avoid creating orphan files in our user_files/ folder.
The base_url parameter is set to None which causes the storage url property to yield an invalid public URL (ie. it points to MEDIA_URL by default). This is not a problem since the url is never used. Our files are private. We want our users to encounter a gateway to the files that they request. We will discuss the gateway later, but for now let's talk about the URLs and how they map to the views.
URLconf: Mapping URLs to Views
This is the mapping of the URLs to the views:
# files/urls.py
app_name = "files"
urlpatterns = [
path("", login_required(FileListView.as_view()), name="list"),
path("from/<slug:slug>/", login_required(FileListView.as_view()), name="list_from"),
path("<int:pk>/", login_required(FileDetailView.as_view()), name="detail"),
path("create/", login_required(FileCreateView.as_view()), name="create"),
path("delete/<int:pk>/", login_required(FileDeleteView.as_view()), name="delete"),
path(
"downloads/<int:pk>/",
login_required(FileDownloadView.as_view()),
name="download",
),
]
One unusual feature to mention is that the FileListView handles two URL patterns: /files/ and /files/from/aUserSlug. The view provides a list of files from all users that allow public downloads of their files and a list of files for a given user if the user allows public downloads. Also, you can always view and download your own files.
Also, the entire Files app is protected from anonymous users. They cannot view or download anything.
Form: FileForm form class is a standard modelform
The FileForm is a regular Django ModelForm that obtains its properties from the model designated in the inner class Meta. It is located in: files/forms.py
Allowing users to upload files can be dangerous. You should restrict the types of files that the users can upload (see the settings.ALLOWED_TYPES list). Never trust user data. Not even innocuous data like the content type and the file name (which come from browser request headers). Examine the clean_file method. I'm sniffing the content type from the file uploaded and cleaning the file name in addition to saving the file data. If we cannot determine the Content-Type then we fall back to the "blob of data" content-type (application/octet-stream).
# files/forms.py
class FileForm(forms.ModelForm):
class Meta:
model = File
fields = ["file"]
def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request", None)
super().__init__(*args, **kwargs)
self.fields["file"].widget.attrs.update({"class": "form-control"})
self.helper = FormHelper()
def clean_file(self):
file_data = self.cleaned_data.get("file")
if not file_data:
return file_data
# Detect MIME type from file contents, not the request header.
if magic:
detected_type = magic.from_buffer(file_data.read(2048), mime=True)
file_data.seek(0)
if detected_type not in settings.ALLOWED_TYPES:
raise ValidationError("Unsupported file type.")
self._detected_content_type = detected_type
# Sanitize the filename: first, strip any path components.
original_name = PurePosixPath(file_data.name or "upload").name
safe_name = "".join(
c for c in original_name if c.isalnum() or c in "._- "
).strip()
self._sanitized_filename = safe_name or "upload"
return file_data
def save(self, commit=True):
instance = super().save(commit=False)
if self.request and self.request.user.is_authenticated:
instance.owner = self.request.user
if instance.file:
instance.content_type = getattr(
self, "_detected_content_type", "application/octet-stream"
)
instance.filename = getattr(self, "_sanitized_filename", instance.filename)
if commit:
# Atomic Transaction for File System hygiene
try:
with transaction.atomic():
instance.save()
except DatabaseError:
# If the DB save fails, check if the file was already written to disk.
if instance.file and hasattr(instance.file, "path"):
if os.path.exists(instance.file.path):
logger.warning(
f"DB transaction failed. Deleting orphan file: {instance.file.path}"
)
os.remove(instance.file.path)
# Inform the view that the save failed
raise
return instance
If there is a problem with the upload then the user will get immediate feedback before we save anything.
We also handle the rare case when the database raises an exception and the file has been written to the attached drive. If that happens, we inspect the SENDFILE_ROOT and delete the orphan file if it is found. We re-raise the exception so that the view can report the issue to the user.
Let's consider the FileCreateView next.
View: FileCreateView handles successful uploads
The FileCreateView uses the generic CreateView class-based view provided by Django. Examine the views.py file:
# files/views.py
class FileCreateView(SuccessMessageMixin, CreateView):
model = File
form_class = FileForm
success_url = reverse_lazy("files:list")
success_message = "File uploaded successfully"
upload_failed_message = "Upload failed. Please try again."
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["request"] = self.request
return kwargs
def form_valid(self, form: Any) -> HttpResponse:
try:
return super().form_valid(form)
except DatabaseError:
logger.exception("File upload failed due to database error.")
form.add_error("file", self.upload_failed_message)
return self.form_invalid(form)
def form_invalid(self, form: Any) -> HttpResponse:
if (
form.errors.get("file")
and form.errors["file"][0] == "Unsupported file type."
):
logger.warning("File upload failed with Unsupported file type error.")
if self.request.headers.get("x-requested-by") == "Dropzone":
file_errors = form.errors.get("file")
error_message = (
file_errors[0] if file_errors else self.upload_failed_message
)
return JsonResponse({"error": error_message}, status=400)
return super().form_invalid(form)
You can see that I have overridden the following class view properties:
form_class- TheFileFormdiscussed earliersuccess_url- Destination on successful uploadsuccess_message- Some feedback for the user
The form_class is the FileForm we discussed earlier. I wanted to save the content type on the model so I needed to provide our special ModelForm to preserve that information.
The success_url is where the user ends up when the file is uploaded successfully. I have used named URLs in my URLConf scheme and therefore I used the lazy version of the URL reverse function. This is necessary since the URLs are not loaded when the views are instantiated. If you see a NameError exception then you might have used the non-lazy version of reverse.
The form_valid method is where we attempt to save the FileForm. It might raise an exception so we have to handle that gracefully by passing on words of encouragement to the user (ie. Please try again).
The form_invalid method needs to handle the JSON that the Dropzone widget is expecting. It also needs to handle the regular invalid form processing in case drop zone was not enabled on the client. To determine if Dropzone was sending data, I added a custom header (X-Requested-By: Dropzone) but for your own app you should consider if either of these request headers are appropriate and sent by your targeted browsers:
X-Requested-With: XMLHttpRequestAccept: application/json
Let's consider File deletion.
View: FileDeleteView to remove File instances
The FileDeleteView is quite simple. The File instance is deleted using a DeleteView in the standard way. The ModelOwnerMixin allows the user to delete their own files.
# files/views.py
class ModelOwnerMixin:
"""You can only see your own files."""
def get_queryset(self):
return self.model.objects.by_owner(self.request.user)
class FileDeleteView(ModelOwnerMixin, SuccessMessageMixin, DeleteView):
model = File
success_url = reverse_lazy("files:list")
success_message = "File deleted successfully"
But deleting the File instance does not remove the actual uploaded file from the drive. You need to do that yourself.
The file itself is deleted off the drive by a post_delete signal. If you don't take care of housekeeping operations like this the orphan files will build up and take up drive space.
# files/signals.py
@receiver(post_delete, sender=File)
def delete_file_on_delete(sender, instance, **kwargs):
"""
Deletes file from filesystem when corresponding `File` object is deleted.
"""
if instance.file:
if os.path.isfile(instance.file.path):
os.remove(instance.file.path)
Caveat: Bulk deletion via
queryset.delete()does not fire signals.
If you need to delete all of a user's files, consider a custom storage object or a cleanup script (ie. all files for a give user are in a single folder named after their primary key). This topic is out of scope for our app, so let's move on to the ListView.
Views: FileListView for listing uploaded files
The home page uses a FileListView to display the list of files. The implementation is standard except that it provides lists of Files for specific users in addition to a list of public files. It also allows you to view your own files.
/files/lists all public files from all users/files/from/alice/lists all Alice's public files/files/from/bob/if you are bob then the view title changes to "Your files"
Users have zero or many Files and they are related by File.owner. To select all File instances for a User we need to determine if they publicly share their files or not. This is determined by User.is_public=True. The is_public property on the User determines if all or none of their files are shared.
class FileListView(ListView):
model: Any = File
context_object_name = "files"
paginate_by = 5
slug_url_kwarg = "slug"
slug_field = "slug"
def get_queryset(self):
self.files_from = None
self.your_files = False
if "slug" in self.kwargs:
# Public users or private users viewing their own files
user_filter = Q(is_public=True) | Q(pk=self.request.user.pk)
try:
user: Any = User.objects.get(user_filter, slug=self.kwargs["slug"])
except User.DoesNotExist:
logging.info(
"Accessible user with slug '%s' does not exist.",
self.kwargs["slug"],
)
else:
self.files_from = user.best_name()
if self.request.user == user:
self.files_from = "You"
self.your_files = True
return self.model.objects.filter(owner=user)
raise Http404
publics = User.objects.filter(is_public=True)
return self.model.objects.filter(owner__in=publics)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["files_from"] = self.files_from
context["your_files"] = self.your_files
return context
In the get_queryset method, we need to handle the case when a private User needs to view their own files. This is accomplished by the Q filters (user_filter) which select User.is_public=True or User.id==request.user.id. This allows viewing of File instances from all public users and yourself.
Let's move on to discuss the downloads.
Views: FileDownloadView uses sendfile
This view gets called when a user wants to download a file:
# files/views.py
class FileDownloadView(DetailView):
model = File
request: Any
def get_queryset(self):
if self.request.user.is_staff or self.request.user.is_superuser:
return self.model.objects.all()
return self.model.objects.filter(
Q(owner=self.request.user) | Q(owner__is_public=True)
)
def render_to_response(self, context):
"""
Serves the file as a download using django-sendfile.
https://django-sendfile2.readthedocs.io/en/latest/index.html
:param context: An unused dictionary
"""
obj = self.get_object()
path = obj.file.name
content_type = obj.content_type
return sendfile(
self.request,
filename=path,
attachment=settings.FILEBABY_AS_ATTACHMENT,
mimetype=content_type,
)
This view allows visitors to download files from public users and also download their own files. As you can see the heavy lifting is done by the sendfile call. The django-sendfile2 package removes some of the complexity of serving private files both during development and in production. Let's consider the two cases.
Serving private user submitted files during development
The sendfile download function is configured by SENDFILE_BACKEND which in development is django_sendfile.backends.development. The simple backend allows the development server to serve the downloads while you're working on the app. Obviously this is not suitable for production.
Serving private user submitted files with Nginx
In production, you can offload the task of serving user file to a real web server like Nginx. Consider these environment settings for django-sendfile2:
SENDFILE_BACKEND=django_sendfile.backends.nginxSENDFILE_ROOT=/user_filesSENDFILE_URL=/protected
The usual configuration for running a Django app in production is to put Nginx in front of the application server (ie. uwsgi, gunicorn, etc) in a reverse proxy configuration. All requests to the app pass through Nginx including the requests for private user files. In our example, the private files are on a network drive mounted on /user_files and Nginx picks them up from there.
In your Nginx configuration, you need to tell Nginx where to find the private files:
location /protected/ {
internal;
alias /user_files/;
}
This internal path is not accessible from the Internet. A browser request to example.com/protected/1234/my_secrets.pdf will fail.
The nginx backend in the Django app sets a header (X-Accel-Redirect: /protected/1234/my_secrets.pdf) which is intercepted by Nginx on the way back to the user's browser. The header allows Nginx to find and serve the file not the Django app server. The Django app workers are then free to serve more requests while Nginx serves the content. This is a much better way to serve large files.
Final advice
When an app like this goes into production you need to take steps to ensure that it cannot be used maliciously. There are two techniques for isolating user content: using a sandbox domain and forcing a content type on the file.
When a browser loads a file, it runs that file in the security context of the domain that served it. If a hacker uploads a malicious HTML or SVG file and your app serves it to your users from your main app domain, the browser may execute it with full access to the site's cookies and session data which may include your authenticated users' credentials.
Setting a server header like
Content-Security-Policy: default-src 'none';on the sandbox domain will prevent the browser from loading any scripts, styles, fonts, or other subresources from an uploaded file, even if the file is rendered inline. This adds a second layer of defense against malicious content if it slips past your upload validation.
A sandbox domain that is similar to your the app's domain can be used to isolate the execution context completely. Google uses the googleusercontent.com domain to host its user content for security and liability reasons. Dropbox uses dropboxusercontent.com. It's a sound practice.
Another option is to set the Content-Type of user files to application/octet-stream to ensure that it is downloaded. Some content like PDFs are better opened in the browser so you can whitelist some content to display inline. This technique is newer but it is becoming more common.
These measures work well together. The separate domain gives you strong isolation even if a file somehow gets rendered inline. Forcing octet-stream reduces the chance of inline rendering in the first place. If you can only do one, prioritize the separate domain since it provides broader protection regardless of how files are eventually served.
Accepting files from users can be perilous, before you move on consider these thought questions. The Django docs linked as references provide some of the answers.
Thought questions
- User uploaded content is commonly served from a separate domain. Why is that?
- The
file_data.content_typein the FileForm shouldn't be trusted. Why? - What is a denial of service attack and how can you avoid them by limiting upload sizes?
References
- File Uploads in Django 6.0
- Django File Upload Settings
- User Uploaded Content Security in Django 6.0
- DropzoneJS - Drag & drop upload widget
- django-sendfile2 - Simple
X-Sendfile/X-Accel-Redirectfile handling for Django