PART 1: Deployment Choices
PART 2: Setting Up Django Cheat Sheet
PART 3: Deployment using Ansible and Pyenv 
PART 4: Alternative – Deployment of a Django app on Caddy server using Ansible & Docker (this post)

Introduction

This tutorial will demonstrate the steps to reliably and repeatably deploy a Django app in production. By the end, you’ll have a production-ready Django stack running behind Caddy with HTTPS, deployed to a remote server using a single Ansible command.  There are several improvements and simplifications in this method compared to part 3 in the series, which include:

  • Docker-first deployment – Develop and run the same containerized stack locally and in production – faster to setup & debug.

  • Gunicorn instead of uWSGI – Simple, config-free, and just works.

  • Caddy instead of Nginx – Minimal configuration, automatic HTTPS with Let’s Encrypt (no messing about trying to automate this with Ansible).

  • Single Ansible playbook – All deployment steps in one file—no juggling roles and templates.

  • uv instead of pyenv – Faster Python installs without extra venv overhead.

  • Hardcoded values for simplicity – Fewer variables to manage; just edit domain, repo, and project names directly.

The software stack will look something like this in diagrammatic form:

Step 1. Prepare your Django app (or other python web framework)

This tutorial assumes:

  • You’re on macOS (Linux/Windows users can still follow along with small adjustments)

  • You’re comfortable running basic Docker, Git, and Python commands.

  • You can set up your own SSH key access to a VPS.
  • There are three ways to use this tutorial:

    • 1. Follow along manually creating each file, starting with following instructions PART 2: Setting Up Django Cheat Sheet – and see notes below about uv
    • 2. Use an existing Django app and start from step 2 below

    • 3. Clone my example repo, and find and replace ‘projectname’ with your project name, and ‘appname’ with your app name

    • Note this will work with other python web frameworks such as FastAPI or Flask with some minor changes.

In general I’m using uv for most of my newer projects, however I don’t think there is a big enough benefit of using uv inside docker as there is already an isolated environment. However, if you are following my Django Cheat Sheet to create the Django boilerplate you can use uv in the following way:

uv init # start uv project
uv add django gunicorn celery python-dotenv # add packages to uv env
uv export --format requirements-txt -o requirements.txt # export packages that can be used by pip in docker
uv run --with-requirements requirements.txt python django-admin startproject djangodockerdemo # run locally to create boilerplate outside of docker
uv run --with-requirements requirements.txt python manage.py startapp appname
uv run --with-requirements requirements.txt python manage.py runserver # test locally outside of docker
requirements.txt
# This file was autogenerated by uv via the following command:
#    uv export --format requirements-txt -o requirements.txt
amqp==5.3.1 \
    --hash=sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2 \
    --hash=sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432
    # via kombu
asgiref==3.11.1 \
    --hash=sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce \
    --hash=sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133
    # via django
billiard==4.2.4 \
    --hash=sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5 \
    --hash=sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f
    # via celery
celery==5.6.3 \
    --hash=sha256:0808f42f80909c4d5833202360ffafb2a4f83f4d8e23e1285d926610e9a7afa6 \
    --hash=sha256:177006bd2054b882e9f01be59abd8529e88879ef50d7918a7050c5a9f4e12912
    # via djangodockerdemo
click==8.3.3 \
    --hash=sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2 \
    --hash=sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613
    # via
    #   celery
    #   click-didyoumean
    #   click-plugins
    #   click-repl
click-didyoumean==0.3.1 \
    --hash=sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463 \
    --hash=sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c
    # via celery
click-plugins==1.1.1.2 \
    --hash=sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6 \
    --hash=sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261
    # via celery
click-repl==0.3.0 \
    --hash=sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9 \
    --hash=sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812
    # via celery
colorama==0.4.6 ; sys_platform == 'win32' \
    --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
    --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
    # via click
django==6.0.4 \
    --hash=sha256:14359c809fc16e8f81fd2b59d7d348e4d2d799da6840b10522b6edf7b8afc1da \
    --hash=sha256:8cfa2572b3f2768b2e84983cf3c4811877a01edb64e817986ec5d60751c113ac
    # via djangodockerdemo
gunicorn==25.3.0 \
    --hash=sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660 \
    --hash=sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889
    # via djangodockerdemo
kombu==5.6.2 \
    --hash=sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55 \
    --hash=sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93
    # via celery
packaging==26.2 \
    --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
    --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
    # via
    #   gunicorn
    #   kombu
prompt-toolkit==3.0.52 \
    --hash=sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855 \
    --hash=sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955
    # via click-repl
python-dateutil==2.9.0.post0 \
    --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
    --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
    # via celery
python-dotenv==1.2.2 \
    --hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \
    --hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3
    # via djangodockerdemo
six==1.17.0 \
    --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
    --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
    # via python-dateutil
sqlparse==0.5.5 \
    --hash=sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba \
    --hash=sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e
    # via django
tzdata==2026.2 \
    --hash=sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10 \
    --hash=sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7
    # via
    #   django
    #   kombu
    #   tzlocal
tzlocal==5.3.1 \
    --hash=sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd \
    --hash=sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d
    # via celery
vine==5.1.0 \
    --hash=sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc \
    --hash=sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0
    # via
    #   amqp
    #   celery
    #   kombu
wcwidth==0.7.0 \
    --hash=sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2 \
    --hash=sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0
    # via prompt-toolkit
Source: GitHub blob

 

Step 2. Containerize with Docker & Docker Compose

Docker files are used to create an isolated container environment, and since several services are required for django project,  docker compose can be used to run multiple independent containers but defined in one place. If you haven’t used it before, download and install the Docker engine for your local development machine from: https://www.docker.com/ The containers for this example project seen in docker-compose.yml are:

  • Django python app – using a simple dockerfile with the latest version of python, and installs any system dependencies  and python packages listed in requirements.txt. Docker compose starts this container with a command to run Django via gunicorn (Gunicorn is a multi threaded server than sits between python and the web server to pass web request data back and forth)
  • Celery python container for long running tasks (outside of the web request cycle so) – uses the same dockerfile as for Django, but the entry command starts the celery worker rather than the Django app
  • RabbitMQ message queue for handling tasks for celery – use the pre-built container image and the standard port
  • Caddy server- use the pre-built container image.

The ‘volumes‘ in docker-compose mount specific folders or files from the host into the container. In this example:

  • Django and container, just mount the code repo itself.
  • Caddy container:
    • Caddyfile of the repo (the Caddy configuration file)
    • static folder of the repo, so Caddy can serve static files directly rather than via Django
    • caddy_data –  Docker automatically creates a named folders on the host machine – caddy’s data folder needs to be outside of the container since this is where it will store the Let’s Encrypt SSL certificates, which are requested automatically but with a limit of X per week, so this needs to be persisted across container restarts!
  • Rabbitmq container
restart: unless-stopped makes sure that the containers restart automatically if they crash, and networks: – webnet connects the containers together over a Docker network.

 

docker-compose.yml
services:
  django:
    build: .
    volumes:
    - .:/djangodockerdemo
    working_dir: /djangodockerdemo
    command: gunicorn projectname.wsgi:application --bind 0.0.0.0:8000 # not asgi not async
    restart: unless-stopped
    networks:
      - webnet
        
  celery:
    build: .
    command: celery -A projectname worker --loglevel=info
    volumes:
      - .:/djangodockerdemo
    depends_on:
      - rabbitmq
    restart: unless-stopped
    networks:
      - webnet

  rabbitmq:
    image: rabbitmq:3-management
    ports:
      - "5672:5672"
      - "15672:15672"
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq
    restart: unless-stopped
    networks:
      - webnet

  caddy:
    image: caddy:latest
    ports:
      - "80:80"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./static:/static
      - caddy_data:/data
    depends_on:
      - django
    restart: unless-stopped
    networks:
      - webnet

networks:
  webnet:

volumes:
  rabbitmq_data:
  caddy_data:
  caddy_config:
Source: GitHub blob
dockerfile
FROM python:3.14.4-bookworm

RUN apt-get update && \
    apt-get install -y libgdal-dev gcc build-essential make ffmpeg nano && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

WORKDIR /djangodockerdemo
COPY requirements.txt /djangodockerdemo/requirements.txt
RUN pip3 install -r /djangodockerdemo/requirements.txt
Source: GitHub blob

 

Step 3. Configure Caddy Server

Caddy server setup is straightforward for a single Django project with docker – just a single ‘Caddyfile’ for configuration with only the requirement to specify the domain and the link to Django:

example.com {
    reverse_proxy django:8000
}

For production, there’s a few other things that can be added:

  • serving static files directly
  • gzip compression
  • security hardening
  • :80 is localhost on port 80 for development – this needs changing to a domain name for production
Caddyfile
:80 {
    reverse_proxy django:8000

    log {
        output stdout
        format json
    }

    encode gzip

    handle_path /static/* {
        root * /static
        file_server
    }

    header {
        Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "no-referrer"
    }
}
Source: GitHub blob

 

Step 4. Automate Deployment with Ansible

The setup of the services, python packages is already done with docker, however, a short Ansible playbook is still required to deploy this to a remote virtual server. The playbook does the following steps:

  • Installs docker
  • Sets the firewall using UFW
  • Copies the git repo from your local machine to the server
    • NOTE 1: this includes the sqlite database – this needs to be excluded for production and a backup system / different database put in place
    • NOTE 2: the git repo includes .env as an example – this should be .gitignored as you will need to add you passwords and API keys in here
  • Starts the docker stack
deploy.yml
---
- hosts: all
  remote_user: root
  become: yes
  vars:
    PROJECT_ROOT: /app
    PROJECT_NAME: djangodockerdemo
    DOMAINS: example.com,www.example.com

  tasks:

    - name: Add Docker GPG key
      apt_key:
        url: https://download.docker.com/linux/ubuntu/gpg
        state: present

    - name: Add Docker repository
      apt_repository:
        repo: "deb [arch=arm64] https://download.docker.com/linux/ubuntu focal stable"
        state: present

    - name: Install required system packages
      apt:
        name: "{{ item }}"
        state: latest
        update_cache: no
      loop:
        - apt-transport-https
        - ca-certificates
        - curl
        - software-properties-common
        - docker-compose
        - docker-ce

    - name: Set default firewall policy to deny all
      become: True
      ufw: direction=incoming policy=deny
      tags: firewall

    - name: enable SSH in firewall
      ufw: rule=allow port=22
      become: true

    - name: enable HTTP connections for web server
      ufw: rule=allow port=80
      become: true

    - name: enable HTTPS connections for web server
      ufw: rule=allow port=443
      become: true
      
    - name: enable firewall
      ufw: state=enabled
      become: true

    - name: Create /app directory
      file:
        path: "{{ PROJECT_ROOT }}"
        state: directory

    - name: Copy all project files (note this will obliterate databases)
      synchronize:
        src: .
        dest: "{{ PROJECT_ROOT }}/"
        delete: yes
        recursive: yes

    - name: Set DEBUG to False in .env
      lineinfile:
        path: "{{ PROJECT_ROOT }}/{{ PROJECT_NAME }}/.env"
        regexp: '^DEBUG='
        line: 'DEBUG=False'
        state: present

    - name: Start Docker daemon
      service:
        name: docker
        state: started
        enabled: yes

    - name: Docker Compose Down (needs to restart!)
      command: docker compose down
      args:
        chdir: "{{ PROJECT_ROOT }}/{{ PROJECT_NAME }}/"
    

    - name: Start Docker Compose services
      command: docker compose -f docker-compose.yml up -d --build
      args:
        chdir: "{{ PROJECT_ROOT }}/{{ PROJECT_NAME }}/"
Source: GitHub blob

 

Step 5. Test Locally & Debug

A) Clone the repo from github:

git clone https://github.com/andy-pi/djangodockerdemo.git

B) Install docker desktop (download from https://www.docker.com/) and uv for python (https://docs.astral.sh/uv/getting-started/installation/)

C) Build the docker containers and run in the background (remove the -d option to see the logs, use Ctrl-C to exit)

docker compose up -d --build

D) List the running containers & their names

docker ps

E) Enter the running docker container (if you need to do debugging / testing)

docker exec -it  /bin/bash

F) To stop the running docker stack:

docker compose down

G) View in your browser at:

localhost

 

Step 6. Go Live in Production

A) Setup a VPS running Ubuntu (with your SSH key)

B) Point your domain DNS to the Ubuntu server IP address

C) Add your domain name in the following places:
– deploy.yml – domains section at the top (replace example.com)
– Caddyfile – replace :80 with the domain
– projectname/settings.py – add you domain inside the ALLOWED_HOSTS list

D) Add the correct IP address to the command below, and run it to deploy to the server and start the stack.

uv run --with ansible ansible-playbook -i "000.000.000.000," deploy.yml

 

Conclusion

In this tutorial we achieved the same goal of production deployment of a Django web app but with less code and less to manage than in part 3!