Media Distribution with Django, Nginx, and Wagtail

...distributing content to paying customers, a short albeit rather complex how-to.

By Neal


• Oct 25, 2021

Want to build a premium media distribution service with Django and Wagtail? Don’t want to pay Patreon or Substack 15%-18% of your gross receipts to do it? Here’s how…

The assumptions and considerations this was built around:

  • User credentials are sent in the request, users do not need to be “logged in” via an app or framework. In this post’s example, we will consider subscriber media distributed as a podcast RSS feed, which the user may want to consume in an app or device that doesn’t support authentication. In short we’re building a widely-compatible backend here, not a walled garden that requires users to consume the content they’ve paid for in a specific application.

  • The storage backend is a storage backend, not intended to be another web server. Rarely mentioned in guides on using object storage services is the fact that in the case of AWS S3 there is a fee per number of requests, and in the case of Digital Ocean as of this document’s writing there is a cap on the number of requests to a storage bucket over certain time periods. We will cache and proxy downloads via our own web server with these considerations in mind, the object storage will truly be “just storage.”

  • Authentication is handled by Django, against Django users in the Django database, which means users need to hit the backend for permissions to get a file. While there are authentication services both standalone and within the offerings of various cloud platforms they can be quite pricey, without even getting into the existential question of whether or not you trust third party services with your user data.

  • Nginx will serve (and cache) the actual files themselves, but not do any sort of authentication on its own. I don’t think spreading authentication functions into multiple services / backends is a good idea in general, so we will begin with the assumption that authentication should live within the framework where the user accounts and credentials live, and that framework’s tools should be used to implement authentication.

With all of this in mind, here’s what our typical request for a premium media file will look like:

And a request for a subscriber-only protected file breaks down like so:

  1. The user requests a file, which Nginx is the reverse proxy for, Nginx sends the request along to the Django backend.

  2. Django authenticates the user based on request parameters, and if the user has permission to get the file, Django generates a temporary signed URL from the storage backend to pass back to Nginx.

  3. Django responds to Nginx with instructions to serve an X-Sendfile response.

  4. Nginx checks to see if the file is in its cache, which we’ll define further along in this post. If the file is in the cache, serve it from there, else add it to the cache while sending it back to the user.

The important part here from the standpoint of the user is that the URL for the file is the one they initially sent. X-Sendfile preserves the original URL. All of the redirects and cache checks happen internally without the user’s knowledge, so the user never sees a “real” URL to the file. The important part from a developer’s standpoint is that the backend is agnostic, you could change storage providers, change hosting services for the front end, change from Nginx to Apache to some other web server if you like, and none of this would matter to the user (or any application the user chooses to use to access their files), because the URLs will stay the same. The only thing we’re locking ourselves into is Django, as the framework holding our user accounts and the interface to the storage backend.

More on that last point later.

Nginx Configuration


http {
    proxy_cache_path /var/cache/nginx keys_zone=media-files:4m max_size=10g inactive=1440m

server {
    listen 80;
    listen [::]:80;
        location / {
            return 301 https://$host$request_uri;
server {
    listen 443 ssl proxy_protocol;
    listen [::]:443 ssl proxy_protocol;
    client_max_body_size 200M;
    include /etc/nginx/ssl.conf;
    include /etc/nginx/header.conf;
    location / {
        proxy_redirect off;
        proxy_force_ranges on;
        proxy_buffering off;
        real_ip_header proxy_protocol;
	proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    location /favicon.ico { 
        access_log off; log_not_found off; 
    location ~ ^/media_download/(.*?)/(.*?)/(.*) {
        resolver ipv6=off;
        set $download_protocol $1;
        set $download_host $2;
        set $download_path $3;
        set $download_url $download_protocol://$download_host/$download_path;
        proxy_set_header Host $download_host;
        proxy_set_header Authorization '';
        proxy_set_header Cookie '';
        proxy_hide_header x-amz-request-id;
        proxy_hide_header x-amz-id-2;
        proxy_cache media-files;
        proxy_cache_key $scheme$proxy_host$download_path;
        proxy_cache_valid 1440m;
        proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
        proxy_cache_revalidate on;
        proxy_ignore_headers Set-Cookie;
        add_header X-Cache-Status $upstream_cache_status;
        proxy_pass $download_url$is_args$args;
        proxy_intercept_errors on;
        error_page 301 302 307 = @handle_redirect;
    location @handle_redirect {
        resolver ipv6=off;
        set $saved_redirect_location '$upstream_http_location';
        proxy_pass $saved_redirect_location;

The proxy_cache_path cache definition is explained in the Nginx docs in detail. We’ve defined the location on the disk (/var/cache/nginx) (make sure this is owned by the user the server runs as), the name of the cache (keys_zone=media-files) which we could re-use for multiple locations or servers, the memory size of the cache index (:4m), and the maximum size of the cached files (max_size=10g). Lastly, we’ll consider the files expired from the cache if they’re older than a day, or 1440 minutes (inactive=1440m). Four megabytes will hold about 32,000 keys in the cache index, which is overkill here but 4 megs of reserved memory won’t kill us. Ten gigabytes you may need to season to taste depending on the size and number of media files you’re serving in any given day. And similarly, speaking of days, you might want to adjust the inactive setting if your files rarely change and you want to cache them longer than 24 hours.

The first part of this Nginx site configuration mysite.conf (through the root / location and the access_log being turned off, as well as a redirect from non-SSL requests to the SSL listening port on 443) is pretty standard and should be familiar to anyone who has deployed Nginx as a reverse proxy for a backend app before.

Where we’re getting into new territory is the media_download location. Lets break down what Nginx will match in a URL here:

~ will match any preceding root URL, and the three (.*?) regex sections will match three URL parameters that we send along to Django for user and file authentication. The first parameter will be the base64-encoded email address of the user, which our backend uses as a user id (this guide covers the process of setting up a new Django projecct with a custom user model, the same user-signup library that I personally use (django-allauth), and changing the default user id in Django to email addresses instead of user names).

internal is important, it lets Nginx know that this is an internal redirect, this prevents the server from sending the client directly to the provided URL. Since Nginx will itself be fetching these files from an external URL, it needs DNS resolution, so we’ve hard-coded some public DNS servers to use ( is one belonging to Google, is one belonging to Cloudflare, and is one belonging to the firewall service Quad9, lastly we fall back to localhost’s DNS resolution if those fail).

In the next few lines beginning with set we’re re-composing the external URL to the file to explicitly declare the host header for caching purposes. A simpler implementation would be to simply point the download to a file on the disk (/path/to/file.mp4), but the problem with this method is by default Nginx will use the components of the original URL to build its cache. Since we have user data in the URL, the URL to the same file will be different for each user, and that method won’t work.

To visualize this, lets consider what a URL will look like:

5AYXdoaWxlY... (shortened) is our user’s base64 encoded email address, which will change for each user even if they request the same file. /2/ is the internal id of the file, which we can use to look up the file the user is requesting, and av2r2j-8f176710bf483694... (shortened) is the token that Django will use to authenticate the user which will also change for each user even if they request the same file. /file.mp4 is the last part of the URL, the $download_path (equivalent to the file name). Since two of these URL components will be different each time the same file is requested, we can’t use the whole URL as a cache key as Nginx does by default, otherwise:

All of the above files would be seen as different by the default Nginx cache key, and thus defeat the whole purpose of our cache: serving the same file to different users rather than fetching it from the storage backend every time it’s requested.

In the proxy_set_ and proxy_hide_ lines we’re setting the response defaults to both accommodate our cache mechanism and determine what the user will see in the response. We’ve set the host header to the backend storage host (i.e. the url to AWS S3 or Digital Ocean or whatever storage service you use), the authorization and cookie headers to be blank, and hidden the headers returned by the backend storage host relating to the request itself, since our user doesn’t need those. Finally, we’ve specified our cache, the media-cache which is created in the main Nginx config file.

Next is the most important part of our cache mechanism, the proxy_cache_key. Rather than letting it default to the full URL of the file requested by the user for the reasons mentioned above, we are setting it to $scheme (http or https) plus $proxy_host (a default variable set by Nginx which should be your website’s hostname, unless you change it…) plus the $download_path; which, as mentioned above, is equivalent to the file name. All of these URL parameters will remain unchanged even if the user requesting them changes, so by this custom implementation of the cache keys we’ll have our desired outcome of the same file being served from the disk cache to multiple users.

Last but not least proxy_pass is set to the URL returned from Django to tell Nginx to proxy the remote file, and we have some error handling, the most mentionable of which is the @handle-redirect directive. It’s not uncommon for storage object services to return redirects in the normal course of serving a file, for instance if you mirror your files across multiple regions the storage service might redirect a user to a datacenter closer to them geographically. If Nginx were not instructed to handle those, it would pass the redirect URL back to the user directly rather than maintaining the URL the user requested. By telling Nginx to handle 3xx redirect responses in the same way it handles the other requests the URL remains the same to the user even if the back end returns a redirect URL.

Django Configuration

On the Django side of things, this all plays very nicely with django-storages. Out of the box it supports all of the major cloud providers’ storage services, plus those API compatible with S3 from other cloud services, plus FTP, Dropbox, and Apache’s libcloud wrapper.

If you’re using Wagtail as I am, the wagtailmedia addon gives you a nice admin UI to manage your media files with. Lets take a look at what happens to a media file uploaded to a private S3 bucket in the Wagtail admin with django-storages and wagtailmedia:

wagtailmedia with django-storages

You can see in the URL at the bottom of the screenshot that when I hover over the file name to get the URL to it, django-storages has generated a signed URL for me to use since this file is in a private S3 bucket. That URL is only good for 10 minutes, but it’s the one Nginx will use to get the file and fill its local disk cache when a user requests the file in question. Subsequent requests during that day will be served from the Nginx cache, but only after the user has been passed to the Django back end first for authentication.

Next let’s look at the Django view which responds to the Nginx media file requests.


from django.urls import include, path, re_path
from myapp.views import PremiumMediaView

urlpatterns = [
re_path(r'^premium_media/(?P<uidb64>[-\w]*)/(?P<fileid>[-\w]*)/(?P<token>[-\w]*)/(?P<file_name>[\w.]{0,256})$', PremiumMediaView.as_view(), name='premium_media'),

The regex patterns beginning with ?P and ending with / are matched by the arg names in the <...> declarations. So for our view, we can pass those args to the function that processes the user data and makes sure our user is granted access to the requested file, like in the view below:


from myapp.models import CustomMedia
from myapp.tokens import premium_token
from urllib.parse import urlparse
from django.views.generic.base import View
from django.utils.http import urlsafe_base64_decode
from django.http import Http404, HttpResponse, HttpResponseForbidden

class PremiumMediaView(View):
    def get(self, request, uidb64, fileid, token, file_name):
            user = UserModel.objects.get(email=urlsafe_base64_decode(uidb64).decode())
            user = None
            file = CustomMedia.objects.get(pk=fileid)
            raise Http404('No such file exists')
        if user and premium_token.check_token(user, token) and user.stripe_subscription.status == 'active':
            url = file.url
            protocol = urlparse(url).scheme
            response = HttpResponse()
            response['X-Accel-Redirect'] = '/media_download/' + protocol + '/' + url.replace(protocol + '://', '')
            return response
            return HttpResponseForbidden()

Those familiar with django will probably wonder at this point, “what’s a premium_token and what does it do?” It’s actually a very cool and simple included-battery of the Django framework: it’s a repurposed password reset token.

Django includes a secure means of generating a one-time password reset link for a user. All the user has to do is click the “forgot password” link on the login form and Django will email that user a self-authenticating link that will expire either after one use or after a set amount of time has passed. We can re-use this functionality to make login-links for subscriber media files, by simply telling the token function to make the token against different user model fields than the password reset token.

Here’s how:


from django.contrib.auth.tokens import PasswordResetTokenGenerator
from datetime import datetime, time
from django.utils.crypto import constant_time_compare, salted_hmac
from django.utils.http import base36_to_int, int_to_base36

class PremiumSubscriberTokenGenerator(PasswordResetTokenGenerator):

    def _make_hash_value(self, user, timestamp):
        return str( + str(user.is_paysubscribed) + str(user.uuid) + str(user.stripe_subscription.status)

    def check_token(self, user, token):
        Check that a password reset token is correct for a given user.
        if not (user and token):
            return False
        # Parse the token
            ts_b36, _ = token.split("-")
            # RemovedInDjango40Warning.
            legacy_token = len(ts_b36) < 4
        except ValueError:
            return False

            ts = base36_to_int(ts_b36)
        except ValueError:
            return False

        # Check that the timestamp/uid has not been tampered with
        if not constant_time_compare(self._make_token_with_timestamp(user, ts), token):
            # RemovedInDjango40Warning: when the deprecation ends, replace
            # with:
            #   return False
            if not constant_time_compare(
                self._make_token_with_timestamp(user, ts, legacy=True),
                return False

        # RemovedInDjango40Warning: convert days to seconds and round to
        # midnight (server time) for pre-Django 3.1 tokens.
        now = self._now()
        if legacy_token:
            ts *= 24 * 60 * 60
            ts += int((now - datetime.combine(, time.min)).total_seconds())
        # Check the timestamp is within limit.
        if (self._num_seconds(now) - ts) > 3153600000:
            return False

        return True

premium_token = PremiumSubscriberTokenGenerator()

Most of this has been copied and pasted from the core Django PasswordResetTokenGenerator class, we’re simply overriding two methods. First, we need to override _make_hash_value to tell our custom generator to use different fields than the default Django generator, which uses the user’s password and last login date/time. We want to use the user id, the field which tells whether or not the user is a paid subscriber (user.is_paysubscribed), a uuid field (user.uuid) that is never shown to the user, and the user’s payment status from our Stripe payment library (user.stripe_subscription.status) which tells whether or not the subscription is active.

Next, the default timeout on Django generated tokens is a few days. You don’t want to change the default for all tokens, because that would make password reset tokens lingering in users’ email accounts valid indefinitely. What we’ve done above with that in mind isn’t rocket science, we’ve just copied and pasted the whole check_token method from the core Django PasswordResetTokenGenerator class, and changed the timeout to 3153600000 seconds instead of the default. Translated to something more readable, that amount of seconds is equal to 100 years.

As you can see here, the Django generated tokens aren’t stored in the database, rather they are generated for the user, and then generated again when Django checks them. If any of the database fields have changed since the token was generated, or if the time limit has expired, the token will check False, otherwise True will be returned by the token check method.

With everything in place, curl is a good tool to use to check and make sure all of this works.

We can make user / token link args in the Django shell:

$ python shell
Python 3.8.5 (v3.8.5:580fbb018f, Jul 20 2020, 12:11:27) 
[Clang 6.0 (clang-600.0.57)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from myapp.tokens import premium_token
>>> from myapp.models import CustomUser # or wherever your usermodel is...
>>> from django.utils.http import urlsafe_base64_encode
>>> from django.utils.encoding import force_bytes
>>> user = CustomUser.objects.get(pk=1)
>>> token = premium_token.make_token(user)
>>> uidb64 = urlsafe_base64_encode(force_bytes(
>>> token
>>> uidb64

With which we can go on to curl with the generated base64 email and token, replace the interior /1/ and the file name with a media file id and name that exist in your private media S3 bucket…

$ curl -I

HTTP/2 200 
server: nginx
date: Mon, 25 Oct 2021 23:45:33 GMT
content-type: audio/x-m4a; charset=utf-8
content-length: 2034429
expires: Tue, 26 Oct 2021 00:45:33 GMT
cache-control: max-age=3600
last-modified: Tue, 17 Aug 2021 23:23:58 GMT
etag: "c7cb4cfbf25ef6b3d3bd9b0d9ef77a7e"
x-cache-status: HIT
accept-ranges: bytes

If you run this twice, you should see a MISS on x-cache-status the first time you request a file, and a HIT on x-cache-status if you request the same file again. If you use a different user and different token to request the same file, you should see another HIT on the cache.

You can use whatever user fields you like to make tokens, but there are some considerations:

  • There should be at least one field that the user cannot possibly see, otherwise a particularly clever user might be able to generate a token on their own whether or not they are actually a paid subscriber. In my case is the user’s email, so that isn’t very obscure, lots of users might know other users’ email addresses. Their Stripe status isn’t very obscure either, since a user familiar with Stripe might know that a valid subscription returns active as a status. What is obscure is the uuid. I generate uuids for users when they sign up and store that uuid in the database, but I don’t use the uuid for anything except authentication token hashes so it’s not feasible for a user to find out what their uuid is.

  • If the user’s subscription status changes, one of the fields used to make tokens also needs to change for existing tokens to be invalidated. If a user pays their subscription fee on whatever schedule we are billing them, we don’t want to invalidate their links. Those links and the tokens within them should remain valid indefinitely. In this example, their stripe_subscription_status will show active if they are paid up and current, but will change to something else if they aren’t.

  • It should also be noted that your Django server doesn’t know what a subscription is, nor does it know what Stripe is, or a subscription_status, or any other such thing. It is, after all, just a big dumb machine that serves things. It knows how to count, check true and false, and parse strings of text, which is to say it doesn’t really “know” anything at all. Therefore it falls to you, the developer, to put conditional checks in your code to tell Django whether or not a user has access to a thing. Otherwise, if a user generates a premium_token when their subscription is lapsed or inactive or any other such status, it will be valid and let them download files, and would only be invalidated if their payment status becomes True instead of False which is the opposite of what you want (assuming you like making money from your subscriptions)! In my case I set my subscription profile page to never cache (so that it always loads fresh user data every time it is requested), and has this conditional statement in it around the fields that show users their subscription links:

{% if request.user.stripe_subscription and request.user.stripe_subscription.status == 'active' %}
<h1 class="mb-4">{% trans "Your Premium Content Links" %}</h1>
<p>{% blocktrans %}Links to the premium content you are subscribed to are shown below. 
DO NOT SHARE THEM. They are bound to your account, and enable password-less login to our 
premium services. You can import them to any device or application you choose such as 
feed readers or podcast players to access your premium episodes.{% endblocktrans %}</p>
{% if podcasts %}
<div class="col-12 text-indent">
  {% for podcast in podcasts %}
    <h4 class="mb3">{{ podcast.parent_page.rss_title }}:</h3>
    <p><a href="{{ podcast.parent_page.url }}premiumfeed/{{ uid }}/{{ token }}/">{{ ... }}/</a></p>
  {% endfor %}
{% endif %}

So for a user to have this section of the subscription profile page rendered to them, they have to be logged in, have a stripe_subscription and that subscription must have an active status. Otherwise, the whole Premium Content Links section of the subscription profile page will not be rendered, and thus a user without a paid subscription can never get Django to show them a premium_token link. The subscription profile page is the only view on my site where a user can see a premium_token link.

All that’s left here then, is how to make a token. That’s easy. As you saw in the Django shell example above in which I manually created a base64 encoded email and a user token, the same PasswordResetTokenGenerator class that checks tokens also makes tokens.


from django.db.models.query_utils import Q
from myapp.tokens import premium_token
from myapp.models import PodcastContentPage

def a_view(request):
    podcast_queryset = PodcastContentPage.objects.filter(Q(id__in=private_queryset_pages)).order_by('something')

    token = premium_token.make_token(request.user)
    uid = urlsafe_base64_encode(force_bytes(

    return render(request, 'myapp/subscriber_profile.html', {
        'token': token,
        'uid': uid,
        'podcasts': podcast_queryset if podcast_queryset else None,

In my case, I use Wagtail’s routable page methods to create an RSS feed under the index page of my individual articles / episodes / etc. Wagtail-personalisation is a nifty library that can be custom-purposed to serve premium content to premium subscribers transparently, I use it for separating average-Joe users from paying users.

from myapp.podcast_feeds import PodcastFeed #(django RSS framework custom feed)
from wagtail.core.models import Page
from wagtail.core.utils import resolve_model_string
from wagtail_personalisation.utils import exclude_variants
from wagtail.contrib.routable_page.models import RoutablePageMixin, route

class PodcastContentIndexPage(RoutablePageMixin, Page):

    @route(r'^rss/$', name='rss')
    def rss_feed(self, request):
        Renders a public RSS Feed for the public podcast episodes under this page.

        queryset = resolve_model_string(self.index_query_pagemodel, self._meta.app_label)

        last = exclude_variants(queryset.objects.child_of(self).live()).first()
        first = exclude_variants(queryset.objects.child_of(self).live()).last()
        all_public = exclude_variants(queryset.objects.child_of(self).live()).order_by('-date_display')
        all_private = None
        rss_link = request.get_raw_uri()
        home_link = self.get_site().root_url
        token = None
        uidb64 = None

        # Construct the feed by passing ourself to it, so it can determine the feed's "link".
        feed = PodcastFeed(request, rss_link, home_link, first, last, all_public, all_private, token, uidb64)
        # 'feed' is a class-based view, so we need to call feed and pass it the request to get our response.
        return feed(request)

    @route(r'^premiumfeed/(?P<uidb64>[-\w]*)/(?P<token>[-\w]*)/$', name='premiumfeed')
    def premium_feed(self, request, uidb64, token):
        basically the same methods as the public RSS feed and the same 
        base64 user id and token as the rest of this guide, but you have 
        to build the private / paid episode queryset rather than the public 

With something like the above we can build a queryset of podcast episode pages, to pass into Django’s syndication feed framework to build an Apple podcasts / iTunes RSS feed, like Patreon does (only without the 15%-18% fee…). Or if you’re doing articles instead of podcasts maybe you want to generate RSS feeds for premium articles, like Substack does (only without the 15%-18% fee…). Or maybe you want to do both all under one banner, without any fees other than what the payment processor charges. And of course the users will be your users, not the users of some “platform” that could simply decide that they’re not going to let you speak (or pay your rent and bills) anymore.

Hosting Considerations

Those of you who are familiar with web hosting might be thinking to yourselves “but, but, by doing this you’re bypassing the egress bandwidth of the storage object service and routing all of the bandwidth through your web server.” Yes, as a matter of fact you are.

This is worth considering from a bandwidth cost perspective.

As of this writing, AWS S3 costs $0.09/GB for outbound transfer, Microsoft Azure seems to cost about the same, and both of them have lots of oddball fees for requests and redundancy tiers and other such stuff. Digital Ocean charges $0.01/GB across the board, and gives you a baseline allowance that adjusts based on the fixed prices of servers and other such services you buy, which is suited particularly well to this guide. For instance, if you have $50.00 worth of servers to run your Django/Wagtail media distribution empire from, you’ll start out with 5 terabytes of bandwidth pre-paid every month, and will only be billed $0.01/GB for the amount you use in excess of that. Linode pricing mirrors that of Digital Ocean as does Backblaze.

With AWS it’s a 6 / half dozen proposition. You pay $0.09/GB, whether you spend that bandwidth serving from S3 or spend it serving from an EC2 Linux instance makes no difference, but for the fact that by proxying requests via EC2 you aren’t paying the request / operations fees from S3 that you would be if you use S3 directly as a server. Azure is similar to AWS in this regard. As mentioned at the top of this article Digital Ocean has a hard cap on requests even though they don’t charge for them, so if you’re doing this with a lot of paid users you have to proxy your downloads on Digital Ocean, lest you exceed the cap and run into a situation where paid users can’t get the files they’ve paid for.

Most interestingly, Cloudflare has announced a competing S3 object storage service that will not charge bandwidth egress fees but rather only charge fees for requests and the actual storage space used. This will likely be a very big deal in media distribution, and could cut into the usage of platforms like Youtube and Twitch.TV, as users will be able to serve even HD video on their own at reasonable costs rather than relying on third parties due to the bandwidth costs of serving video.

In any case, with the tools and the know-how there’s no reason to pay the egregious percentage-of-your-gross fees that platforms like Patreon and Substack charge, go forth and serve your own content!

Thanks to Ewen @ Mediasuite and Oleksiy Kovyrin @ Scribd for their previous posts on this topic. Despite being over a decade apart the how-to for all three guides remains basically unchanged. If only front end frameworks were as stable as back end servers…