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.
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 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. 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 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:
- configure environment variables
- update
DEBUG
,SECRET_KEY
, andDATABASES
configurations - install
Gunicorn
as a production web server - install
Psycopg
to connect with a PostgreSQL database - update
ALLOWED_HOSTS
andCSRF_TRUSTED_ORIGINS
- install
WhiteNoise
for static files - generate a
requirements.txt
file
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 = ["https://*.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:
- add
whitenoise
toINSTALLED_APPS
above the built-instaticfiles
app - under
MIDDLEWARE
add a newWhiteNoiseMiddleware
on the third line - update
STATICFILES_STORAGE
to useWhiteNoise
# 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, fly.toml, and .dockerignore
The fly launch
command creates three new files in the project that are automatically configured: Dockerfile
, fly.toml
, and .dockerignore
.
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.
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 minimum, we want it to not look at our fly.toml
file, the local SQLite database, Git, or our .env
secret files.
# .dockerignore *.sqlite3 .git .env
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
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.