Deploying Django to Heroku With Docker

Last updated November 24th, 2021

This article looks at how to deploy a Django app to Heroku with Docker via the Heroku Container Runtime.

Contents

Objectives

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

  1. Explain why you may want to use Heroku's Container Runtime to run an app
  2. Dockerize a Django app
  3. Deploy and run a Django app in a Docker container on Heroku
  4. Configure GitLab CI to deploy Docker images to Heroku
  5. Manage static assets with WhiteNoise
  6. Configure Postgres to run on Heroku
  7. Create a production Dockerfile that uses multistage Docker builds
  8. Use the Heroku Container Registry and Build Manifest for deploying Docker to Heroku

Heroku Container Runtime

Along with the traditional Git plus slug compiler deployments (git push heroku master), Heroku also supports Docker-based deployments, with the Heroku Container Runtime.

A container runtime is program that manages and runs containers. If you'd like to dive deeper into container runtimes, check out A history of low-level Linux container runtimes.

Docker-based Deployments

Docker-based deployments have many advantages over the traditional approach:

  1. No slug limits: Heroku allows a maximum slug size of 500MB for the traditional Git-based deployments. Docker-based deployments, on the other hand, do not have this limit.
  2. Full control over the OS: Rather than being constrained by the packages installed by the Heroku buildpacks, you have full control over the operating system and can install any package you'd like with Docker.
  3. Stronger dev/prod parity: Docker-based builds have stronger parity between development and production since the underlying environments are the same.
  4. Less vendor lock-in: Finally, Docker makes it much easier to switch to a different cloud hosting provider such as AWS or GCP.

In general, Docker-based deployments give you greater flexibility and control over the deployment environment. You can deploy the apps you want within the environment that you want. That said, you're now responsible for security updates. With the traditional Git-based deployments, Heroku is responsible for this. They apply relevant security updates to their Stacks and migrate your app to the new Stacks as necessary. Keep this in mind.

There are currently two ways to deploy apps with Docker to Heroku:

  1. Container Registry: deploy pre-built Docker images to Heroku
  2. Build Manifest: given a Dockerfile, Heroku builds and deploys the Docker image

The major difference between these two is that with the latter approach -- e.g., via the Build Manifest -- you have access to the Pipelines, Review, and Release features. So, if you're converting an app from a Git-based deployment to Docker and are using any of those features then you should use the Build Manifest approach.

Rest assured, we'll look at both approaches in this article.

In either case you will still have access to the Heroku CLI, all of the powerful addons, and the dashboard. All of these features work with the Container Runtime, in other words.

Deployment Type Deployment Mechanism Security Updates (who handles) Access to Pipelines, Review, Release Access to CLI, Addons, and Dashboard Slug size limits
Git + Slug Compiler Git Push Heroku Yes Yes Yes
Docker + Container Runtime Docker Push You No Yes No
Docker + Build Manifest Git Push You Yes Yes No

Keep in mind Docker-based deployments are limited to the same constraints that Git-based deployments are. For example, persistent volumes are not supported since the file system is ephemeral and web processes only support HTTP(S) requests. For more on this, review Dockerfile commands and runtime.

Docker vs Heroku Concepts

Docker Heroku
Dockerfile BuildPack
Image Slug
Container Dyno

Project Setup

Make a project directory, create and activate a new virtual environment, and install Django:

$ mkdir django-heroku-docker
$ cd django-heroku-docker

$ python3.10 -m venv env
$ source env/bin/activate

(env)$ pip install django==3.2.9

Feel free to swap out virtualenv and Pip for Poetry or Pipenv. For more, review Modern Python Environments.

Next, create a new Django project, apply the migrations, and run the server:

(env)$ django-admin startproject hello_django .
(env)$ python manage.py migrate
(env)$ python manage.py runserver

Navigate to http://localhost:8000/ to view the Django welcome screen. Kill the server and exit from the virtual environment once done.

Docker

Add a Dockerfile to the project root:

# pull official base image
FROM python:3.10-alpine

# set work directory
WORKDIR /app

# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV DEBUG 0

# install psycopg2
RUN apk update \
    && apk add --virtual build-essential gcc python3-dev musl-dev \
    && apk add postgresql-dev \
    && pip install psycopg2

# install dependencies
COPY ./requirements.txt .
RUN pip install -r requirements.txt

# copy project
COPY . .

# add and run as non-root user
RUN adduser -D myuser
USER myuser

# run gunicorn
CMD gunicorn hello_django.wsgi:application --bind 0.0.0.0:$PORT

Here, we started with an Alpine-based Docker image for Python 3.10. We then set a working directory along with two environment variables:

  1. PYTHONDONTWRITEBYTECODE: Prevents Python from writing pyc files to disc
  2. PYTHONUNBUFFERED: Prevents Python from buffering stdout and stderr

Next, we installed system-level dependencies and Python packages, copied over the project files, created and switched to a non-root user (which is recommended by Heroku), and used CMD to run Gunicorn when a container spins up at runtime. Take note of the $PORT variable. Essentially, any web server that runs on the Container Runtime must listen for HTTP traffic at the $PORT environment variable, which is set by Heroku at runtime.

Create a requirements.txt file:

Django==3.2.9
gunicorn==20.1.0

Then add a .dockerignore file:

__pycache__
*.pyc
env/
db.sqlite3

Update the SECRET_KEY, DEBUG, and ALLOWED_HOSTS variables in settings.py:

SECRET_KEY = os.environ.get('SECRET_KEY', default='foo')

DEBUG = int(os.environ.get('DEBUG', default=0))

ALLOWED_HOSTS = ['localhost', '127.0.0.1']

Don't forget the import:

import os

To test locally, build the image and run the container, making sure to pass in the appropriate environment variables:

$ docker build -t web:latest .
$ docker run -d --name django-heroku -e "PORT=8765" -e "DEBUG=1" -p 8007:8765 web:latest

Ensure then app is running at http://localhost:8007/ in your browser. Stop then remove the running container once done:

$ docker stop django-heroku
$ docker rm django-heroku

Add a .gitignore:

__pycache__
*.pyc
env/
db.sqlite3

Next, let's create a quick Django view to easily test the app when debug mode is off.

Add a views.py file to the "hello_django" directory:

from django.http import JsonResponse


def ping(request):
    data = {'ping': 'pong!'}
    return JsonResponse(data)

Next, update urls.py:

from django.contrib import admin
from django.urls import path

from .views import ping


urlpatterns = [
    path('admin/', admin.site.urls),
    path('ping/', ping, name="ping"),
]

Test this again with debug mode off:

$ docker build -t web:latest .
$ docker run -d --name django-heroku -e "PORT=8765" -e "DEBUG=0" -p 8007:8765 web:latest

Verify http://localhost:8007/ping/ works as expected:

{
  "ping": "pong!"
}

Stop then remove the running container once done:

$ docker stop django-heroku
$ docker rm django-heroku

WhiteNoise

If you'd like to use WhiteNoise to manage your static assets, first add the package to the requirements.txt file:

Django==3.2.9
gunicorn==20.1.0
whitenoise==5.3.0

Update the middleware in settings.py like so:

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'whitenoise.middleware.WhiteNoiseMiddleware',  # new
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

Then configure the handling of your staticfiles with STATIC_ROOT:

STATIC_ROOT = BASE_DIR / 'staticfiles'

FInally, add compression and caching support:

STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'

Add the collectstatic command to the Dockerfile:

# pull official base image
FROM python:3.10-alpine

# set work directory
WORKDIR /app

# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV DEBUG 0

# install psycopg2
RUN apk update \
    && apk add --virtual build-essential gcc python3-dev musl-dev \
    && apk add postgresql-dev \
    && pip install psycopg2

# install dependencies
COPY ./requirements.txt .
RUN pip install -r requirements.txt

# copy project
COPY . .

# collect static files
RUN python manage.py collectstatic --noinput

# add and run as non-root user
RUN adduser -D myuser
USER myuser

# run gunicorn
CMD gunicorn hello_django.wsgi:application --bind 0.0.0.0:$PORT

To test, build the new image and spin up a new container:

$ docker build -t web:latest .
$ docker run -d --name django-heroku -e "PORT=8765" -e "DEBUG=1" -p 8007:8765 web:latest

You should be able to view the static files when you run:

$ docker exec django-heroku ls /app/staticfiles
$ docker exec django-heroku ls /app/staticfiles/admin

Stop then remove the running container again:

$ docker stop django-heroku
$ docker rm django-heroku

Postgres

To get Postgres up and running, we'll use the dj_database_url package to generate the proper database configuration dictionary for the Django settings based on a DATABASE_URL environment variable.

Add the dependency to the requirements file:

Django==3.2.9
dj-database-url==0.5.0
gunicorn==20.1.0
whitenoise==5.3.0

Then, make the following changes to the settings to update the database configuration if the DATABASE_URL is present:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
    }
}

DATABASE_URL = os.environ.get('DATABASE_URL')
db_from_env = dj_database_url.config(default=DATABASE_URL, conn_max_age=500, ssl_require=True)
DATABASES['default'].update(db_from_env)

So, if the DATABASE_URL is not present, SQLite will still be used.

Add the import to the top as well:

import dj_database_url

We'll test this out in a bit after we spin up a Postgres database on Heroku.

Heroku Setup

Sign up for Heroku account (if you don’t already have one), and then install the Heroku CLI (if you haven't already done so).

Create a new app:

$ heroku create
Creating app... done, ⬢ limitless-atoll-51647
https://limitless-atoll-51647.herokuapp.com/ | https://git.heroku.com/limitless-atoll-51647.git

Add the SECRET_KEY environment variable:

$ heroku config:set SECRET_KEY=SOME_SECRET_VALUE -a limitless-atoll-51647

Change SOME_SECRET_VALUE to a randomly generated string that's at least 50 characters.

Add the above Heroku URL to the list of ALLOWED_HOSTS in hello_django/settings.py like so:

ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'limitless-atoll-51647.herokuapp.com']

Make sure to replace limitless-atoll-51647 in each of the above commands with the name of your app.

Heroku Docker Deployment

At this point, we're ready to start deploying Docker images to Heroku. Did you decide which approach you'd like to take?

  1. Container Registry: deploy pre-built Docker images to Heroku
  2. Build Manifest: given a Dockerfile, Heroku builds and deploys the Docker image

Unsure? Try them both!

Approach #1: Container Registry

Skip this section if you're using the Build Manifest approach.

Again, with this approach, you can deploy pre-built Docker images to Heroku.

Log in to the Heroku Container Registry, to indicate to Heroku that we want to use the Container Runtime:

$ heroku container:login

Re-build the Docker image and tag it with the following format:

registry.heroku.com/<app>/<process-type>

Make sure to replace <app> with the name of the Heroku app that you just created and <process-type> with web since this will be for a web process.

For example:

$ docker build -t registry.heroku.com/limitless-atoll-51647/web .

Push the image to the registry:

$ docker push registry.heroku.com/limitless-atoll-51647/web

Release the image:

$ heroku container:release -a limitless-atoll-51647 web

This will run the container. You should be able to view the app at https://APP_NAME.herokuapp.com. It should return a 404.

Try running heroku open -a limitless-atoll-51647 to open the app in your default browser.

Verify https://APP_NAME.herokuapp.com/ping works as well:

{
  "ping": "pong!"
}

You should also be able to view the static files:

$ heroku run ls /app/staticfiles -a limitless-atoll-51647
$ heroku run ls /app/staticfiles/admin -a limitless-atoll-51647

Make sure to replace limitless-atoll-51647 in each of the above commands with the name of your app.

Jump down to the "Postgres Test" section once done.

Approach #2: Build Manifest

Skip this section if you're using the Container Registry approach.

Again, with the Build Manifest approach, you can have Heroku build and deploy Docker images based on a heroku.yml manifest file.

Set the Stack of your app to container:

$ heroku stack:set container -a limitless-atoll-51647

Add a heroku.yml file to the project root:

build:
  docker:
    web: Dockerfile

Here, we're just telling Heroku which Dockerfile to use for building the image.

Along with build, you can also define the following stages:

  • setup is used to define Heroku addons and configuration variables to create during app provisioning.
  • release is used to define tasks that you'd like to execute during a release.
  • run is used to define which commands to run for the web and worker processes.

Be sure to review the Heroku documentation to learn more about these four stages.

It's worth noting that the gunicorn hello_django.wsgi:application --bind 0.0.0.0:$PORT command could be removed from the Dockerfile and added to the heroku.yml file under the run stage:

build:
  docker:
    web: Dockerfile
run:
  web: gunicorn hello_django.wsgi:application --bind 0.0.0.0:$PORT

Also, be sure to place the 'collectstatic' command inside your Dockerfile. Don't move it to the release stage. For more on this, review this Stack Overflow question.

Next, install the heroku-manifest plugin from the beta CLI channel:

$ heroku update beta
$ heroku plugins:install @heroku-cli/plugin-manifest

With that, initialize a Git repo and create a commit.

Then, add the Heroku remote:

$ heroku git:remote -a limitless-atoll-51647

Push the code up to Heroku to build the image and run the container:

$ git push heroku master

You should be able to view the app at https://APP_NAME.herokuapp.com. It should return a 404.

Try running heroku open -a limitless-atoll-51647 to open the app in your default browser.

Verify https://APP_NAME.herokuapp.com/ping works as well:

{
  "ping": "pong!"
}

You should also be able to view the static files:

$ heroku run ls /app/staticfiles -a limitless-atoll-51647
$ heroku run ls /app/staticfiles/admin -a limitless-atoll-51647

Make sure to replace limitless-atoll-51647 in each of the above commands with the name of your app.

Postgres Test

Create the database:

$ heroku addons:create heroku-postgresql:hobby-dev -a limitless-atoll-51647

This command automatically sets the DATABASE_URL environment variable for the container.

Once the database is up, run the migrations:

$ heroku run python manage.py makemigrations -a limitless-atoll-51647
$ heroku run python manage.py migrate -a limitless-atoll-51647

Then, jump into psql to view the newly created tables:

$ heroku pg:psql -a limitless-atoll-51647

# \dt
                      List of relations
 Schema |            Name            | Type  |     Owner
--------+----------------------------+-------+----------------
 public | auth_group                 | table | siodzhzzcvnwwp
 public | auth_group_permissions     | table | siodzhzzcvnwwp
 public | auth_permission            | table | siodzhzzcvnwwp
 public | auth_user                  | table | siodzhzzcvnwwp
 public | auth_user_groups           | table | siodzhzzcvnwwp
 public | auth_user_user_permissions | table | siodzhzzcvnwwp
 public | django_admin_log           | table | siodzhzzcvnwwp
 public | django_content_type        | table | siodzhzzcvnwwp
 public | django_migrations          | table | siodzhzzcvnwwp
 public | django_session             | table | siodzhzzcvnwwp
(10 rows)

# \q

Again, make sure to replace limitless-atoll-51647 in each of the above commands with the name of your Heroku app.

GitLab CI

Sign up for a GitLab account (if necessary), and then create a new project (again, if necessary).

Retrieve your Heroku auth token:

$ heroku auth:token

Then, save the token as a new variable called HEROKU_AUTH_TOKEN within your project's CI/CD settings: Settings > CI / CD > Variables.

gitlab config

Next, we need to add a GitLab CI/CD config file called .gitlab-ci.yml to the project root. The contents of this file will vary based on the approach used.

Approach #1: Container Registry

Skip this section if you're using the Build Manifest approach.

.gitlab-ci.yml:

image: docker:stable
services:
  - docker:dind

variables:
  DOCKER_DRIVER: overlay2
  HEROKU_APP_NAME: <APP_NAME>
  HEROKU_REGISTRY_IMAGE: registry.heroku.com/${HEROKU_APP_NAME}/web

stages:
  - build_and_deploy

build_and_deploy:
  stage: build_and_deploy
  script:
    - apk add --no-cache curl
    - docker login -u _ -p $HEROKU_AUTH_TOKEN registry.heroku.com
    - docker pull $HEROKU_REGISTRY_IMAGE || true
    - docker build
      --cache-from $HEROKU_REGISTRY_IMAGE
      --tag $HEROKU_REGISTRY_IMAGE
      --file ./Dockerfile
      "."
    - docker push $HEROKU_REGISTRY_IMAGE
    - chmod +x ./release.sh
    - ./release.sh

release.sh:

#!/bin/sh


IMAGE_ID=$(docker inspect ${HEROKU_REGISTRY_IMAGE} --format={{.Id}})
PAYLOAD='{"updates": [{"type": "web", "docker_image": "'"$IMAGE_ID"'"}]}'

curl -n -X PATCH https://api.heroku.com/apps/$HEROKU_APP_NAME/formation \
  -d "${PAYLOAD}" \
  -H "Content-Type: application/json" \
  -H "Accept: application/vnd.heroku+json; version=3.docker-releases" \
  -H "Authorization: Bearer ${HEROKU_AUTH_TOKEN}"

Here, we defined a single build_and_deploy stage where we:

  1. Install cURL
  2. Log in to the Heroku Container Registry
  3. Pull the previously pushed image (if it exists)
  4. Build and tag the new image
  5. Push the image up to the registry
  6. Create a new release via the Heroku API using the image ID within the release.sh script

Make sure to replace <APP_NAME> with your Heroku app's name.

With that, initialize a Git repo, commit, add the GitLab remote, and push your code up to GitLab to trigger a new pipeline. This will run the build_and_deploy stage as a single job. Once complete, a new release should automatically be created on Heroku.

Approach #2: Build Manifest

Skip this section if you're using the Container Registry approach.

.gitlab-ci.yml:

variables:
  HEROKU_APP_NAME: <APP_NAME>

stages:
  - deploy

deploy:
  stage: deploy
  script:
    - apt-get update -qy
    - apt-get install -y ruby-dev
    - gem install dpl
    - dpl --provider=heroku --app=$HEROKU_APP_NAME --api-key=$HEROKU_AUTH_TOKEN

Here, we defined a single deploy stage where we:

  1. Install Ruby along with a gem called dpl
  2. Deploy the code to Heroku with dpl

Make sure to replace <APP_NAME> with your Heroku app's name.

Commit, add the GitLab remote, and push your code up to GitLab to trigger a new pipeline. This will run the deploy stage as a single job. Once complete, the code should be deployed to Heroku.

Advanced CI

Rather than just building the Docker image and creating a release on GitLab CI, let's also run the Django tests, Flake8, Black, and isort.

Again, this will vary depending on the approach you used.

Approach #1: Container Registry

Skip this section if you're using the Build Manifest approach.

Update .gitlab-ci.yml like so:

stages:
  - build
  - test
  - deploy

variables:
  IMAGE: ${CI_REGISTRY}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME}

build:
  stage: build
  image: docker:stable
  services:
    - docker:dind
  variables:
    DOCKER_DRIVER: overlay2
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_JOB_TOKEN $CI_REGISTRY
    - docker pull $IMAGE:latest || true
    - docker build
      --cache-from $IMAGE:latest
      --tag $IMAGE:latest
      --file ./Dockerfile
      "."
    - docker push $IMAGE:latest

test:
  stage: test
  image: $IMAGE:latest
  services:
    - postgres:latest
  variables:
    POSTGRES_DB: test
    POSTGRES_USER: runner
    POSTGRES_PASSWORD: ""
    DATABASE_URL: postgresql://runner@postgres:5432/test
  script:
    - python manage.py test
    - flake8 hello_django --max-line-length=100
    - black hello_django --check
    - isort hello_django --check --profile black

deploy:
  stage: deploy
  image: docker:stable
  services:
    - docker:dind
  variables:
    DOCKER_DRIVER: overlay2
    HEROKU_APP_NAME: <APP_NAME>
    HEROKU_REGISTRY_IMAGE: registry.heroku.com/${HEROKU_APP_NAME}/web
  script:
    - apk add --no-cache curl
    - docker login -u _ -p $HEROKU_AUTH_TOKEN registry.heroku.com
    - docker pull $HEROKU_REGISTRY_IMAGE || true
    - docker build
      --cache-from $HEROKU_REGISTRY_IMAGE
      --tag $HEROKU_REGISTRY_IMAGE
      --file ./Dockerfile
      "."
    - docker push $HEROKU_REGISTRY_IMAGE
    - chmod +x ./release.sh
    - ./release.sh

Make sure to replace <APP_NAME> with your Heroku app's name.

So, we now have three stages: build, test, and deploy.

In the build stage, we:

  1. Log in to the GitLab Container Registry
  2. Pull the previously pushed image (if it exists)
  3. Build and tag the new image
  4. Push the image up to the GitLab Container Registry

Then, in the test stage we configure Postgres, set the DATABASE_URL environment variable, and then run the Django tests, Flake8, Black, and isort using the image that was built in the previous stage.

In the deploy stage, we:

  1. Install cURL
  2. Log in to the Heroku Container Registry
  3. Pull the previously pushed image (if it exists)
  4. Build and tag the new image
  5. Push the image up to the registry
  6. Create a new release via the Heroku API using the image ID within the release.sh script

Add the new dependencies to the requirements file:

# prod
Django==3.2.9
dj-database-url==0.5.0
gunicorn==20.1.0
whitenoise==5.3.0

# dev and test
black==21.11b1
flake8==4.0.1
isort==5.10.1

Before pushing up to GitLab, run the Django tests locally:

$ source env/bin/activate
(env)$ pip install -r requirements.txt
(env)$ python manage.py test

System check identified no issues (0 silenced).

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK

Ensure Flake8 passes, and then update the source code based on the Black and isort recommendations:

(env)$ flake8 hello_django --max-line-length=100
(env)$ black hello_django
(env)$ isort hello_django --profile black

Commit and push your code yet again. Ensure all stages pass.

Approach #2: Build Manifest

Skip this section if you're using the Container Registry approach.

Update .gitlab-ci.yml like so:

stages:
  - build
  - test
  - deploy

variables:
  IMAGE: ${CI_REGISTRY}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME}

build:
  stage: build
  image: docker:stable
  services:
    - docker:dind
  variables:
    DOCKER_DRIVER: overlay2
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_JOB_TOKEN $CI_REGISTRY
    - docker pull $IMAGE:latest || true
    - docker build
      --cache-from $IMAGE:latest
      --tag $IMAGE:latest
      --file ./Dockerfile
      "."
    - docker push $IMAGE:latest

test:
  stage: test
  image: $IMAGE:latest
  services:
    - postgres:latest
  variables:
    POSTGRES_DB: test
    POSTGRES_USER: runner
    POSTGRES_PASSWORD: ""
    DATABASE_URL: postgresql://runner@postgres:5432/test
  script:
    - python manage.py test
    - flake8 hello_django --max-line-length=100
    - black hello_django --check
    - isort hello_django --check --profile black

deploy:
  stage: deploy
  variables:
    HEROKU_APP_NAME: <APP_NAME>
  script:
    - apt-get update -qy
    - apt-get install -y ruby-dev
    - gem install dpl
    - dpl --provider=heroku --app=$HEROKU_APP_NAME --api-key=$HEROKU_AUTH_TOKEN

Make sure to replace <APP_NAME> with your Heroku app's name.

So, we now have three stages: build, test, and deploy.

In the build stage, we:

  1. Log in to the GitLab Container Registry
  2. Pull the previously pushed image (if it exists)
  3. Build and tag the new image
  4. Push the image up to the GitLab Container Registry

Then, in the test stage we configure Postgres, set the DATABASE_URL environment variable, and then run the Django tests, Flake8, Black, and isort using the image that was built in the previous stage.

In the deploy stage, we:

  1. Install Ruby along with a gem called dpl
  2. Deploy the code to Heroku with dpl

Add the new dependencies to the requirements file:

# prod
Django==3.2.9
dj-database-url==0.5.0
gunicorn==20.1.0
whitenoise==5.3.0

# dev and test
black==21.11b1
flake8==4.0.1
isort==5.10.1

Before pushing up to GitLab, run the Django tests locally:

$ source env/bin/activate
(env)$ pip install -r requirements.txt
(env)$ python manage.py test

System check identified no issues (0 silenced).

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK

Ensure Flake8 passes, and then update the source code based on the Black and isort recommendations:

(env)$ flake8 hello_django --max-line-length=100
(env)$ black hello_django
(env)$ isort hello_django --profile black

Commit and push your code yet again. Ensure all stages pass.

Multi-stage Docker Build

Finally, update the Dockerfile like so to use a multi-stage build in order to reduce the final image size:

FROM python:3.10-alpine AS build-python
RUN apk update && apk add --virtual build-essential gcc python3-dev musl-dev postgresql-dev
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY ./requirements.txt .
RUN pip install -r requirements.txt

FROM python:3.10-alpine
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV DEBUG 0
ENV PATH="/opt/venv/bin:$PATH"
COPY --from=build-python /opt/venv /opt/venv
RUN apk update && apk add --virtual build-deps gcc python3-dev musl-dev postgresql-dev
RUN pip install psycopg2-binary
WORKDIR /app
COPY . .
RUN python manage.py collectstatic --noinput
RUN adduser -D myuser
USER myuser
CMD gunicorn hello_django.wsgi:application --bind 0.0.0.0:$PORT

Next, we need to update the GitLab config to take advantage of Docker layer caching.

Approach #1: Container Registry

Skip this section if you're using the Build Manifest approach.

.gitlab-ci.yml:

stages:
  - build
  - test
  - deploy

variables:
  IMAGE: ${CI_REGISTRY}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME}
  HEROKU_APP_NAME: <APP_NAME>
  HEROKU_REGISTRY_IMAGE: registry.heroku.com/${HEROKU_APP_NAME}/web

build:
  stage: build
  image: docker:stable
  services:
    - docker:dind
  variables:
    DOCKER_DRIVER: overlay2
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_JOB_TOKEN $CI_REGISTRY
    - docker pull $IMAGE:build-python || true
    - docker pull $IMAGE:production || true
    - docker build
      --target build-python
      --cache-from $IMAGE:build-python
      --tag $IMAGE:build-python
      --file ./Dockerfile
      "."
    - docker build
      --cache-from $IMAGE:production
      --tag $IMAGE:production
      --tag $HEROKU_REGISTRY_IMAGE
      --file ./Dockerfile
      "."
    - docker push $IMAGE:build-python
    - docker push $IMAGE:production

test:
  stage: test
  image: $IMAGE:production
  services:
    - postgres:latest
  variables:
    POSTGRES_DB: test
    POSTGRES_USER: runner
    POSTGRES_PASSWORD: ""
    DATABASE_URL: postgresql://runner@postgres:5432/test
  script:
    - python manage.py test
    - flake8 hello_django --max-line-length=100
    - black hello_django --check
    - isort hello_django --check --profile black

deploy:
  stage: deploy
  image: docker:stable
  services:
    - docker:dind
  variables:
    DOCKER_DRIVER: overlay2
  script:
    - apk add --no-cache curl
    - docker login -u $CI_REGISTRY_USER -p $CI_JOB_TOKEN $CI_REGISTRY
    - docker pull $IMAGE:build-python || true
    - docker pull $IMAGE:production || true
    - docker build
      --target build-python
      --cache-from $IMAGE:build-python
      --tag $IMAGE:build-python
      --file ./Dockerfile
      "."
    - docker build
      --cache-from $IMAGE:production
      --tag $IMAGE:production
      --tag $HEROKU_REGISTRY_IMAGE
      --file ./Dockerfile
      "."
    - docker push $IMAGE:build-python
    - docker push $IMAGE:production
    - docker login -u _ -p $HEROKU_AUTH_TOKEN registry.heroku.com
    - docker push $HEROKU_REGISTRY_IMAGE
    - chmod +x ./release.sh
    - ./release.sh

Make sure to replace <APP_NAME> with your Heroku app's name.

Review the changes on your own. Then, test it out one last time.

For more on this caching pattern, review the "Multi-stage" section from the Faster CI Builds with Docker Cache article.

Approach #2: Build Manifest

Skip this section if you're using the Container Registry approach.

.gitlab-ci.yml:

stages:
  - build
  - test
  - deploy

variables:
  IMAGE: ${CI_REGISTRY}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME}
  HEROKU_APP_NAME: <APP_NAME>

build:
  stage: build
  image: docker:stable
  services:
    - docker:dind
  variables:
    DOCKER_DRIVER: overlay2
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_JOB_TOKEN $CI_REGISTRY
    - docker pull $IMAGE:build-python || true
    - docker pull $IMAGE:production || true
    - docker build
      --target build-python
      --cache-from $IMAGE:build-python
      --tag $IMAGE:build-python
      --file ./Dockerfile
      "."
    - docker build
      --cache-from $IMAGE:production
      --tag $IMAGE:production
      --file ./Dockerfile
      "."
    - docker push $IMAGE:build-python
    - docker push $IMAGE:production

test:
  stage: test
  image: $IMAGE:production
  services:
    - postgres:latest
  variables:
    POSTGRES_DB: test
    POSTGRES_USER: runner
    POSTGRES_PASSWORD: ""
    DATABASE_URL: postgresql://runner@postgres:5432/test
  script:
    - python manage.py test
    - flake8 hello_django --max-line-length=100
    - black hello_django --check
    - isort hello_django --check --profile black

deploy:
  stage: deploy
  script:
    - apt-get update -qy
    - apt-get install -y ruby-dev
    - gem install dpl
    - dpl --provider=heroku --app=$HEROKU_APP_NAME --api-key=$HEROKU_AUTH_TOKEN

Make sure to replace <APP_NAME> with your Heroku app's name.

Review the changes on your own. Then, test it out one last time.

For more on this caching pattern, review the "Multi-stage" section from the Faster CI Builds with Docker Cache article.

Conclusion

In this article, we walked through two approaches for deploying a Django app to Heroku with Docker -- the Container Registry and Build Manifest.

So, when should you think about using the Heroku Container Runtime over the traditional Git and slug compiler for deployments?

When you need more control over the production deployment environment.

Examples:

  1. Your application and dependencies exceed the 500MB maximum slug limit.
  2. Your application requires packages not installed by the regular Heroku buildpacks.
  3. You want greater assurance that your application will behave the same in development as it does in production.
  4. You really, really enjoy working with Docker.

--

You can find the code in the following repositories on GitLab:

  1. Container Registry Approach - django-heroku-docker
  2. Build Manifest Aproach - django-heroku-docker-build-manifest

Best!

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.