Mariusz Felisiak, a Django and Python contributor and a Django Fellow, explores how to create staging environments on the Fly.io with GitHub actions. Django on Fly.io is pretty sweet! Check it out: you can be up and running on Fly.io in just minutes.
Creating staging environments for testing changes to our apps can be a challenge. This
article shows how to use GitHub actions to smoothly create a separate staging
environment for each pull request using the
fly-pr-review-apps
action, which will create and deploy our Django project with changes from the specific
pull request. It will also destroy it when it’s no longer needed, such as after closing
or merging a pull request. The entire staging process enclosed in one GitHub action, so
we don’t have to worry about anything else (NoOps).
Let’s check how it works and why it’s worth using.
Set up
We assume you’ve already set up your Fly.io account, so go ahead and sign in. If you haven’t done that yet, you can sign up to Fly.io.
We also need an existing or new Django project with a
fly.toml
configuration file. Here are
some great resources for getting started with Django
or deploying your Django app to Fly.io.
With a project ready, let’s get started!
Basic flow
GitHub action workflows are defined by YAML files
in the .github/workflows/
directory of our repository. Let’s add a new flow that will
create and deploy staging environment for each pull request. But first we need to create
a new repository secret called FLY_API_TOKEN
to use for authentication. Go to a GitHub
repository page and open:
Settings (tab) → Security → Secrets and variables → Actions → Repository secrets → New repository secret
next, create a new secret called FLY_API_TOKEN
with a value from:
fly auth token
It’s also possible to create a new token on the dashboard.
Now, we’re ready to add a new flow. This is how we can define it in the
.github/workflows/fly_pr_preview.yml
file:
# .github/workflows/fly_pr_preview.yml
name: Start preview app
on:
pull_request:
types: [labeled, synchronize, opened, reopened, closed]
concurrency:
group: ${{ github.workflow }}-pr-${{ github.event.number }}
cancel-in-progress: true
permissions:
contents: read
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
jobs:
preview-app:
if: contains(github.event.pull_request.labels.*.name, 'PR preview app')
runs-on: ubuntu-latest
name: Preview app
environment:
name: pr-${{ github.event.number }}
url: ${{ steps.deploy.outputs.url }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy preview app
uses: superfly/fly-pr-review-apps@1.2.0
id: deploy
with:
region: waw
org: personal
It’s time to split our configuration up into its component parts:
on → pull_request → types
: specifies events on which a new staging project will be deployed:
pull_request:
types: [labeled, synchronize, opened, reopened, closed]
concurrency
: prevents concurrent deploys for the same PR (Pull Request). Thegroup
name contains a PR number and workflow name to create a separate group for each workflow and PR:
concurrency:
group: ${{ github.workflow }}-pr-${{ github.event.number }}
cancel-in-progress: true
env
: makes theFLY_API_TOKEN
secret available to use for authentication:
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
jobs → preview-app → if
: skips deploy on PRs without the PR preview app label. Both for safety reasons and to avoid creating a staging environments when no needed:
if: contains(github.event.pull_request.labels.*.name, 'PR preview app')
jobs → preview-app → environment
: describes the deployment target to show up in a pull request UI.steps.deploy.outputs.url
is filled by thesuperfly/fly-pr-review-apps
and will contain the URL of a deployed staging project:
environment:
name: pr-${{ github.event.number }}
url: ${{ steps.deploy.outputs.url }}
jobs → preview-app → steps → with
: specifies all inputs that we want to pass to our flow. You can check available options in the README. We pass the Fly.io region and organization as a starting point:
with:
region: waw
org: personal
The action configured in this way will deploy an app with the name created according to the following pattern:
pr-{{ PR number }}-{{ repository owner }}-{{ repository name }}
to the: https://{{ app name }}.fly.dev
, e.g.
https://pr-9-felixxm-fly-pr-preview-example.fly.dev/
(for PR number 9 in my personal repository called pr-preview-example
).
So far so good. However, deploying from scratch a fully functional Django project may
require a few more steps such as running migrations or collecting static files.
Furthermore, deploying a staging environment in particular may involve even more
additional tasks to perform, such as loading fixtures with test data or using a
dedicated staging database. The question is how to handle them and where to place each
one. Luckily for us, all of them can be handled seamlessly with the
fly-pr-review-apps
action. The next two sections show you how to do this.
Use Postgres cluster
Using a dedicated staging database is a good practice for test environments. This gives us more control and appropriate separation from the production environment which eliminates a potential data leak vector.
If you don’t have a Postgres cluster specifically for
testing purposes you can create one with fly postgres create
:
fly postgres create --name pg-fly-pr-staging-preview
Once created, we can specify it in our action (jobs → preview-app → steps → with
)
using the postgres
input:
# .github/workflows/fly_pr_preview.yml
name: Start preview app
on:
pull_request:
types: [labeled, synchronize, opened, reopened, closed]
concurrency:
group: ${{ github.workflow }}-pr-${{ github.event.number }}
cancel-in-progress: true
permissions:
contents: read
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
jobs:
preview-app:
if: contains(github.event.pull_request.labels.*.name, 'PR preview app')
runs-on: ubuntu-latest
name: Preview app
environment:
name: pr-${{ github.event.number }}
url: ${{ steps.deploy.outputs.url }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy preview app
uses: superfly/fly-pr-review-apps@1.2.0
id: deploy
with:
postgres: pg-fly-pr-staging-preview # ← Added
region: waw
org: personal
With that in place, our staging Postgres cluster will be automatically attached to the
test app, which will make a DATABASE_URL
environment variable available in the test
VM.
The next section shows how to perform additional release steps when deploying a staging app.
Additional release steps
Let’s assume that we want to perform additional release steps when deploying our staging environment. For this, we can use a custom Fly.io TOML configuration file dedicated for staging with a release script to run before a deployment. First, make a copy of an existing configuration:
mkdir staging
cp fly.toml staging/fly_staging.toml
Next, create a script (staging/post_deploy.sh
) to prepare our staging database:
# staging/post_deploy.sh
#!/usr/bin/env bash
# Migrate database.
python /code/manage.py migrate
# Load fixtures with test data.
python /code/manage.py loaddata /code/staging/test_groups.json
Finally, we need to add release_command
to the TOML configuration calling our script:
# staging/fly_staging.toml
console_command = "/code/manage.py shell"
[build]
[env]
PORT = "8000"
[deploy]
release_command = "sh ./staging/post_deploy.sh" # ← Added.
...
and specify the custom TOML configuration in our action
(jobs → preview-app → steps → with
) using the config
input:
# .github/workflows/fly_pr_preview.yml
name: Start preview app
on:
pull_request:
types: [labeled, synchronize, opened, reopened, closed]
concurrency:
group: ${{ github.workflow }}-pr-${{ github.event.number }}
cancel-in-progress: true
permissions:
contents: read
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
jobs:
preview-app:
if: contains(github.event.pull_request.labels.*.name, 'PR preview app')
runs-on: ubuntu-latest
name: Preview app
environment:
name: pr-${{ github.event.number }}
url: ${{ steps.deploy.outputs.url }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy preview app
uses: superfly/fly-pr-review-apps@1.2.0
id: deploy
with:
config: staging/fly_staging.toml # ← Added
postgres: pg-fly-pr-staging-preview
region: waw
org: personal
With this small effort we have staging database created on Fly! 🚀
🚨 Be aware that release_command
is run on a temporary VM and cannot modify the
local storage or state. It’s fine to run database operations but not to perform release
steps that attempt to modify a local storage, e.g. collecting static files. Such steps
should be added to the Dockerfile
. For example, if we want to collect static files,
then add the collectstatic
management command to the Dockerfile
:
ARG PYTHON_VERSION=3.10-slim-bullseye
FROM python:${PYTHON_VERSION}
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
RUN mkdir -p /code
WORKDIR /code
COPY requirements.txt /tmp/requirements.txt
RUN set -ex && \
pip install --upgrade pip && \
pip install -r /tmp/requirements.txt && \
rm -rf /root/.cache/
COPY . /code
# ↓ Added ↓
RUN set -ex && \
python /code/manage.py collectstatic --noinput
EXPOSE 8000
CMD ["gunicorn", "--bind", ":8000", "--workers", "2", "hello_pr_preview_example.wsgi"]
Let’s take a look how it works:
We did it 🚀 Check other options and give it a spin!