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:
- Explain what Fly.io is and how it works.
- Deploy a Django application to Fly.io.
- Spin up a PostgreSQL instance on Fly.io.
- Set up persistent storage via Fly Volumes.
- Link a domain name to your web app.
- 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:
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.
Environment variables
We shouldn't store secrets in the source code, so let's utilize environment 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 environment 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 environment 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:
- Dockerfile
- Buildpacks
- 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:
- Choose an app name: Custom name or leave blank to generate a random name
- Choose a region for deployment: The region the closest to you
- Would you like to set up a Postgresql database now: Yes
- Database configuration: Development
- Would you like to set up an Upstash Redis database now: No
- 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="w7a8a@lj8nax7tem0caa2f2rjm2ahsascyf83sa5alyv68vea"
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 that 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 set up 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"
source
is the name of your Fly volumedestination
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:
- Use
fly ssh
to execute a command. - 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?
- Swap Fly Volumes with AWS S3 or a similar service to serve static/media files better and in a more secure way.
- 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. - Take a look at Scaling and Autoscaling and Regional Support.