← Retour au blog

De scp dist/ à GHCR : pourquoi j'ai conteneurisé un site statique Astro

Retour d'expérience : déployer un site statique Astro avec Docker, nginx, GitHub Actions et GHCR sur un VPS auto-géré. Pourquoi j'ai abandonné le scp dist/ et ce que j'y ai gagné — artefact immuable, rollback propre, serveur minimal.

Par Alexandre Petitjean 9 min de lecture Mis à jour le 8 juin 2026
#docker #devops #astro #ghcr #ci-cd
Conteneurs maritimes empilés dans un port
Photo : Chanaka E — Pexels
Sommaire

Ce site est un site statique. Astro génère un dossier dist/ avec du HTML, du CSS et un peu de JS. Pas de runtime Node en production, pas de base de données, pas d’API. Le serveur n’a qu’un seul job : servir des fichiers.

Pendant des mois, mon pipeline de déploiement tenait en une ligne :

scp -r dist/ user@vps:/var/www/portfolio/

Ça marchait. Je l’ai remplacé par un Dockerfile Technique de Dockerfile à plusieurs étapes : une étape compile l'application, une autre — minimale — ne garde que le résultat à servir.Docs Docker ↗ et une image publiée sur GitHub Container Registry : le registre d'images de conteneurs intégré à GitHub, où l'on publie et stocke ses images Docker.Docs GHCR ↗. À première vue, c’est du sur-engineering pour servir une centaine de fichiers HTML. En pratique, j’y ai gagné trois choses que je n’aurais pas obtenues autrement.

Cet article s’adresse surtout aux développeurs et aux freelances qui hébergent plusieurs petits projets sur un Virtual Private Server : un serveur virtuel loué chez un hébergeur, que l'on administre soi-même (OS, services, sécurité). auto-géré et veulent sortir du déploiement artisanal sans repasser par un Platform as a Service : une plateforme (Vercel, Netlify…) qui gère le build, l'hébergement et le déploiement à ta place, en échange de moins de contrôle.. Voici le flux complet, en un coup d’œil :

flowchart LR
    A[GitHub Actions] -->|push master| B["Image GHCR<br/>latest + sha-xxxx"]
    A -->|release| E["Image GHCR<br/>version + stable"]
    E -->|"docker compose pull<br/>(deploy SSH)"| C[VPS]
    C --> D["Conteneur nginx<br/>sert /dist"]

Le point de départ et ce qui me dérangeait

Le workflow d’origine était minimal :

  1. npm ci && npm run build sur ma machine ou dans une GitHub Action.
  2. scp du dossier dist/ vers le VPS.
  3. nginx servait le contenu.

Trois irritants se sont accumulés :

Node devait être installé sur le VPS (ou une action devait builder dans la CI avec une version de Node potentiellement différente). Le VPS finissait par traîner des dépendances dont il n’avait rien à faire à l’exécution.

Aucun rollback simple. Pour revenir à la version précédente, je devais rebuilder un ancien commit et le repousser. Pas catastrophique pour un portfolio, plus embêtant pour un projet où l’historique des artefacts compte.

L’état du serveur dérivait dans le temps. Le classique : un apt upgrade, la version de Node qui change, et le prochain build qui casse pour une raison incompréhensible.

Aucun de ces points n’est bloquant en soi. Mais accumulés, ils signifiaient que mon “déploiement en une ligne” reposait sur la stabilité d’un VPS que je ne voulais pas avoir à maintenir.

Pourquoi Docker pour quelque chose d’aussi simple

L’argument qu’on entend souvent contre Docker sur du statique : “C’est nginx qui sert les fichiers, ajouter une couche de conteneur n’apporte rien de plus.” C’est vrai pour la phase de service. Ce n’est pas vrai pour la phase de livraison de ces fichiers.

Conteneuriser m’apporte trois choses concrètes :

  1. L’artefact déployé est immuable. Un site Astro buildé dans une GitHub Action avec une version de Node pinnée est déjà reproductible — Docker n’invente rien là-dessus. Le vrai gain, c’est qu’au lieu de pousser des fichiers reconstruits à chaque fois, je déploie exactement l’image testée en CI, au bit près. Ce qui tourne en prod est précisément ce qui a été validé, pas une reconstruction qui peut diverger.

  2. Chaque déploiement est une image versionnée. Je tague chaque image avec le SHA du commit qui l’a produite. Revenir en arrière, c’est re-déployer le tag voulu — sans rebuild ni archéologie git (j’y reviens plus bas : le faire proprement demande deux précautions).

  3. Le VPS redevient bête. Il n’a plus besoin de Node, de npm, de devDependencies. Il sait puller une image et lancer un conteneur. C’est tout. Le jour où je change de VPS, je migre en cinq minutes.

Le point 3 est, pour moi, le plus important. La conteneurisation, ce n’est pas vraiment une histoire de “isoler les apps” — c’est une histoire de déplacer la complexité hors de la prod. Le serveur ne sait plus comment construire le site, et c’est mieux ainsi.

Le Dockerfile

L’idiome Docker canonique pour ce cas : un build multi-stage. Un stage qui compile (lourd, jetable), un stage qui sert (minimal, déployé).

# syntax=docker/dockerfile:1.7

# Build stage: compile the Astro site to static assets
FROM node:20-alpine AS builder
WORKDIR /app

# Install dependencies first to leverage Docker layer caching
COPY package.json package-lock.json ./
RUN npm ci

# Copy the rest of the source and build
COPY . .
RUN npm run build

# Runtime stage: serve the static output with nginx
FROM nginx:1.27-alpine AS runtime

# Replace the default site config with our optimized one
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Copy the built static site
COPY --from=builder /app/dist /usr/share/nginx/html

EXPOSE 80

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget -q --spider http://127.0.0.1/ || exit 1

Deux détails qui font la différence :

  • COPY package*.json avant COPY . . : tant que tu ne touches pas aux dépendances, Docker réutilise la couche npm ci du cache. Le build passe de plus d’une minute à quelques secondes.
  • HEALTHCHECK : laisse Docker répondre à la question “ce conteneur est-il vivant ?”. Utile pour docker compose et tout orchestrateur en amont.

Le résultat : une image finale d’environ 50 Mo (nginx alpine + le dist/), sans aucune trace de Node, npm, ou des sources TypeScript.

Le pipeline GitHub Actions vers GHCR

Côté CI, un push sur master déclenche le build et la publication de l’image sur le GitHub Container Registry — mais pas le déploiement. Celui-ci n’est lancé que sur publication d’une release, pour garder la main sur les mises en prod plutôt que de déployer à chaque commit.

L’extrait clé du workflow :

- name: Metadata
  id: meta
  uses: docker/metadata-action@v6
  with:
    images: ghcr.io/${{ github.repository }}
    tags: |
      type=raw,value=latest,enable={{is_default_branch}}
      type=sha,prefix=sha-
      type=ref,event=tag
      type=raw,value=stable,enable=${{ github.event_name == 'release' }}

- name: Build & push
  uses: docker/build-push-action@v7
  with:
    context: .
    push: true
    tags: ${{ steps.meta.outputs.tags }}
    labels: ${{ steps.meta.outputs.labels }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

Deux choix volontaires :

  • Tags multiples. Sur master : latest (le sommet de la branche, buildé mais pas déployé) + sha-<short> (pour pointer un commit précis lors d’un rollback). Sur une release : le numéro de version (immuable) + stable (le pointeur que suit la prod).
  • Cache Le moteur de build moderne de Docker : builds parallélisés, cache de couches avancé et fonctionnalités comme le montage de cache.Docs BuildKit ↗ via GitHub Actions cache. Les builds successifs réutilisent les couches inchangées (typiquement npm ci), ce qui ramène le temps de build CI à quelques dizaines de secondes en régime stable.

Côté VPS, le déploiement (lancé en SSH par la GitHub Action sur release) se résume à :

# L'image est privée : on s'authentifie auprès de GHCR avant de puller
echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin
docker compose pull portfolio
docker compose up -d portfolio
docker logout ghcr.io
docker image prune -f

Le docker login est indispensable : l’image est privée sur GHCR (le token est celui, éphémère, du run GitHub Actions). Ensuite docker compose pull récupère la nouvelle image, up -d recrée le conteneur si elle a changé, et image prune nettoie les anciennes versions pour ne pas saturer le disque. Pas de build sur le serveur, pas de Node, pas de npm. Juste un pull et un restart.

Le rollback, en vrai

C’est le point que j’ai sous-estimé au début. Dans le flux nominal, la prod suit le tag stable, repoussé à chaque release, et un déploiement se résume au pull + up -d ci-dessus. Mais stable est un pointeur mobile : un docker compose pull te donne toujours la dernière release, jamais une version antérieure. Pour pouvoir revenir en arrière, il faut que le docker-compose.yml référence le tag via une variable d’environnement.

services:
  portfolio:
    image: ghcr.io/alexandre-petitjean/portfolio:${IMAGE_TAG:-stable}
    container_name: portfolio
    restart: unless-stopped
    # Bind sur localhost : le reverse proxy de l'hôte route vers ce port
    ports:
      - "127.0.0.1:${HOST_PORT:-8080}:80"

En temps normal, IMAGE_TAG n’est pas défini : compose retombe sur stable, c’est-à-dire la release courante. Pour revenir à une version précise, je pin son tag le temps du rollback :

IMAGE_TAG=v1.2.3 docker compose pull
IMAGE_TAG=v1.2.3 docker compose up -d

Le conteneur redémarre sur l’image exacte de cette release, sans rebuild ni édition manuelle du fichier (un sha-<short> fait aussi bien l’affaire pour cibler un commit). Une fois le correctif livré dans une nouvelle release, je repasse simplement sur stable. C’est ce détail — un tag piloté par variable — qui transforme le rollback de promesse théorique en manœuvre d’une seule ligne.

Le résultat concret

Avant / après, en pratique :

MétriqueAvant (scp dist/)Après (Docker + GHCR)
Dépendances sur le VPSNode, npm, nginxDocker, docker compose
RollbackRebuild de l’ancien commitIMAGE_TAG=v… up -d
Artefact déployéReconstruit à chaque foisImage immuable (tag SHA)
Taille de l’artefact~3 Mo (dist/ brut)~50 Mo (image)

Le seul point où Docker perd, c’est la taille de l’artefact. Pour un site statique servi à quelques visiteurs par jour, c’est complètement négligeable.

Quand ne PAS faire ça

Soyons honnêtes : ce setup n’a pas que des avantages.

Si tu déploies déjà sur un PaaS qui fait le build pour toi (Vercel, Netlify, Cloudflare Pages), conteneuriser est un retour en arrière. Tu reprends à ta charge ce que le PaaS faisait gratuitement (HTTPS, CDN, builds, monitoring).

Si tu n’as qu’un seul site et un seul environnement, le scp marche très bien. La complexité supplémentaire ne se justifie pas tant que tu n’as pas le besoin de rollback ou de reproductibilité.

Si tu n’es pas à l’aise avec Docker en prod, l’apprentissage de la stack (compose, registry, secrets, healthchecks, Serveur placé devant tes applications : il reçoit le trafic et le redirige vers le bon service (souvent aussi TLS, cache, routage).) coûte plus cher que ce qu’il rapporte à court terme.

Pour mon cas — VPS auto-géré, plusieurs projets perso à héberger côte à côte, envie de pouvoir migrer de machine en un après-midi — le ratio bénéfice/coût est largement positif. Pour un projet vraiment unique, je serais resté sur scp.

Ce que je ferais différemment sur un projet client

Pour mon portfolio, le ratio effort/bénéfice est déjà bon tel quel. Sur un projet client, je durcirais plusieurs points avant de parler de production :

  • Tags immuables en prod : déploiement sur une version précise, jamais sur un tag mobile (latest, stable).
  • Pas de mise en prod automatique sans validation : une étape d’approbation entre le build et le déploiement.
  • Monitoring externe : le HEALTHCHECK Docker dit si le conteneur vit, pas si le site répond vu d’Internet.
  • Retention policy sur GHCR : purger les vieilles images plutôt que de les accumuler indéfiniment.
  • Rollback documenté : une procédure écrite, pas un IMAGE_TAG=… retrouvé dans un historique de shell.
  • Sauvegarde versionnée de la config (nginx, compose), restaurable en l’état.

C’est précisément ce travail — prendre un déploiement artisanal et le transformer en système maintenable, sans le sur-industrialiser — qui constitue l’essentiel de mes missions d’industrialisation et de mise en place de CI/CD.

Ce que je creuserais dans un prochain article

Cet article reste haut niveau. Dans les suivants, je détaillerai :

  • La construction du Dockerfile multi-stage ligne par ligne, et ce qu’on peut encore optimiser sur la taille d’image.
  • Le pipeline GitHub Actions → GHCR → VPS dans le détail, avec la gestion des clés SSH et des secrets (j’ai pas mal galéré sur ce point).

Si tu hésites à conteneuriser un projet statique : le coût d’entrée est réel, mais l’investissement se rentabilise dès le deuxième projet hébergé sur le même VPS. Et tu gagnes une chose dont on parle peu — la sérénité d’avoir un serveur qui ne sait plus rien faire d’autre que pull et up -d.