Deploy Django + PostgreSQL on Fly.io

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

Make sure that Python is already installed on your computer along with a way to create virtual environments. We will use venv in this example but any of the other popular choices such as Poetry, Pipenv, or pyenv work too.

New Virtual Environment

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) $

Install Django

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.1.0
(.venv) $ django-admin startproject django_project .
(.venv) $ python manage.py migrate

You should end up with the following directory structure.

├── db.sqlite3  
├── django_project
│   ├── __init__.py
|   ├── asgi.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── manage.py

Django Welcome Page

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

New Django 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 demonstate 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. It's 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 and an <img> tag.

<!-- templates/home.html -->
{% load static %}
<h1>Django Todo App</h1>
<img src="{% static 'logo.svg' %}" alt="Fly logo">
<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:

Environment Variables

Some Django developers use multiple settings.py files to switch between local and production environments but these days most prefer environment variables, which are stored externally and loaded in at runtime. This allows for a single settings.py file and greater security.

There are multiple ways to use environment variables but a good option is the environs package. It comes with several helpers for Django including dj-database-url. Go ahead and install it now and make sure to include the single quotes, '', to install the Django extension.

(.venv) $ python -m pip install 'environs[django]==9.5.0'

Then add three new lines to the top of our settings.py file for it to work.

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

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

We can now load in environment variables but they must be stored somewhere. The standard approach is to create a .env file at the root-level directory. If you are using Git for source control it is important to use a .gitignore file so that .env is not tracked.

DEBUG, SECRET_KEY, DATABASES

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

In our .env file set DEBUG to True so we can use it locally for debugging purposes.

# .env
DEBUG=True

The next environment variable to set is our SECRET_KEY, a random 50 character string generated each time startproject is run. Starting in Django 3.0 the key begins with django-insecure as an additional prompt to tell developers not to use this specific key in production.

Update django_project/settings.py so that SECRET_KEY points to a new environment variable.

# django_project/settings.py
SECRET_KEY = env.str("SECRET_KEY")

And use Python's built-in secrets module to generate a new SECRET_KEY.

(.venv) $ python -c "import secrets; print(secrets.token_urlsafe())"
imDnfLXy-8Y-YozfJmP2Rw_81YA_qx1XKl5FeY0mXyY

Copy and paste this new value into the .env file.

# .env
DEBUG=True
SECRET_KEY=imDnfLXy-8Y-YozfJmP2Rw_81YA_qx1XKl5FeY0mXyY

Finally, update the DATABASES configuration to default to a local SQLite database but in production look for a database connection called DATABASE_URL that will contain our necessary information in production.

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

Gunicorn

Django's local webserver is explicitly not recommended for production. The two most common packages are Gunicorn and uWSGI. We will use Gunicorn as it is somewhat easier to configure so go ahead and install it in our Python virtual environment.

(.venv) $ python -m pip install gunicorn==20.1.0

Psycopg

We should also install Psycopg, a database adapter that lets Python apps talk to PostgreSQL databases. This can be installed via pip on Windows. If you are on macOS it is necessary to install PostgreSQL first via Homebrew and then the psycopg2-binary package.

# Windows
(.venv) $ python -m pip install psycopg2-binary==2.9.3

# macOS
(.venv) $ brew install postgresql
(.venv) $ python -m pip install psycopg2-binary==2.9.3

ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS

ALLOWED_HOSTS is a list of strings representing the host/domain names that this Django site can serve. We'll include the two domains for local development--localhost and 127.0.0.1--and update our production URL once provided by Fly shortly.

# django_project/settings.py
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]  # new

CSRF_TRUSTED_ORIGINS is a list of trusted origins for "unsafe" requests that use POST. We'll need it to log into the Django admin in production as well as any forms that make POST requests. To set it properly we need our deployed domain which we won't know until later so for now set a placeholder value of *.fly.dev.

# django_project/settings.py
CSRF_TRUSTED_ORIGINS = ["*.fly.dev"]  # new

Static Files

The Django local web server displays static files automatically but for production we must switch to a more powerful solution such as WhiteNoise. Install the latest version with pip.

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

Then in the django_project/settings.py file make three changes:

# 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",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'  # new

Now run collectstatic so that the files are stored using WhiteNoise.

(.venv) $ python manage.py collectstatic

requirements.txt

There are many Python packages now installed in our virtual environment. Creating a requirements.txt file is a way to track these installations and later reproduce it on production servers. Use the pip freeze command to generate a new requirements.txt file.

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

Deploy to Fly.io

Fly has its own command-line utility for managing apps, flyctl. If not already installed, follow the instructions on the installation guide and log in to Fly.

To configure and launch the app, use the command fly launch and follow the wizard. You can set a name for the app, choose a default region, launch and attach a Postgresql database. You can also set up a Redis database though we will not be doing so in this example.

(.venv) $ fly launch
Creating app in ~/django-fly-tutorial
Scanning source code
Detected a Django app
? Choose an app name (leave blank to generate one): django-fly-tutorial
automatically selected personal organization: Will Vincent
? Choose a region for deployment: Ashburn, Virginia (US) (iad)
Created app django-fly-tutorial in organization personal
Set secrets on django-fly-tutorial: SECRET_KEY
Creating database migrations
Wrote config file fly.toml
? Would you like to set up a Postgresql database now? Yes
? Select configuration: Development - Single node, 1x shared CPU, 256MB RAM, 1GB disk
Creating postgres cluster in organization personal
Creating app...
Setting secrets on app django-fly-tutorial...Provisioning 1 of 1 machines with image flyio/postgres:14.4
Waiting for machine to start...
Machine 217811c53e0896 is created
==> Monitoring health checks
  Waiting for 217811c53e0896 to become healthy (started, 3/3)

Postgres cluster django-fly-tutorial created
  Username:    postgres
  Password:    lt5JoEIVons5INJ
  Hostname:    django-fly-tutorial.internal
  Proxy port:  5432
  Postgres port:  5433
  Connection string: postgres://postgres:lt5JoEIVons5INJ@django-fly-tutorial-db.internal:5432

Save your credentials in a secure place -- you won't be able to see them again!

Connect to postgres
Any app within the Jane Smith organization can connect to this Postgres using the following credentials:
For example: postgres://postgres:lt5JoEIVons5INJ@django-fly-tutorial-db.internal:5432


Now that you've set up postgres, here's what you need to understand: https://fly.io/docs/reference/postgres-whats-next/

Postgres cluster django-fly-tutorial-db is now attached to django-fly-tutorial
The following secret was added to django-fly-tutorial:
  DATABASE_URL=postgres://django_fly_tutorial:6YuIWrdwzVOgVe1@top2.nearest.of.django-fly-tutorial-db.internal:5432/django_fly_tutorial?sslmode=disable
Postgres cluster django-fly-tutorial-db is now attached to django-fly-tutorial
? Would you like to set up an Upstash Redis database now? No

Your Django app is almost ready to deploy!

We recommend using the database_url(pip install dj-database-url) to parse the DATABASE_URL from os.environ['DATABASE_URL']

For detailed documentation, see https://fly.dev/docs/django/

Dockerfile and fly.toml

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

The Dockerfile is essentially instructions for creating an image. On the last line make sure to replace "demo.wsgi" with our Django project's name which is django_project.

# Dockerfile 
...
CMD ["gunicorn", "--bind", ":8000", "--workers", "2", "django_project.wsgi"]

The fly.toml file is used by Fly to configure applications for deployment. Configuration of builds, environment variables, internet-exposed services, disk mounts and release commands go here.

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 = ["127.0.0.1", "localhost", "django-fly-tutorial.fly.dev"]  # new

CSRF_TRUSTED_ORIGINS = ["https://django-fly-tutorial.fly.dev"]  # new

.dockerignore

Just as Git has .gitignore files to ignore certain things, we can use a .dockerignore file to tell Docker what to ignore. Fly doesn't automatically run .gitignore files but it does execute .dockerignore files. At a minumum, we want it to not look at our local SQLite database or any Git files. Create a new root-level file called .dockerignore with the following two lines:

# .dockerignore
*.sqlite3
.git

Deploy Your Application

To deploy the application use the following command:

(.venv) $ fly deploy

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

If everything went as planned you will see your Django application homepage.

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 -C 'python /code/manage.py createsuperuser'`

Then visit the /admin page. In this app that is located at https://fly-django-tutorial.fly.dev/admin/. After logging in, create any 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.

Secrets

Secrets allow sensitive values, such as credentials and API keys, to be securely passed to your Django applications. You can set, remove, or list all secrets with the fly secrets command.

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.

Join My Newsletter

Subscribe to get the latest tutorials/writings by email.

    No spam. Unsubscribe at any time.