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:
- Explain why you may want to use Heroku's Container Runtime to run an app
- Dockerize a Django app
- Deploy and run a Django app in a Docker container on Heroku
- Configure GitLab CI to deploy Docker images to Heroku
- Manage static assets with WhiteNoise
- Configure Postgres to run on Heroku
- Create a production Dockerfile that uses multistage Docker builds
- 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:
- 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.
- 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.
- Stronger dev/prod parity: Docker-based builds have stronger parity between development and production since the underlying environments are the same.
- 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:
- Container Registry: deploy pre-built Docker images to Heroku
- 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:
PYTHONDONTWRITEBYTECODE
: Prevents Python from writing pyc files to discPYTHONUNBUFFERED
: 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?
- Container Registry: deploy pre-built Docker images to Heroku
- 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 therun
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.
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:
- Install cURL
- Log in to the Heroku Container Registry
- Pull the previously pushed image (if it exists)
- Build and tag the new image
- Push the image up to the registry
- 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:
- Install Ruby along with a gem called dpl
- 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:
- Log in to the GitLab Container Registry
- Pull the previously pushed image (if it exists)
- Build and tag the new image
- 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:
- Install cURL
- Log in to the Heroku Container Registry
- Pull the previously pushed image (if it exists)
- Build and tag the new image
- Push the image up to the registry
- 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:
- Log in to the GitLab Container Registry
- Pull the previously pushed image (if it exists)
- Build and tag the new image
- 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:
- Install Ruby along with a gem called dpl
- 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:
- Your application and dependencies exceed the 500MB maximum slug limit.
- Your application requires packages not installed by the regular Heroku buildpacks.
- You want greater assurance that your application will behave the same in development as it does in production.
- You really, really enjoy working with Docker.
--
You can find the code in the following repositories on GitLab:
- Container Registry Approach - django-heroku-docker
- Build Manifest Aproach - django-heroku-docker-build-manifest
Best!