Deploying a Django App to Fly.io

Last updated November 9th, 2022

In this tutorial, we'll look at how to deploy a Django app to Fly.io.

Contents

Objectives

By the end of this tutorial, you should be able to:

  1. Explain what Fly.io is and how it works.
  2. Deploy a Django application to Fly.io.
  3. Spin up a PostgreSQL instance on Fly.io.
  4. Set up persistent storage via Fly Volumes.
  5. Link a domain name to your web app.
  6. Obtain an SSL certificate with Let's Encrypt and serve your application on HTTPS.

What is Fly.io?

Fly.io is a popular Platform as a Service (PaaS) that provides hosting for web applications. Unlike many of the other PaaS hosting providers, rather than reselling AWS or GCP services, they host your applications on top of physical dedicated servers that run all over the world. Because of that, they're able to offer cheaper hosting than other PaaS, like Heroku. Their main focus is to deploy apps as close to their customers as possible (as of writing, you can pick between 24 regions). Fly.io supports three kinds of builders: Dockerfile, Buildpacks, or pre-built Docker images.

They offer great scaling and auto-scaling features.

Fly.io takes a different approach to managing your resources compared to other PaaS providers. It doesn't come with a fancy management dashboard; instead, all the work is done via their CLI named flyctl.

Their free plan includes:

  • Up to 3 shared-cpu-1x 256 MB VMs
  • 3GB persistent volume storage (total)
  • 160GB outbound data transfer

That should be more than enough to run a few small apps to test their platform.

Why Fly.io?

  • Free plan for small projects
  • Great regional support
  • Great documentation and fully documented API
  • Easy horizontal and vertical scaling
  • Relatively cheap

Project Setup

In this tutorial, we'll be deploying a simple image hosting application called django-images.

Check your understanding by deploying your own Django application as you follow along with the tutorial.

First, grab the code from the repository on GitHub:

$ git clone [email protected]:duplxey/django-images.git
$ cd django-images

Create a new virtual environment and activate it:

$ python3 -m venv venv && source venv/bin/activate

Install the requirements and migrate the database:

(venv)$ pip install -r requirements.txt
(venv)$ python manage.py migrate

Run the server:

(venv)$ python manage.py runserver

Open your favorite web browser and navigate to http://localhost:8000. Make sure everything works correctly by using the form on the right to upload an image. After you upload an image, you should see it displayed in the table:

django-images Application Preview

Install Flyctl

To work with the Fly platform, you first need to install Flyctl, a command-line interface that allows you to do everything from creating an account to deploying apps to Fly.

To install it on Linux run:

$ curl -L https://fly.io/install.sh | sh

For other operating systems take a look at the installation guide.

Once the installation is complete add flyctl to PATH:

$ export FLYCTL_INSTALL="/home/$USER/.fly"
$ export PATH="$FLYCTL_INSTALL/bin:$PATH"

Next, authenticate with your Fly.io account:

$ fly auth login

# In case you don't have an account yet:
# fly auth signup

The command will open your default web browser and ask you to log in. After you've logged in click "Continue" to sign in to the Fly CLI.

To make sure everything works try listing the apps:

$ fly apps list

NAME         OWNER           STATUS          PLATFORM        LATEST DEPLOY

You should see an empty table since you don't have any apps yet.

Configure Django Project

In this part of the tutorial, we'll prepare and Dockerize our Django app for deployment to Fly.io.

Environmental variables

We shouldn't store secrets in the source code, so let's utilize environmental variables. The easiest way to do this is to use a third-party Python package called python-dotenv. Start by adding it to requirements.txt:

python-dotenv==0.21.0

Feel free to use a different package for handling environmental variables like django-environ or python-decouple.

Then, import and initialize python-dotenv at the top of core/settings.py like so:

# core/settings.py

from pathlib import Path

from dotenv import load_dotenv

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent

load_dotenv(BASE_DIR / '.env')

Next, load SECRET_KEY, DEBUG, ALLOWED_HOSTS, and CSRF_TRUSTED_ORIGINS from the environment:

# core/settings.py

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.getenv('SECRET_KEY')

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.getenv('DEBUG', '0').lower() in ['true', 't', '1']

ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS').split(' ')
CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS').split(' ')

Don't forget to import os at the top of the file:

import os

Database

To use Postgres instead of SQLite, we first need to install the database adapter.

Add the following line to requirements.txt:

psycopg2-binary==2.9.5

As we spin up a Postgres instance later in the tutorial, a Twelve-Factor App inspired environmental variable named DATABASE_URL will be set and passed down to our web app in the following format:

postgres://USER:PASSWORD@HOST:PORT/NAME

To utilize it with Django, we can use a package called dj-database-url. This package transforms the URL into Django database parameters.

Add it to requirements.txt like so:

dj-database-url==1.0.0

Next, navigate to core/settings.py and change DATABASES like so:

# core/settings.py

DATABASES = {
    'default': dj_database_url.parse(os.environ.get('DATABASE_URL'), conn_max_age=600),
}

Don't forget the import:

import dj_database_url

Gunicorn

Moving along, let's install Gunicorn, a production-grade WSGI server that will be used in production instead of Django's development server.

Add it to requirements.txt:

gunicorn==20.1.0

Dockerfile

As mentioned in the introduction, there are three ways of deploying an application to Fly.io:

  1. Dockerfile
  2. Buildpacks
  3. Pre-built Docker image

In this tutorial, we'll use the first approach since it's the most flexible and gives us the most control over the web application. It's also great since it allows us to easily switch to another hosting service (that supports Docker) in the future.

First, create a new file named Dockerfile in the project root with the following content:

# pull official base image
FROM python:3.9.6-alpine

# set work directory
WORKDIR /usr/src/app

# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

# create the app directory - and switch to it
RUN mkdir -p /app
WORKDIR /app

# install dependencies
COPY requirements.txt /tmp/requirements.txt
RUN set -ex && \
    pip install --upgrade pip && \
    pip install -r /tmp/requirements.txt && \
    rm -rf /root/.cache/

# copy project
COPY . /app/

# expose port 8000
EXPOSE 8000

CMD ["gunicorn", "--bind", ":8000", "--workers", "2", "core.wsgi:application"]

If you're deploying your own Django application, make sure to change CMD accordingly.

Next, create a .dockerignore:

*.pyc
*.pyo
*.mo
*.db
*.css.map
*.egg-info
*.sql.gz
.cache
.project
.idea
.pydevproject
.DS_Store
.git/
.sass-cache
.vagrant/
__pycache__
dist
docs
env
logs
Dockerfile

This is a generic .dockerignore template for Django. Make sure to change it if there's anything else you want to exclude from the image.

Deploy App

In this section of the tutorial, we'll spin up a Postgres instance and deploy our Django app to Fly.io.

Launch

To create and configure a new app, run:

$ fly launch

Creating app in /dev/django-flyio
Scanning source code
Detected a Dockerfile app
? Choose an app name (leave blank to generate one): django-images
automatically selected personal organization: Nik Tomazic
? Choose a region for deployment: Frankfurt, Germany (fra)
Created app django-images in organization personal
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-images-db...
Provisioning 1 of 1 machines with image flyio/postgres:14.4
Waiting for machine to start...
Machine 21781350b24d89 is created
==> Monitoring health checks
  Waiting for 21781350b24d89 to become healthy (started, 3/3)
Postgres cluster django-images-db created
Postgres cluster django-images-db is now attached to django-images
? Would you like to set up an Upstash Redis database now? No
? Would you like to deploy now? No
Your app is ready! Deploy with `flyctl deploy`

Notes:

  1. Choose an app name: Custom name or leave blank to generate a random name
  2. Choose a region for deployment: The region the closest to you
  3. Would you like to set up a Postgresql database now: Yes
  4. Database configuration: Development
  5. Would you like to set up an Upstash Redis database now: No
  6. Would you like to deploy now: No

The command will create an app on Fly.io, spin up a Postgres instance, and create an app configuration file named fly.toml in your project root.

Make sure the app has been created successfully:

$ fly apps list

NAME                            OWNER           STATUS          PLATFORM        LATEST DEPLOY
django-images                   personal        pending
django-images-db                personal        deployed        machines
fly-builder-damp-wave-89        personal        deployed        machines

You'll notice three apps. The first one is for your actual web app, then a Postgres instance, and lastly a Fly builder. Fly builders are used to build your Docker images, push them to the container registry, and deploy your apps.

Check the status of your app:

$ fly status

App
  Name     = django-images
  Owner    = personal
  Version  = 0
  Status   = pending
  Hostname = django-images.fly.dev
  Platform =

App has not been deployed yet.

The hostname tells you which address your web application is accessible. Take note of it since we'll need to add it to ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS later in the tutorial.

App Configuration

Let's slightly modify the app configuration file to make it work well with Django.

First, change the port 8080 to Django's preferred 8000:

# fly.toml

app = "django-images"
kill_signal = "SIGINT"
kill_timeout = 5
processes = []

[env]
  PORT = "8000"         # new

[experimental]
  allowed_public_ports = []
  auto_rollback = true

[[services]]
  http_checks = []
  internal_port = 8000  # changed
  processes = ["app"]
  protocol = "tcp"
  script_checks = []
  [services.concurrency]
    hard_limit = 25
    soft_limit = 20
    type = "connections"

Next, to make sure the database gets migrated, add a deploy section and define a release_command within the newly created section:

# fly.toml

[deploy]
  release_command = "python manage.py migrate --noinput"

The release_command runs in a temporary VM -- using the successfully built release -- before that release is deployed. It's useful for running one-off commands like database migrations.

If you ever need to run multiple commands in the future, you can create a bash script in your project files and then execute it like so:

# fly.toml

[deploy]
  release_command = "sh /path/to/your/script"

Secrets

Set the secrets that we used in Django's settings.py:

$ fly secrets set DEBUG="1"
$ fly secrets set ALLOWED_HOSTS="localhost 127.0.0.1 [::1] <your_app_hostname>"
$ fly secrets set CSRF_TRUSTED_ORIGINS="https://<your_app_hostname>"
$ fly secrets set SECRET_KEY="[email protected]"

Make sure to replace <your_app_hostname> with your actual app hostname. For example:

$ fly secrets set ALLOWED_HOSTS="localhost 127.0.0.1 [::1] django-images.fly.dev"
$ fly secrets set CSRF_TRUSTED_ORIGINS="https://django-images.fly.dev"

To make debugging a bit easier, we temporarily enabled the debug mode. Don't worry about it since we'll change it later in the tutorial.

Make sure the secrets have been set successfully:

$ fly secrets list

NAME                    DIGEST                  CREATED AT
ALLOWED_HOSTS           06d92bcb15cf7eb1        30s ago
CSRF_TRUSTED_ORIGINS    06d92bcb15cf7eb1        21s ago
DATABASE_URL            e63c286f83782cf3        5m31s ago
DEBUG                   3baf154b33091aa0        45s ago
SECRET_KEY              62ac51c770a436f9        10s ago

After your Fly app is deployed, each secret modification will trigger a redeploy. If you ever need to set multiple secrets at once and don't want your app to redeploy multiple times, you can concatenate the secrets in one command like so:

$ fly secrets set NAME1="VALUE1" NAME2="VALUE2"

Deploy

To deploy the app to the Fly platform, run:

$ fly deploy

==> Verifying app config
--> Verified app config
==> Building image
Remote builder fly-builder-damp-wave-89 ready
==> Creating build context
--> Creating build context done
==> Building image with Docker
--> docker host: 20.10.12 linux x86_64
[+] Building 24.3s (11/11) FINISHED                                                             0.0s
--> Building image done
==> Pushing image to fly
The push refers to repository [registry.fly.io/django-images]
bee487f02b7f: Pushed
deployment-01GH4AGWQEZ607T7F93RB9G4NB: digest: sha256:85309cd5c7fe58f3a59b13d50576d8568525012bc6e665ba7b5cc1df3da16a9e size: 2619
--> Pushing image done
image: registry.fly.io/django-images:deployment-01GH4AGWQEZ607T7F93RB9G4NB
image size: 152 MB
==> Creating release
--> release v2 created
==> Monitoring deployment

 1 desired, 1 placed, 1 healthy, 0 unhealthy [health checks: 1 total, 1 passing]
--> v1 deployed successfully

This command will use the Fly builder to build the Docker image, push it to the container registry, and use it to deploy your application. Before your app is deployed, the release_command will run on a temporary VM.

The first time you deploy your app it will take roughly five minutes, so feel free to grab a cup of coffee while you wait.

After your app has been deployed, check its status:

$ fly status

App
  Name     = django-images
  Owner    = personal
  Version  = 0
  Status   = running
  Hostname = django-images.fly.dev
  Platform = nomad

Deployment Status
  ID          = 563c84b4-bf10-874c-e0e9-9820cbdd6725
  Version     = v1
  Status      = successful
  Description = Deployment completed successfully
  Instances   = 1 desired, 1 placed, 1 healthy, 0 unhealthy

Instances
ID              PROCESS VERSION REGION  DESIRED STATUS  HEALTH CHECKS           RESTARTS        CREATED
c009e8b0        app     1       fra     run     running 1 total, 1 passing      0               1m8s ago

Check the logs:

$ fly logs

[info]Starting init (commit: 81d5330)...
[info]Mounting /dev/vdc at /app/data w/ uid: 0, gid: 0 and chmod 0755
[info]Preparing to run: `gunicorn --bind :8000 --workers 2 core.wsgi:application` as root
[info]2022/11/05 17:09:36 listening on [fdaa:0:c65c:a7b:86:2:bd43:2]:22 (DNS: [fdaa::3]:53)
[info][2022-11-05 17:09:36 +0000] [529] [INFO] Starting gunicorn 20.1.0
[info][2022-11-05 17:09:36 +0000] [529] [INFO] Listening at: http://0.0.0.0:8000 (529)
[info][2022-11-05 17:09:36 +0000] [529] [INFO] Using worker: sync
[info][2022-11-05 17:09:36 +0000] [534] [INFO] Booting worker with pid: 534
[info][2022-11-05 17:09:36 +0000] [535] [INFO] Booting worker with pid: 535

Everything looks great. Let's make sure the app works by opening it in the browser:

$ fly open

Test by uploading an image.

Persistent Storage

Fly.io (as well as many other similar services like Heroku) offers an ephemeral filesystem. This means that your data isn't persistent and might vanish when your application shuts down or is redeployed. This is extremely bad if your app requires files to stick around.

To counter this problem, Fly.io offers Volumes, persistent storage for Fly apps. It sounds pretty great, but it isn't the best solution for production, since Volumes are tied to a region and a server -- this limits your app's scalability and distribution across different regions.

Additionally, Django by itself isn't made to serve static/media files in production.

I strongly recommend you use AWS S3 or a similar service instead of Volumes. In case your application doesn't have to deal with media files, you could still get away with using Volumes and WhiteNoise to serve static files.

To learn how to setup AWS S3 with Django take a look at Storing Django Static and Media Files on Amazon S3.

For the sake of simplicity and completeness of this tutorial, we'll still use Fly Volumes.

First, create a volume in the same region as your app:

$ fly volumes create <volume_name> --region <region> --size <in_gigabytes>

# For example:
# fly volumes create django_images_data --region fra --size 1

        ID: vol_53q80vdd16xvgzy6
      Name: django_images_data
       App: django-images
    Region: fra
      Zone: d7f9
   Size GB: 1
 Encrypted: true
Created at: 04 Nov 22 13:20 UTC

In my case, I created a 1 GB volume in the Frankfurt region.

Next, go to core/settings.py and modify STATIC_ROOT and MEDIA_ROOT like so:

# core/settings.py

STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'data/staticfiles'

MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'data/mediafiles'

We have to place static and media files in a subfolder since a Fly volume allows us to mount only one directory.

To mount the directory to the Fly volume go to your fly.toml and add the following:

# fly.toml

[mounts]
  source="django_images_data"
  destination="/app/data"
  1. source is the name of your Fly volume
  2. destination is the absolute path of the directory you want to mount

Redeploy your app:

$ fly deploy

Lastly, let's collect the static files.

SSH into the Fly server, navigate to the "app" directory, and run collectstatic:

$ fly ssh console
# cd /app
# python manage.py collectstatic --noinput

To make sure the files were collected successfully take a look at the /app/data folder:

# ls /app/data

lost+found   staticfiles

Great! You can exit the SSH via exit.

Make sure the static files have been collected successfully by checking out the admin panel:

http://<your_app_hostname>/admin

Django Admin Access

To access the Django administration panel we need to create a superuser. We have two options:

  1. Use fly ssh to execute a command.
  2. Create a Django command that creates a superuser and add it in fly.toml.

Since it's a one-off task, we'll use the first approach.

First, use the Fly CLI to SSH into the server:

$ fly ssh console

Connecting to fdaa:0:e25c:a7b:8a:4:b5c5:2... complete

Then, navigate to /app where our application files are, and run the createsuperuser command:

# cd /app
# python manage.py createsuperuser

Follow the prompts and then run exit to close the SSH connection.

To make sure the superuser has been successfully created navigate to the admin dashboard and log in:

http://<your_app_hostname>/admin

Or use:

$ fly open

And then navigate to /admin.

Add Domain

This part of the tutorial requires that you have a domain name.

Need a cheap domain to practice with? Several domain registrars have specials on '.xyz' domains. Alternatively, you can create a free domain at Freenom.

To add a domain to your app you first need to obtain a certificate:

$ fly certs add <your_full_domain_name>

# For example:
# fly certs add fly.testdriven.io

Next, go to your domain's registrar DNS settings and add a new "CNAME Record" pointing to your app's hostname like so:

+----------+--------------+----------------------------+-----------+
| Type     | Host         | Value                      | TTL       |
+----------+--------------+----------------------------+-----------+
| A Record | <some host>  | <your_app_hostname>        | Automatic |
+----------+--------------+----------------------------+-----------+

Example:

+----------+--------------+----------------------------+-----------+
| Type     | Host         | Value                      | TTL       |
+----------+--------------+----------------------------+-----------+
| A Record | fly          | django-images.fly.dev      | Automatic |
+----------+--------------+----------------------------+-----------+

If you don't want to use a subdomain, you can follow the same steps but just change the DNS host to @ and configure Fly.io certs accordingly.

Check if the domain has been added successfully:

$ fly certs list

Host Name                 Added                Status
fly.testdriven.io         5 minutes ago        Awaiting configuration

Check if the certificate has been issued:

$ fly certs check <your_full_domain_name>

# For example:
# fly certs check fly.testdriven.io

The certificate for fly.testdriven.io has not been issued yet.
Your certificate for fly.testdriven.io is being issued. Status is Awaiting certificates.

If the certificate hasn't been issued yet, wait for around ten minutes and then try again:

$ fly certs check fly.testdriven.io

The certificate for fly.testdriven.io has been issued.
Hostname                  = fly.testdriven.io
DNS Provider              = enom
Certificate Authority     = Let's Encrypt
Issued                    = rsa,ecdsa
Added to App              = 8 minutes ago
Source                    = fly

Lastly, add the new domain to ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS:

$ fly secrets set ALLOWED_HOSTS="localhost 127.0.0.1 [::1] <your_app_hostname> <your_full_domain_name>"
$ fly secrets set CSRF_TRUSTED_ORIGINS="https://<your_app_hostname> https://<your_full_domain_name>"

# For example:
# fly secrets set ALLOWED_HOSTS="localhost 127.0.0.1 [::1] django-images.fly.dev fly.testdriven.io"
# fly secrets set CSRF_TRUSTED_ORIGINS="https://django-images.fly.dev https://fly.testdriven.io"

As you modify your secrets your app will restart. After the restart, your web application should be accessible at the newly added domain.

Conclusion

In this tutorial, we've successfully deployed a Django app to Fly.io. We've taken care of the PostgreSQL database, persistent storage via Fly Volumes, and added a domain name. You should now have a fair understanding of how the Fly Platform works and be able to deploy your own apps.

What's next?

  1. Swap Fly Volumes with AWS S3 or a similar service to serve static/media files better and in a more secure way.
  2. Set DEBUG=0 to disable the debug mode. Keep in mind that the app only serves static/media files when debug mode is enabled. For more on how to deal with static and media files in production, refer to Working with Static and Media Files in Django.
  3. Take a look at Scaling and Autoscaling and Regional Support.

Nik Tomazic

Nik Tomazic

Nik is a software developer from Slovenia. He's interested in object-oriented programming and web development. He likes learning new things and accepting new challenges. When he's not coding, Nik's either swimming or watching movies.

Share this tutorial

Featured Course

Test-Driven Development with Django, Django REST Framework, and Docker

In this course, you'll learn how to set up a development environment with Docker in order to build and deploy a RESTful API powered by Python, Django, and Django REST Framework.

Featured Course

Test-Driven Development with Django, Django REST Framework, and Docker

In this course, you'll learn how to set up a development environment with Docker in order to build and deploy a RESTful API powered by Python, Django, and Django REST Framework.