DEV Community

Roméo DOSsOu
Roméo DOSsOu

Posted on

Comment dockeriser une API FastAPI de A à Z — Dockerfile expliqué ligne par ligne

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

requirements.txt

fastapi==0.111.0
uvicorn==0.29.0
sqlalchemy==2.0.30
pydantic==2.7.1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Résultat : tu modifies main.pyrequirements.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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 . .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

⚠️ EXPOSE ne fait rien au niveau réseau. Il documente simplement le port que l'app utilisera. L'ouverture réelle se fait avec -p dans docker run.

# 6. COMMANDE DE DÉMARRAGE
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

-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
Enter fullscreen mode Exit fullscreen mode

Logs attendus :

INFO:     Uvicorn running on http://0.0.0.0:8000
INFO:     Application startup complete.
Enter fullscreen mode Exit fullscreen mode

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 :

  1. POST /produits — crée un produit avec nom, prix, stock
  2. GET /produits — vérifie qu'il apparaît dans la liste
  3. GET /produits/1 — récupère-le par son id
  4. DELETE /produits/1 — supprime-le
  5. GET /produits/1404 confirmé

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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.0 est obligatoire
  • La différence entre EXPOSE et -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)