1. Le Constat et les Objectifs Architecturaux
Pour mettre en pratique et consolider mes connaissances, j’ai voulu monter moi-même l’infrastructure qui héberge ce portfolio, sur un serveur physique chez moi (on-premise).
L’idée de départ n’était pas juste de conteneuriser le site, mais de construire quelque chose de plus solide, avec quelques principes en tête :
- Sécurité (Zero Trust) : ne pas ouvrir de ports sur le routeur (NAT/PAT), pour éviter le scan et le bruteforce depuis internet.
- Automatisation (IaC) : toute l’infra doit pouvoir se redéployer à partir du code, sans étape manuelle cachée.
- Immutabilité (CI/CD) : pas de code monté en volume — tout est packagé dans des images Docker versionnées, stockées dans un registre privé.
- Routage dynamique (Layer 7) : un reverse proxy doit gérer l’exposition des services à la volée, sans intervention manuelle à chaque ajout.
Avant de rentrer dans le détail de chaque brique, voici la vue d’ensemble : le trajet d’une requête, depuis l’extérieur jusqu’au conteneur final.

2. L’Infrastructure de Base : Réseau SDN (Software Defined Network)
Première étape : isoler les flux réseau. Plutôt que d’utiliser le réseau bridge par défaut de Docker, j’ai créé un réseau dédié. Les conteneurs peuvent ainsi se trouver entre eux via la résolution DNS interne de Docker, sans être exposés à l’extérieur.
# Création du sous-réseau isolé pour le reverse proxy
docker network create proxy_net
3. Le Cœur du Routage : Déploiement de Traefik
Pour router le trafic entrant vers les bons conteneurs (le registre, le site web…), j’ai déployé Traefik, un reverse proxy qui fait aussi office de load-balancer.
Ce qui est pratique avec Traefik, c’est qu’il écoute directement l’API de Docker : il détecte tout seul les nouveaux conteneurs (via leurs labels) et met à jour ses routes en temps réel, sans avoir besoin de redémarrer quoi que ce soit.
Voici le manifeste docker-compose.yml du routeur principal, configuré pour accepter le trafic HTTP venant du tunnel de sécurité.
Note sécurité : en prod, l’accès au dashboard Traefik (activé ici via l’API) devrait être verrouillé par un middleware
BasicAuth. Je ne l’ai pas mis dans le bloc ci-dessous pour garder l’exemple lisible.
services:
traefik:
image: traefik:latest
container_name: traefik_proxy
restart: unless-stopped
security_opt:
- no-new-privileges:true
networks:
- proxy_net
ports:
- "80:80"
- "443:443"
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./dynamic_conf:/dynamic_conf:ro
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.file.directory=/dynamic_conf"
- "--providers.file.watch=true"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--api.dashboard=true"
- "--serverstransport.insecureskipverify=true"
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.rule=Host(`traefik.local`)"
- "traefik.http.routers.api.service=api@internal"
- "traefik.http.routers.api.entrypoints=web"
networks:
proxy_net:
external: true

4. Les Fondations d’un Pipeline CI/CD : Déploiement d’un Registre Privé
Compiler le code et l’exécuter, ce sont deux étapes que je voulais garder séparées. Pour éviter de dépendre du Docker Hub public ou de builder en direct sur le serveur de prod, j’ai monté mon propre registre Docker privé.
L’idée : avoir un stockage local pour les images Docker versionnées, chiffré, plutôt que de tout faire passer par un service tiers. C’est le conteneur final (Nginx) qui vient pull son image depuis ce registre.
Pour sécuriser les échanges entre le moteur Docker (l’hôte) et le registre, j’ai généré des certificats locaux avec mkcert, placés dans le dossier d’approbation système /etc/docker/certs.d/.
Note : la résolution du nom
registry.localpar Docker au moment du push passe simplement par une entrée statique dans le fichier/etc/hostsdu serveur.
Voici le manifeste docker-compose.yml du registre, routé par Traefik sur ce domaine interne virtuel :
services:
registry:
image: registry:2
container_name: docker_registry
restart: unless-stopped
networks:
- proxy_net
security_opt:
- no-new-privileges:true
volumes:
- ./registry-data:/var/lib/registry
- ./auth:/auth
- /opt/docker/certs:/certs:ro
environment:
- REGISTRY_AUTH=htpasswd
- REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm
- REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd
- REGISTRY_HTTP_ADDR=0.0.0.0:5000
- REGISTRY_HTTP_TLS_CERTIFICATE=/certs/registry.local.pem
- REGISTRY_HTTP_TLS_KEY=/certs/registry.local-key.pem
labels:
- "traefik.enable=true"
- "traefik.http.routers.registry.rule=Host(`registry.local`)"
- "traefik.http.routers.registry.entrypoints=websecure"
- "traefik.tcp.routers.registry.rule=HostSNI(`registry.local`)"
- "traefik.tcp.routers.registry.tls.passthrough=true"
- "traefik.tcp.services.registry.loadbalancer.server.port=5000"
networks:
proxy_net:
external: true
Mécanique de l’Artefact (Build & Push)
Pour l’instant je lance ces commandes à la main pendant les phases de test, mais ce registre privé pose la base technique pour brancher plus tard des runners GitLab CI ou GitHub Actions et automatiser tout ça :
# 1. Compilation de l'image (Scellement du code source dans Nginx)
docker build -t registry.local/portfolio_web:v2.3 .
# 2. Transfert sécurisé de la nouvelle couche vers le registre local
docker push registry.local/portfolio_web:v2.3

Le piège du 404 sur le push
En mettant en place le registre, docker login passait sans problème, mais docker push renvoyait systématiquement une erreur 404 Not Found, sans plus d’explication.
En reprenant les logs de bout en bout, j’ai fini par comprendre que le problème venait d’un décalage entre couches réseau : Traefik déchiffrait la requête HTTPS avant de la transmettre au registre en HTTP pur. Le registre, pensant être contacté directement en HTTP, générait des URL de redirection en http:// pour le transfert des couches (blobs) de l’image. Le démon Docker, qui s’attendait à du chiffrement de bout en bout, rejetait ces URL non sécurisées.
La correction : ajouter le middleware Traefik X-Forwarded-Proto=https, pour que le registre comprenne qu’il est derrière un proxy en HTTPS et génère des URL de callback correctement chiffrées.
5. L’Exposition Sécurisée : Cloudflare Tunnels (Zero Trust)
Exposer un serveur local directement sur internet via une redirection de ports (NAT/PAT) sur un routeur, c’est s’exposer en continu au scan et au bruteforce. Lors de mes premiers tests avec une IP publique ouverte, j’ai d’ailleurs vu la connexion se dégrader (lags SSH, pertes de paquets), simplement à cause du bruit de fond permanent d’internet.
Pour éviter ça, j’ai mis en place du Zero Trust avec Cloudflare Tunnels.
Le principe : aucun port entrant ouvert sur le pare-feu. Un démon local (cloudflared) initie une connexion sortante vers le réseau de Cloudflare. Le serveur devient invisible depuis internet.
Découplage SSL : Le trafic public est chiffré en HTTPS (Layer 7) par le CDN Cloudflare, puis transporté via le protocole QUIC (Layer 4 / UDP) dans le tunnel, avant d’être déchargé en HTTP simple vers Traefik en interne. Ça évite d’avoir à gérer des certificats Let’s Encrypt côté serveur.
Voici le manifeste docker-compose.yml du connecteur, placé sur le même réseau proxy_net pour qu’il puisse résoudre le nom de Traefik via le DNS Docker :
services:
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared_tunnel
restart: unless-stopped
security_opt:
- no-new-privileges:true
networks:
- proxy_net
environment:
- TUNNEL_TOKEN=VOTRE_TOKEN_CLOUDFLARE
command: tunnel --no-autoupdate run
networks:
proxy_net:
external: true

Un service bien lancé… mais dans le mauvais réseau
Au premier lancement du tunnel Cloudflare, le conteneur démarrait mais renvoyait une erreur 502 Bad Gateway, avec dans les logs : lookup traefik_proxy on 1.1.1.1:53: no such host.
Le souci venait du mode de déploiement : j’avais suivi la doc officielle et lancé le conteneur avec un simple docker run. Résultat, il s’était retrouvé sur le réseau bridge par défaut de Docker, sans accès au résolveur DNS interne du réseau proxy_net — donc incapable de retrouver Traefik par son nom.
La solution a été d’abandonner la commande en ligne de commande au profit du docker-compose.yml, en forçant l’attachement au réseau proxy_net et en injectant le token via une variable d’environnement — ce qui, au passage, colle bien mieux à la logique IaC du reste de l’infra.
6. Hébergement Final : L’Artefact Immuable (Hugo + Nginx)
Le portfolio est rédigé en Markdown et compilé via Hugo, un générateur de site statique très rapide. Plutôt que de monter les fichiers HTML générés via un volume (bind mount) — ce qui casserait l’immutabilité du déploiement — ils sont directement copiés dans une image Nginx au moment du build.
Voici la recette (Dockerfile) utilisée pour construire l’artefact :
FROM nginx:alpine
# Suppression de la page par défaut de Nginx
RUN rm -rf /usr/share/nginx/html/*
# Copie du site généré par Hugo à l'intérieur de l'image Nginx
COPY ./public /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Une fois l’image compilée et poussée dans le registre privé (étape 4), elle est déployée via son manifeste de prod. Pour éviter qu’un conteneur ne monopolise les ressources de l’hôte, le CPU et la mémoire du conteneur web sont limités via les Linux Control Groups (cgroups), dans la directive deploy.
services:
web:
image: registry.local/portfolio_web:v2.3
container_name: portfolio_web
restart: unless-stopped
security_opt:
- no-new-privileges:true
deploy:
resources:
limits:
cpus: '0.50'
memory: 128M
networks:
- proxy_net
labels:
- "traefik.enable=true"
- "traefik.http.routers.portfolio.rule=Host(`aghilesportfolio.com`)"
- "traefik.http.routers.portfolio.entrypoints=web"
- "traefik.http.services.portfolio.loadbalancer.server.port=80"
networks:
proxy_net:
external: true

Conclusion
Au final, cette combinaison du réseau Docker, du routage Traefik et des tunnels Cloudflare donne une infra plutôt solide pour un usage perso :
- Pas de port exposé, IP publique masquée, SSL géré par Cloudflare, ressources isolées via cgroups.
- Un workflow de build/tag/push qui pose les bases d’une vraie CI/CD, même si pour l’instant tout est encore déclenché à la main.
- Un rollback simple en cas de souci : il suffit de repasser le tag de l’image dans le fichier compose (par exemple
v2.3→v2.2) et de redéployer.