Introduction
Tu sais faire tourner une API FastAPI en local. Tu lances uvicorn main:app --reload, tu ouvres localhost:8000/docs, et tout fonctionne.
Maintenant, comment tu fais tourner cette même API sur n'importe quelle machine, sans installer Python, sans configurer un environnement virtuel, sans que "ça marche chez moi mais pas chez toi" ?
La réponse, c'est Docker.
Dans cet article, on prend une API CRUD simple — gestion de produits — et on la dockerise de A à Z. Pas de raccourci. Pas d'image mystérieuse qu'on colle sans comprendre. Chaque ligne du Dockerfile sera expliquée, avec la raison exacte pour laquelle elle existe.
À la fin, tu seras capable de dockeriser n'importe quelle API Python simple par toi-même.
Ce qu'on va construire
Une API de gestion de produits avec 4 endpoints :
-
POST /produits— créer un produit -
GET /produits— lister tous les produits -
GET /produits/{id}— récupérer un produit par son id -
DELETE /produits/{id}— supprimer un produit
Stack : FastAPI + Uvicorn + SQLAlchemy + SQLite + Docker
Repo GitHub : [lien]
Étape 1 — L'API FastAPI
Structure du projet
fastapi-produits/
├── Dockerfile
├── .dockerignore
├── requirements.txt
├── main.py
├── database.py
├── models.py
└── schemas.py
database.py
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
DATABASE_URL = "sqlite:///./produits.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
Le yield dans get_db() est important : FastAPI ouvre la session avant la requête, la passe à la route, et la ferme proprement après — même en cas d'erreur.
models.py
from sqlalchemy import Column, Integer, String, Float
from database import Base
class Produit(Base):
__tablename__ = "produits"
id = Column(Integer, primary_key=True, index=True)
nom = Column(String, nullable=False)
prix = Column(Float, nullable=False)
stock = Column(Integer, default=0)
schemas.py
from pydantic import BaseModel
class ProduitCreate(BaseModel):
nom: str
prix: float
stock: int = 0
class ProduitRead(ProduitCreate):
id: int
class Config:
from_attributes = True
Deux schémas distincts : ce que le client envoie (ProduitCreate, sans id) et ce que l'API renvoie (ProduitRead, avec id). Le client n'envoie jamais l'id — c'est la base qui le génère.
main.py
from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.orm import Session
import models, schemas
from database import engine, get_db
models.Base.metadata.create_all(bind=engine)
app = FastAPI(title="API Produits", version="1.0.0")
@app.post("/produits", response_model=schemas.ProduitRead, status_code=201)
def creer_produit(produit: schemas.ProduitCreate, db: Session = Depends(get_db)):
nouveau = models.Produit(**produit.model_dump())
db.add(nouveau)
db.commit()
db.refresh(nouveau)
return nouveau
@app.get("/produits", response_model=list[schemas.ProduitRead])
def lister_produits(db: Session = Depends(get_db)):
return db.query(models.Produit).all()
@app.get("/produits/{produit_id}", response_model=schemas.ProduitRead)
def lire_produit(produit_id: int, db: Session = Depends(get_db)):
produit = db.query(models.Produit).filter(models.Produit.id == produit_id).first()
if not produit:
raise HTTPException(status_code=404, detail="Produit non trouvé")
return produit
@app.delete("/produits/{produit_id}", status_code=204)
def supprimer_produit(produit_id: int, db: Session = Depends(get_db)):
produit = db.query(models.Produit).filter(models.Produit.id == produit_id).first()
if not produit:
raise HTTPException(status_code=404, detail="Produit non trouvé")
db.delete(produit)
db.commit()
requirements.txt
fastapi==0.111.0
uvicorn==0.29.0
sqlalchemy==2.0.30
pydantic==2.7.1
Pourquoi épingler les versions avec
==? Pour garantir que le build est reproductible. Sans version fixe, pip installe la dernière version disponible — et une mise à jour inattendue peut casser l'app silencieusement.
Teste en local avant de continuer :
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
uvicorn main:app --reload
http://localhost:8000/docs doit afficher Swagger UI. Si c'est bon, on passe à Docker.
Étape 2 — Le piège du cache Docker
Avant d'écrire le Dockerfile, il faut comprendre un concept qui change tout : le cache des couches.
Docker construit une image couche par couche. Chaque instruction (FROM, COPY, RUN...) crée une couche. Si une couche n'a pas changé depuis le dernier build, Docker la réutilise depuis le cache — sans la reconstruire.
La règle critique : dès qu'une couche change, toutes les couches suivantes sont reconstruites.
❌ L'erreur classique du débutant
COPY . . # copie tout le code
RUN pip install -r requirements.txt # installation après le code
Résultat : tu modifies une ligne dans main.py → Docker voit que COPY . . a changé → il réinstalle toutes les dépendances. À chaque modification.
✅ L'ordre correct
COPY requirements.txt . # copie UNIQUEMENT requirements.txt
RUN pip install -r requirements.txt # cette couche est mise en cache
COPY . . # le code vient APRÈS
Résultat : tu modifies main.py → requirements.txt n'a pas changé → Docker réutilise la couche pip install → rebuild en 2 secondes.
Sur une connexion lente, ce détail peut faire passer de 3 minutes à 3 secondes par rebuild.
Étape 3 — Le Dockerfile, ligne par ligne
# 1. IMAGE DE BASE
FROM python:3.11-slim
Point de départ : une image Linux avec Python 3.11 déjà installé. La version slim fait ~150MB au lieu de ~1GB pour la version complète. Elle contient tout ce qu'il faut pour faire tourner Python, sans les outils de développement inutiles.
# 2. RÉPERTOIRE DE TRAVAIL
WORKDIR /app
Crée le dossier /app dans le conteneur et le définit comme répertoire courant. Sans ça, les fichiers atterrissent à la racine / — mélangés avec les fichiers système Linux.
# 3. DÉPENDANCES (stratégie de cache)
COPY requirements.txt .
RUN pip install -r requirements.txt --no-cache-dir
Copie uniquement requirements.txt puis installe les dépendances. --no-cache-dir supprime le cache interne de pip — il ne servira jamais dans un conteneur et occupe inutilement de l'espace.
# 4. CODE SOURCE
COPY . .
Copie le reste du projet dans /app. Vient après pip install — c'est la stratégie de cache vue à l'étape 2.
# 5. PORT (documentation uniquement)
EXPOSE 8000
⚠️
EXPOSEne fait rien au niveau réseau. Il documente simplement le port que l'app utilisera. L'ouverture réelle se fait avec-pdansdocker run.
# 6. COMMANDE DE DÉMARRAGE
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Deux points importants ici :
Pourquoi Uvicorn ? FastAPI est un framework, pas un serveur. Il définit les routes et la logique, mais il ne sait pas écouter sur un port réseau. Uvicorn est le serveur ASGI qui fait ce travail. Sans Uvicorn, FastAPI ne reçoit aucune requête.
Pourquoi --host 0.0.0.0 ? 127.0.0.1 = "écoute uniquement les connexions internes à cette machine". Dans un conteneur, "cette machine" c'est le conteneur lui-même. Le navigateur de l'hôte ne peut pas y accéder. 0.0.0.0 = "écoute tout le trafic réseau" — y compris ce qui vient de l'extérieur du conteneur.
Étape 4 — Builder et observer le cache
docker build -t fastapi-produits .
Sortie du terminal (premier build) :
[1/5] FROM python:3.11-slim 12.4s ← téléchargement
[2/5] WORKDIR /app 0.0s
[3/5] COPY requirements.txt . 0.0s
[4/5] RUN pip install ... 28.3s ← installation
[5/5] COPY . . 0.1s
Maintenant, modifie une ligne dans main.py et rebuilde :
[1/5] FROM python:3.11-slim 0.0s ← CACHED
[2/5] WORKDIR /app 0.0s ← CACHED
[3/5] COPY requirements.txt . 0.0s ← CACHED
[4/5] RUN pip install ... 0.0s ← CACHED ✅ (pas de réinstallation)
[5/5] COPY . . 0.1s ← seulement ça est reconstruit
La stratégie de cache fonctionne. 2 secondes au lieu de 30.
Étape 5 — Lancer le conteneur
docker run -d -p 8000:8000 --name api-produits fastapi-produits
-d : mode détaché — libère le terminal
-p 8000:8000 : relie le port 8000 de ta machine au port 8000 du conteneur
--name : nom lisible pour référencer le conteneur
Pour voir que tout tourne :
docker ps
docker logs api-produits
Logs attendus :
INFO: Uvicorn running on http://0.0.0.0:8000
INFO: Application startup complete.
Teste l'erreur classique : lance le conteneur sans
-p, puis essaie d'accéder àlocalhost:8000. Connexion refusée. L'app tourne parfaitement à l'intérieur, mais sans le mapping de port, le trafic externe n'entre pas.
Étape 6 — Tester via Swagger UI
Ouvre http://localhost:8000/docs.
L'interface Swagger générée automatiquement par FastAPI est disponible — identique à ce qu'on avait en local. La dockerisation n'a rien cassé.
Teste le cycle CRUD complet :
-
POST /produits— crée un produit avecnom,prix,stock -
GET /produits— vérifie qu'il apparaît dans la liste -
GET /produits/1— récupère-le par son id -
DELETE /produits/1— supprime-le -
GET /produits/1→404confirmé
Maintenant, arrête le conteneur, supprime-le, et relances-en un nouveau :
docker stop api-produits && docker rm api-produits
docker run -d -p 8000:8000 --name api-produits-2 fastapi-produits
GET /produits → liste vide. Les données ont disparu.
C'est la limite principale de ce projet : la base SQLite est dans le conteneur. Quand le conteneur est supprimé, les données le sont aussi. La solution — les volumes Docker — sera l'objet d'un article dédié.
Résumé — Le Dockerfile complet commenté
# Image de base Python minimale
FROM python:3.11-slim
# Répertoire de travail dans le conteneur
WORKDIR /app
# Dépendances en premier — stratégie de cache
COPY requirements.txt .
RUN pip install -r requirements.txt --no-cache-dir
# Code source après les dépendances
COPY . .
# Documentation du port (n'ouvre rien)
EXPOSE 8000
# Uvicorn sur 0.0.0.0 — obligatoire pour l'accessibilité hors conteneur
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Limites documentées
Ce Dockerfile est volontairement simple. Ce qu'il ne fait pas :
| Limite | Solution à venir |
|---|---|
| Données non persistantes | Volumes Docker |
| Image non optimisée | Multi-stage build |
| Processus en root |
USER non-root |
| Pas de health check |
HEALTHCHECK Docker |
| SQLite | PostgreSQL + Docker Compose |
Ces points seront couverts dans les prochains articles de la série.
Conclusion
Tu viens de dockeriser une API FastAPI de A à Z. Pas de magie. Chaque ligne a une raison d'être.
Ce que tu comprends maintenant que tu ne comprenais pas avant :
- La différence entre image et conteneur
- Pourquoi FastAPI a besoin d'Uvicorn
- Comment le cache Docker fonctionne et comment l'exploiter
- Pourquoi
--host 0.0.0.0est obligatoire - La différence entre
EXPOSEet-p
Le repo complet est disponible sur GitHub : https://github.com/Bordley/fastapi-produits.git
Cet article fait partie d'une série sur Docker et DevOps orientée développeurs francophones.
Suivant : Dockerfile Senior — multi-stage, non-root user, production-ready.
Tags Dev.to : docker fastapi python devops beginners tutorial api backend webdev programming
Tags Hashnode : docker fastapi python devops beginners tutorial rest-api backend web-development containerization
Top comments (0)