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
- Architecture cible
- Prérequis
- Côté local : le projet Astro
- Le dépôt GitHub + le workflow
- Côté VPS : l’utilisateur et le dossier
- Docker : le conteneur qui sert le blog
- Le reverse proxy (Caddy ou Traefik)
- La clé SSH de déploiement
- Les secrets GitHub
- Le premier déploiement
- Publier un article au quotidien
- Surveiller les builds
- Sécurité
- 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 -vetgit --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-site → Private → 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
ghrefuse avec une erreur403 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 scopesrepoetworkflow, 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-lauransici).- 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 affiche0.0.0.0:80->80/tcp, un blocports:s’est glissé dans le compose : retirez-le, puisdocker 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.
-
Identifier le réseau Docker de Caddy :
docker inspect caddy -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' -
Brancher le conteneur blog sur ce réseau (
docker network connect <réseau> blog-laurans). -
Ajouter le site à son Caddyfile (trouver le fichier monté :
docker inspect caddy -f '{{json .Mounts}}') :votre-domaine.org { reverse_proxy blog-laurans:80 } -
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...), viatraefik.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 :
| Secret | Valeur |
|---|---|
VPS_HOST | le domaine (ex. blog.mondomaine.org) ou l’IP |
VPS_USER | deploy |
SSH_PRIVATE_KEY | le 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 :
src/content/journal/→ Add file → Create new file ;- écrire le frontmatter + le contenu ;
- 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
deploylimité : 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 :
- le DNS ne pointe pas (encore) vers le VPS ;
- le proxy n’a jamais eu l’occasion de tenter l’émission → redémarrez-le
(
docker restart caddyoudocker restart traefik) ; - 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_KEYmal posé (retour à la ligne manquant) ;- la clé publique absente de
authorized_keys; VPS_HOSTintrouvable (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