Hablaremos sobre cómo arrancar un proyecto Django conectado a un PostgreSQL desde cero, estando los dos servicios separados con su propio contenedor y orquestados con docker compose.
Lo más habitual suele ser que nos unamos a un proyecto ya iniciado, en el que únicamente tengamos que clonarnos el repositorio, construir la imagen (espero que uséis, vosotros o vuestra empresa, Docker o cualquier otro servicio de empaquetado de aplicaciones, sino comenzamos mal 😤), y levantar los servicios necesarios para el proyecto. Pero en ciertas ocasiones, tendremos la necesidad de tener que construir el proyecto desde cero.
Es por ello, por lo que he decidido realizar este post para guiaros punto por punto en la implementación de una aplicación Django conectada a un PostgreSQL, estando ambos servicios orquestados con docker compose.
Tabla de contenido
- Crear un entorno virtual Python
- Instalar Django, crear proyecto base y aplicar las migraciones
- Preparar la estructura de directorios
- Definir la imagen para nuestra app Django en un Dockerfile
- Definir los servicios en el fichero docker-compose.yml
- Construir la imagen y levantar los servicios
- Siguientes pasos
Dicho esto… code time! 🪄✨
Crear un entorno virtual Python
Antes de nada, nos creamos el directorio donde alojaremos nuestro proyecto.
mkdir dj-pg-docker && cd dj-pg-docker
En mi caso tengo la versión de Python 3.12.5 disponible en mi sistema por haberla instalado previamente con pyenv. Para más información sobre como instalar una versión en concreto, visitar la documentación en su proyecto de GitHub.
Dicho esto, creamos un entorno virtual y lo activamos.
python -m venv venv
source venv/bin/activate
Con el primer comando habremos creado un directorio en la raíz de nuestro proyecto conteniendo el entorno virtual, y con el segundo lo habremos activado. El prompt de la shell debe haber cambiado indicando al principio el nombre del entorno virtual activo. Algo parecido a esto:
(venv) user@machine:~/projects/dj-pg-docker$
Instalar Django, crear proyecto base y aplicar las migraciones
Tener la última versión de Django nos brinda tener todas sus novedades, pero (la vida está llena de peros 🥲) yo soy partidario de usar siempre la última versión LTS, por eso de tener varios años cubiertos de soporte oficial. En el momento de escribir este post, la última versión LTS disponible es la 4.2, con lo que procederemos a instalarla en nuestro entorno virtual usando el gestor de paquetes Python; pip.
pip install Django==4.2
Una vez instalado Django, procedemos a crear el proyecto base.
django-admin startproject my_project
Deberíamos tener la estructura del proyecto similar a como se muestra a continuación.
dj-pg-docker
├─── my_project/
│ ├─── manage.py
│ └─── my_project/
│ ├─── asgi.py
│ ├─── __init__.py
│ ├─── settings.py
│ ├─── urls.py
│ └─── wsgi.py
└─── venv
Para asegurarnos de que todo funciona correctamente, accedemos al directorio del proyecto, aplicamos las migraciones iniciales, y arrancamos el server.
cd my_project
python manage.py migrate
python manage.py runserver
Deberíamos ver la pantalla principal de nuestro proyecto accediendo a http://localhost:8000.
It works! 🚀 Paramos el servicio con Ctrl + C
y seguimos.
Preparar la estructura de directorios
Como vamos a usar Docker y docker compose para construir la imagen de nuestra app y levantar los servicios, hay ciertos directorios que ya no nos harán falta.
Desactivamos el entorno virtual y lo eliminamos.
deactivate
rm -r venv
Dentro del proyecto base creado por Django, en el directorio my_project, eliminaremos la bbdd SQLite (se trata del gestor de bbdd que establece Django de forma predeterminada, más adelante lo cambiaremos) que se creó cuando aplicamos las migraciones.
rm my_project/db.sqlite3
Y en el mismo directorio aprovechamos para crear el fichero requirements.txt, donde añadiremos la misma versión indicada cuando lo instalamos dentro del entorno virtual.
touch my_project/requirements.txt
echo "Django==4.2" > my_project/requirements.txt
Para crear el fichero requirements.txt también podríamos haber usado la instrucción pip freeze > my_project/requirements.txt
teniendo activo el entorno virtual, lo que sucede es que de esa manera también se añaden dependencias extras que se instalan de forma indirecta al instalar Django (u otros paquetes). Yo particularmente, prefiero especificar las dependencias de librerías que necesito exactamente, y las sub dependencias que se encargue pip de instalarlas. De esta manera tendremos un fichero de dependencias mucho más limpio.
Por último, renombramos el directorio del proyecto base a app. Esto no influye en nada, está claro, pero así lo dejamos más limpio cara a dockerizarlo.
mv my_project app
Estructura del proyecto tras los cambios realizados.
dj-pg-docker
└─── app/
├─── manage.py
├─── my_project
│ ├─── asgi.py
│ ├─── __init__.py
│ ├─── settings.py
│ ├─── urls.py
│ └─── wsgi.py
└─── requirements.txt
Definir la imagen para nuestra app Django en un Dockerfile
El directorio app será donde alojaremos todos los subdirectorios y ficheros para nuestro proyecto Django. Con lo que dentro de dicho directorio crearemos la estructura de carpetas compose/local:
mkdir -p app/compose/local
Y dentro del directorio local añadiremos los ficheros Dockerfile, entrypoint y start con el siguiente contenido.
# dj-pg-docker/app/compose/local/Dockerfile
# Imagen oficial Python
ARG PYTHON_VERSION=3.12.5-slim-bullseye
FROM python:${PYTHON_VERSION} as python
FROM python as builder
# Dependencias del sistema
RUN apt-get update && apt-get install -y \
build-essential \
# Dependencias librería psycopg2 (conector a PosgreSQL)
libpq-dev
# Dependencias de Python
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip
RUN pip wheel --wheel-dir /usr/src/app/wheels -r requirements.txt
FROM python as executor
# Variables de entorno
ENV PYTHONUNBUFFERED 1
ENV PYTHONDONTWRITEBYTECODE 1
# Dependencias del sistema
RUN apt-get update && apt-get install -y \
build-essential \
libpq-dev \
# Para comprobar el estado de la bbdd
netcat \
# Limpieza de ficheros innecesarios
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& rm -rf /var/lib/apt/lists/*
# Copiamos las dependencias de Python desde el builder
COPY --from=builder /usr/src/app/wheels /wheels/
# Y las instalamos
RUN pip install --no-cache-dir --upgrade pip
RUN pip install --no-cache-dir --no-index --find-links=/wheels/ /wheels/* \
&& rm -rf /wheels/
# Copiamos el entrypoint y scripts
COPY ./compose/local/entrypoint /entrypoint
COPY ./compose/local/start /start
# Y les damos permisos de ejecución
RUN sed -i 's/\r$//g' /entrypoint \
&& sed -i 's/\r$//g' /start \
&& chmod +x /entrypoint \
&& chmod +x /start
WORKDIR /app
ENTRYPOINT [ "/entrypoint" ]
# dj-pg-docker/app/compose/local/entrypoint
#!/bin/sh
if [ "$DATABASE" = "postgres" ]
then
echo "Waiting for postgres..."
while ! nc -z $SQL_HOST $SQL_PORT; do
sleep 0.1
done
echo "PostgreSQL started"
fi
exec "$@"
# dj-pg-docker/app/compose/local/start
#!/bin/bash
python manage.py migrate
python manage.py runserver 0.0.0.0:8000
Wow! Parece mucha info así de golpe, pero vamos a explicar la finalidad de cada fichero para ver qué implican exactamente.
- Dockerfile. Aquí es donde definimos la imagen de nuestra app Django. Entre otras cosas, le indicamos la imagen de Python base que usaremos (fijaros que hemos indicado la misma versión que estuvimos usando en nuestro entorno virtual), instalamos las dependencias del sistema y las de Python. También copiamos un par de scripts y les damos permiso de ejecución. Por último le indicamos el directorio de trabajo /app (dicho directorio lo crea por defecto la imagen base de Python que hemos declarado al principio del todo) y el script para el entrypoint (a continuación lo vemos más en detalle).
Como curiosidad, hemos definido varios stages (o etapas) en el Dockerfile. Tenemos el stage python, que sería la imagen base de Python, la cual usamos en los dos stages restantes. El stage builder, que se encarga principalmente de instalar las dependencias Python. Y el stage executor, el cual se encarga de copiar las dependencias previamente instaladas en el stage builder y de copiar los scripts necesarios para que el servicio Django pueda arrancar. Usar los stages es una buena práctica en la definición de ficheros Dockerfile, más info aquí. - entrypoint. Se trata de la primera acción que hará nuestro servicio una vez arranque. En este caso, le estamos diciendo que compruebe la conectividad con el servicio de PostgreSQL. En caso de que no haya comunicación, hará un sleep de una décima de segundo para volver a comprobarlo. Tiene sentido que hagamos esto, ¿no? Es decir, antes de arrancar el servicio de Django, debemos asegurarnos que haya comunicación con el servicio de la bbdd, de lo contrario nuestro servicio no funcionará.
- start. Se ejecutará una vez haya finalizado el entrypoint. Aquí hay poca magia; simplemente aplicamos las migraciones y arrancamos el servicio Django sobre el puerto 8000.
Antes de seguir, aseguraros que tengáis la estructura del proyecto tal que así:
dj-pg-docker
└─── app/
├─── compose
│ └─── local
│ ├─── Dockerfile
│ ├─── entrypoint
│ └─── start
├─── manage.py
├─── my_project
│ ├─── asgi.py
│ ├─── __init__.py
│ ├─── settings.py
│ ├─── urls.py
│ └─── wsgi.py
└─── requirements.txt
Definir los servicios en el fichero docker-compose.yml
Turno de que definamos los servicios en el fichero docker-compose.yml. Vamos allá.
Creamos el fichero en la raíz del proyecto, en dj-pg-docker, con el siguiente contenido:
# dj-pg-docker/docker-compose.yml
services:
web:
image: dj-pg-docker:latest
build:
context: app/
dockerfile: ./compose/local/Dockerfile
command: /start
volumes:
- ./app:/app
ports:
- 8000:8000
env_file:
- ./.env
depends_on:
- db
db:
image: postgres:16-alpine
volumes:
- postgres_data:/var/lib/postgresql/data/
environment:
- POSTGRES_USER=${SQL_USER}
- POSTGRES_PASSWORD=${SQL_PASSWORD}
- POSTGRES_DB=${SQL_DATABASE}
volumes:
postgres_data:
Hemos definido dos servicios, uno para nuestra app Django que se encarga de usar nuestro Dockerfile para construir la imagen, y otro para PostgreSQL, que en este caso usamos la imagen oficial publicada en dockerhub.
Cosas que debemos tener en cuenta:
- Ambos servicios usan volúmenes. El de Django simplemente es un mapeo entre nuestro directorio app y el directorio /app contenido dentro de la imagen. De esta manera, cada vez que hagamos un cambio en nuestro código no tendremos que recrear la imagen, automáticamente se verán los cambios reflejados en el contenedor (😎). El de PosgreSQL sin embargo, sí que usa un volumen como tal, el cual hemos definido abajo con el nombre postgres_data. De esta manera conseguimos que los cambios en las tablas de nuestra bbdd persistan en todo momento, independientemente de que paremos o matemos los servicios.
- En el servicio web es importante la instrucción build, ya que de esta manera le estamos diciendo a Docker que todas las referencias a la hora de copiar ficheros en nuestro Dockerfile tiene como base el directorio app de nuestro proyecto, y no la raíz, que sería desde donde levantaremos los servicios, ya que es donde se encuentra nuestro docker-compose.yml.
- env_file. De esta manera le estamos pasando las variables de entorno definidas en dicho fichero al contenedor que sirva nuestra app Django. Más adelante definiremos el fichero .env, y también configuraremos el settings.py de nuestra app para que use ciertas variables de entorno.
- Por último, al servicio db le pasamos tres variables de entorno (las cuales también cogerá del fichero .env, debido a que lo crearemos en la raíz de nuestro proyecto) que espera la imagen de PosgreSQL para inicializar el servicio correctamente.
Dado que hemos hablado varias veces del fichero .env, vamos a crearlo en nuestra raíz.
touch .env
Y añadimos las siguientes variables.
# dj-pg-docker/.env
# Django
DEBUG=1
SECRET_KEY=super_secure_django_secret_key
DJANGO_ALLOWED_HOSTS="localhost 127.0.0.1 [::1]"
# PostgreSQL
SQL_USER=pythoninspanish
SQL_PASSWORD=pythoninspanish
SQL_DATABASE=pythoninspanish
SQL_HOST=db
SQL_PORT=5432
DATABASE=postgres
Usar las variables de entorno es más que recomendable, ya que evitamos meter usuarios o credenciales en nuestro código directamente, con las vulnerabilidades de seguridad que conllevaría. En entornos de producción usaríamos secrets que nos brinden las propias plataformas que usemos para desplegar nuestro proyecto, pero eso ya se sale del foco de este post.
Para el SECRET_KEY que usa Django, podemos usar el módulo secrets
de Python para generar una key aleatoria.
import secrets
secrets.token_urlsafe(64)
Establecer a vuestro gusto los valores del usuario, contraseña y base de datos de PostgreSQL.
Para terminar de configurar nuestro proyecto, nos quedaría usar ciertas variables de entorno en nuestro settings.py, vamos allá.
# dj-pg-docker/app/my_project/settings.py
...
import os
...
SECRET_KEY = os.environ.get("SECRET_KEY")
DEBUG = bool(os.environ.get("DEBUG", 0))
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "").split()
...
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.getenv("SQL_DATABASE"),
"USER": os.getenv("SQL_USER"),
"PASSWORD": os.getenv("SQL_PASSWORD"),
"HOST": os.getenv("SQL_HOST"),
"PORT": os.getenv("SQL_PORT"),
}
}
...
A parte de usar los valores definidos en nuestro fichero con las variables de entorno (.env), lo cual nos permite gestionar diferentes parámetros de forma dinámica, lo más reseñable es la configuración para conectarnos a la base de datos. En este caso le estamos indicando a Django que debe conectarse al PostgreSQL indicado, pero para ello necesitará una librería para poder realizar dicha conexión. Añadimos el paquete al fichero requirements.txt.
echo "psycopg2==2.9.9" >> app/requirements.txt
Quedando nuestro fichero de dependencias de la siguiente manera.
# dj-pg-docker/app/requirements.txt
Django==4.2
psycopg2==2.9.9
Nos aseguramos que tengamos la estructura del proyecto como se muestra a continuación antes de construir y levantar los servicios.
dj-pg-docker
├─── app/
│ ├─── compose
│ │ └─── local
│ │ ├─── Dockerfile
│ │ ├─── entrypoint
│ │ └─── start
│ ├─── manage.py
│ ├─── my_project
│ │ ├─── asgi.py
│ │ ├─── __init__.py
│ │ ├─── settings.py
│ │ ├─── urls.py
│ │ └─── wsgi.py
│ └─── requirements.txt
├─── docker-compose.yml
└─── .env
Construir la imagen y levantar los servicios
Primero construimos las imágenes necesarias en función de los servicios definidos en nuestro docker-compose.yml. En nuestro caso, únicamente se nos creará la imagen para el servicio app, el cual usará nuestro Dockerfile. El servicio db al usar una imagen publicada en dockerhub no hará nada, simplemente se la bajará cuando posteriormente levantemos los servicios. Dicho esto, construyamos nuestra imagen.
docker compose build
Una vez termine, nuestra imagen debería aparecer en el listado de las imágenes que tengamos en nuestra máquina.
docker images | grep dj-pg-docker
Mostrando algo parecido a esto.
dj-pg-docker latest 8b930233872f 1 minute ago 412MB
Por último, levantamos los dos servicios.
docker compose up
Si no tenéis la imagen de PostgreSQL descargada en vuestra máquina, el proceso tardará un poco hasta que se descargue la imagen de dockerhub. Una vez descargada, se inicializarán ambos servicios.
Deberíais poder acceder de nuevo a la instancia yendo a http://localhost:8000.
Podéis parar los servicios con Ctrl + C
.
Llegados a este punto tendremos dos servicios, uno de Django y otro de PostgreSQL, ambos corriendo en contenedores independientes y orquestados con docker compose 🚀.
Siguientes pasos
Ahora que ya tenemos la base del proyecto configurada con docker compose, existen varios puntos que se podrían mejorar/implementar. Veámoslos:
- Añadir un healthcheck al docker-compose.yml. Podemos asegurarnos cada x tiempo (30 segundos por ejemplo) que nuestra app Django está funcionando. Se podría habilitar un endpoint (expuesto o con un middleware) para que el propio servicio de Docker compruebe si tiene respuesta de nuestra app.
- Volúmenes para los static y media. Igual que hemos hecho para mapear el código fuente de nuestra aplicación, podemos hacer algo similar para mapear los ficheros estáticos (css, js, etc.) y los media (imágenes subidas por el usuario, por ejemplo).
- Añadir tests. Siempre meter tests, ¡siempre! Y en este caso, siendo un proyecto que estamos montando desde cero, aún más fácil para comenzar a usar buenas prácticas. Yo soy muy fan de usar pytest, pero vamos, sed libres de usar cualquier otra librería.
Espero os sea de utilidad este post. Hasta la próxima, ¡pythonistas! 🐍❤️