Staging environments with GitHub actions

Creating staging environments for testing changes to our apps can be a challenge. This guide 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).

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). The group 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 the FLY_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 the superfly/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 the repository called pr-preview-example).

Using 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.

Additional release steps

For performing additional release steps when deploying our staging environment, 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.io!

🚨 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"]

Additional resources