Quand on déploie une application en production, la question du transport de l’image Docker se pose rapidement. Docker Hub est la solution évidente, mais elle a ses limites : images publiques par défaut sur le plan gratuit, dépendance à un tiers, latence variable. La solution : héberger son propre registry privé sur un VPS, et intégrer ça proprement dans son pipeline de déploiement.
C’est exactement le schéma utilisé sur ce projet. L’idée est simple : on build une image en local (ou en CI), on la pousse vers registry.domain.tld, puis le VPS la tire et extrait les fichiers statiques. Aucun code source ne transite par le serveur, aucune dépendance Node.js en production.
Dans cet article, on va voir comment monter ce registry, le sécuriser, et construire le workflow complet de build → push → deploy.
Disclaimers Ce qui sera décrit ci-dessous, n’est qu’une suggestion d’architecture et de configuration et n’a pour objet que de montrer qu’un exemple.
Mettre en place le registry Docker sur le VPS
Lancer le registry avec Docker Compose
Docker fournit une image officielle registry:2 qui suffit pour un usage personnel ou en équipe réduite. On la lance avec un docker-compose.yml sur le VPS :
services:
registry:
image: registry:2
container_name: docker-registry
restart: always
ports:
- "5000:5000"
volumes:
- ./data:/var/lib/registry
environment:
REGISTRY_AUTH: htpasswd
REGISTRY_AUTH_HTPASSWD_REALM: "Registry Realm"
REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY: /var/lib/registry
volumes:
- ./data:/var/lib/registry
- ./auth:/auth
Le registry écoute sur le port 5000. On ne l’expose pas directement — un reverse proxy (Caddy, Nginx, Traefik) s’occupera du HTTPS.
Créer les credentials d’authentification
On génère un fichier htpasswd pour protéger le registry :
mkdir -p auth
docker run --rm --entrypoint htpasswd httpd:2 -Bbn monuser monmotdepasse > auth/htpasswd
⚠️ Utilise un mot de passe fort. Ce registry sera exposé sur internet.
Exposer le registry en HTTPS avec Caddy
Le registry Docker exige le HTTPS pour fonctionner (sauf en localhost). Avec Caddy, la configuration est minimale — il gère les certificats Let’s Encrypt automatiquement :
registry.domain.tld {
reverse_proxy localhost:5000
}
Avec Nginx :
server {
listen 443 ssl;
server_name registry.domain.tld;
ssl_certificate /etc/letsencrypt/live/registry.domain.tld/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/registry.domain.tld/privkey.pem;
location / {
proxy_pass http://localhost:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
client_max_body_size 2000m;
}
}
client_max_body_size : sans cette directive, Nginx rejettera les push d’images volumineuses.
Vérifier que le registry répond
curl -u monuser:monmotdepasse https://registry.domain.tld/v2/_catalog
{"repositories":[]}
Le registry est opérationnel.
Construire l’image de production
Le pattern multi-stage avec scratch
L’objectif est de produire une image aussi petite que possible, qui ne contient que ce dont on a besoin en production. Pour un site statique (Astro, Next.js export, etc.), ça signifie : uniquement le dossier dist/.
On utilise un Dockerfile multi-stage :
ARG NODE_VERSION=node:lts-alpine
# Stage 1 : build
FROM ${NODE_VERSION} AS builder
ENV NODE_ROOT=/app
RUN apk --no-cache add yarn npm git
RUN mkdir -p $NODE_ROOT
WORKDIR ${NODE_ROOT}
COPY ./app .
RUN npm ci && npm run build
# Stage 2 : image finale (scratch = vide)
FROM scratch
COPY --from=builder /app/dist /dist
Le mot-clé scratch crée une image vide — pas d’OS, pas de shell, pas de runtime. Elle contient uniquement /dist. Le résultat est une image de quelques mégaoctets, impossible à exploiter directement.
Automatiser le build et le push avec Make
On câble le tout dans un Makefile pour éviter de taper les commandes Docker à la main :
REGISTRY = registry.domain.tld/mon-projet
GIT_TAG = $(shell git tag --sort=-v:refname | head -1)
IMAGE_TAG = $(if $(GIT_TAG),$(GIT_TAG),latest)
build-prod: ## Build l'image de production taguée avec le git tag
@echo "Building $(REGISTRY):$(IMAGE_TAG) ..."
@docker build \
--file Dockerfile-build \
--tag $(REGISTRY):$(IMAGE_TAG) \
--tag $(REGISTRY):latest \
.
push: ## Pousse l'image vers le registry privé
@docker push $(REGISTRY):$(IMAGE_TAG)
@docker push $(REGISTRY):latest
Le tag de l’image correspond au tag git du projet. On pousse toujours deux tags : le tag versionné et latest. Ainsi, le VPS peut tirer latest sans connaître le numéro de version.
Pour builder et pousser :
git tag v1.2.0
make build-prod
make push
Déployer depuis le VPS
Se connecter au registry depuis le VPS
Sur le VPS, on s’authentifie une seule fois :
docker login registry.domain.tld
Les credentials sont stockés dans ~/.docker/config.json. Docker les réutilise automatiquement pour les pull suivants.
Tirer l’image et extraire les fichiers statiques
Le pattern est élégant : on crée un container temporaire depuis l’image, on en copie le contenu /dist, puis on supprime le container. Le site statique est déposé directement là où nginx le sert.
REGISTRY_URL ?= registry.domain.tld
VERSION ?= latest
DEPLOY_PATH ?= /var/www/mon-projet/app
deploy: ## Tire l'image et met à jour les fichiers statiques
docker pull $(REGISTRY_URL)/mon-projet:$(VERSION)
docker create --name tmp_dist $(REGISTRY_URL)/mon-projet:$(VERSION) /bin/true
docker cp tmp_dist:/dist $(DEPLOY_PATH)
docker rm tmp_dist
@echo "✓ Déployé : $(REGISTRY_URL)/mon-projet:$(VERSION)"
On lance le déploiement depuis le VPS :
make -f Makefile.vps deploy
# ou avec une version précise :
make -f Makefile.vps deploy VERSION=v1.2.0
latest: Pulling from mon-projet
Digest: sha256:abc123...
Status: Image is up to date for registry.domain.tld/mon-projet:latest
✓ Déployé : registry.domain.tld/mon-projet:latest
Vue d’ensemble du pipeline
Nettoyer les anciennes images
Un registry qui grossit sans contrôle finit par saturer le disque. L’image officielle fournit une commande de garbage collection :
docker exec docker-registry registry garbage-collect /etc/docker/registry/config.yml
Pour supprimer un tag précis via l’API :
# Récupérer le digest du tag
DIGEST=$(curl -s -u monuser:monmotdepasse \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
https://registry.domain.tld/v2/mon-projet/manifests/v1.0.0 \
-I | grep Docker-Content-Digest | tr -d '\r' | cut -d' ' -f2)
# Supprimer le manifest
curl -u monuser:monmotdepasse -X DELETE \
https://registry.domain.tld/v2/mon-projet/manifests/$DIGEST
Puis relancer le garbage collect pour libérer l’espace disque.
Ce qu’on a mis en place
Ce pipeline résout plusieurs problèmes d’un coup : plus de dépendance à Docker Hub, images privées par défaut, images ultra-légères grâce à scratch, et un déploiement réduit à une seule commande make deploy sur le VPS. Le VPS de production ne construit rien — il tire et dépose, ce qui évite d’y installer Node.js ou tout autre outil de build.
C’est une architecture simple, maîtrisée de bout en bout, et qui scale correctement pour des projets personnels ou des petites équipes.