Dockerizing Masonite with Postgres, Gunicorn, and Nginx

Last updated December 5th, 2019

This is a step-by-step tutorial that details how to configure Masonite to run on Docker with Postgres. For production environments, we'll add on Nginx and Gunicorn. We'll also take a look at how to serve static and user-uploaded media files via Nginx.

Masonite is a modern, developer centric, batteries included Python web framework. If you're new to the framework check out the 5 reasons why people are choosing Masonite over Django blog post.

Dependencies:

  1. Masonite v2.2
  2. Docker v19.03.5
  3. Python v3.8.0

Contents

Project Setup

Create a project directory, install the Masonite CLI (craft), and create a new Masonite project:

$ mkdir masonie-on-docker && cd masonie-on-docker
$ python3.8 -m venv env
$ source env/bin/activate
(env)$ pip install masonite-cli==2.2.2
(env)$ craft new web
(env)$ cd web
(env)$ craft install
(env)$ craft serve

Feel free to swap out virtualenv and Pip for Pipenv if that's your tool of choice.

Navigate to http://localhost:8000/ to view the Masonite welcome screen. Kill the server and exit from the virtual environment once done. We now have a simple Masonite project to work with.

Next, before adding Docker, let's clean up the project structure a bit.

Remove the following files from the "web" directory:

  • .env-example
  • .gitignore
  • .travis.yml

Move the .env file to the project folder and rename it to .env.dev.

Your project structure should look like:

├── .env
└── web
    ├── .env.testing
    ├── LICENSE
    ├── README.md
    ├── app
    │   ├── User.py
    │   ├── http
    │   │   ├── controllers
    │   │   │   └── WelcomeController.py
    │   │   └── middleware
    │   │       ├── AuthenticationMiddleware.py
    │   │       ├── CsrfMiddleware.py
    │   │       ├── LoadUserMiddleware.py
    │   │       └── VerifyEmailMiddleware.py
    │   └── providers
    │       └── .gitignore
    ├── bootstrap
    │   ├── cache
    │   │   └── .gitignore
    │   └── start.py
    ├── config
    │   ├── __init__.py
    │   ├── application.py
    │   ├── auth.py
    │   ├── broadcast.py
    │   ├── cache.py
    │   ├── database.py
    │   ├── factories.py
    │   ├── mail.py
    │   ├── middleware.py
    │   ├── packages.py
    │   ├── providers.py
    │   ├── queue.py
    │   ├── session.py
    │   └── storage.py
    ├── craft
    ├── databases
    │   ├── migrations
    │   │   ├── 2018_01_09_043202_create_users_table.py
    │   │   └── __init__.py
    │   └── seeds
    │       ├── __init__.py
    │       ├── database_seeder.py
    │       └── user_table_seeder.py
    ├── requirements.txt
    ├── resources
    │   ├── __init__.py
    │   └── templates
    │       ├── __init__.py
    │       ├── base.html
    │       └── welcome.html
    ├── routes
    │   └── web.py
    ├── storage
    │   ├── compiled
    │   │   └── style.css
    │   ├── public
    │   │   ├── favicon.ico
    │   │   └── robots.txt
    │   ├── static
    │   │   ├── __init__.py
    │   │   └── sass
    │   │       └── style.scss
    │   └── uploads
    │       └── __init__.py
    ├── tests
    │   ├── database
    │   │   └── test_user.py
    │   └── unit
    │       └── test_works.py
    └── wsgi.py

Docker

Install Docker, if you don't already have it, then add a Dockerfile to the "web" directory:

# pull official base image
FROM python:3.8.0-alpine

# set work directory
WORKDIR /usr/src/app

# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

# install system dependencies
RUN apk update && apk --no-cache add \
    libressl-dev libffi-dev gcc musl-dev python3-dev

# install dependencies
RUN pip install --upgrade pip
RUN pip install masonite-cli==2.2.2
COPY ./requirements.txt /usr/src/app/requirements.txt
RUN pip install -r requirements.txt

# copy project
COPY . /usr/src/app/

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

  1. PYTHONDONTWRITEBYTECODE: Prevents Python from writing pyc files to disc (equivalent to python -B option)
  2. PYTHONUNBUFFERED: Prevents Python from buffering stdout and stderr (equivalent to python -u option)

Finally, we updated Pip, copied over the requirements.txt file, installed the dependencies, and copied over the Masonite app itself.

Review Docker for Python Developers for more on structuring Dockerfiles as well as some best practices for configuring Docker for Python-based development.

Next, add a docker-compose.yml file to the project root:

version: '3.7'

services:
  web:
    build: ./web
    command: craft serve -p 8000 -b 0.0.0.0
    volumes:
      - ./web/:/usr/src/app/
    ports:
      - 8000:8000
    env_file:
      - .env.dev

Review the Compose file reference for info on how this file works.

Let's simplify .env.dev by removing any unused variables:

APP_NAME=Masonite on Docker (dev)
APP_ENV=local
APP_DEBUG=True
AUTH_DRIVER=cookie
APP_URL=http://localhost:8000
KEY=AEPLtORB_AddF8avUCCkftIJ-I6E0pWs4PnQD3YgUA0=

MAIL_DRIVER=terminal

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=masonite
DB_USERNAME=root
DB_PASSWORD=root
DB_LOG=True

Build the image:

$ docker-compose build

Once the image is built, run the container:

$ docker-compose up -d

Navigate to http://localhost:8000/ to again view the welcome screen. This time you should see "Masonite on Docker (dev)".

Check for errors in the logs if this doesn't work via docker-compose logs -f.

To test auto-reload, first open up the Docker logs -- docker-compose logs -f -- and then make a change to web/routes/web.py locally:

"""Web Routes."""

from masonite.routes import Get, Post

ROUTES = [
    Get('/', '[email protected]').name('welcome'),
    Get('/sample', '[email protected]').name('welcome'),
]

As soon as you save, you should see the app reload in your terminal like so:

web_1  | /usr/src/app/routes/web.py changed; reloading ...
web_1  | Gracefully killing the server.
web_1  | Starting monitor for PID 22.
web_1  | Serving at: http://0.0.0.0:8000

Ensure http://localhost:8000/sample works as expected.

Postgres

To configure Postgres, we'll need to add a new service to the docker-compose.yml file, update the environment variables, and install Psycopg2.

First, add a new service called db to docker-compose.yml:

version: '3.7'

services:
  web:
    build: ./web
    command: craft serve -p 8000 -b 0.0.0.0
    volumes:
      - ./web/:/usr/src/app/
    ports:
      - 8000:8000
    env_file:
      - .env.dev
    depends_on:
      - db
  db:
    image: postgres:12.1-alpine
    volumes:
      - postgres_data:/var/lib/postgresql/data/
    environment:
      - POSTGRES_USER=hello_masonite
      - POSTGRES_PASSWORD=hello_masonite
      - POSTGRES_DB=hello_masonite_dev

volumes:
  postgres_data:

To persist the data beyond the life of the container we configured a volume. This config will bind postgres_data to the "/var/lib/postgresql/data/" directory in the container.

We also added an environment key to define a name for the default database and set a username and password.

Review the "Environment Variables" section of the Postgres Docker Hub page for more info.

Update the following database-related environment variables in the .env.dev file as well:

DB_CONNECTION=postgres
DB_HOST=db
DB_PORT=5432
DB_DATABASE=hello_masonite_dev
DB_USERNAME=hello_masonite
DB_PASSWORD=hello_masonite

Review the web/config/database.py file for info on how the database is configured based on the defined environment variables for the Masonite project.

Update the Dockerfile to install the appropriate packages required for Psycopg2:

# pull official base image
FROM python:3.8.0-alpine

# set work directory
WORKDIR /usr/src/app

# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

# install system dependencies
RUN apk update && apk --no-cache add \
    libressl-dev libffi-dev gcc musl-dev python3-dev \
    postgresql-dev

# install dependencies
RUN pip install --upgrade pip
RUN pip install masonite-cli==2.2.2
COPY ./requirements.txt /usr/src/app/requirements.txt
RUN pip install -r requirements.txt

# copy project
COPY . /usr/src/app/

Add Psycopg2 to web/requirements.txt:

masonite>=2.2,<2.3
psycopg2-binary==2.8.4

Review this GitHub Issue for more info on installing Psycopg2 in an Alpine-based Docker Image.

Build the new image and spin up the two containers:

$ docker-compose up -d --build

Apply the migrations (from the "web/databases/migrations" folder):

$ docker-compose exec web craft migrate

You should see:

It took 3.53ms to execute the query SELECT * FROM information_schema.tables WHERE table_name = 'migrations'
It took 7.95ms to execute the query CREATE TABLE "migrations" ("migration" VARCHAR(255) NOT NULL, "batch" INTEGER NOT NULL)
It took 1.66ms to execute the query SELECT "migration" FROM "migrations"
It took 1.53ms to execute the query SELECT MAX("batch") AS aggregate FROM "migrations"
It took 9.48ms to execute the query CREATE TABLE "users" ("id" SERIAL PRIMARY KEY NOT NULL, "name" VARCHAR(255) NOT NULL, "email" VARCHAR(255) NOT NULL, "password" VARCHAR(255) NOT NULL, "remember_token" VARCHAR(255) NULL, "verified_at" TIMESTAMP(6) WITHOUT TIME ZONE NULL, "created_at" TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP(6) NOT NULL, "updated_at" TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP(6) NOT NULL)
It took 3.52ms to execute the query ALTER TABLE "users" ADD CONSTRAINT users_email_unique UNIQUE ("email")
It took 1.59ms to execute the query INSERT INTO "migrations" ("migration", "batch") VALUES ('2018_01_09_043202_create_users_table', 1)
It took 0.62ms to execute the query SELECT "migration" FROM "migrations"

[OK] Migrated 2018_01_09_043202_create_users_table

Ensure the users table was created:

$ docker-compose exec db psql --username=hello_masonite --dbname=hello_masonite_dev

psql (12.1)
Type "help" for help.

hello_masonite_dev=# \l
                                              List of databases
        Name        |     Owner      | Encoding |  Collate   |   Ctype    |         Access privileges
--------------------+----------------+----------+------------+------------+-----------------------------------
 hello_masonite_dev | hello_masonite | UTF8     | en_US.utf8 | en_US.utf8 |
 postgres           | hello_masonite | UTF8     | en_US.utf8 | en_US.utf8 |
 template0          | hello_masonite | UTF8     | en_US.utf8 | en_US.utf8 | =c/hello_masonite                +
                    |                |          |            |            | hello_masonite=CTc/hello_masonite
 template1          | hello_masonite | UTF8     | en_US.utf8 | en_US.utf8 | =c/hello_masonite                +
                    |                |          |            |            | hello_masonite=CTc/hello_masonite
(4 rows)

hello_masonite_dev=# \c hello_masonite_dev
You are now connected to database "hello_masonite_dev" as user "hello_masonite".

hello_masonite_dev=# \dt
              List of relations
 Schema |    Name    | Type  |     Owner
--------+------------+-------+----------------
 public | migrations | table | hello_masonite
 public | users      | table | hello_masonite
(2 rows)

hello_masonite_dev=# \q

You can check that the volume was created as well by running:

$ docker volume inspect masonite-on-docker_postgres_data

You should see something similar to:

[
    {
        "CreatedAt": "2019-12-04T16:55:37Z",
        "Driver": "local",
        "Labels": {
            "com.docker.compose.project": "masonite-on-docker",
            "com.docker.compose.version": "1.24.1",
            "com.docker.compose.volume": "postgres_data"
        },
        "Mountpoint": "/var/lib/docker/volumes/masonite-on-docker_postgres_data/_data",
        "Name": "masonite-on-docker_postgres_data",
        "Options": null,
        "Scope": "local"
    }
]

Next, add an entrypoint.sh file to the "web" directory to verify that Postgres is up and healthy before applying the migrations and running the Masonite development server:

#!/bin/sh

if [ "$DB_CONNECTION" = "postgres" ]
then
    echo "Waiting for postgres..."

    while ! nc -z $DB_HOST $DB_PORT; do
      sleep 0.1
    done

    echo "PostgreSQL started"
fi

craft migrate:refresh  # you may want to remove this
craft migrate

exec "[email protected]"

Take note of the environment variables.

Then, update the Dockerfile to run the entrypoint.sh file as the Docker entrypoint command:

# pull official base image
FROM python:3.8.0-alpine

# set work directory
WORKDIR /usr/src/app

# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

# install system dependencies
RUN apk update && apk --no-cache add \
    libressl-dev libffi-dev gcc musl-dev python3-dev \
    postgresql-dev

# install dependencies
RUN pip install --upgrade pip
RUN pip install masonite-cli==2.2.2
COPY ./requirements.txt /usr/src/app/requirements.txt
RUN pip install -r requirements.txt

# copy project
COPY . /usr/src/app/

# run entrypoint.sh
ENTRYPOINT ["/usr/src/app/entrypoint.sh"]

Update the file permissions locally:

$ chmod +x web/entrypoint.sh

Test it out again:

  1. Re-build the images
  2. Run the containers
  3. Try http://localhost:8000/

Want to seed some users?

$ docker-compose exec web craft seed:run

$ docker-compose exec db psql --username=hello_masonite --dbname=hello_masonite_dev

psql (12.1)
Type "help" for help.

hello_masonite_dev=# \c hello_masonite_dev
You are now connected to database "hello_masonite_dev" as user "hello_masonite".

hello_masonite_dev=# select count(*) from users;
 count
-------
    50
(1 row)

hello_masonite_dev=# \q

Gunicorn

Moving along, for production environments, let's add Gunicorn, a production-grade WSGI server, to the requirements file:

gunicorn==20.0.4
masonite>=2.2,<2.3
psycopg2-binary==2.8.4

Since we still want to use Masonite's built-in server in development, create a new compose file called docker-compose.prod.yml for production:

version: '3.7'

services:
  web:
    build: ./web
    command: gunicorn --bind 0.0.0.0:8000 wsgi:application
    ports:
      - 8000:8000
    env_file:
      - .env.prod
    depends_on:
      - db
  db:
    image: postgres:12.1-alpine
    volumes:
      - postgres_data:/var/lib/postgresql/data/
    env_file:
      - .env.prod.db

volumes:
  postgres_data:

If you have multiple environments, you may want to look at using a docker-compose.override.yml configuration file. With this approach, you'd add your base config to a docker-compose.yml file and then use a docker-compose.override.yml file to override those config settings based on the environment.

Take note of the default command. We're running Gunicorn rather than the Masonite development server. We also removed the volume from the web service since we don't need it in production. Finally, we're using separate environment variable files to define environment variables for both services that will be passed to the container at runtime.

.env.prod:

APP_NAME=Masonite on Docker (prod)
APP_DEBUG=False
AUTH_DRIVER=cookie
APP_URL=http://localhost:8000
KEY=03yy13HOC_0VeXt9B8lNEK6HIl0ZSPLp5hpQ27IVXRw=

MAIL_DRIVER=terminal

DB_CONNECTION=postgres
DB_HOST=db
DB_PORT=5432
DB_DATABASE=hello_masonite_prod
DB_USERNAME=hello_masonite
DB_PASSWORD=hello_masonite
DB_LOG=True

.env.prod.db:

POSTGRES_USER=hello_masonite
POSTGRES_PASSWORD=hello_masonite
POSTGRES_DB=hello_masonite_prod

Add the two files to the project root. You'll probably want to keep them out of version control, so add them to a .gitignore file.

Bring down the development containers (and the associated volumes with the -v flag):

$ docker-compose down -v

Then, build the production images and spin up the containers:

$ docker-compose -f docker-compose.prod.yml up -d --build

Verify that the hello_masonite_prod database was created along with the users table. Test out http://localhost:8000/. You should see "Masonite on Docker (prod)" on the welcome screen.

Again, if the container fails to start, check for errors in the logs via docker-compose -f docker-compose.prod.yml logs -f.

Production Dockerfile

Did you notice that we're still running the migrate:refresh (which clears out the database) and migrate commands every time the container is run? This is fine in development, but let's create a new entrypoint file for production.

entrypoint.prod.sh:

#!/bin/sh

if [ "$DB_CONNECTION" = "postgres" ]
then
    echo "Waiting for postgres..."

    while ! nc -z $DB_HOST $DB_PORT; do
      sleep 0.1
    done

    echo "PostgreSQL started"
fi

exec "[email protected]"

Alternatively, instead of creating a new entrypoint file, you could alter the existing one like so:

#!/bin/sh

if [ "$DB_CONNECTION" = "postgres" ]
then
    echo "Waiting for postgres..."

    while ! nc -z $DB_HOST $DB_PORT; do
      sleep 0.1
    done

    echo "PostgreSQL started"
fi

if [ "$APP_ENV" = "local" ]
then
    echo "Refreshing the database..."
    craft migrate:refresh  # you may want to remove this
    echo "Applying migrations..."
    craft migrate
    echo "Tables created"
fi

exec "[email protected]"

To use this file, create a new Dockerfile called Dockerfile.prod for use with production builds:

###########
# BUILDER #
###########

# pull official base image
FROM python:3.8.0-alpine as builder

# set work directory
WORKDIR /usr/src/app

# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

# install system dependencies
RUN apk update && apk --no-cache add \
    libressl-dev libffi-dev gcc musl-dev python3-dev \
    postgresql-dev

# lint
RUN pip install --upgrade pip
RUN pip install flake8
COPY . /usr/src/app/
RUN flake8 --ignore=E501,F401 .

# install dependencies
COPY ./requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels -r requirements.txt
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels masonite-cli==2.2.2

#########
# FINAL #
#########

# pull official base image
FROM python:3.8.0-alpine

# create directory for the app user
RUN mkdir -p /home/app

# create the app user
RUN addgroup -S app && adduser -S app -G app

# create the appropriate directories
ENV HOME=/home/app
ENV APP_HOME=/home/app/web
RUN mkdir $APP_HOME
WORKDIR $APP_HOME

# install dependencies
RUN apk update && apk --no-cache add \
    libressl-dev libffi-dev gcc musl-dev python3-dev \
    postgresql-dev
COPY --from=builder /usr/src/app/wheels /wheels
COPY --from=builder /usr/src/app/requirements.txt .
RUN pip install --no-cache /wheels/*

# copy project
COPY . $APP_HOME
RUN chmod +x /home/app/web/entrypoint.prod.sh

# chown all the files to the app user
RUN chown -R app:app $APP_HOME

# change to the app user
USER app

# run entrypoint.prod.sh
ENTRYPOINT ["/home/app/web/entrypoint.prod.sh"]

Here, we used a Docker multi-stage build to reduce the final image size. Essentially, builder is a temporary image that's used for building the Python wheels. The wheels are then copied over to the final production image and the builder image is discarded.

You could take the multi-stage build approach a step further and use a single Dockerfile instead of creating two Dockerfiles. Think of the pros and cons of using this approach over two different files.

Did you notice that we created a non-root user? By default, Docker runs container processes as root inside of a container. This is a bad practice since attackers can gain root access to the Docker host if they manage to break out of the container. If you're root in the container, you'll be root on the host.

Update the web service within the docker-compose.prod.yml file to build with Dockerfile.prod:

web:
  build:
    context: ./web
    dockerfile: Dockerfile.prod
  command: gunicorn --bind 0.0.0.0:8000 wsgi:application
  ports:
    - 8000:8000
  env_file:
    - .env.prod
  depends_on:
    - db

Try it out:

$ docker-compose -f docker-compose.prod.yml down -v
$ docker-compose -f docker-compose.prod.yml up -d --build
$ docker-compose -f docker-compose.prod.yml exec web craft migrate

Nginx

Next, let's add Nginx into the mix to act as a reverse proxy for Gunicorn to handle client requests as well as serve up static files.

Add the service to docker-compose.prod.yml:

nginx:
  build: ./nginx
  ports:
    - 1337:80
  depends_on:
    - web

Then, in the local project root, create the following files and folders:

└── nginx
    ├── Dockerfile
    └── nginx.conf

Dockerfile:

FROM nginx:1.17.6-alpine

RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d

nginx.conf:

upstream hello_masonite {
    server web:8000;
}

server {

    listen 80;

    location / {
        proxy_pass http://hello_masonite;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
        proxy_redirect off;
    }

}

Review Understanding the Nginx Configuration File Structure and Configuration Contexts for more info on the Nginx configuration file.

Then, update the web service, in docker-compose.prod.yml, replacing ports with expose:

web:
  build:
    context: ./web
    dockerfile: Dockerfile.prod
  command: gunicorn --bind 0.0.0.0:8000 wsgi:application
  expose:
    - 8000
  env_file:
    - .env.prod
  depends_on:
    - db

Now, port 8000 is only exposed internally, to other Docker services. The port will no longer be published to the host machine.

For more on ports vs expose, review this Stack Overflow question.

Test it out again.

$ docker-compose -f docker-compose.prod.yml down -v
$ docker-compose -f docker-compose.prod.yml up -d --build
$ docker-compose -f docker-compose.prod.yml exec web craft migrate

Ensure the app is up and running at http://localhost:1337.

Your project structure should now look like:

├── .env.dev
├── .env.prod
├── .env.prod.db
├── .gitignore
├── docker-compose.prod.yml
├── docker-compose.yml
├── nginx
│   ├── Dockerfile
│   └── nginx.conf
└── web
    ├── .env.testing
    ├── Dockerfile
    ├── Dockerfile.prod
    ├── README.md
    ├── app
    │   ├── User.py
    │   ├── http
    │   │   ├── controllers
    │   │   │   └── WelcomeController.py
    │   │   └── middleware
    │   │       ├── AuthenticationMiddleware.py
    │   │       ├── CsrfMiddleware.py
    │   │       ├── LoadUserMiddleware.py
    │   │       └── VerifyEmailMiddleware.py
    │   └── providers
    │       └── .gitignore
    ├── bootstrap
    │   ├── cache
    │   │   └── .gitignore
    │   └── start.py
    ├── config
    │   ├── __init__.py
    │   ├── application.py
    │   ├── auth.py
    │   ├── broadcast.py
    │   ├── cache.py
    │   ├── database.py
    │   ├── factories.py
    │   ├── mail.py
    │   ├── middleware.py
    │   ├── packages.py
    │   ├── providers.py
    │   ├── queue.py
    │   ├── session.py
    │   └── storage.py
    ├── craft
    ├── databases
    │   ├── migrations
    │   │   ├── 2018_01_09_043202_create_users_table.py
    │   │   └── __init__.py
    │   └── seeds
    │       ├── __init__.py
    │       ├── database_seeder.py
    │       └── user_table_seeder.py
    ├── entrypoint.prod.sh
    ├── entrypoint.sh
    ├── requirements.txt
    ├── resources
    │   ├── __init__.py
    │   └── templates
    │       ├── __init__.py
    │       ├── base.html
    │       └── welcome.html
    ├── routes
    │   └── web.py
    ├── storage
    │   ├── compiled
    │   │   └── style.css
    │   ├── public
    │   │   ├── favicon.ico
    │   │   └── robots.txt
    │   ├── static
    │   │   ├── __init__.py
    │   │   └── sass
    │   │       └── style.scss
    │   └── uploads
    │       └── __init__.py
    ├── tests
    │   ├── database
    │   │   └── test_user.py
    │   └── unit
    │       └── test_works.py
    └── wsgi.py

Bring the containers down once done:

$ docker-compose -f docker-compose.prod.yml down -v

Since Gunicorn is an application server, it will not serve up static files. So, how should both static and media files be handled in this particular configuration?

Static Files

First, update the STATICFILES and SASSFILES config in web/config/storage.py:

STATICFILES = {
    # folder          # template alias
    'storage/static': 'static/',
    'storage/uploads': 'uploads/',
    'storage/public': '/',
}

SASSFILES = {
    'importFrom': [
        'storage/static'
    ],
    'includePaths': [
        'storage/static/sass'
    ],
    'compileTo': 'storage/static'
}

Essentially, all compiled assets will be stored in the "storage/static" directory and served up from the static URL. Feel free to delete the "web/storage/compiled" directory as well.

In order to enable asset compilation, add libsass to the requirements file:

gunicorn==20.0.4
libsass==0.19.4
masonite>=2.2,<2.3
psycopg2-binary==2.8.4

For more on asset complication, review Compiling Assets from the Masonite docs.

Next, to test a regular static asset, add a text file called hello.txt "web/storage/static":

hi!

Development

Update the Dockerfile to install the g++ compiler-drivers required for libsass:

# pull official base image
FROM python:3.8.0-alpine

# set work directory
WORKDIR /usr/src/app

# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

# install system dependencies
RUN apk update && apk --no-cache add \
    libressl-dev libffi-dev gcc musl-dev python3-dev \
    postgresql-dev \
    g++

# install dependencies
RUN pip install --upgrade pip
RUN pip install masonite-cli==2.2.2
COPY ./requirements.txt /usr/src/app/requirements.txt
RUN pip install -r requirements.txt

# copy project
COPY . /usr/src/app/

# run entrypoint.sh
ENTRYPOINT ["/usr/src/app/entrypoint.sh"]

To test, first re-build the images and spin up the new containers per usual. Once done, ensure the following static assets load correctly:

  1. http://localhost:8000/robots.txt (root static asset)
  2. http://localhost:8000/static/hello.txt (regular static asset)
  3. http://localhost:8000/static/style.css (compiled static asset)

Production

For production, add a volume to the web and nginx services in docker-compose.prod.yml so that each container will share the "storage" directory:

version: '3.7'

services:
  web:
    build:
      context: ./web
      dockerfile: Dockerfile.prod
    command: gunicorn --bind 0.0.0.0:8000 wsgi:application
    volumes:
      - storage_volume:/home/app/web/storage
    expose:
      - 8000
    env_file:
      - .env.prod
    depends_on:
      - db
  db:
    image: postgres:12.1-alpine
    volumes:
      - postgres_data:/var/lib/postgresql/data/
    env_file:
      - .env.prod.db
  nginx:
    build: ./nginx
    volumes:
      - storage_volume:/home/app/web/storage
    ports:
      - 1337:80
    depends_on:
      - web

volumes:
  postgres_data:
  storage_volume:

Next, update the Nginx configuration to route static file requests to the appropriate folder:

upstream hello_masonite {
    server web:8000;
}

server {

    listen 80;

    location /public/ {
        alias /home/app/web/storage/public/;
    }

    location /static/ {
        alias /home/app/web/storage/static/;
    }

    location / {
        proxy_pass http://hello_masonite;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
        proxy_redirect off;
    }

}

Update the production Dockerfile to install the g++ compiler-drivers:

###########
# BUILDER #
###########

# pull official base image
FROM python:3.8.0-alpine as builder

# set work directory
WORKDIR /usr/src/app

# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

# install system dependencies
RUN apk update && apk --no-cache add \
    libressl-dev libffi-dev gcc musl-dev python3-dev \
    postgresql-dev \
    g++

# lint
RUN pip install --upgrade pip
RUN pip install flake8
COPY . /usr/src/app/
RUN flake8 --ignore=E501,F401 .

# install dependencies
COPY ./requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels -r requirements.txt
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels masonite-cli==2.2.2

#########
# FINAL #
#########

# pull official base image
FROM python:3.8.0-alpine

# create directory for the app user
RUN mkdir -p /home/app

# create the app user
RUN addgroup -S app && adduser -S app -G app

# create the appropriate directories
ENV HOME=/home/app
ENV APP_HOME=/home/app/web
RUN mkdir $APP_HOME
WORKDIR $APP_HOME

# install dependencies
RUN apk update && apk --no-cache add \
    libressl-dev libffi-dev gcc musl-dev python3-dev \
    postgresql-dev
COPY --from=builder /usr/src/app/wheels /wheels
COPY --from=builder /usr/src/app/requirements.txt .
RUN pip install --no-cache /wheels/*

# copy project
COPY . $APP_HOME
RUN chmod +x /home/app/web/entrypoint.prod.sh

# chown all the files to the app user
RUN chown -R app:app $APP_HOME

# change to the app user
USER app

# run entrypoint.prod.sh
ENTRYPOINT ["/home/app/web/entrypoint.prod.sh"]

Spin down the development containers:

$ docker-compose down -v

Test:

$ docker-compose -f docker-compose.prod.yml up -d --build

Requests to http://localhost:1337/static/* and http://localhost:1337/public/* will be served from the "static" and "public" directories, respectively.

Again, ensure the following static assets are loaded correctly:

  1. http://localhost:1337/robots.txt
  2. http://localhost:1337/static/hello.txt
  3. http://localhost:1337/static/style.css

You can also verify in the logs -- via docker-compose -f docker-compose.prod.yml logs -f -- that requests to the static files are served up successfully via Nginx:

nginx_1  | 192.168.144.1 - - [04/Dec/2019:20:13:44 +0000] "GET /robots.txt HTTP/1.1" 200 0 "http://localhost:8001/blog/dockerizing-masonite-with-postgres-gunicorn-and-nginx/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36" "-"
nginx_1  | 192.168.144.1 - - [04/Dec/2019:20:13:53 +0000] "GET /static/hello.txt HTTP/1.1" 304 0 "http://localhost:8001/blog/dockerizing-masonite-with-postgres-gunicorn-and-nginx/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36" "-"
nginx_1  | 192.168.144.1 - - [04/Dec/2019:20:13:59 +0000] "GET /static/style.css HTTP/1.1" 304 0 "http://localhost:8001/blog/dockerizing-masonite-with-postgres-gunicorn-and-nginx/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36" "-"

Bring the containers once done:

$ docker-compose -f docker-compose.prod.yml down -v

Media Files

To test out the handling of user-uploaded media files, update the content block in the web/resources/templates/welcome.html template:

{% block content %}
<html>
  <body>
  <form action="/" method="POST" enctype="multipart/form-data">
    {{ csrf_field }}
    <input type="file" name="image_upload">
    <input type="submit" value="submit" />
  </form>
  {% if image_url %}
    <p>File uploaded at: <a href="{{ image_url }}">{{ image_url }}</a></p>
  {% endif %}
  </body>
</html>
{% endblock %}

Add a new method called upload to WelcomeController in web/app/http/controllers/WelcomeController.py:

def upload(self, upload: Upload, view: View, request: Request):
    filename = upload.driver('disk').store(request.input('image_upload'))
    return view.render('welcome', {'image_url': f'/uploads/{filename}'})

Don't forget the import:

from masonite import Upload

Next, to wire up the controller to a new route in web/routes/web.py:

"""Web Routes."""

from masonite.routes import Get, Post

ROUTES = [
    Get('/', '[email protected]').name('welcome'),
    Get('/sample', '[email protected]').name('welcome'),
    Post('/', '[email protected]'),
]

Development

Test:

$ docker-compose up -d --build

You should be able to upload an image at http://localhost:8000/, and then view the image at http://localhost:8000/uploads/IMAGE_FILE_NAME.

Production

For production, update the Nginx configuration to route media file requests to the "uploads" folder:

upstream hello_masonite {
    server web:8000;
}

server {

    listen 80;

    location /public/ {
        alias /home/app/web/storage/public/;
    }

    location /static/ {
        alias /home/app/web/storage/static/;
    }

    location /uploads/ {
        alias /home/app/web/storage/uploads/;
    }

    location / {
        proxy_pass http://hello_masonite;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
        proxy_redirect off;
    }

}

Re-build:

$ docker-compose down -v

$ docker-compose -f docker-compose.prod.yml up -d --build

Test it out one final time:

  1. Upload an image at http://localhost:1337.
  2. Then, view the image at http://localhost:1337/uploads/IMAGE_FILE_NAME.

Conclusion

In this tutorial, we walked through how to containerize a Masonite application with Postgres for development. We also created a production-ready Docker Compose file that adds Gunicorn and Nginx into the mix to handle static and media files. You can now test out a production setup locally.

In terms of actual deployment to a production environment, you'll probably want to use a:

  1. Fully managed database service -- like RDS or Cloud SQL -- rather than managing your own Postgres instance within a container.
  2. Non-root user for the db and nginx services

You can find the code in the masonite-on-docker repo.

Thanks for reading!

Featured Course

Learn Vue by Building and Deploying a CRUD App

This course is focused on teaching the fundamentals of Vue by building and testing a web application using Test-Driven Development (TDD).

Featured Course

Learn Vue by Building and Deploying a CRUD App

This course is focused on teaching the fundamentals of Vue by building and testing a web application using Test-Driven Development (TDD).