Django, Postgres, Gunicorn, Nginx with Docker (Part-2)

Django, Postgres, Gunicorn, Nginx with Docker (Part-2)

Table of contents

Continued... part-1

Gunicorn

Now, install Gunicorn. It's production grade WSGI server.

For now, since we want to use default django's built-in server, create production compose file:

version: '3.5'

services:
    app:
        build:
            context: .
        command: gunicorn personal.wsgi:application --bind 0.0.0.0:8000
        volumes:
            - static_data:/vol/static
        ports:
            - "8000:8000"
        restart: always
        env_file:
            - .env.prod
        depends_on:
            - app-db

    app-db:
        image: postgres:12-alpine
        ports:
            - "5432:5432"
        restart: always
        volumes:
            - postgres_data:/var/lib/postgresql/data:rw
        env_file:
            - .env.prod
volumes:
    static_data:
    postgres_data:

Here, we're using commang gunicorn instead of django server command. we can static_data volume as it's not needed in production. For now, let's create .env.prod file for environemental variables:

DEBUG=0
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
DB_ENGINE=django.db.backends.postgresql_psycopg2
POSTGRES_HOST_AUTH_METHOD=trust
POSTGRES_USER=sagar
POSTGRES_PASSWORD=********
POSTGRES_DB=portfolio_db_prod
POSTGRES_HOST=app-db
POSTGRES_PORT=5432

Add both files to .gitignore file if you want to keep them out from version control. Now, down all containers with -v flag, -v flag removes associated volumes:

$ docker-compose down -v

Then, re-build images and run the containers:

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

Run with -d flag if you wan't to run services in background. If any error when running, check errors with command:

$ docker-compose -f docker-compose.prod.yml logs -f

Wow, let's create production Dockerfile as Dockerfile.prod with production entrypoint.prod.sh file inside scripts directory of the root. entrypoint.prod.sh script file:

#!/bin/sh

if [ "$DATABASE" = "postgres" ]
then
    echo "Waiting for postgres..."
    while ! nc -z "$POSTGRES_HOST" "$POSTGRES_PORT"; do
      sleep 0.1
    done
    echo "PostgreSQL started"
fi

exec "$@"

Dockerfile.prod file with scripts permission:

FROM python:3.8.9-alpine as builder

ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONNUNBUFFERED 1

RUN apk update
RUN apk add postgresql-dev gcc python3-dev musl-dev libc-dev linux-headers

RUN apk add jpeg-dev zlib-dev libjpeg

RUN pip install --upgrade pip
COPY ./requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /wheels -r requirements.txt

#### FINAL ####

FROM python:3.8.9-alpine

RUN mkdir /app
COPY . /app
WORKDIR /app

RUN apk update && apk add libpq
COPY --from=builder ./wheels /wheels
COPY --from=builder ./requirements.txt .
RUN pip install --no-cache /wheels/*
#RUN pip install -r requirements.txt

COPY ./scripts /scripts
RUN chmod +x /scripts/

RUN mkdir -p /vol/media
RUN mkdir -p /vol/static
RUN chmod -R 755 /vol

ENTRYPOINT ["/scripts/entrypoint.prod.sh"]

Here we used a multi-stage build as it reduces final image size. 'builder' is a temporary image that's used just to build python wheels with dependencies, that are copied to the Final stage. we can create a non-root user. Because that is the best practice to be safe from attackers. Now, update the compose production file with docker production file:

version: '3.5'

services:
    app:
        build:
            context: .
            dockerfile: Dockerfile.prod
        command: gunicorn personal.wsgi:application --bind 0.0.0.0:8000
        volumes:
            - static_data:/vol/static
        expose:
            - "8000:8000"
        restart: always
        env_file:
            - .env.prod
        depends_on:
            - app-db

    app-db:
        image: postgres:12-alpine
        ports:
            - "5432:5432"
        restart: always
        volumes:
            - postgres_data:/var/lib/postgresql/data:rw
        env_file:
            - .env.prod
volumes:
    static_data:
    postgres_data:

Rebuild, and run:

$ 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 app python manage.py migrate --noinput

Ngnix

Nginx really gives you the ultimate power. You can do whatever you want. Let's add Nginx to act as a reverse proxy for Gunicorn. Add service on docker-compose file (production):

version: '3.5'

services:
    app:
        build:
            context: .
            dockerfile: Dockerfile.prod
        command: gunicorn personal.wsgi:application --bind 0.0.0.0:8000
        volumes:
            - static_data:/vol/static
            - media_data: /vol/media
        ports:
            - "8000:8000"
        restart: always
        env_file:
            - .env.prod
        depends_on:
            - app-db

    app-db:
        image: postgres:12-alpine
        ports:
            - "5432:5432"
        restart: always
        volumes:
            - postgres_data:/var/lib/postgresql/data:rw
        env_file:
            - .env.prod

    proxy:
        build: ./proxy
        volumes:
            - static_data:/vol/static
            - media_data:/vol/media
        restart: always
        ports:
            - "8008:80"
        depends_on:
            - app
volumes:
    static_data:
    media_data:
    postgres_data:

Inside root directory create a proxy(whatever you want to name it) directory and add a configuration file, in my case I have created default.conf file as:

server {
    listen 80;

    location /static {
        alias /vol/static;
    }

    location /media {
        alias /vol/media;
    }

    location / {
        uwsgi_pass app:8000;
        include /etc/nginx/uwsgi_params;
    }
}

And create uwsgi_params file for this.

uwsgi_param QUERY_STRING $query_string;
uwsgi_param REQUEST_METHOD $request_method;
uwsgi_param CONTENT_TYPE $content_type;
uwsgi_param CONTENT_LENGTH $content_length;
uwsgi_param REQUEST_URI $request_uri;
uwsgi_param PATH_INFO $document_uri;
uwsgi_param DOCUMENT_ROOT $document_root;
uwsgi_param SERVER_PROTOCOL $server_protocol;
uwsgi_param REMOTE_ADDR $remote_addr;
uwsgi_param REMOTE_PORT $remote_port;
uwsgi_param SERVER_ADDR $server_addr;
uwsgi_param SERVER_PORT $server_port;
uwsgi_param SERVER_NAME $server_name;

Also, add a Dockerfile inside the proxy directory for Nginx configuration:

FROM nginxinc/nginx-unprivileged:1-alpine

COPY ./default.conf /etc/nginx/conf.d/default.conf
COPY uwsgi_params /etc/nginx/uwsgi_params

You can use expose instead of ports in docker-compose.prod.yml file for app service:

app:
        build:
            context: .
            dockerfile: Dockerfile.prod
        command: gunicorn personal.wsgi:application --bind 0.0.0.0:8000
        volumes:
            - static_data:/vol/static
            - media_data:/vol/media
        expose:
            - 8000
        restart: always
        env_file:
            - .env.prod
        depends_on:
            - app-db

Again, re-build run and try:

$ 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 python manage.py migrate --noinput
$ docker-compose -f docker-compose.prod.yml exec web python manage.py collectstatic --no-input --clear

Ensure the app is running in localhost:8008.

That's it.

Thank You!

Previous: Part-1