Django + Fly.io Tutorial

Updated

Table of Contents

In this guide we will develop a Django Todo application locally and then deploy it on Fly.io with a Postgres production database. There are a number of steps needed to convert a local Django project to be production-ready and then successfully deployed on Fly. We will cover them all here.

Initial Set Up

From the command line navigate to an empty directory to store your code. On both Windows and macOS the desktop is a good default if you don't already have a preferred location. We will call create a new directory called django-fly and then activate a new Python virtual environment within it.

# Windows
$ cd onedrive\desktop\code
$ mkdir django-fly
$ cd django-fly
$ python -m venv .venv
$ .venv\Scripts\Activate.ps1
(.venv) $

# macOS
$ cd ~/desktop/code
$ mkdir django-fly
$ cd django-fly
$ python3 -m venv .venv
$ source .venv/bin/activate
(.venv) $

Next install Django, create a new project called django_project, and run migrate to initialize our database. Don't forget that period, ., at the end of the startproject command.

(.venv) $ python -m pip install django~=4.2.0
(.venv) $ django-admin startproject django_project .
(.venv) $ python manage.py migrate

Start up Django's local web server with the runserver command.

(.venv) $ python manage.py runserver

And then navigate to http://127.0.0.1:8000/ to see the Django welcome page.

Django Welcome Page

Todo App

We will now build a simple Django Todo application from scratch. Stop the local web server with Control + c and then use startapp to create a new app called todo.

(.venv) $ python manage.py startapp todo

Next register the app by adding it to the INSTALLED_APPS configuration within the django_project/settings.py file.

# django_project/settings.puy
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "todo",  # new
]

Models

We need a database model for our Todo app so create one now by updating the todo/models.py file with the following code. This creates a Todo model with a single field, todo, and also defines a human-readable __str__ method for that will be useful shortly in the Django admin.

# todo/models.py
from django.db import models


class Todo(models.Model):  
    todo = models.TextField()

    def __str__(self):  
        return self.todo[:50]

Since we have created a new database model called Todo we must make a migrations file and then migrate it.

(.venv) $ python manage.py makemigrations
(.venv) $ python manage.py migrate

Admin

One of Django's killer features is its built-in admin which we can use to manipulate and view data. To use it, first create a superuser account and respond to the prompts for a username, email, and password.

(.venv) $ python manage.py createsuperuser

Then update the existing todo/admin.py file so that our Todo model is visible within the admin.

# todo/admin.py
from django.contrib import admin

from .models import Todo

admin.site.register(Todo)

Now we can start up the local web server again with the runserver command:

(.venv) $ python manage.py runserver

In your web browser navigate to http://127.0.0.1:8000/admin/ and log in with your superuser credentials. On the admin homepage, click on the + Add button next to Todos and create at least one item. Click the Save button once finished.

Views

In order to display this database content in our web app we need to wire up views, templates, and URLs. Let's start with the todo/views.py file. We'll use a basic Django ListView to display all available Todos from the database.

# todo/views.py
from django.views.generic import ListView
from .models import Todo


class HomePageView(ListView):
    model = Todo
    template_name = "todo/home.html"

Templates

In Django, template files display our data. Within the todo app create a new directory called templates, within it a directory called todo, and then a file called home.html.

Create a new directory called templates within the todo app. Inside that folder create a new file called home.html. This is what the structure should look like:

├── todo
│   ├── ...
│   ├── templates
│   │   └── todo
│   │       └── home.html

The templates/home.html file lists all the todos in our database.

<!-- templates/home.html -->
<h1>Django Todo App</h1>
<ul>
  {% for text in todo_list %}
    <li>{{ text.todo }}</li>
  {% endfor %}
</ul>

URLs

Now for our URL routing. Create a new file called todo/urls.py.

# todo/urls.py
from django.urls import path
from .views import HomePageView

urlpatterns = [
    path("", HomePageView.as_view(), name="home"),
]

Then in the existing django_project/urls.py file update the code so the Todos are displayed on the homepage:

# django_project/urls.py
from django.contrib import admin
from django.urls import path, include  # new

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("todo.urls")),  # new
]

Todo Homepage

Restart the local web server with the python manage.py runserver command.

(.venv) $ python manage.py runserver

And navigate to our homepage at http://127.0.0.1:8000/ which now lists all Todos in our database.

Static Files

Static files such as images, JavaScript, or CSS require additional configuration. To demonstrate them we will add the Fly.io logo to our website.

Create a root-level static folder for the Django project. The overall structure should now look like this:

├── db.sqlite3
├── django_project
│   ├── ...
├── manage.py
├── static
├── todo
│   ├── ...

Download the primary Fly.io logo and move it into the static folder. Its default name is logo.svg.

The django_project/settings.py file already has a configuration for STATIC_URL but we need to add two more for static files to work correctly.

# django_project/settings.py
STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "static"]  # new
STATIC_ROOT = BASE_DIR / "staticfiles"  # new

Now we can run the collectstatic command to compile all static files from our project into the staticfiles directory.

(.venv) $ python manage.py collectstatic

To add the Fly logo to our homepage update the existing template with {% load static %} at the top and an <img> tag.

<!-- templates/home.html -->
{% load static %}
<h1>Django Todo App</h1>
<img src="{% static 'logo.svg' %}" alt="Fly logo"
    style="width:500px; height: 500px;">
<ul>
    {% for text in todo_list %}
    <li>{{ text.todo }}</li>
    {% endfor %}
</ul>

Restart the local webserver with runserver again.

(.venv) $ python manage.py runserver

And refresh the homepage at http://127.0.0.1:8000/ to see the new Fly logo on it.

Django Deployment Checklist

Django defaults to a local development configuration with the startproject command. Many of the settings that are useful locally are a security risk in production. We therefore need a way to switch easily between local and production set ups. You can see the official guide here.

At a minimum we need to do the following:

  • install Gunicorn as a production web server
  • install Psycopg to connect with a PostgreSQL database
  • install environs for environment variables
  • update DATABASES in django_project/settings.py
  • install WhiteNoise for static files
  • generate a requirements.txt file
  • update DEBUG, SECRET_KEY, ALLOWED_HOSTS, and CSRF_TRUSTED_ORIGINS

Gunicorn, Psycopg, and environs

Let's start by installing Gunicorn, Psycopg, and environs. If you are on macOS, it is necessary to install PostgreSQL first via Homebrew--brew install postgresql--before installing Psycogp.

(.venv) $ python -m pip install gunicorn==20.1.0
(.venv) $ python -m pip install "psycopg[binary]"==3.1.8
(.venv) $ python -m pip install "environs[django]"==9.5.0

Then update django_project/settings.py with three new lines so environment variables can be loaded in.

# django_project/settings.py
from pathlib import Path
from environs import Env  # new

env = Env()  # new
env.read_env()  # new

DATABASES

Next, update the DATABASES setting in the django_project/settings.py file so that SQLite is used locally but PostgreSQL in production.

# django_project/settings.py
DATABASES = {
    "default": env.dj_db_url("DATABASE_URL", default="sqlite:///db.sqlite3"),
}

WhiteNoise

WhiteNoise is needed to serve static files in production. Install the latest version using pip:

(.venv) $ python -m pip install whitenoise==6.4.0

Then in django_project/settings.py, there are three updates to make:

  • add whitenoise to the INSTALLED_APPS above the built-in staticfiles app
  • under MIDDLEWARE add a new line for WhiteNoiseMiddleware after SessionMiddleware
  • configure STATICFILES_STORAGE to use WhiteNoise

The updated file should look as follows:

{title="Code",lang="python"}

# django_project/settings.py
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "whitenoise.runserver_nostatic",  # new
    "django.contrib.staticfiles",
    "todo",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",  # new
    "django.middleware.common.CommonMiddleware",
    ...
]

STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_STORAGE =
    "whitenoise.storage.CompressedManifestStaticFilesStorage"  # new

We have updated the default STATICFILES_STORAGE engine to use WhiteNoise when running the collectstatic management command. Run it one more time now:

(.venv) $ python manage.py collectstatic

There will be a short warning, This will overwrite existing files! Are you sure you want to do this? Type "yes" and hit Enter. The collected static files are now regenerated in the same staticfiles folder using WhiteNoise.

requirements.txt

Now that all additional third-party packages are installed, we can generate a requirements.txt file containing the contents of our virtual environment.

(.venv) $ python -m pip freeze > requirements.txt

DEBUG, SECRET_KEY, ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS

DEBUG mode helps with local development but is a massive security issue in production. We'll use an elegant approach of setting a default value so that DEBUG is False unless specified otherwise.

# django_project/settings.py
DEBUG = env.bool("DEBUG", default=False)  # new

Create a new hidden file called .env in the project-root directory. It will hold local environment variables. Then within it, set DEBUG to True so we can use it locally for debugging purposes.

# .env
DEBUG=True

Fly will generate a SECRET_KEY environment variable for us in production and for building the Docker image in the fly launch stage. We can again use a default value for local development.

# django_project/settings.py
SECRET_KEY = env.str(
  "SECRET_KEY", 
  default="django-insecure-^qi19(+(oo-ere5b&$@275chw)k@7ob1)74aol5d$(k*)5kk5)",
)

For now, we can set both ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS to wildcard values. This is insecure so the production URL should be swapped in once available.

# django_project/settings.py
ALLOWED_HOSTS = ["*"]  
CSRF_TRUSTED_ORIGINS = ["https://*.fly.dev"]  

Deploy to Fly.io

Fly's command-line utility flyctl, will help configure our project for deployment. Use the command fly launch and follow the wizard.

  • Choose an app name: this will be your dedicated fly.dev subdomain
  • Choose the region for deployment: select the one closest to you or another region if you prefer
  • Decline overwriting our .dockerignore file: our choices are already optimized for the project
  • Setup a Postgres database cluster: the "Development" option is appropriate for this project. Fly Postgres is a regular app deployed to Fly.io, not a managed database.
  • Select "Yes" to scale a single node pg to zero after one hour: this will save money for toy projects
  • Decline to setup a Redis database: we don't need one for this project
(.venv) $ fly launch
? Choose an app name (leave blank to generate one): django-todo
automatically selected personal organization: Will Vincent
Some regions require a paid plan (fra, maa).
See https://fly.io/plans to set up a plan.

? Choose a region for deployment: Boston, Massachusetts (US) (bos)
App will use 'bos' region as primary
Created app 'django-todo' in organization 'personal'
Admin URL: https://fly.io/apps/django-todo
Hostname: django-todo.fly.dev
Set secrets on django-todo: SECRET_KEY
? Would you like to set up a Postgresql database now? Yes
? Select configuration: Development - Single node, 1x shared CPU, 256MB RAM, 1GB disk
? Scale single node pg to zero after one hour? Yes
Creating postgres cluster in organization personal
...
? Would you like to set up an Upstash Redis database now? No
Wrote config file fly.toml
...
Your Django app is ready to deploy!

The fly launch command creates three new files in the project that are automatically configured: Dockerfile, fly.toml, and .dockerignore.

ALLOWED_HOSTS & CSRF_TRUSTED_ORIGINS

The dedicated URL for your deployment will be <app_name>.fly.dev. Update the ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS configurations with your <app_name> to include it. In this example it looks like this:

# django_project/settings.py
ALLOWED_HOSTS = ["django-todo.fly.dev", "localhost", "127.0.0.1"]
CSRF_TRUSTED_ORIGINS = ["https://django-todo.fly.dev/"]

Deploy Your Application

To deploy the application use the following command:

(.venv) $ fly deploy
 1 desired, 1 placed, 1 healthy, 0 unhealthy [health checks: 1 total, 1 passing]
--> v0 deployed successfully

This will take a few seconds as it uploads your application, verifies the app configuration, builds the image, and then monitors to ensure it starts successfully. Once complete visit your app with the following command:

(.venv) $ fly open

SSH Commands

The Django web app will be live now but there are no todos visible because our PostgreSQL production database has no data. To fix this, SSH in to set a superuser account.

(.venv) $ fly ssh console --pty -C "python /code/manage.py createsuperuser"

Then visit the /admin page, create new Todos, and save them. Upon refresh the live production site will display them.

View Log Files

If your application didn't boot on the first deploy, run fly logs to see what's going on.

(.venv) $ fly logs

This shows the past few log file entries and tails your production log files. Additional flags are available for filtering.

Custom Domain & SSL Certificates

After you finish deploying your application to Fly and have tested it extensively, read through the Custom Domain docs and point your domain at Fly.

In addition to supporting CNAME DNS records, Fly also supports A and AAAA records for those who want to point example.com (without the www.example.com) directly at Fly.

Next Steps

Although we have covered a lot of material here, there are still many more steps required for a truly production-ready deployment that is secure and performant. To learn more, check out my book Django for Professionals.