Self-Hosted File Storage with Django

Build a multi-user file storage system deployed on your own Ubuntu server.

Introduction

30 Second Summary

Every time you upload a photo to Google Drive or share a document through Dropbox, you hand control of your files to someone else's server. What if you could build your own personal cloud storage that runs on hardware you control?

In this project, you will build a self-hosted file storage system using Django where multiple users can register, log in, and manage their files through a polished web interface deployed on your own Ubuntu server.

What You'll Build

A personal cloud file storage system with user authentication, file uploads of any type, a responsive card-based dashboard, and production deployment accessible from any device on your network.

By the end of this project, you'll have:

  • A working Django web application with custom user registration, login, and logout.
  • A polished, mobile-first responsive dashboard built with Tailwind CSS that displays files in a card grid with download and delete functionality.
  • A production deployment on an Ubuntu server with Gunicorn and Nginx serving the app to any device on your network.
  • Secret Mission: Add file type filtering and search to your dashboard for quick file discovery.

Are there any prerequisites?

You should have basic Python knowledge and some familiarity with Django concepts. You will also need VS Code installed on Windows 11 and an Ubuntu 26.04 server ready for the deployment step.

Build User Authentication

In the last step, you scaffolded your Django project and got the development server running. Now it's time to let users create accounts and sign in.

A file storage system is only useful if each person sees their own files. In this step, you'll build a complete authentication flow with registration, login, and logout pages styled with Tailwind CSS.

In this step, get ready to:
  • Create a registration form and view that signs users up and logs them in.
  • Set up URL routing for all authentication flows.
  • Style the auth pages with Tailwind CSS for a polished, mobile-friendly look.
Create the forms and registration view

Django ships with a built-in UserCreationForm that handles username and password fields with validation. You'll extend it to also collect an email address.

  • In VS Code, create a new file called forms.py inside the files/ folder.
  • Add the following code to define both your registration form and a file upload form:
from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User


class RegisterForm(UserCreationForm):
    # Add an email field that UserCreationForm doesn't include by default
    email = forms.EmailField(required=True)

    class Meta:
        model = User
        fields = ["username", "email", "password1", "password2"]


class FileUploadForm(forms.Form):
    # Simple form with a single file input
    file = forms.FileField()

What does this code do?

RegisterForm extends Django's UserCreationForm to add a required email field. The Meta class tells Django which model to save to (User) and which fields to display on the form.

FileUploadForm is a plain form with a single file field. You'll use it later when building the upload feature.

  • Save files/forms.py.

You won't see a visible change yet. The form will be used by the registration view you create next.

Now you need a view that uses RegisterForm to handle user sign-ups. The view also needs placeholder stubs for the other URL patterns you'll wire up shortly.

  • Open files/views.py in VS Code.
  • Replace the existing content with the following code:
from django.shortcuts import render, redirect
from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse

from .forms import RegisterForm, FileUploadForm


def register_view(request):
    # Handle both GET (show form) and POST (process form)
    if request.method == "POST":
        form = RegisterForm(request.POST)
        if form.is_valid():
            user = form.save()
            # Log the user in immediately after registration
            login(request, user)
            return redirect("/")
    else:
        form = RegisterForm()
    return render(request, "registration/register.html", {"form": form})


@login_required
def dashboard_view(request):
    # Placeholder - you'll build this in a later step
    return HttpResponse("Dashboard coming soon!")


@login_required
def download_file(request, file_id):
    # Placeholder - you'll build this in a later step
    return HttpResponse("Download coming soon!")


@login_required
def delete_file(request, file_id):
    # Placeholder - you'll build this in a later step
    return HttpResponse("Delete coming soon!")

What does this code do?

register_view checks whether the request is a GET (display the empty form) or a POST (the user submitted the form). If the form data is valid, it saves the new user, logs them in automatically, and redirects to the homepage.

The @login_required decorator on the placeholder views means unauthenticated visitors get redirected to the login page. You'll replace these stubs with real logic in upcoming steps.

  • Save files/views.py.

Seeing an import error?

Make sure files/forms.py exists and is saved before saving views.py. The import from .forms import RegisterForm, FileUploadForm looks for forms.py in the same files/ directory.

Help me debug my import error in views.py

Wire up URL routing

Django needs a URL configuration that maps browser addresses to your views. You'll define all the routes your app needs now, even though some point to placeholder views.

  • Open files/urls.py in VS Code (create it if it doesn't exist yet).
  • Replace any existing content with the following URL patterns:
from django.urls import path
from django.contrib.auth.views import LoginView, LogoutView

from . import views

urlpatterns = [
    # Homepage - the file dashboard (placeholder for now)
    path("", views.dashboard_view, name="dashboard"),
    # Custom registration view
    path("register/", views.register_view, name="register"),
    # Django's built-in auth views with custom templates
    path("login/", LoginView.as_view(template_name="registration/login.html"), name="login"),
    path("logout/", LogoutView.as_view(), name="logout"),
    # File management routes (placeholders for now)
    path("download/<int:file_id>/", views.download_file, name="download_file"),
    path("delete/<int:file_id>/", views.delete_file, name="delete_file"),
]

What does this code do?

Each path() maps a URL pattern to a view function. Django's built-in LoginView and LogoutView handle session management for you. You only need to provide a custom template for the login page.

The name argument on each route lets you reference URLs in templates using {% url 'login' %} instead of hardcoding paths.

  • Save files/urls.py.

Server throwing an error?

If the development server shows an error about missing templates, that's expected. You'll create the templates in the next substep.

Help me fix my Django URL routing error

Build the styled auth templates

Every page in your app will share the same navigation bar and dark theme. Django's template inheritance lets you define this once in a base template and extend it in every page.

  • Inside the templates/ folder (at the project root), create a file called base.html.
  • Paste the following code into base.html:
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
    <title>FileVault</title>
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen">
    <nav class="bg-gray-800 border-b border-gray-700 px-4 py-3">
        <div class="max-w-7xl mx-auto flex justify-between items-center">
            <a href="/" class="text-xl font-bold text-indigo-400">FileVault</a>
            {% if user.is_authenticated %}
            <div class="flex items-center gap-4">
                <span class="text-sm text-gray-400">{{ user.username }}</span>
                <form method="post" action="{% url 'logout' %}">
                    {% csrf_token %}
                    <button type="submit" class="text-sm text-gray-400 hover:text-white">Logout</button>
                </form>
            </div>
            {% endif %}
        </div>
    </nav>
    <main class="max-w-7xl mx-auto px-4 py-6">
        {% block content %}{% endblock %}
    </main>
</body>
</html>

What does this code do?

The <script> tag loads the Tailwind CSS Play CDN, giving you utility classes without any build tools. The dark theme uses bg-gray-900 for the background and text-gray-100 for light text.

The {% if user.is_authenticated %} block conditionally shows the username and a logout button only when someone is signed in. The {% block content %} tag is where child templates inject their page-specific content.

  • Save templates/base.html.

Now create the login template. Django's LoginView expects a template at the path you specified in urls.py.

  • Inside the templates/ folder, create a subfolder called registration/.
  • Inside templates/registration/, create a file called login.html.
  • Paste the following code into login.html:
{% extends "base.html" %}
{% block content %}
<div class="flex items-center justify-center min-h-[80vh]">
    <div class="bg-gray-800 rounded-2xl p-8 w-full max-w-md shadow-xl">
        <h2 class="text-2xl font-bold text-center mb-6">Sign In</h2>
        <form method="post" class="space-y-4">
            {% csrf_token %}
            {% for field in form.visible_fields %}
            <div>
                <label class="block text-sm font-medium text-gray-300 mb-1">{{ field.label }}</label>
                <input type="{{ field.field.widget.input_type }}" name="{{ field.name }}" value="{{ field.value|default:'' }}"
                       class="w-full px-4 py-3 rounded-lg bg-gray-700 border border-gray-600 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 text-base" />
                {% for error in field.errors %}
                <p class="text-red-400 text-sm mt-1">{{ error }}</p>
                {% endfor %}
            </div>
            {% endfor %}
            <button type="submit" class="w-full py-3 bg-indigo-600 hover:bg-indigo-500 rounded-lg font-semibold text-white text-base transition">Sign In</button>
        </form>
        <p class="text-center text-gray-400 text-sm mt-4">
            No account? <a href="{% url 'register' %}" class="text-indigo-400 hover:underline">Register</a>
        </p>
    </div>
</div>
{% endblock %}

What does this code do?

This template extends base.html and fills in the content block with a centered card layout. The {% for field in form.visible_fields %} loop renders each form field dynamically, so the template works without hardcoding field names.

The {% csrf_token %} tag adds a security token that Django requires on all POST forms to prevent cross-site request forgery.

  • Save templates/registration/login.html.

Finally, create the registration template with the same card layout.

  • Inside templates/registration/, create a file called register.html.
  • Paste the following code into register.html:
{% extends "base.html" %}
{% block content %}
<div class="flex items-center justify-center min-h-[80vh]">
    <div class="bg-gray-800 rounded-2xl p-8 w-full max-w-md shadow-xl">
        <h2 class="text-2xl font-bold text-center mb-6">Create Account</h2>
        <form method="post" class="space-y-4">
            {% csrf_token %}
            {% for field in form.visible_fields %}
            <div>
                <label class="block text-sm font-medium text-gray-300 mb-1">{{ field.label }}</label>
                <input type="{{ field.field.widget.input_type }}" name="{{ field.name }}" value="{{ field.value|default:'' }}"
                       class="w-full px-4 py-3 rounded-lg bg-gray-700 border border-gray-600 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 text-base" />
                {% if field.help_text %}
                <p class="text-gray-500 text-xs mt-1">{{ field.help_text }}</p>
                {% endif %}
                {% for error in field.errors %}
                <p class="text-red-400 text-sm mt-1">{{ error }}</p>
                {% endfor %}
            </div>
            {% endfor %}
            <button type="submit" class="w-full py-3 bg-indigo-600 hover:bg-indigo-500 rounded-lg font-semibold text-white text-base transition">Register</button>
        </form>
        <p class="text-center text-gray-400 text-sm mt-4">
            Already have an account? <a href="{% url 'login' %}" class="text-indigo-400 hover:underline">Sign in</a>
        </p>
    </div>
</div>
{% endblock %}

What does this code do?

This template follows the same pattern as the login page but adds {% if field.help_text %} to display Django's built-in password requirement hints below each field. These hints tell users the minimum length and complexity rules.

  • Save templates/registration/register.html.
  • Make sure the development server is running (python manage.py runserver in your terminal).
  • Open your browser and go to http://127.0.0.1:8000/register/.
  • You should see a dark-themed registration card with fields for username, email, password, and password confirmation.
  • Fill in the form with a test username and password, then click Register.
  • After registering, you should be redirected to the homepage where you see "Dashboard coming soon!" confirming you're logged in.

Seeing a TemplateDoesNotExist error?

Double-check that your folder structure matches templates/registration/login.html and templates/registration/register.html. The templates/ folder must be at the project root (same level as manage.py).

Also confirm that DIRS in your settings.py includes BASE_DIR / "templates". This was set up in Step 1.

Help me fix my TemplateDoesNotExist error in Django

Now test the login flow to confirm everything works end to end.

  • Navigate to http://127.0.0.1:8000/logout/ (or click Logout in the nav bar) to sign out.
  • You should be redirected to the login page at http://127.0.0.1:8000/login/.
  • Sign in with the credentials you just created.
  • After logging in, you should be redirected back to the homepage showing "Dashboard coming soon!" again.

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files.

ⓧ I'd like to double check the full code

Here is the complete code for each file at this point.

from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User


class RegisterForm(UserCreationForm):
    email = forms.EmailField(required=True)

    class Meta:
        model = User
        fields = ["username", "email", "password1", "password2"]


class FileUploadForm(forms.Form):
    file = forms.FileField()
from django.urls import path
from django.contrib.auth.views import LoginView, LogoutView

from . import views

urlpatterns = [
    path("", views.dashboard_view, name="dashboard"),
    path("register/", views.register_view, name="register"),
    path("login/", LoginView.as_view(template_name="registration/login.html"), name="login"),
    path("logout/", LogoutView.as_view(), name="logout"),
    path("download/<int:file_id>/", views.download_file, name="download_file"),
    path("delete/<int:file_id>/", views.delete_file, name="delete_file"),
]
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
    <title>FileVault</title>
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen">
    <nav class="bg-gray-800 border-b border-gray-700 px-4 py-3">
        <div class="max-w-7xl mx-auto flex justify-between items-center">
            <a href="/" class="text-xl font-bold text-indigo-400">FileVault</a>
            {% if user.is_authenticated %}
            <div class="flex items-center gap-4">
                <span class="text-sm text-gray-400">{{ user.username }}</span>
                <form method="post" action="{% url 'logout' %}">
                    {% csrf_token %}
                    <button type="submit" class="text-sm text-gray-400 hover:text-white">Logout</button>
                </form>
            </div>
            {% endif %}
        </div>
    </nav>
    <main class="max-w-7xl mx-auto px-4 py-6">
        {% block content %}{% endblock %}
    </main>
</body>
</html>
{% extends "base.html" %}
{% block content %}
<div class="flex items-center justify-center min-h-[80vh]">
    <div class="bg-gray-800 rounded-2xl p-8 w-full max-w-md shadow-xl">
        <h2 class="text-2xl font-bold text-center mb-6">Sign In</h2>
        <form method="post" class="space-y-4">
            {% csrf_token %}
            {% for field in form.visible_fields %}
            <div>
                <label class="block text-sm font-medium text-gray-300 mb-1">{{ field.label }}</label>
                <input type="{{ field.field.widget.input_type }}" name="{{ field.name }}" value="{{ field.value|default:'' }}"
                       class="w-full px-4 py-3 rounded-lg bg-gray-700 border border-gray-600 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 text-base" />
                {% for error in field.errors %}
                <p class="text-red-400 text-sm mt-1">{{ error }}</p>
                {% endfor %}
            </div>
            {% endfor %}
            <button type="submit" class="w-full py-3 bg-indigo-600 hover:bg-indigo-500 rounded-lg font-semibold text-white text-base transition">Sign In</button>
        </form>
        <p class="text-center text-gray-400 text-sm mt-4">
            No account? <a href="{% url 'register' %}" class="text-indigo-400 hover:underline">Register</a>
        </p>
    </div>
</div>
{% endblock %}
{% extends "base.html" %}
{% block content %}
<div class="flex items-center justify-center min-h-[80vh]">
    <div class="bg-gray-800 rounded-2xl p-8 w-full max-w-md shadow-xl">
        <h2 class="text-2xl font-bold text-center mb-6">Create Account</h2>
        <form method="post" class="space-y-4">
            {% csrf_token %}
            {% for field in form.visible_fields %}
            <div>
                <label class="block text-sm font-medium text-gray-300 mb-1">{{ field.label }}</label>
                <input type="{{ field.field.widget.input_type }}" name="{{ field.name }}" value="{{ field.value|default:'' }}"
                       class="w-full px-4 py-3 rounded-lg bg-gray-700 border border-gray-600 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 text-base" />
                {% if field.help_text %}
                <p class="text-gray-500 text-xs mt-1">{{ field.help_text }}</p>
                {% endif %}
                {% for error in field.errors %}
                <p class="text-red-400 text-sm mt-1">{{ error }}</p>
                {% endfor %}
            </div>
            {% endfor %}
            <button type="submit" class="w-full py-3 bg-indigo-600 hover:bg-indigo-500 rounded-lg font-semibold text-white text-base transition">Register</button>
        </form>
        <p class="text-center text-gray-400 text-sm mt-4">
            Already have an account? <a href="{% url 'login' %}" class="text-indigo-400 hover:underline">Sign in</a>
        </p>
    </div>
</div>
{% endblock %}

Your authentication system is ready. Users can now create accounts, sign in, and sign out. Next up, you'll build the file upload model and the logic to store files on disk.

Create the File Upload Model

Your users can register and log in. Now it's time to give them something to do once they're authenticated.

Every file storage system needs a way to track what's been uploaded, who owns it, and where it lives on disk. In this step, you'll create the database model that stores file metadata and build the views that handle uploading, downloading, and deleting files.

In this step, get ready to:
  • Define the UploadedFile model with file metadata fields and user ownership.
  • Build the views that handle file uploads, downloads, and deletions.
  • Run migrations to create the database table and test uploading a file.
Define the UploadedFile model

A Django model defines the shape of your database table. Each field on the model becomes a column, and each instance becomes a row.

Your UploadedFile model will track the file itself, its name, its MIME type, its size in bytes, when it was uploaded, and which user owns it.

  • In VS Code, open files/models.py from the Explorer sidebar.
  • Replace the entire contents of the file with the following code:
from django.db import models
from django.contrib.auth.models import User


class UploadedFile(models.Model):
    # The actual file stored on disk
    file = models.FileField(upload_to="uploads/%Y/%m/%d/")
    # Human-readable metadata
    filename = models.CharField(max_length=255)
    file_type = models.CharField(max_length=100)
    file_size = models.PositiveIntegerField()
    uploaded_at = models.DateTimeField(auto_now_add=True)
    # Link each file to the user who uploaded it
    owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name="files")

    class Meta:
        ordering = ["-uploaded_at"]

    def __str__(self):
        return self.filename

What does this model do?

  • FileField handles saving the file to disk. The upload_to="uploads/%Y/%m/%d/" pattern organises files into date-based folders inside your media/ directory.
  • The owner ForeignKey links each file to a specific user. If that user is deleted, on_delete=models.CASCADE removes their files too.
  • ordering = ["-uploaded_at"] makes queries return the newest files first by default.

Why store metadata separately from the file?

Django's FileField only stores the relative path to the file on disk. By saving the original filename, MIME type, and size as separate database columns, you can search, filter, and display file information without opening the actual file.

  • Save files/models.py.

Now register this model in the admin panel so you can inspect uploaded files through Django's built-in admin interface.

  • Open files/admin.py and replace its contents with this code:
from django.contrib import admin
from .models import UploadedFile


@admin.register(UploadedFile)
class UploadedFileAdmin(admin.ModelAdmin):
    # Columns shown in the admin list view
    list_display = ["filename", "owner", "file_type", "file_size", "uploaded_at"]
    # Sidebar filters for quick browsing
    list_filter = ["file_type", "uploaded_at"]
    # Search by filename or owner's username
    search_fields = ["filename", "owner__username"]

What does this code do?

The @admin.register decorator connects your model to Django's admin site. The list_display, list_filter, and search_fields options customise how the file list appears when you visit /admin/.

  • Save files/admin.py.

Seeing a red underline on the import?

VS Code may show an import error for UploadedFile until you save models.py. Make sure both files are saved and the error should disappear.

Help me fix import errors in my Django project

Build the file management views

Your URL patterns already reference dashboard_view, download_file, and delete_file. Now you'll implement the logic that makes them work with actual files.

  • Open files/views.py and replace the entire contents with the following imports and register_view (which stays the same) plus the new dashboard_view:
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
from django.http import FileResponse

from .models import UploadedFile
from .forms import RegisterForm, FileUploadForm


def register_view(request):
    if request.method == "POST":
        form = RegisterForm(request.POST)
        if form.is_valid():
            user = form.save()
            login(request, user)
            return redirect("/")
    else:
        form = RegisterForm()
    return render(request, "registration/register.html", {"form": form})


@login_required
def dashboard_view(request):
    # Handle file upload on POST
    if request.method == "POST":
        form = FileUploadForm(request.POST, request.FILES)
        if form.is_valid():
            uploaded_file = request.FILES["file"]
            UploadedFile.objects.create(
                file=uploaded_file,
                filename=uploaded_file.name,
                file_type=uploaded_file.content_type,
                file_size=uploaded_file.size,
                owner=request.user,
            )
            return redirect("/")
    else:
        form = FileUploadForm()

    # Show only files belonging to the logged-in user
    files = UploadedFile.objects.filter(owner=request.user)
    return render(request, "files/dashboard.html", {"form": form, "files": files})

What does the dashboard view do?

  • On a POST request, it reads the uploaded file from request.FILES and creates a new UploadedFile record. Django automatically saves the file to the media/ directory.
  • On a GET request, it fetches only the files owned by the current user and passes them to the template for display.
  • The @login_required decorator redirects unauthenticated visitors to the login page.

Now add the download and delete views below dashboard_view in the same file.

  • Add the following two functions at the bottom of files/views.py:
@login_required
def download_file(request, file_id):
    # Only let users download their own files
    uploaded_file = get_object_or_404(UploadedFile, id=file_id, owner=request.user)
    return FileResponse(uploaded_file.file.open("rb"), as_attachment=True, filename=uploaded_file.filename)


@login_required
def delete_file(request, file_id):
    # Only let users delete their own files
    uploaded_file = get_object_or_404(UploadedFile, id=file_id, owner=request.user)
    if request.method == "POST":
        uploaded_file.file.delete()
        uploaded_file.delete()
    return redirect("/")

What do these views do?

  • download_file uses FileResponse to stream the file to the browser as a download. The as_attachment=True flag tells the browser to save it rather than display it.
  • delete_file removes the file from disk with uploaded_file.file.delete() and then removes the database record with uploaded_file.delete(). Both steps are needed to avoid orphaned files.
  • Both views filter by owner=request.user so users can only access their own files.
  • Save files/views.py.

✔️ Awesome, I've got everything!

Great. Make sure you've saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the complete files/views.py for comparison:

from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
from django.http import FileResponse

from .models import UploadedFile
from .forms import RegisterForm, FileUploadForm


def register_view(request):
    if request.method == "POST":
        form = RegisterForm(request.POST)
        if form.is_valid():
            user = form.save()
            login(request, user)
            return redirect("/")
    else:
        form = RegisterForm()
    return render(request, "registration/register.html", {"form": form})


@login_required
def dashboard_view(request):
    if request.method == "POST":
        form = FileUploadForm(request.POST, request.FILES)
        if form.is_valid():
            uploaded_file = request.FILES["file"]
            UploadedFile.objects.create(
                file=uploaded_file,
                filename=uploaded_file.name,
                file_type=uploaded_file.content_type,
                file_size=uploaded_file.size,
                owner=request.user,
            )
            return redirect("/")
    else:
        form = FileUploadForm()

    files = UploadedFile.objects.filter(owner=request.user)
    return render(request, "files/dashboard.html", {"form": form, "files": files})


@login_required
def download_file(request, file_id):
    uploaded_file = get_object_or_404(UploadedFile, id=file_id, owner=request.user)
    return FileResponse(uploaded_file.file.open("rb"), as_attachment=True, filename=uploaded_file.filename)


@login_required
def delete_file(request, file_id):
    uploaded_file = get_object_or_404(UploadedFile, id=file_id, owner=request.user)
    if request.method == "POST":
        uploaded_file.file.delete()
        uploaded_file.delete()
    return redirect("/")

Seeing an error about missing template?

The dashboard_view references files/dashboard.html which doesn't exist yet. That's expected. You'll create the dashboard template in the next step.

Help me debug a TemplateDoesNotExist error in Django

Run migrations and test the upload

Django needs to know about your new model before it can create the corresponding database table. Migrations are how Django translates model definitions into database schema changes.

  • Generate the migration file by running this command in your terminal:
python manage.py makemigrations
  • You should see output mentioning Create model UploadedFile. This confirms Django detected your new model.
  • Apply the migration to your database by running:
python manage.py migrate
  • You should see several lines of output ending with OK for each applied migration.

What just happened?

The makemigrations command created a Python file describing the table structure. The migrate command executed that file against your SQLite database. Your db.sqlite3 file now has a table ready to store uploaded file records.

Verify the setup by confirming the migration file was created.

  • Check that the migration file exists by running:
ls files/migrations/
  • You should see 0001_initial.py in the output. This is the migration file Django generated from your model.

No changes detected?

If makemigrations says "No changes detected", make sure the files app is listed in INSTALLED_APPS in filestorage/settings.py. Also check that you saved files/models.py with the UploadedFile class.

Help me fix makemigrations detecting no changes

Your file storage backend is complete. The model, views, and database are all wired together. Next up, you'll build the responsive dashboard UI that lets users see and interact with their files.

Build the File Dashboard

Your backend can handle file uploads, downloads, and deletions. But without a frontend template, users have no way to interact with it.

In this step, you will build a responsive dashboard template where users can upload files and browse them in a card-based grid. The layout will adapt between mobile, tablet, and desktop screen sizes using Tailwind CSS breakpoint classes.

In this step, get ready to:
  • Create a file upload form with a polished drag-style interface.
  • Build a responsive card grid that displays file type icons, names, sizes, and dates.
  • Add download and delete actions with a confirmation prompt.
Create the dashboard template with the upload form

The dashboard_view in your views already renders files/dashboard.html, but that template does not exist yet. You need to create it inside a files subfolder of templates.

  • In VS Code's Explorer sidebar, right-click the templates folder.
  • Click New Folder and name it files.
  • Right-click the new files folder and click New File.
  • Name the file dashboard.html.
  • Paste the following upload form code into dashboard.html:
{% extends "base.html" %}
{% block content %}
<div class="mb-6">
    <h1 class="text-2xl font-bold mb-4">My Files</h1>
    <!-- Upload form with multipart encoding for file data -->
    <form method="post" enctype="multipart/form-data" class="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
        {% csrf_token %}
        <!-- Custom file input styled as a dashed drop zone -->
        <label class="flex-1 w-full cursor-pointer">
            <div class="flex items-center justify-center px-4 py-3 bg-gray-800 border-2 border-dashed border-gray-600 rounded-lg hover:border-indigo-500 transition">
                <span class="text-gray-400 text-sm" id="file-label">Choose a file or drag it here</span>
                <input type="file" name="file" class="hidden" onchange="document.getElementById('file-label').textContent = this.files[0].name" required />
            </div>
        </label>
        <button type="submit" class="px-6 py-3 bg-indigo-600 hover:bg-indigo-500 rounded-lg font-semibold text-white text-base transition min-w-[120px]">Upload</button>
    </form>
</div>

What does this code do?

This template extends base.html to inherit the navigation bar and dark theme you built earlier.

  • The enctype="multipart/form-data" attribute tells the browser to send file binary data (not just text) when the form submits.
  • The actual file input is hidden. The visible dashed box acts as a clickable label that opens the file picker.
  • The onchange handler updates the label text to display the selected filename before upload.
Build the responsive file card grid

Below the upload form, you need a grid that renders each file as a card. Each card shows an icon based on file type, the filename, file size, upload date, and buttons for download and delete.

  • In dashboard.html, add the following code directly below the closing </div> of the upload section (after the mb-6 div), before {% endblock %}:

{% if files %}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
    {% for file in files %}
    <div class="bg-gray-800 rounded-xl p-4 border border-gray-700 hover:border-indigo-500 transition group">
        <!-- File type icon -->
        <div class="flex items-start justify-between mb-3">
            <div class="w-10 h-10 rounded-lg bg-gray-700 flex items-center justify-center text-lg">
                {% if "image" in file.file_type %}
                    <span>&#128247;</span>
                {% elif "pdf" in file.file_type %}
                    <span>&#128196;</span>
                {% elif "video" in file.file_type %}
                    <span>&#127910;</span>
                {% elif "text" in file.file_type %}
                    <span>&#128221;</span>
                {% else %}
                    <span>&#128193;</span>
                {% endif %}
            </div>
            <!-- Delete button visible on hover -->
            <form method="post" action="{% url 'delete_file' file.id %}" onsubmit="return confirm('Delete this file?')">
                {% csrf_token %}
                <button type="submit" class="text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition p-2 -m-2 text-lg" title="Delete">&#10005;</button>
            </form>
        </div>
        <!-- File metadata and download link -->
        <h3 class="font-medium text-sm truncate mb-1" title="{{ file.filename }}">{{ file.filename }}</h3>
        <p class="text-xs text-gray-500">{{ file.file_size|filesizeformat }} &middot; {{ file.uploaded_at|date:"M d, Y" }}</p>
        <a href="{% url 'download_file' file.id %}" class="mt-3 block text-center py-2 px-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-indigo-300 font-medium transition">Download</a>
    </div>
    {% endfor %}
</div>
{% else %}
<!-- Empty state when user has no files -->
<div class="text-center py-16">
    <p class="text-gray-500 text-lg">No files yet. Upload your first file above.</p>
</div>
{% endif %}
{% endblock %}

What does this code do?

  • The grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 classes create a responsive grid. On mobile it shows 1 column, tablets get 2, laptops get 3, and wide desktops get 4.
  • Django template tags check file.file_type and display a matching emoji icon (camera for images, page for PDFs, film for videos, notepad for text, folder for anything else).
  • The delete button uses opacity-0 group-hover:opacity-100 so it only appears when the user hovers over the card. The confirm() call triggers a browser dialog before actually deleting.
  • The truncate class prevents long filenames from breaking the card layout. The full name appears in a tooltip via the title attribute.
  • Django's filesizeformat filter converts raw bytes to human-readable sizes like "2.4 MB".
  • Save dashboard.html.

Seeing a TemplateSyntaxError?

  • Make sure every {% if %} has a matching {% endif %} and every {% for %} has a matching {% endfor %}.
  • Check that the file is saved at templates/files/dashboard.html (inside a files subfolder, not directly in templates/).
  • Verify the first line is {% extends "base.html" %} with double quotes around base.html.

Help me debug my dashboard template error.

✔️ Awesome, I've got everything!

Great. Make sure you have saved dashboard.html before moving on.

⭓ I'd like to double check the full code

{% extends "base.html" %}
{% block content %}
<div class="mb-6">
    <h1 class="text-2xl font-bold mb-4">My Files</h1>
    <form method="post" enctype="multipart/form-data" class="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
        {% csrf_token %}
        <label class="flex-1 w-full cursor-pointer">
            <div class="flex items-center justify-center px-4 py-3 bg-gray-800 border-2 border-dashed border-gray-600 rounded-lg hover:border-indigo-500 transition">
                <span class="text-gray-400 text-sm" id="file-label">Choose a file or drag it here</span>
                <input type="file" name="file" class="hidden" onchange="document.getElementById('file-label').textContent = this.files[0].name" required />
            </div>
        </label>
        <button type="submit" class="px-6 py-3 bg-indigo-600 hover:bg-indigo-500 rounded-lg font-semibold text-white text-base transition min-w-[120px]">Upload</button>
    </form>
</div>

{% if files %}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
    {% for file in files %}
    <div class="bg-gray-800 rounded-xl p-4 border border-gray-700 hover:border-indigo-500 transition group">
        <div class="flex items-start justify-between mb-3">
            <div class="w-10 h-10 rounded-lg bg-gray-700 flex items-center justify-center text-lg">
                {% if "image" in file.file_type %}
                    <span>&#128247;</span>
                {% elif "pdf" in file.file_type %}
                    <span>&#128196;</span>
                {% elif "video" in file.file_type %}
                    <span>&#127910;</span>
                {% elif "text" in file.file_type %}
                    <span>&#128221;</span>
                {% else %}
                    <span>&#128193;</span>
                {% endif %}
            </div>
            <form method="post" action="{% url 'delete_file' file.id %}" onsubmit="return confirm('Delete this file?')">
                {% csrf_token %}
                <button type="submit" class="text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition p-2 -m-2 text-lg" title="Delete">&#10005;</button>
            </form>
        </div>
        <h3 class="font-medium text-sm truncate mb-1" title="{{ file.filename }}">{{ file.filename }}</h3>
        <p class="text-xs text-gray-500">{{ file.file_size|filesizeformat }} &middot; {{ file.uploaded_at|date:"M d, Y" }}</p>
        <a href="{% url 'download_file' file.id %}" class="mt-3 block text-center py-2 px-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-indigo-300 font-medium transition">Download</a>
    </div>
    {% endfor %}
</div>
{% else %}
<div class="text-center py-16">
    <p class="text-gray-500 text-lg">No files yet. Upload your first file above.</p>
</div>
{% endif %}
{% endblock %}
Test uploads, downloads, and responsive layout

Your dashboard template is complete. Time to see it working end-to-end and verify that file operations function correctly.

  • Start the development server by running this command:
python manage.py runserver
  • Open your browser and go to http://127.0.0.1:8000/.
  • Log in with the account you created in the previous step.

You should see the "My Files" heading and the dashed upload area. Below it, the empty state message reads "No files yet. Upload your first file above."

  • Click the dashed upload area and select any file from your computer (a PDF, image, or text file works well for testing).
  • Click Upload.

The page reloads and your file appears as a card with an icon, filename, size, and upload date.

  • Click the Download button on the file card to verify the download works.
  • Hover over the card and click the X button that appears in the top-right corner.
  • Click OK on the confirmation dialog to delete the file.

The file card disappears and the empty state message returns.

  • Upload 3-4 different files to see the grid layout in action.
  • Press F12 to open DevTools, then click the device toolbar icon (or press Ctrl+Shift+M) to toggle responsive mode.
  • Resize the viewport width to see the grid switch between 1 column (mobile), 2 columns (tablet), 3 columns (laptop), and 4 columns (wide desktop).

Why mobile-first with Tailwind?

Tailwind CSS uses a mobile-first approach. The base classes (like grid-cols-1) apply to all screen sizes. The prefixed classes (sm:, lg:, xl:) override at larger breakpoints.

This means your layout works on small screens by default and progressively adds columns as the screen gets wider.

Files upload but don't appear?

  • Check that MEDIA_ROOT is set in settings.py. Django creates the media/ directory automatically on first upload.
  • Verify that filestorage/urls.py includes the static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) line for serving uploaded files in development.
  • Make sure you are logged in. The dashboard view requires authentication and redirects to the login page if you are not signed in.

Help me debug why uploaded files are not showing on the dashboard.

Your file storage app is fully functional locally. Users can register, log in, upload any file type, browse their files in a responsive grid, download files, and delete them. Next up, you will deploy this to your Ubuntu server so it is accessible from any device on your network.

Deploy to Your Ubuntu Server

In the previous step, you built a polished, responsive file dashboard that adapts to any screen size. Your app works locally, but right now only your Windows machine can reach it.

The whole point of a self-hosted file storage system is that anyone on your network can access it from their phone, tablet, or laptop. In this step, you will deploy your Django app to an Ubuntu server using Gunicorn as the application server and Nginx as the reverse proxy.

In this step, get ready to:
  • Transfer your project files to the Ubuntu server and install dependencies.
  • Configure Django for production with PostgreSQL.
  • Set up Gunicorn and Nginx to serve your app on the network.
Transfer your project and set up the server environment

Your Ubuntu server needs the same tools your local machine has: Python, a database, and your project files. The difference is that in production you will use PostgreSQL instead of SQLite, because PostgreSQL handles concurrent users reliably.

  • Open a terminal on your Windows machine and transfer your project files to the server by running this command:
scp -r [[PROJECT_PATH="C:\Users\YourName\Desktop\filestorage"]] ubuntu@[[SERVER_IP="your server's IP address"]]:/home/ubuntu/filestorage

What does this command do?

The scp command securely copies your entire project folder from your Windows machine to the /home/ubuntu/filestorage directory on the server.

  • SSH into your Ubuntu server by running:
ssh ubuntu@[[SERVER_IP="your server's IP address"]]
  • Install Python, PostgreSQL, and Nginx on the server by running:
sudo apt update
sudo apt install python3 python3-pip python3-venv postgresql postgresql-contrib nginx -y

Packages failing to install?

Make sure your server has internet access. If you see permission errors, ensure you are using sudo before the commands.

help me troubleshoot package installation on Ubuntu

  • Create the PostgreSQL database and user by running these commands:
sudo -u postgres psql -c "CREATE DATABASE filestorage_db;"
sudo -u postgres psql -c "CREATE USER filestorage_user WITH PASSWORD 'your-db-password-here';"
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE filestorage_db TO filestorage_user;"
sudo -u postgres psql -c "ALTER DATABASE filestorage_db OWNER TO filestorage_user;"

What do these commands do?

You are creating a dedicated database called filestorage_db and a user called filestorage_user that Django will use to connect. Replace your-db-password-here with a strong password of your choosing and remember it for the next substep.

  • Move into your project directory and create a virtual environment on the server by running:
cd /home/ubuntu/filestorage
python3 -m venv venv
source venv/bin/activate
  • Install Django, Gunicorn, and the PostgreSQL adapter by running:
pip install Django==6.0.6 gunicorn psycopg2-binary
  • Confirm all three packages installed by running:
pip list

You should see Django, gunicorn, and psycopg2-binary in the output.

Seeing installation errors?

If psycopg2-binary fails, make sure postgresql-contrib was installed in the earlier step. Re-run the apt install command if needed.

help me fix psycopg2-binary installation errors on Ubuntu

Configure Django for production

Your local settings.py is configured for development: debug mode on, SQLite database, no allowed hosts. For production, you need to lock things down and point Django at PostgreSQL.

  • Open filestorage/settings.py on the server for editing by running:
nano filestorage/settings.py
  • Find the line that says DEBUG = True and change it to:
DEBUG = False
  • Find the line that says ALLOWED_HOSTS = [] and add your server's IP address:
ALLOWED_HOSTS = ["your-server-ip-here"]
  • Find the DATABASES section and replace it with the PostgreSQL configuration:
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "filestorage_db",
        "USER": "filestorage_user",
        "PASSWORD": "your-db-password-here",
        "HOST": "localhost",
        "PORT": "5432",
    }
}

What changed here?

You switched the database engine from SQLite to PostgreSQL. The NAME, USER, and PASSWORD must match what you created in the previous substep.

Setting DEBUG = False stops Django from showing detailed error pages to the public. ALLOWED_HOSTS tells Django which IP addresses or domains are allowed to make requests.

  • Save and exit nano by pressing Ctrl+O, Enter, then Ctrl+X.
  • Run migrations against the new PostgreSQL database:
python manage.py makemigrations
python manage.py migrate
  • Collect all static files into the staticfiles/ directory so Nginx can serve them by running:
python manage.py collectstatic --noinput

You should see a message confirming static files were copied to staticfiles/.

Migration errors?

If you see a connection refused error, make sure PostgreSQL is running with sudo systemctl status postgresql. If it shows inactive, start it with sudo systemctl start postgresql.

help me debug Django PostgreSQL connection errors

Set up Gunicorn and Nginx

Django's built-in development server is not designed for production traffic. Gunicorn handles multiple requests in parallel, while Nginx sits in front to serve static files and route traffic efficiently.

Why Gunicorn and Nginx together?

Gunicorn is a WSGI server that runs your Python code across multiple worker processes. Nginx is a reverse proxy that handles incoming HTTP connections, serves static files directly from disk, and forwards dynamic requests to Gunicorn.

This separation means Nginx handles the things it does best (serving files, managing connections) while Gunicorn focuses on running your Django code.

  • Create a Gunicorn systemd service file by running:
sudo nano /etc/systemd/system/gunicorn.service
  • Paste the following configuration into the file:
[Unit]
Description=gunicorn daemon
After=network.target

[Service]
User=ubuntu
Group=www-data
WorkingDirectory=/home/ubuntu/filestorage
ExecStart=/home/ubuntu/filestorage/venv/bin/gunicorn \
          --access-logfile - \
          --workers 3 \
          --bind unix:/run/gunicorn.sock \
          filestorage.wsgi:application

[Install]
WantedBy=multi-user.target

What does this service file do?

The [Service] section tells systemd to run Gunicorn as the ubuntu user, from your project directory. The ExecStart line launches Gunicorn with 3 worker processes, binding to a Unix socket at /run/gunicorn.sock.

The filestorage.wsgi:application part tells Gunicorn where to find your Django WSGI entry point.

  • Save and exit nano.
  • Start and enable the Gunicorn service so it runs on boot:
sudo systemctl daemon-reload
sudo systemctl start gunicorn
sudo systemctl enable gunicorn
  • Check that Gunicorn is running by running:
sudo systemctl status gunicorn

You should see active (running) in the output.

Gunicorn shows failed?

Check the logs with sudo journalctl -u gunicorn to see the full error. Common issues include a typo in the WorkingDirectory path or a missing package in the virtual environment.

help me troubleshoot why my Gunicorn systemd service failed to start

  • Now create the Nginx configuration file by running:
sudo nano /etc/nginx/sites-available/filestorage
  • Paste the following server block configuration:
server {
    listen 80;
    server_name your-server-ip-here;

    location /static/ {
        alias /home/ubuntu/filestorage/staticfiles/;
    }

    location /media/ {
        alias /home/ubuntu/filestorage/media/;
    }

    location / {
        include proxy_params;
        proxy_pass http://unix:/run/gunicorn.sock;
    }
}

What does this Nginx configuration do?

Nginx listens on port 80 and does three things. Requests for /static/ are served directly from the staticfiles/ folder. Requests for /media/ are served from the media/ folder (where uploaded files live).

Everything else gets forwarded to Gunicorn through the Unix socket at /run/gunicorn.sock. Replace your-server-ip-here with your actual server IP address.

  • Save and exit nano.
  • Enable the site by creating a symbolic link:
sudo ln -s /etc/nginx/sites-available/filestorage /etc/nginx/sites-enabled
  • Test the Nginx configuration for syntax errors by running:
sudo nginx -t

You should see a message saying the configuration test is successful.

  • Restart Nginx to apply the new configuration:
sudo systemctl restart nginx
  • Create the media/ directory on the server so uploaded files have somewhere to go:
mkdir -p /home/ubuntu/filestorage/media

Your app is now live. Open a browser on any device connected to the same network and navigate to your server's IP address. You should see the FileVault login page.

Can't reach the site from another device?

Make sure port 80 is open on your server's firewall. Run sudo ufw allow 80 if you are using UFW. Also verify that both devices are on the same network.

If you see a 502 Bad Gateway error, Gunicorn may not be running. Check with sudo systemctl status gunicorn and review the logs.

help me troubleshoot why my Django app shows 502 Bad Gateway through Nginx

  • Test the full flow: register a new user, log in, upload a file, and download it.
  • Try accessing the site from your phone or another computer on the same network.

Secret mission

Add File Type Filtering and Search

Your file dashboard works great for a handful of files. But what happens when you have dozens or hundreds? In this mission, you'll add a search bar and category filter tabs so users can quickly find what they need.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely on your own server, so there are no ongoing cloud costs.

Resources you used:

  • PostgreSQL database (filestorage_db) on the Ubuntu server.
  • Gunicorn systemd service (gunicorn.service) running continuously.
  • Nginx reverse proxy configuration serving the application on port 80.
  • Uploaded files stored in the media/ directory on the server.

Keep everything running

No action needed. Choose this if you are still actively using or building on your file storage system.

  • Gunicorn and Nginx will keep running and serving the application.
  • PostgreSQL will continue storing your user accounts and file metadata.
  • Your server consumes minimal resources when idle since it only responds to incoming requests.

Pause - I'll come back to this later

Stop the Gunicorn service to free up server resources. Your data and configuration remain intact so you can restart anytime.

  • Stop the Gunicorn service by running this command on your Ubuntu server:
sudo systemctl stop gunicorn

Your application will be unreachable while Gunicorn is stopped. Nginx will return a 502 error to visitors.

  • When you are ready to resume, restart the service by running:
sudo systemctl start gunicorn

All your files, user accounts, and database records will still be there when you come back.

Delete - I don't want to use this again

Remove all project resources and start fresh. Run these commands on your Ubuntu server.

  • Stop and disable the Gunicorn service, then remove its unit file:
sudo systemctl disable --now gunicorn
sudo rm /etc/systemd/system/gunicorn.service
sudo systemctl daemon-reload
  • Remove the Nginx site configuration and restart Nginx:
sudo rm /etc/nginx/sites-enabled/filestorage
sudo rm /etc/nginx/sites-available/filestorage
sudo systemctl restart nginx
  • Drop the PostgreSQL database and user:
sudo -u postgres dropdb filestorage_db
sudo -u postgres dropuser filestorage_user
  • Remove the project directory and all uploaded files from the server. Replace the path below with the actual location of your project:
rm -rf ~/filestorage
  • On your Windows machine, delete the local development folder from your Desktop. Open File Explorer, navigate to your Desktop, right-click the project folder, and select Delete.

Nice Work!

Nice Work!

Nice work! You just built and deployed a fully functional self-hosted file storage system using Django, complete with user authentication, file management, and a responsive interface.

You've learned how to:

  • Build a Django web application with custom user authentication including registration, login, and logout flows.
  • Create a file upload system using Django's FileField that accepts any file type and links uploads to individual user accounts.
  • Design a polished, mobile-first responsive UI with Tailwind CSS and deploy to a self-hosted Ubuntu server with Gunicorn and Nginx.
  • (Secret Mission) Added file search and type filtering to the dashboard for quick file discovery.

Ready to quiz yourself?