Since January, I’ve worked full-time in Django and Wagtail development. In my last post, I shared my Dockerfile for deploying such projects. That Dockerfile includes a multi-stage build to reduce the final image size and to improve security. This directly builds on that Dockerfile.

The Dockerfile

# Python build stage for dependencies via Poetry
FROM python:3.13-slim AS pythonbuilder

RUN pip install poetry

ENV POETRY_NO_INTERACTION=1 \
    POETRY_VIRTUALENVS_IN_PROJECT=1 \
    POETRY_VIRTUALENVS_CREATE=1 \
    POETRY_CACHE_DIR=/tmp/poetry_cache

WORKDIR /app

COPY pyproject.toml poetry.lock ./
RUN touch README.md

RUN poetry install --without dev --no-root && rm -rf $POETRY_CACHE_DIR

# Node build stage for static assets
FROM node:20-slim AS nodebuilder

WORKDIR /app

COPY . .

RUN npm install

RUN npm run build

# Final runtime stage
FROM python:3.13-slim AS runtime

ENV VIRTUAL_ENV=/app/.venv \
    PATH="/app/.venv/bin:$PATH"

# Install CRON
RUN apt-get update && \
	apt-get install -y cron && \
	rm -rf /var/lib/apt/lists/*

COPY --from=pythonbuilder ${VIRTUAL_ENV} ${VIRTUAL_ENV}

WORKDIR /app

COPY . .

COPY --from=nodebuilder /app/foo/static ./foo/static

COPY crontab.txt /etc/cron.d/django_jobs
RUN chmod 0644 /etc/cron.d/django_jobs && \
    crontab /etc/cron.d/django_jobs

RUN python manage.py collectstatic --noinput

EXPOSE 8000

ENTRYPOINT [ "sh", "entrypoint.sh" ]

The important part here is lines 48-50. We’re copying the file crontab.txt from the current directory into etc/cron.d/django_jobs. We’re then modifying the permissions of the file and running crontab. If you’re intimately familiar with CRON and aren’t quite sure about what this does, you can research it online. Otherwise, this doesn’t ever need to be modified, so you can use this as-is.

The only other thing that must be made if you don’t have one already is the file entrypoint.sh. This is the script that the Dockerfile will use as the entry point to run the program within the container. That is where we’ll create the CRON runner itself.

crontab.txt

SHELL=/bin/bash

# https://crontab.guru/

# To verify CRON is running while testing
# * * * * * (cd /app && /app/.venv/bin/python ./manage.py check) >> /var/log/cron.log 2>&1

# Sync Youtube at 04:00
0 4 * * * (cd /app && /app/.venv/bin/python ./manage.py import) >> /var/log/cron.log 2>&1
0 16 * * * (cd /app && /app/.venv/bin/python ./manage.py import) >> /var/log/cron.log 2>&1

This is the crontab.txt file I’m using for a project which imports data from the YouTube API twice a day via a custom management command within Django. Within this file, I’ve noted the crontab.guru website, which I always use for creating my CRON strings.

On line 6, I’ve commented out a command that runs python ./manage.py check. This is a built-in management command that outputs if everything seems to be working correctly with the Django environment. The CRON string is * * * * *, which means it’ll run once per minute. I like to uncomment this and build the image if I’m making changes to the Dockerfile or environment, and want to validate that the CRON scheduler is working correctly and that the Django environment is loading.

You’ll notice that all of my commands are wrapped in brackets and are comprised of two commands. One to enter the correct directory, and then another to run the command itself. In my experience, this is required to ensure that Python and its dependencies are all accessible. You’ll also see that instead of just calling python I’m calling /app/.venv/bin/python. This is the absolute path of the Python installation, which has the dependencies installed via Poetry. This is required with my Dockerfile, due to the way packages are installed and copied from a dedicated build stage. If you use the Dockerfile I’ve provided, then you’ll likely need to do this too.

entrypoint.sh

#!/bin/sh

# Set container ENV vars to be accessible by CRON
printenv > /etc/environment

# Create CRON's log file so it's immediately available
touch /var/log/cron.log

# Start CRON runner in the background
cron &

# Tail the cron log file with a prefix, in the background
tail -f /var/log/cron.log &

# Apply database migrations
python ./manage.py migrate --no-input

# Start Gunicorn server
exec gunicorn app.wsgi --bind 0.0.0.0:8000 --access-logfile -

Finally, this is the entrypoint.sh file that the Dockerfile uses to start the program within the container. I’ve already detailed this clearly by adding comments to the file itself, but I’ll still go into a little extra detail for those interested in how it works.

The printenv command takes the environment variables which you set while creating the Docker container and makes them available within the terminal session created by the script. This means that Python can access these via the os.getenv function.

touch /var/log/cron.log is required because we’re using tail to output the content to the container logs. CRON will automatically create this file if it’s missing when it has some output. But because CRON runs on a schedule, it could be hours before the file gets created. Because tail requires the file to exist, manually creating it first is required.

cron & runs CRON in the background. This means that it’ll keep running so long as the terminal session continues. The & is the secret sauce to making this approach work.

Finally, the last two commands are specific to Django projects. You may change these to suit your needs, but leave everything up to line 13 unmodified.

Help & Troubleshooting

How do I test the CRON runner is working?

Assuming your project is running like normal otherwise, you can test that the CRON schedule is running by creating a CRON command and setting it to run as * * * * *. This means it’ll run once per minute at the start of the minute. As demonstrated in my crontab.txt file above, I like using the check command within Django for Django-based projects. This is useful because it allows me to see that the dependencies and database connection are all working as expected. For a simpler test, you could just set it to echo "Foo bar", or something similar. This should just output Foo bar to the console. Build your image, create your container, and watch the logs for the expected output at the top of the minute.

My CRON schedule isn’t changing when I update the file.

Because the crontab.txt file is built into the image, if you make any changes to the file then you must first rebuild the container for the changes to take effect.

I’m getting errors for Python modules not existing.

This happens when the Python command running within the context of CRON doesn’t have access to the packages/modules installed via Pip/Poetry/Pipenv/etc.

In my solution above, I have the Python commands within the crontab.txt file split into two. The first part cds into the correct directory, and the second part runs Python itself. This may change for you if you’re using a different method for managing packages, so you’ll need to locate where Python is installed and use it accordingly.

Leave a Reply

Your email address will not be published. Required fields are marked *