Your editorial team publishes weekly PDF reports, downloadable whitepapers, and legal documents. Currently every file requires a developer to upload and link it. This lesson teaches you to leverage Wagtail's Documents and Collections system for centralized, permission-controlled document management.

1. Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what Wagtail Documents are and how they differ from images and other media
  • Create and organize documents using Wagtail Collections
  • Add document choosers to page models and StreamField blocks
  • Control access to documents using collection-level permissions and privacy settings
  • Serve and render documents in templates with custom styling
  • Manage documents programmatically through Python and the REST API
  • Implement document upload workflows and bulk operations

2. Why This Matters (Real-World Scenario)

Your editorial team publishes weekly PDF reports, downloadable whitepapers, press kit assets, and legal documents on your Wagtail-powered corporate site. Currently, every new document requires a developer to upload it via the admin and manually add a link to the page. The design team needs to organize assets by campaign, the legal team needs restricted access to confidential documents, and the marketing team wants self-service uploads for case studies. Without a proper document management system, files end up scattered across shared drives, Google Docs, and the server's media folder. This lesson teaches you to leverage Wagtail's built-in Documents and Collections system to give your team a centralized, permission-controlled, and template-friendly document repository.

3. Core Concepts

Documents — In Wagtail, a Document is any uploaded file (PDF, DOCX, XLSX, ZIP, images as non-image assets) stored in the media directory with a database record. Each Document has a title, file reference, file size, upload timestamp, and optional tags. Documents are served through Wagtail's serve view, which enforces privacy settings.

Collections — Collections are hierarchical folders that organize both images and documents. They provide a way to group related files by campaign, department, project, or any logical grouping. Collections can be nested (parent/child) and support per-collection group permissions, enabling fine-grained access control.

DocumentChooserBlock — A StreamField block type that lets content editors pick a document from the Wagtail admin chooser modal. It stores a foreign key to the Document model and exposes the chosen document in templates.

Collection Privacy & Permissions — Each collection has configurable privacy settings: public (anyone), private (logged-in users), or group-specific access (certain Django groups). Document serve views respect these settings, blocking unauthorized downloads at the URL level.

Document Serving — Wagtail serves documents through a dedicated view at /documents/{id}/{filename}. This view checks privacy settings, increments download counts, and can be overridden for custom serve logic (e.g., tracking downloads, watermarking PDFs).

4. Hands-On Practice: Step-by-Step Document Management

4.1 Setting Up the Document Model

Wagtail's Document model comes built-in — no additional models required. Ensure your project's INSTALLED_APPS includes wagtail.documents (it's there by default in a Wagtail project):

# myproject/settings/base.py
INSTALLED_APPS = [
    # ... other apps
    'wagtail.documents',
    # ...
]

# Document storage (default: local media/documents/)
# For production, configure AWS S3 or Google Cloud Storage
# DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
Check wagtail.documents is in INSTALLED_APPS

4.2 Creating Collections in the Admin

Navigate to Settings → Collections in the Wagtail admin sidebar. You'll see a default Root collection. Create a hierarchy like this:

  1. Root (default, all files)
  2. ├── Marketing (brochures, case studies, logos)
  3. │ ├── 2026 Campaign (Q1-Q4 campaign assets)
  4. │ └── Brand Assets (logos, style guides)
  5. ├── Legal (contracts, terms of service, privacy policies)
  6. ├── Technical (API docs, white papers, architecture diagrams)
  7. └── Press (press releases, media kits, executive photos)

Each collection can have its own set of permissions. Set Legal to private, accessible only by the Legal group. Set Marketing to private but accessible by the Marketing and Editors groups.

# You can also create collections programmatically:
from wagtail.documents import get_document_model
from wagtail.models import Collection

root = Collection.get_first_root_node()
marketing = root.add_child(name='Marketing')
legal = root.add_child(name='Legal')
marketing_2026 = marketing.add_child(name='2026 Campaign')

print(f'Created collections: {root}, {marketing}, {legal}, {marketing_2026}')
Creating collections via Python shell

4.3 Uploading and Managing Documents

Go to Documents in the Wagtail admin. Click Add Document.

Fill in:

  • File — Choose the file to upload
  • Title — Display name (auto-filled from filename)
  • Collection — Pick the target collection from the dropdown
  • Tags — Optional keywords for filtering
  • File size — Auto-populated after upload

Wagtail stores the file at media/documents/{year}/{month}/{filename} by default. The database record tracks the original filename, title, upload date, and collection membership.

# Upload a document programmatically:
from wagtail.documents import get_document_model
from django.core.files.base import ContentFile

Document = get_document_model()
from wagtail.models import Collection

marketing_collection = Collection.objects.get(name='Marketing')

# Create a document from a file-like object
doc = Document(
    title='2026 Product Brochure',
    collection=marketing_collection,
)
doc.file.save('brochure-2026.pdf', ContentFile(b'%PDF-1.4...'), save=False)
doc.save()

print(f'Document created: #{doc.id} - {doc.title} ({doc.file.size} bytes)')
Programmatic document upload

4.4 Adding DocumentChooserBlock to Page Models

The most common integration is adding a document chooser to a page model. Use DocumentChooserBlock in your StreamField definition or as a direct field:

# myapp/models.py
from wagtail.fields import StreamField
from wagtail.documents.blocks import DocumentChooserBlock
from wagtail.models import Page

class ResourcePage(Page):
    body = StreamField([
        ('heading', blocks.CharBlock()),
        ('paragraph', blocks.RichTextBlock()),
        ('document', DocumentChooserBlock()),
        ('document_list', blocks.ListBlock(DocumentChooserBlock())),
    ], use_json_field=True)

    content_panels = Page.content_panels + [
        FieldPanel('body'),
    ]
Using DocumentChooserBlock in StreamField

4.5 Rendering Documents in Templates

When you use DocumentChooserBlock, the template receives the Document model instance. Render it with a download link:

{% load wagtailcore_tags %}
{% load wagtailimages_tags %}

{# Single document #}
{% for block in page.body %}
    {% if block.block_type == 'document' %}
        <div class="document-download">
            <a href="{{ block.value.url }}" class="btn btn-download" download>
                <i class="icon-download"></i>
                {{ block.value.title }}
                <span class="file-meta">
                    ({{ block.value.file_extension|upper }},
                    {{ block.value.file.size|filesizeformat }})
                </span>
            </a>
        </div>
    {% endif %}
{% endfor %}
Rendering a DocumentChooserBlock in template
{% load wagtailcore_tags %}

{# Iterating a list of documents #}
{% for block in page.body %}
    {% if block.block_type == 'document_list' %}
        <ul class="document-list">
        {% for doc in block.value %}
            <li>
                <a href="{{ doc.url }}">
                    {{ doc.title }}
                </a>
                <span class="file-size">{{ doc.file.size|filesizeformat }}</span>
                <span class="file-date">{{ doc.created_at|date:'M j, Y' }}</span>
            </li>
        {% endfor %}
        </ul>
    {% endif %}
{% endfor %}
Rendering a list of documents in template

4.6 Document Privacy and Collection Permissions

Wagtail supports two levels of document access control:

Collection-level permissions — Configured under Settings → Collections → Edit → Privacy. Options:

  • Public — Accessible to anyone with the URL
  • Private (Login required) — Must be authenticated on the site
  • Group-specific — Only members of specific Django groups can view/download
# Set collection privacy programmatically:
from wagtail.models import Collection, GroupCollectionPermission
from django.contrib.auth.models import Group

legal_collection = Collection.objects.get(name='Legal')
editors_group = Group.objects.get(name='Editors')

# Grant the Editors group 'choose' and 'add' permissions on Legal collection
from wagtail.documents.permissions import permission_policy

permission_policy = permission_policy
permission_policy.add_permission(editors_group, 'add', legal_collection)
permission_policy.add_permission(editors_group, 'choose', legal_collection)

print(f'Permissions set for {editors_group.name} on {legal_collection.name}')
Programmatic collection permissions

4.7 Custom Document Serve Logic

Override Wagtail's built-in document serve view to add download tracking, authentication checks, or custom headers:

# myapp/views.py
from wagtail.documents.views.serve import serve
from django.http import HttpResponse
import logging

logger = logging.getLogger(__name__)

def custom_document_serve(request, document_id, document_filename):
    """Custom document serve view with download tracking."""
    from wagtail.documents import get_document_model
    Document = get_document_model()
    
    doc = Document.objects.get(id=document_id)
    
    # Log download
    logger.info(f'Document downloaded: {doc.title} by {request.user}')
    
    # Track download count (add a custom field)
    doc.download_count += 1
    doc.save(update_fields=['download_count'])
    
    # Pass to default serve view for privacy checks and serving
    return serve(request, document_id, document_filename)
Custom document serve view with tracking
# myproject/urls.py
from django.urls import re_path
from myapp.views import custom_document_serve
from wagtail.documents.views.serve import serve

urlpatterns = [
    # Override the default document serve URL
    re_path(
        r'^documents/(\d+)/(.*)$',
        custom_document_serve,
        name='wagtail_documents_serve_custom'
    ),
    # ... other URL patterns
]
Register the custom serve view in URLs

4.8 Document Management via REST API

Wagtail's REST API exposes Documents through a dedicated endpoint. You can list, filter, create, and update documents programmatically:

# List documents via REST API
curl -s "http://localhost:8000/api/v2/documents/" | python3 -m json.tool

# Filter by collection
curl -s "http://localhost:8000/api/v2/documents/?collection_id=3"

# Search documents by title
curl -s "http://localhost:8000/api/v2/documents/?search=brochure"
REST API document endpoints
# Upload a document via REST API (authenticated):
import requests

url = 'http://localhost:8000/api/v2/documents/'
token = 'YOUR_API_TOKEN'
headers = {'Authorization': f'Token {token}'}
files = {
    'file': open('report-q2.pdf', 'rb'),
}
data = {
    'title': 'Q2 Financial Report',
    'collection': 5,  # ID of the Legal collection
}

response = requests.post(url, headers=headers, files=files, data=data)
print(response.json())
Upload document via REST API

4.9 Bulk Document Operations

For migrating existing files into Wagtail, write a management command that scans a directory and imports documents:

# myapp/management/commands/import_documents.py
from django.core.management.base import BaseCommand
from wagtail.documents import get_document_model
from wagtail.models import Collection
from django.core.files import File
import os

Document = get_document_model()

class Command(BaseCommand):
    help = 'Import documents from a directory into a collection'
    
    def add_arguments(self, parser):
        parser.add_argument('directory', type=str)
        parser.add_argument('--collection', type=str, default='Root')
        parser.add_argument('--recursive', action='store_true')
    
    def handle(self, *args, **options):
        directory = options['directory']
        collection = Collection.objects.get(name=options['collection'])
        
        for root, dirs, files in os.walk(directory):
            for filename in files:
                filepath = os.path.join(root, filename)
                with open(filepath, 'rb') as f:
                    doc = Document(
                        title=os.path.splitext(filename)[0].replace('-', ' ').title(),
                        collection=collection,
                    )
                    doc.file.save(filename, File(f), save=True)
                self.stdout.write(f'Imported: {filename}')
            if not options['recursive']:
                break
Bulk document import management command
# Run the import command
python manage.py import_documents /path/to/pdf/files --collection Marketing --recursive
Execute bulk import

5. Common Errors & Solutions

Error: Document URL returns 403 Forbidden

The document is in a private collection and the request is unauthenticated. Solution: Set the collection privacy to Public if the document should be accessible to everyone, or ensure users are logged in when accessing the document URL.

Error: DocumentChooserBlock returns None in template

The document was deleted but the block still references it. Solution: Add null handling in templates — {% if block.value %} — and set up a signal handler to clean up orphaned blocks when documents are deleted.

Error: File size exceeds upload limit

Django/Wagtail enforces DATA_UPLOAD_MAX_MEMORY_SIZE (default 2.5MB) and FILE_UPLOAD_MAX_MEMORY_SIZE. Solution: Increase these in settings or configure chunked uploads for large files. Also set Nginx/Apache client_max_body_size limits accordingly.

Error: Document upload fails silently in production

When using S3/GCS storage, file permissions or bucket policies may prevent writes. Solution: Check storage backend credentials, bucket CORS settings, and the Django storage backend configuration. Enable Django's logging for django.request to see the actual error.

Error: Collection chooser shows no collections

The user doesn't have 'choose' permission on any collection. Solution: Grant 'choose' document permission for the relevant collections to the user's group via Settings → Collections → Edit → Group Permissions.

6. Summary Checklist

  • [ ] Created hierarchical Collections for organizing documents (Marketing, Legal, Technical, Press)
  • [ ] Set collection-level privacy and group permissions for restricted access
  • [ ] Uploaded documents via admin interface and verified download links work
  • [ ] Added DocumentChooserBlock to a page model for editor-friendly document selection
  • [ ] Rendered document download cards in templates with file size and type information
  • [ ] Implemented a custom document serve view for download tracking
  • [ ] Tested REST API document endpoints for programmatic access
  • [ ] Ran bulk import for existing documents into appropriate collections
  • [ ] Verified private documents return 403 for unauthenticated users
  • [ ] Set appropriate file upload size limits in settings

7. Practice Exercise

Challenge: Build a Press Kit Page with Access-Controlled Documents

Create a new Wagtail page type called PressKitPage with the following requirements:

  1. Add a StreamField with heading, paragraph, and a document list block that uses DocumentChooserBlock
  2. Create collections named Press Kit 2026 with sub-collections: Logos, Screenshots, Press Releases
  3. Set the Press Releases sub-collection to private, accessible only by the Press group
  4. Upload at least one document to each sub-collection
  5. Build a template that renders documents grouped by collection, with download buttons showing file type icon, file size, and upload date
  6. Write a test that verifies: (a) public documents are accessible without authentication, and (b) private collection documents return 403 for anonymous requests

Bonus: Write a management command that generates a JSON manifest of all documents in a collection, including their URLs, file sizes, and tags — useful for CDN preloading or sitemap generation.

8. Next Steps

You now have a complete document management system built into your Wagtail site — with organized collections, permission-controlled access, template-ready document rendering, and a custom download tracker. Your editorial team can upload and manage documents independently, and your legal team has confidence that sensitive files are properly restricted.

Up next: Wagtail Hooks & Custom Admin Views — Learn to extend Wagtail's admin interface with custom panels, buttons, and views. You'll build a document usage report panel that shows which pages reference each document, a bulk tag editor for documents, and a custom admin view for document upload analytics. These hooks transform Wagtail from a CMS into a tailored content platform for your team's specific workflows.

Further resources:

Common pitfalls to avoid:

  • Don't store documents in the default Root collection — always organize into named collections from day one. Renaming/reorganizing later is painful.
  • Avoid hardcoding document URLs in templates — use {{ doc.url }} which respects privacy settings and handles filename changes.
  • Don't forget to grant 'choose' permission — without it, editors can upload documents but can't select them in chooser modals.
  • Never skip privacy testing — a misconfigured collection privacy setting can expose confidential documents to the public. Always test with an incognito browser session.

Gataya Med

DevOps Engineer & Backend Developer. Sharing insights on cloud, automation, and scalable systems.

Comments (0)

Sarah Chen July 19, 2026

This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!

Reply

Leave a Comment