How to Run a Python Flask App in Docker with Gunicorn, Nginx, Redis, Celery and Crontab
Tags: docker, python, flask, cron
Published: 2023-08-10 14:09:02

If you have built a Python Flask application that has need for Redis and cron jobs and you're looking to host your application using Docker, this post will provide you with how to set up your app to run smoothly using Nginx as reverse webserver proxy and Gunicorn as app server.

_This post assumes you know how to build applications using Python Flask. Also, for this post, I assumed using a remote database server (MySQL)_

You can check my previous post on how [Build a User Authentication API using Python Flask and MySQL](https://dev.to/andychukse/building-a-user-authentication-api-using-python-flask-and-mysql-4lki)

**Challenges with docker**
There can only be one CMD instruction in a Dockerfile. Considering our application uses Celery and Redis to handle queue and also requires running cron jobs. Running a background process to keep running your jobs in a single Docker container can be tricky. 

**You can use an `entrypoint.sh` script**

```
FROM python:3.12-rc-alpine
COPY app_process app_process
COPY bin/crontab /etc/cron.d/crontab 
RUN chmod +x /etc/cron.d/crontab
RUN crontab /etc/cron.d/crontab
COPY start.sh start.sh
CMD /start.sh
```

Start.sh  script could be 

```
#!/bin/bash

# turn on bash's job control
set -m

# Start the primary process and put it in the background
gunicorn --bind 0.0.0.0:5000 wsgi:app --log-level=debug --workers=2 &

# cron
cron -f &

#celery
celery -A myapp.celery worker --loglevel=INFO

# now we bring the primary process back into the foreground
# and leave it there
fg %1

```


**You can chain multiple commands to start all services in a single**
```

CMD gunicorn --bind 0.0.0.0:5000 wsgi:app --log-level=debug --workers=2 & cron -f & celery -A myapp.celery worker --loglevel=INFO
```

**You can also use `supervisord` to manage the processes.**
```
# syntax=docker/dockerfile:1
FROM python:3.12-rc-alpine
RUN apt-get update && apt-get install -y supervisor
RUN mkdir -p /var/log/supervisor
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY app_process app_process
COPY bin/crontab /etc/cron.d/crontab 
RUN chmod +x /etc/cron.d/crontab
RUN crontab /etc/cron.d/crontab
CMD ["/usr/bin/supervisord"]
```

Your `supervisord` config could be something like

```
[supervisord]
nodaemon=true
user=root

[program:celeryworker]
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
command=celery -A myapp.celery worker --loglevel=INFO
autostart=true
autorestart=true

[program:myapp_gunicorn]
command=gunicorn --bind 0.0.0.0:5000 wsgi:app --log-level=debug --workers=2
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

[program:cron]
command = cron -f -L 15
autostart=true
autorestart=true
stdout_logfile=/var/log/supervisor/cron.log
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
```

The issue with any of the above approach is you will be responsible for monitoring them if any service fails and tries to recover. For example crontab could stop running while the main app is working, you have to handle how to recover the crontab without restarting the whole container.

It’s best practice to separate areas of concern by using one service per container.


## Using Multiple Containers.
You can use multiple containers to run the different services. In this solution I used 
- one container for the Flask App,
- one container for the redis service
- one container for the cronjob and Celery (Queue service) using `Supervisord` to manage celery.

Note: _You decide to further move the celery (queue service) into a separate container if you want to._


**The DockerFile for Flask App**

```
FROM python:3.11.4-slim-bullseye

# set work directory
WORKDIR /app

# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

ARG UID=1000
ARG GID=1000

RUN apt-get update \
  && apt-get install -y --no-install-recommends build-essential default-libmysqlclient-dev default-mysql-client curl libpq-dev pkg-config \
  && rm -rf /var/lib/apt/lists/* /usr/share/doc /usr/share/man \
  && apt-get clean

# RUN useradd -m python
# RUN chown -R python:python /app

# USER python


# If you have a requirement.txt file
COPY requirements/main.txt requirements/main.txt

# install dependencies
RUN pip install --upgrade pip
RUN pip install -r requirements/main.txt

COPY . /app/

RUN pip install -e .


CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--worker-tmp-dir", "/dev/shm", "--workers", "2", "--worker-class", "gevent", "--worker-connections", "1000", "wsgi:app", "--log-level", "debug"]
```


**Docker File for Crontab and Celery**

```
FROM python:3.11.4-slim-bullseye

# set work directory
WORKDIR /cronapp/


# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

ARG UID=1000
ARG GID=1000

RUN apt-get update \
  && apt-get install -y --no-install-recommends supervisor build-essential default-libmysqlclient-dev default-mysql-client curl cron libpq-dev pkg-config \
  && rm -rf /var/lib/apt/lists/* /usr/share/doc /usr/share/man \
  && apt-get clean

# RUN useradd -m python
# RUN chown -R python:python /app
# USER python


COPY requirements/main.txt requirements/main.txt

# install dependencies
RUN pip install --upgrade pip
RUN pip install -r requirements/main.txt

COPY . /cronapp/

RUN pip install -e .

# Setup cronjob
RUN touch /var/log/cron.log 

# Copying the crontab file 
COPY cron/bin/crontab /etc/cron.d/crontab 
RUN chmod +x /etc/cron.d/crontab


# run the crontab file
RUN crontab /etc/cron.d/crontab

RUN mkdir -p /var/log/supervisor

COPY services/cron/bin/supervisord.conf /etc/supervisor/conf.d/supervisord.conf

# CMD ["/usr/bin/supervisord", "-n"]

CMD cron -f & /usr/bin/supervisord -n
```

**The Supervisord config**

```
[supervisord]
nodaemon=true
user=root

[program:celeryworker]
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
command=celery -A myapp.celery worker --loglevel=INFO
autostart=true
autorestart=true

[program:myapp_gunicorn]
command=gunicorn --bind 0.0.0.0:5000 wsgi:app --log-level=debug --workers=4
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
```

**Sample crontab**

```
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

# run notify users every day at 1:05AM
5 1 * * *  flask --app myapp notify-users >> /var/log/cron.log 2>&1
```

For this approach to work, your app has to be structured to use the [package pattern](https://flask.palletsprojects.com/en/2.3.x/patterns/packages/). (This is same in the [previous post](https://dev.to/andychukse/building-a-user-authentication-api-using-python-flask-and-mysql-4lki)).

This way, you can run a function from the command line on your app like below:

`flask --app myapp notify-users`

Remember to specify a function to run on command line by using the `@app.cli.command` to create custom commands

Example:
```
from myapp.users import users
from myapp import app
from myapp.models.user import User
from myapp.queue.sendmail import send_email_to_user

@app.cli.command('notify-users')
def notify_users():
    offset = 0
    limit = 100
    users = User.filter(User.is_verified == 1).order_by(User.created_at.desc()).limit(limit).offset(offset)

    for user in users:
        send_email_to_user(user)
   
```

**Nginx Dockerfile**

```
FROM nginx:1.23-alpine

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

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


CMD ["nginx", "-g", "daemon off;"]
```

You can now use docker-compose to manage all containers

**Sample `docker-compose.yml`**

```
version: "3.8"

services:
  backend:
    container_name: "app"
    build:
      context: .
      args:
        - "UID=-1000"
        - "GID=-1000"
        - "FLASK_DEBUG=false"
    volumes:
      - .:/app
    ports:
      - "5000:5000"
    env_file:
      - ".env"
    restart: "-unless-stopped"
    stop_grace_period: "2s"
    tty: true
    deploy:
      resources:
        limits:
          cpus: "-0"
          memory: "-0"
    depends_on:
      - "redis"
    profiles: ["myapp"]

  cron:
    container_name: "cron"
    build:
      context: .
      dockerfile: ./services/cron/Dockerfile
      args:
        - "UID=-1000"
        - "GID=-1000"
    env_file:
      - ".env"
    restart: "-unless-stopped"
    stop_grace_period: "2s"
    tty: true
    deploy:
      resources:
        limits:
          cpus: "-0"
          memory: "-0"
    depends_on:
      - "redis"
    volumes:
      - .:/cronapp/
    profiles: ["myapp"]

  redis:
    deploy:
      resources:
        limits:
          cpus: "-0"
          memory: "-0"
    image: "redis:7.0.5-bullseye"
    restart: "-unless-stopped"
    stop_grace_period: "3s"
    command: "redis-server --bind redis --maxmemory 256mb --maxmemory-policy allkeys-lru --appendonly yes"
    volumes:
      - "./redis:/data"
    profiles: ["redis"]

  nginx:
    container_name: "nginx"
    build:
      context: ./services/nginx
    restart: "-unless-stopped"
    stop_grace_period: "2s"
    tty: true
    deploy:
      resources:
        limits:
          cpus: "-0"
          memory: "-0"
    ports:
      - "80:80"
    depends_on:
      - "backend"
    volumes:
      - .:/nginx/
    profiles: ["nginx"]

```

You can now start your application and all services by running

```
docker compose up --detach --build app redis cron nginx
```