Django et Tailwind CSS : stratégie de build multi‑étape pour réduire l'image Docker

Pour les développeurs backend, le CSS est souvent une corvée sans fin. Après avoir passé des heures à écrire un style.css à la main, l’arrivée de Bootstrap a été saluée comme une révolution. Mais à mesure que le projet grandit, personnaliser Bootstrap devient de plus en plus douloureux.

La tendance actuelle est sans conteste Tailwind CSS. Sa philosophie Utility‑First, où l’on compose des classes pour obtenir le style désiré, s’avère être un véritable salut pour les ingénieurs backend. J’ai moi‑même retiré Bootstrap de plusieurs projets et migré vers Tailwind, et cet outil est devenu indispensable, surtout dans les projets Django.

Dans cet article, je partage une stratégie d’optimisation d’image Docker pour la production lorsqu’on utilise Tailwind CSS dans un projet Django, en particulier comment garder l’image légère grâce à un build multi‑étape.


Problème : l’image Docker devient trop volumineuse



Pour intégrer Tailwind dans un environnement Django, le package le plus utilisé est django‑tailwind. Son installation et sa documentation sont claires, ce qui ne pose pas de problème en développement local.

Le vrai problème apparaît lors du déploiement, surtout dans un conteneur Docker.

Sur une machine locale ou un serveur bare‑metal, on peut simplement construire le CSS avec :

python manage.py tailwind build

Mais ce processus nécessite en interne un Node.js et un npm. Ainsi, l’image Docker de production doit inclure Node.js, ce qui est inefficace sur plusieurs plans.

  • Problème 1 : inclure Node.js dans l’image runtime pour une tâche unique (la construction du CSS).
  • Problème 2 : l’image devient inutilement grosse.
  • Problème 3 : du binaire superflu augmente la surface d’attaque.

Notre objectif est clair.

« Utiliser une image runtime pure Python sans Node.js, et copier uniquement les fichiers CSS construits »

Pour cela, nous allons exploiter le build multi‑étape de Docker.


Solution : build multi‑étape en 3 étapes

Le build multi‑étape de Docker permet de séparer le processus de construction en trois phases distinctes.

  1. Python Builder : construction des paquets Python en wheel.
  2. Node Builder : compilation de Tailwind CSS (via npm).
  3. Runtime final : copie uniquement des artefacts nécessaires pour l’exécution.

Avec cette approche, l’image finale ne contient ni Node.js ni outils de build, seulement les fichiers requis pour l’exécution.


Exemple de Dockerfile



Voici un Dockerfile adapté à un projet Django + Tailwind. Ajustez les chemins selon votre structure (nom de l’app theme, emplacement des fichiers statiques, etc.).

# -------------------------------------------------------------------
# Étape 1 : Python Builder
# Installe les dépendances Linux et construit les paquets Python en wheel.
# -------------------------------------------------------------------
FROM python:3.11-slim as python-builder

WORKDIR /app

COPY requirements.txt .
RUN pip install --upgrade pip && \
    pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt


# -------------------------------------------------------------------
# Étape 2 : Node Builder
# Utilise une image Node pour compiler Tailwind CSS.
# -------------------------------------------------------------------
FROM node:20-alpine as node-builder

WORKDIR /app

# Copie l’ensemble du projet pour que Tailwind JIT puisse scanner les templates.
COPY . .

WORKDIR /app/theme/static_src

# Installe les dépendances et exécute le build.
RUN npm ci
RUN npm run build

# Supposons que le résultat se trouve dans :
#   /app/theme/static/css/dist.css


# -------------------------------------------------------------------
# Étape 3 : Runtime final
# Image finale sans Node.js, uniquement Python et les fichiers statiques.
# -------------------------------------------------------------------
FROM python:3.11-slim

WORKDIR /app

# 1. Installe les paquets Python construits.
COPY --from=python-builder /app/wheels /wheels
COPY --from=python-builder /app/requirements.txt .
RUN pip install --no-cache-dir /wheels/*

# 2. Copie le code source de l’application.
COPY . .

# 3. Copie uniquement le CSS compilé.
COPY --from=node-builder /app/theme/static/css/dist.css \
    /app/theme/static/css/dist.css

# 4. Exécute collectstatic.
RUN python manage.py collectstatic --noinput

# Commande de démarrage.
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "config.wsgi:application"]

Vous pouvez également ajouter d’autres optimisations :

  • Utiliser .dockerignore pour exclure les fichiers inutiles (ex. .git, médias locaux).
  • Définir des variables d’environnement comme PYTHONDONTWRITEBYTECODE=1 ou PYTHONUNBUFFERED=1.
  • Sur Alpine, n’installer que les bibliothèques système nécessaires.

Points clés

1. Séparation des responsabilités de l’étape Node

Dans l’étape 2, on se concentre uniquement sur la construction du CSS. Il est crucial que Tailwind puisse scanner les templates Django (templates/**/*.html) et le code Python. Dans l’exemple, on copie l’ensemble du projet, mais si votre configuration est plus restreinte, vous pouvez copier uniquement les répertoires nécessaires.

2. Utilisation du script npm run build

Le package.json généré par django‑tailwind contient généralement un script comme :

{
  "scripts": {
    "build": "tailwindcss -c tailwind.config.js -o ../static/css/dist.css --minify"
  }
}

Dans le build multi‑étape, il suffit d’exécuter ce script et de copier le résultat.

3. Légèreté et sécurité de l’image finale

Dans l’étape 3, on ne retrouve pas Node, npm, ni Tailwind CLI. On conserve uniquement les fichiers nécessaires. Cela réduit la taille de l’image, diminue la surface d’attaque et accélère le temps de démarrage.


process of build runtime docker image

Conclusion

Il est tentant d’ajouter tout ce qui est nécessaire pour que l’application fonctionne, mais en production, la taille de l’image, le temps de build et la sécurité sont primordiaux. En appliquant la stratégie de build multi‑étape décrite ci‑dessus, vous limitez les dépendances à Node.js uniquement à l’étape de build, et l’image finale reste une image Python pure avec les fichiers statiques compilés.

Si vous avez déjà un projet en production, essayez d’intégrer cette approche dans votre pipeline CI/CD. Une fois la structure en place, elle peut être réutilisée presque identiquement pour de nouveaux projets.