Guide complet : blog statique Astro + Markdown + GitHub Actions → VPS


Tout ce qu’il faut pour reproduire, de zéro, un site/blog statique où l’on publie de simples fichiers Markdown : git push → build → déploiement automatique sur un VPS. Testé et validé de bout en bout (dépôt GitHub privé + VPS Debian/Ubuntu + Docker/Caddy).


Sommaire

  1. Architecture cible
  2. Prérequis
  3. Côté local : le projet Astro
  4. Le dépôt GitHub + le workflow
  5. Côté VPS : l’utilisateur et le dossier
  6. Docker : le conteneur qui sert le blog
  7. Le reverse proxy (Caddy ou Traefik)
  8. La clé SSH de déploiement
  9. Les secrets GitHub
  10. Le premier déploiement
  11. Publier un article au quotidien
  12. Surveiller les builds
  13. Sécurité
  14. Dépannage

1. Architecture cible

Votre PC                  GitHub Actions                     VPS (Debian/Ubuntu)
  |                          |                                  |
  |--- git push ------------->|                                  |
  |   (fichiers .md)          |--- VM Linux temporaire           |
  |                           |   npm install + npm run build  ->|  (tout se passe ici)
  |                           |--- rsync (SSH) ---> /srv/blog ---|
  |                           |                                  |--- conteneur nginx (sert les fichiers)
  |                           |                                  |--- Caddy/Traefik : HTTPS + routage
  |                                                              |--- visiteur -> https://votre-domaine
  • Votre PC : écrit et pousse les fichiers Markdown. Plus rien à faire après le push (vous pouvez éteindre l’ordinateur, la publication continue).
  • GitHub Actions : fournit gratuitement une VM Ubuntu temporaire par déploiement, qui construit le site puis le synchronise sur le VPS par SSH.
  • Le VPS : ne construit rien, il sert des fichiers statiques via nginx dans Docker, derrière un reverse proxy qui gère le HTTPS.

Coût : GitHub Actions = gratuit (dépôt public : illimité ; dépôt privé : 2 000 minutes/mois ≈ 4 000 déploiements/mois ; chaque run dure ~30 s). Le seul coût est le VPS (déjà possédé) et le nom de domaine.


2. Prérequis

  • Un ordinateur local avec Node.js 18+ (recommandé : 20/22) et git (vérifier : node -v et git --version).
  • Un compte GitHub et la CLI gh (facultative mais très pratique).
  • Un VPS Debian/Ubuntu avec Docker installé (docker + docker compose).
  • Un nom de domaine dont vous contrôlez le DNS (ex. blog.mondomaine.org).

3. Côté local : le projet Astro

Créer le projet dans un dossier, par exemple mon-site :

mkdir mon-site && cd mon-site

Créer la structure de dossiers :

mkdir -p src/pages/journal src/layouts src/styles src/content/journal \
         public/fonts .github/workflows scripts

3.1 package.json

{
  "name": "mon-site",
  "type": "module",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "dev": "astro dev",
    "build": "astro build",
    "preview": "astro preview"
  },
  "dependencies": {
    "@astrojs/check": "^0.9.4",
    "astro": "^4.16.19",
    "typescript": "^5.6.3"
  }
}

3.2 astro.config.mjs

import { defineConfig } from 'astro/config';

export default defineConfig({
  site: 'https://votre-domaine.org', // ← votre domaine réel
});

3.3 tsconfig.json

{
  "extends": "astro/tsconfigs/base"
}

3.4 .gitignore

# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
# environment variables
.env
.env.production
# macOS
.DS_Store

# clé de déploiement (NE JAMAIS COMMITTER)
.deploy/
deploy_key*

3.5 Le schéma des articles — src/content/config.ts

Chaque article .md devra commencer par un frontmatter (titre, date…) validé ici :

import { defineCollection, z } from 'astro:content';

const journal = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    date: z.coerce.date(),
    description: z.string().optional(),
    tags: z.array(z.string()).optional(),
    cover: z.string().optional(),
  }),
});

export const collections = { journal };

3.6 Exemple d’article — src/content/journal/premier-article.md

---
title: "Bonjour, monde !"
date: 2026-09-20
description: "Le premier billet, pour vérifier la chaîne de bout en bout."
tags: [site]
---

Écrivez votre contenu en **Markdown**. Les règles de frontmatter ci-dessus
(`title` et `date`) sont obligatoires ; `description` et `tags` sont facultatifs.

3.7 Le squelette HTML — src/layouts/Base.astro

---
import '../styles/global.css';

interface Props {
  title?: string;
  description?: string;
}

const { title = 'Mon site', description = '' } = Astro.props;
---
<!doctype html>
<html lang="fr">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="generator" content={Astro.generator}>
  <title>{title}</title>
  <meta name="description" content={description}>
  <link rel="icon" type="image/svg+xml" href="/favicon.svg">
  <link rel="canonical" href={new URL(Astro.url.pathname, Astro.site).href}>
</head>
<body>
  <header>
    <nav>
      <a href="/">Accueil</a>
      <a href="/journal">Journal</a>
    </nav>
  </header>
  <main id="contenu">
    <slot />
  </main>
  <footer>© {new Date().getFullYear()} · Tous droits réservés</footer>
</body>
</html>

3.8 Les pages qui rendent les articles

src/pages/journal/[...slug].astro — une page générée par article :

---
import { getCollection, render } from 'astro:content';
import Base from '../../layouts/Base.astro';

export async function getStaticPaths() {
  const posts = await getCollection('journal', ({ data }) => data.date <= new Date());
  return posts
    .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
    .map((post) => ({ params: { slug: post.slug }, props: { post } }));
}

const { post } = Astro.props;
const { Content } = await render(post);
---

<Base title={`${post.data.title} · Mon site`} description={post.data.description}>
  <article>
    <h1>{post.data.title}</h1>
    <time datetime={post.data.date.toISOString()}>
      {post.data.date.toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' })}
    </time>
    <Content />
  </article>
</Base>

src/pages/journal/index.astro — la liste des articles :

---
import { getCollection } from 'astro:content';
import Base from '../../layouts/Base.astro';

const posts = (await getCollection('journal', ({ data }) => data.date <= new Date()))
  .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
---

<Base title="Journal · Mon site" description="Tous les billets.">
  <h1>Le journal</h1>
  <ul>
    {
      posts.map((post) => (
        <li>
          <a href={`/journal/${post.slug}`}>
            <strong>{post.data.title}</strong> —{' '}
            {post.data.date.toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' })}
          </a>
        </li>
      ))
    }
  </ul>
</Base>

src/pages/index.astro — la page d’accueil (liste les derniers articles :

---
import { getCollection } from 'astro:content';
import Base from '../layouts/Base.astro';

const posts = (await getCollection('journal', ({ data }) => data.date <= new Date()))
  .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
  .slice(0, 5);
---

<Base title="Mon site" description="Un site statique généré par Astro.">
  <h1>Mon site</h1>
  <p>Bienvenue.</p>
  <h2>Derniers articles</h2>
  <ul>
    {
      posts.map((post) => (
        <li><a href={`/journal/${post.slug}`}>{post.data.title}</a></li>
      ))
    }
  </ul>
</Base>

3.9 src/styles/global.css

Un fichier de styles minimal (stylez librement) :

* ,*::before,*::after{box-sizing:border-box}
html,body{margin:0;padding:0}
body{font-family:system-ui,sans-serif;line-height:1.6;max-width:760px;margin:0 auto;padding:1.5rem}
img{max-width:100%}
pre{background:#111;color:#eee;padding:1rem;border-radius:8px;overflow-x:auto}

3.10 Fichiers statiques dans public/

Astro copie tels quels tout ce qui est dans public/. Utile pour : public/favicon.svg, public/robots.txt, public/llms.txt (le « brief » des robots IA), ou encore un guide HTML écrit à la main dans public/guide/index.html.

3.11 Vérifier que le projet compile

npm install
npm run build     # génère dist/ (HTML statique)
npm run dev       # serveur local http://localhost:4321

4. Le dépôt GitHub + le workflow

4.1 Créer le dépôt

À la souris : New repository → nom mon-sitePrivate → créer.

En CLI (gh) :

gh auth login                      # connexion une fois
gh repo create mon-site --private --source . --remote origin --push

⚠️ Piège fréquent. Si gh refuse avec une erreur 403 Resource not accessible by personal access token, le token n’a pas les bons droits. La voie fiable : générer un classic token (Settings → Developer settings → Personal access tokens → Tokens (classic)) avec les scopes repo et workflow, puis :

gh auth logout && gh auth login --with-token <<< "ghp_..."

4.2 Le workflow — .github/workflows/deploy.yml

C’est le fichier central. Il construit le site et le pousse sur le VPS :

name: Deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

env:
  VPS_HOST: ${{ secrets.VPS_HOST }}
  VPS_USER: ${{ secrets.VPS_USER }}
  VPS_PATH: /srv/blog-laurans

jobs:
  deploy:
    name: Build puis déploiement sur le VPS
    runs-on: ubuntu-latest
    steps:
      - name: Récupère le dépôt
        uses: actions/checkout@v4

      - name: Node.js 22
        uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - name: Installe les dépendances
        run: npm ci

      - name: Construit le site (Markdown → HTML statique)
        run: npm run build

      - name: Déploie dist/ sur le VPS via rsync
        run: |
          mkdir -p "$HOME/.ssh"
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > "$HOME/.ssh/deploy_key"
          chmod 600 "$HOME/.ssh/deploy_key"
          ssh-keyscan -H "$VPS_HOST" >> "$HOME/.ssh/known_hosts"
          rsync -az --delete -e "ssh -i $HOME/.ssh/deploy_key" \
            dist/ "$VPS_USER@$VPS_HOST:$VPS_PATH"
        # Pas de reload : le conteneur nginx monte le dossier en volume,
        # chaque rsync est servi immédiatement.

Paramètres à adapter :

  • VPS_PATH : le dossier servi sur le VPS (/srv/blog-laurans ici).
  • Les trois secrets (VPS_HOST, VPS_USER, SSH_PRIVATE_KEY) sont définis dans Settings → Secrets and variables → Actions (voir section 9).

4.3 Pousser la première version

git init -b main
git add -A
git commit -m "Init : site Astro + workflow de déploiement"
git remote add origin https://github.com/VOTRE_COMPTE/mon-site.git
git push -u origin main

Si le dépôt distant contient déjà un README auto-généré, forcer le push est sans danger : git push -u origin main --force (votre projet local est l’autorité).


5. Côté VPS : l’utilisateur et le dossier

Connecté en SSH sur le VPS (Debian/Ubuntu) :

# le seul paquet système nécessaire
sudo apt update && sudo apt install -y rsync

# utilisateur de déploiement (celui que GitHub Action utilisera en SSH)
sudo useradd -m -s /bin/bash deploy

# dossier servi par nginx, propriété de 'deploy'
sudo mkdir -p /srv/blog-laurans
sudo chown -R deploy:deploy /srv/blog-laurans

6. Docker : le conteneur qui sert le blog

Le rôle de nginx ici est uniquement de servir les fichiers statiques montés en volume. Il ne faut pas lui ouvrir de port sur l’hôte (il n’est joignable que via le réseau Docker, par le reverse proxy).

~/blog-laurans/docker-compose.yml (un fichier séparé de celui du proxy) :

services:
  blog-laurans:
    image: nginx:alpine
    container_name: blog-laurans
    restart: unless-stopped
    volumes:
      - /srv/blog-laurans:/usr/share/nginx/html:ro
    networks:
      - web-proxy          # ← le réseau partagé avec le reverse proxy (voir section 7)
    labels:
      # Cas Traefik uniquement (voir 7.2) — ignorer si vous utilisez Caddy :
      - "traefik.enable=true"
      - "traefik.http.routers.blog-laurans.rule=Host(`votre-domaine.org`)"
      - "traefik.http.routers.blog-laurans.entrypoints=websecure"
      - "traefik.http.routers.blog-laurans.tls=true"

networks:
  web-proxy:
    external: true

Lancer :

cd ~/blog-laurans && docker compose up -d
docker ps --filter name=blog-laurans   # Doit afficher « 80/tcp » (interne, PAS « 0.0.0.0:80 »)

⚠️ La colonne PORTS doit afficher 80/tcp. Si elle affiche 0.0.0.0:80->80/tcp, un bloc ports: s’est glissé dans le compose : retirez-le, puis docker compose down && docker compose up -d.


7. Le reverse proxy (Caddy ou Traefik)

Un seul reverse proxy doit occuper les ports 80/443 de l’hôte. Généralement, celui que le VPS possède déjà. Deux scénarios.

7.1 Cas A — un serveur Caddy existe déjà (recommandé si présent)

Caddy gère le HTTPS Let’s Encrypt automatiquement, sans aucune config ACME.

  1. Identifier le réseau Docker de Caddy :

    docker inspect caddy -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}  {{end}}'
    
  2. Brancher le conteneur blog sur ce réseau (docker network connect <réseau> blog-laurans).

  3. Ajouter le site à son Caddyfile (trouver le fichier monté : docker inspect caddy -f '{{json .Mounts}}') :

    votre-domaine.org {
        reverse_proxy blog-laurans:80
    }
    
  4. Recharger : docker restart caddy.

Caddy émet alors le certificat et route le domaine vers le blog. Aucune autre étape.

7.2 Cas B — Traefik

Ajouter au blog des labels traefik.* (voir section 6) et s’assurer que :

  • le routeur référence le réglage de certificats déjà déclaré dans le compose Traefik (ex. --certificatesresolvers.myresolver.acme...), via traefik.http.routers.blog-laurans.tls.certresolver=myresolver ;
  • le réseau partagé correspond à celui de Traefik (cf. le networks: du compose).

7.3 Le DNS indispensable

Le record A (ou CNAME) pointe le domaine vers l’IP du VPS :

votre-domaine.org.  A  1.2.3.4

Sans ça, ni Caddy ni Traefik ne peuvent émettre de certificat (le challenge ACME doit joindre le domaine pour vérifier qu’il vous appartient).


8. La clé SSH de déploiement

Une clé SSH dédiée (à ne jamais confondre avec votre clé personnelle).

# sur votre machine locale, dans le dossier du projet (gitignoré)
ssh-keygen -t ed25519 -f .deploy/deploy_key -C "github-actions-deploy" -N ""

La clé publier doit rester uniquement sur le VPS, la privée uniquement dans le secret GitHub (section 9) et sur votre machine.

Sur le VPS, autoriser la clé publique pour l’utilisateur deploy :

sudo mkdir -p /home/deploy/.ssh
echo 'ssh-ed25519 AAAA...contenu-de-deploy_key.pub' | sudo tee /home/deploy/.ssh/authorized_keys
sudo chown -R deploy:deploy /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys

Tester, depuis la machine locale :

ssh -i .deploy/deploy_key deploy@IP_DU_VPS        # doit se connecter sans mot de passe
ss -tlnp | grep ':80 '                            # vérifier qui tient http

9. Les secrets GitHub

Le workflow lit 3 secrets (déclarés dans env: du workflow). À créer dans dépôt → Settings → Secrets and variables → Actions → New repository secret :

SecretValeur
VPS_HOSTle domaine (ex. blog.mondomaine.org) ou l’IP
VPS_USERdeploy
SSH_PRIVATE_KEYle contenu complet de .deploy/deploy_key

Le nom de domaine en VPS_HOST est plus robuste que l’IP (insensible aux changements d’adresse tant que le DNS suit).


10. Le premier déploiement

Faire un commit et le pousser — cela déclenche la première exécution du workflow :

git add -A && git commit -m "Premier déploiement" && git push

Surveiller la progression sur github.com/VOTRE_COMPTE/mon-site/actions. Le run (≈ 30 s) doit afficher 5 étapes vertes : Récupère le dépôt, Node.js 22, Installe, Construit, Déploie via rsync.

Puis ouvrir https://votre-domaine.org/ : l’accueil et le premier billet sont en ligne.


11. Publier un article au quotidien

En local — un fichier Markdown, deux commandes :

# créer l'article (optionnel : il suffit de copier un article existant)
./scripts/nouvel-article.sh "Mon nouveau billet"

# publier
git add -A && git commit -m "nouvel article" && git push

Environ 30 s plus tard, l’article est en ligne à l’adresse /journal/mon-nouveau-billet/ et apparaît sur l’accueil.

Sans terminal — dans l’interface web de GitHub :

  1. src/content/journal/Add fileCreate new file ;
  2. écrire le frontmatter + le contenu ;
  3. Commit changes : le déploiement part tout seul.

Depuis un éditeur Markdown (Obsidian, VSCode…) : ouvrir le dossier du projet et enregistrer les .md dans src/content/journal/.


12. Surveiller les builds

gh run list  --repo VOTRE_COMPTE/mon-site    # liste des derniers déploiements
gh run view --repo VOTRE_COMPTE/mon-site 123456789   # logs complets d'un run
gh run watch --repo VOTRE_COMPTE/mon-site   # suivre un run en direct

Ou à la souris : onglet Actions du dépôt ; chaque étape y est déroulable avec ses logs et durées.


13. Sécurité

  • Ne jamais committer la clé privée. Le dossier .deploy/ est dans .gitignore. Si une clé part sur GitHub par erreur : supprimez le fichier du dépôt ET régénérez la clé (rotation), puis remettez-la sur le VPS et dans le secret.
  • Le dépôt peut rester privé : seul le résultat publié (le site) est public.
  • Utilisateur deploy limité : il ne possède que le dossier /srv/blog-laurans, pas de droits sudo.
  • Secrets GitHub : jamais dans le workflow en clair ; toujours via ${{ secrets.* }}.
  • Le certificat est renouvelé automatiquement (Caddy/Traefik gèrent le renouvellement).

À noter aussi : robots.txt peut explicitement autoriser les crawlers IA (GPTBot, ClaudeBot, PerplexityBot…) — c’est un choix de visibilité, à documenter dans un public/llms.txt si vous voulez un résumé du site pour les agents.


14. Dépannage

14.1 « Bind for 0.0.0.0:80 failed: port is already allocated »

Un autre conteneur occupe déjà le 80/443 (souvent un autre reverse proxy, ex. Caddy oublié). Diagnostic :

docker ps --format '{{.Names}} {{.Ports}}' | grep -E ':(80|443)' 
sudo ss -tlnp | grep -E ':(80|443) '

Décidez qui garde le 80/443 (un seul proxy !) et retirez le ports: du second.

14.2 Le HTTPS répond « tlsv1 alert internal error »

Le certificat n’est pas encore émis. Ordre des causes les plus fréquentes :

  1. le DNS ne pointe pas (encore) vers le VPS ;
  2. le proxy n’a jamais eu l’occasion de tenter l’émission → redémarrez-le (docker restart caddy ou docker restart traefik) ;
  3. la tentative d’émission a eu lieu avant la propagation DNS → redémarrez encore.

14.3 Le premier run échoue à l’étape rsync

  • SSH_PRIVATE_KEY mal posé (retour à la ligne manquant) ;
  • la clé publique absente de authorized_keys ;
  • VPS_HOST introuvable (DNS pas encore posé).

14.4 Le run échoue à l’étape « Installe les dépendances »

npm ci exige un package-lock.json présent dans le dépôt : générez-le localement avec npm install et committez-le.

14.5 403 sur l’API GitHub (gh)

Le token n’a pas les bons scopes : utilisez un classic token avec repo + workflow (voir section 4.1).

14.6 L’article n’apparaît pas

date est dans le futur : le filtre data.date <= new Date() l’exclut du site. Mettez la date du jour.


Rappel des fichiers clés du projet

mon-site/
├── .github/workflows/deploy.yml     # le workflow de build + déploiement
├── astro.config.mjs                 # site: https://votre-domaine.org
├── package.json                     # astro + scripts
├── scripts/nouvel-article.sh        # (optionnel) gabarit d'article
├── public/                          # copié tel quel (robots, favicon, fonts…)
└── src/
    ├── content/config.ts            # schéma des articles (frontmatter)
    ├── content/journal/*.md         # ← LES ARTICLES, on n'écrit que là
    ├── layouts/Base.astro           # squelette HTML commun
    ├── pages/
    │   ├── index.astro              # accueil
    │   └── journal/
    │       ├── index.astro          # liste du journal
    │       └── [...slug].astro      # 1 page / article
    └── styles/global.css            # le design