Serving Static Files from Flask with WhiteNoise and Amazon CloudFront

Last updated February 14th, 2023

WhiteNoise drastically simplifies static file management since it enables your Flask app to serve up its own static files. Couple it with a CDN like CloudFront or Cloudflare, and it's a convenient solution -- i.e., a good balance between simplicity and performance -- for handling static files on a Platform as a Service (PaaS) like Heroku or PythonAnywhere.

This tutorial details how to manage static files with Flask and WhiteNoise. We'll also configure Amazon CloudFront to get the best possible performance.

It's worth noting that this tutorial does not cover how to handle user-uploaded media files. Feel free to set this up as well as you work your way through the tutorial. Refer to the Storing Django Static and Media Files on Amazon S3 tutorial for more info.

Contents

WhiteNoise

Assuming you have a Flask project set up that uses the Application Factory function pattern, import and configure WhiteNoise:

import os

from flask import Flask, jsonify
from whitenoise import WhiteNoise


def create_app(script_info=None):

    app = Flask(__name__, static_folder="staticfiles")

    WHITENOISE_MAX_AGE = 31536000 if not app.config["DEBUG"] else 0

    # configure WhiteNoise
    app.wsgi_app = WhiteNoise(
        app.wsgi_app,
        root=os.path.join(os.path.dirname(__file__), "staticfiles"),
        prefix="assets/",
        max_age=WHITENOISE_MAX_AGE,
    )

    @app.route("/")
    def hello_world():
        return jsonify(hello="world")

    return app

Configuration:

  • root is the absolute path to the directory of static files.
  • prefix is the prefix string for all static URLs. In other words, based on the above configuration, a main.css static file will be available at http://localhost:5000/assets/main.css.
  • max_age is the length of time in seconds that browsers and proxies should cache the static files.

Review the Configuration attributes section, from the official WhiteNoise documentation, for more info on optional arguments.

Add a "static" directory in the project root and, for testing purpose, download a copy of boostrap.css and add it to that newly created directory. Add a "staticfiles" directory to the project root as well.

Your project structure should now look something like this:

├── app.py
├── static
│   └── bootstrap.css
└── staticfiles

Next, add the following script -- called compress.py -- to your project root that compresses the files from the "static" directory and then copies them over to the "staticfiles" directory:

import os
import gzip


INPUT_PATH = os.path.join(os.path.dirname(__file__), "static")
OUTPUT_PATH = os.path.join(os.path.dirname(__file__), "staticfiles")
SKIP_COMPRESS_EXTENSIONS = [
    # Images
    ".jpg",
    ".jpeg",
    ".png",
    ".gif",
    ".webp",
    # Compressed files
    ".zip",
    ".gz",
    ".tgz",
    ".bz2",
    ".tbz",
    ".xz",
    ".br",
    # Flash
    ".swf",
    ".flv",
    # Fonts
    ".woff",
    ".woff2",
]


def remove_files(path):
    print(f"Removing files from {path}")
    for filename in os.listdir(path):
        file_path = os.path.join(path, filename)
        try:
            if os.path.isfile(file_path):
                os.unlink(file_path)
        except Exception as e:
            print(e)


def main():
    # remove all files from "staticfiles"
    remove_files(OUTPUT_PATH)

    for dirpath, dirs, files in os.walk(INPUT_PATH):
        for filename in files:
            input_file = os.path.join(dirpath, filename)
            with open(input_file, "rb") as f:
                data = f.read()
            # compress if file extension is not part of SKIP_COMPRESS_EXTENSIONS
            name, ext = os.path.splitext(filename)
            if ext not in SKIP_COMPRESS_EXTENSIONS:
                # save compressed file to the "staticfiles" directory
                compressed_output_file = os.path.join(OUTPUT_PATH, f"{filename}.gz")
                print(f"\nCompressing {filename}")
                print(f"Saving {filename}.gz")
                output = gzip.open(compressed_output_file, "wb")
                try:
                    output.write(data)
                finally:
                    output.close()
            else:
                print(f"\nSkipping compression of {filename}")
            # save original file to the "staticfiles" directory
            output_file = os.path.join(OUTPUT_PATH, filename)
            print(f"Saving {filename}")
            with open(output_file, "wb") as f:
                f.write(data)


if __name__ == "__main__":
    main()

This script:

  1. Removes any existing files in the "staticfiles" directory
  2. Iterates through the files in the "static" directory and compresses then saves the compressed version to the "staticfiles" directory along with the original, uncompressed version

By having both the compressed and uncompressed versions available, WhiteNoise will serve up the compressed version when a client specifically asks for it. You'll see an example of this shortly.

To test, first install WhiteNoise, if you haven't already done so:

$ pip install whitenoise

Next, add a dummy PNG file to the "static" directory, to ensure that it gets skipped in the compress script, and then run the script:

$ touch static/test.png
$ python compress.py

You should see:

Removing files from staticfiles

Compressing bootstrap.css
Saving bootstrap.css.gz
Saving bootstrap.css

Skipping compression of test.png
Saving test.png

The "staticfiles" directory should now be populated:

├── app.py
├── compress.py
├── static
│   ├── bootstrap.css
│   └── test.png
└── staticfiles
    ├── bootstrap.css
    ├── bootstrap.css.gz
    └── test.png

To verify that this worked, install then run Gunicorn:

$ pip install gunicorn
$ gunicorn "app:create_app()" -b 127.0.0.1:5000

Now, to test out WhiteNoise's gzip functionality with cURL, run:

$ curl -I -H "Accept-Encoding: gzip" http://localhost:5000/assets/bootstrap.css

You should see the following response:

HTTP/1.1 200 OK
Server: gunicorn
Date: Mon, 13 Feb 2023 18:21:35 GMT
Connection: close
Content-Type: text/css; charset="utf-8"
Cache-Control: max-age=31536000, public
Access-Control-Allow-Origin: *
Vary: Accept-Encoding
Last-Modified: Mon, 13 Feb 2023 18:13:44 GMT
ETag: "63ead238-305f6"
Content-Length: 25881
Content-Encoding: gzip

Take note of Content-Encoding: gzip. This indicates that the gzipped version of the file was served.

CloudFront

Although it's not required, using a Content Delivery Network (CDN) is highly recommended since it will store cached versions of your static files on multiple geographic edge locations. Your visitors will then be served your static content from the location closest to them, which will improve the web server's overall response time. CloudFront, in particular, provides a number of additional features as well like protection against DDoS attacks and access control permissions, to name a few.

To set up, log in to the AWS Console and navigate to the CloudFront dashboard. Click "Create Distribution". Add your domain (without http or https) in the "Origin domain" field and leave the remaining defaults. Then, click "Create Distribution".

If you don't have a domain name configured, feel free to test this setup locally with ngrok. With your Gunicorn server up and running on port 5000, download (if necessary) then start ngrok:

$ ngrok http 5000

Once started, you should see a public URL that you can use with CloudFront.

Want to see a demo of this in action? Check out the video below.

It generally takes about 15 minutes for CloudFront to fully configure your distribution. You can test it out before it's been fully distributed out to all edge locations while the creation status is still "In Progress", though. It still may take a few minutes before you can begin testing.

To test, grab the URL associated with the CloudFront distribution and run:

$ curl -I -H "Accept-Encoding: gzip" https://dxquy3iqeuay6.cloudfront.net/assets/bootstrap.css

You should see something similar to:

HTTP/2 200
content-type: text/css; charset="utf-8"
content-length: 25881
access-control-allow-origin: *
cache-control: max-age=31536000, public
content-encoding: gzip
date: Tue, 23 Feb 2021 15:39:01 GMT
etag: "6035739d-305f6"
last-modified: Tue, 23 Feb 2021 15:29:01 GMT
server: gunicorn/20.0.4
vary: Accept-Encoding
x-cache: Miss from cloudfront
via: 1.1 5f09c808a81a33267d5cc58d93ce6353.cloudfront.net (CloudFront)
x-amz-cf-pop: DFW53-C1
x-amz-cf-id: _aLbrgkskBos4G1tjMFR34__rgmmBSkxaCNGiSdMBmxauX4f4CFO1Q==

You can now use the provided CloudFront domain in the Flask app to handle static file requests:

import os
from urllib.parse import urljoin

from flask import Flask, jsonify, render_template
from whitenoise import WhiteNoise


def create_app(script_info=None):

    app = Flask(__name__, static_folder="staticfiles")

    WHITENOISE_MAX_AGE = 31536000 if not app.config["DEBUG"] else 0
    CDN = "https://dxquy3iqeuay6.cloudfront.net"

    app.config["STATIC_URL"] = CDN if not app.config["DEBUG"] else ""

    # configure WhiteNoise
    app.wsgi_app = WhiteNoise(
        app.wsgi_app,
        root=os.path.join(os.path.dirname(__file__), "staticfiles"),
        prefix="assets/",
        max_age=WHITENOISE_MAX_AGE,
    )

    @app.template_global()
    def static_url(prefix, filename):
        return urljoin(app.config["STATIC_URL"], f"{prefix}/{filename}")

    @app.route("/")
    def hello_world():
        return jsonify(hello="world")

    return app

The static_url should be used instead of url_for in your templates.

Sanity Check

Let's configure a template to test this out.

Add a new handler:

import os
from urllib.parse import urljoin

from flask import Flask, jsonify, render_template
from whitenoise import WhiteNoise


def create_app(script_info=None):

    app = Flask(__name__, static_folder="staticfiles")

    WHITENOISE_MAX_AGE = 31536000 if not app.config["DEBUG"] else 0
    CDN = "https://dxquy3iqeuay6.cloudfront.net"

    app.config["STATIC_URL"] = CDN if not app.config["DEBUG"] else ""

    # configure WhiteNoise
    app.wsgi_app = WhiteNoise(
        app.wsgi_app,
        root=os.path.join(os.path.dirname(__file__), "staticfiles"),
        prefix="assets/",
        max_age=WHITENOISE_MAX_AGE,
    )

    @app.template_global()
    def static_url(prefix, filename):
        return urljoin(app.config["STATIC_URL"], f"{prefix}/{filename}")

    @app.route("/")
    def hello_world():
        return jsonify(hello="world")

    @app.route("/hi")
    def index():
        return render_template("index.html")

    return app

Create a new directory called "templates" in the project root and add an index.html file to that directory:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <link rel="stylesheet" href="{{ static_url('assets', filename='bootstrap.css') }}">
  <title>Hello, world!</title>
</head>
<body>
  <div class="container" style="padding-top:100px">
    <h1>Hello, world!</h1>
  </div>
</body>
</html>

Restart the Gunicorn server and then test it out at http://localhost:5000/hi.

Within your browser's dev tools, the

  1. bootstrap.css file should have been loaded from CloudFront: https://dxquy3iqeuay6.cloudfront.net/assets/bootstrap.css
  2. gzipped version of the file should have been served: content-encoding: gzip
  3. file should also have been served from the cache of the edge location: x-cache: Hit from cloudfront

cloudfront http response

Try running a WebPageTest to ensure static files are being compressed and cached correctly:

webpagetest results

Demo video:

Featured Course

Test-Driven Development with Python, Flask, and Docker

In this course, you'll learn how to set up a development environment with Docker in order to build and deploy a microservice powered by Python and Flask. You'll also apply the practices of Test-Driven Development with pytest as you develop a RESTful API.

Featured Course

Test-Driven Development with Python, Flask, and Docker

In this course, you'll learn how to set up a development environment with Docker in order to build and deploy a microservice powered by Python and Flask. You'll also apply the practices of Test-Driven Development with pytest as you develop a RESTful API.