Back to home

Dockerise Python with a background CRON runner

Table of Contents

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 mycrontab.txtfile above, I like using thecheckcommand 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 toecho "Foo bar", or something similar. This should just outputFoo barto 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 thecrontab.txtfile 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 thecrontab.txtfile split into two. The first partcds 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.

Related blogs

  • django-testing-patterns
    Useful Django unit test patterns

    This is a collection of some common unit test patterns I find myself re-implementing across various projects. To save myself (and maybe some others) time in the future, I've decided to collect them all in one place.

    20 March 2026
  • writing-wagtail-streamfield-content-migrations
    Writing Wagtail StreamField content migrations

    When you drastically change one of your StreamFields and generate a migration for your new structure, the migration won't actually update what's already in your database. It'll describe the new, intended structure behind the fields, but the JSON data itself will remain exactly as it is.

    18 February 2026
  • directly-filtering-wagtail-pages
    Directly filtering Wagtail parent/child pages

    In Wagtail, pages don't have a typical, direct relationship like you'd expect in Django. Instead of using foreign keys to link them, each page has a `path` and a `depth` attribute...

    24 November 2025