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

flowchart TD A[git tag vX.Y.Z] --> B[make build-prod] B --> C[Docker multi-stage build] C --> D[Image scratch /dist] D --> E[make push] E --> F[registry.domain.tld] F --> G[make deploy sur VPS] G --> H[docker pull] H --> I[docker cp /dist] I --> J[nginx sert les fichiers]

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.