Pour Pilou — monte ton serveur de dev, étape par étape
ton serveur de dev — de zéro à https://tout.dev.tondomaine.fr█
// deux façons de suivre ce guide : à la main, bloc [copier] par bloc [copier] — ou tu fais les étapes 02→06, puis tu colles l'url de cette page à claude code en lui disant « installe-moi tout ça ». il sait faire.
mondomaine.fr = ton domaine · contact@mondomaine.fr = ton email (Let's Encrypt) · user = ubuntu
// système d'origine conçu par kwiss, adapté et éprouvé sur le serveur de jérémy depuis juin 2026.
00 LE PRINCIPE_
Internet
│
*.dev.mondomaine.fr (DNS wildcard → IP du serveur)
│
┌────────▼────────┐
│ Caddy (:443) │ HTTPS auto (Let's Encrypt on-demand)
└────────┬────────┘
┌────────────────┼──────────────────────┐
│ │ │
folio.dev.… arcads.dev.… planning.dev.…
│ │ │
localhost:3025 localhost:3041 localhost:3050
│ │ │
~/projects/folio ~/projects/arcads ~/projects/planning-boutiquesLa convention tient en deux lignes :
Un script, devproxy-sync, lancé par cron toutes les minutes, scanne ces dossiers, régénère la config de Caddy et la recharge seulement si elle a changé.
Tu crées un dossier → une minute plus tard, l'URL existe. Tu poses un fichier .devproxy.json avec { "port": 3123 } dans le dossier pour choisir le port. C'est tout.
La méthodo qui va avec : ssh → tmux → claude dans le dossier du projet → il code et lance le serveur de dev → toi (ou ton client) regardez le résultat en direct sur l'URL publique, depuis n'importe quel appareil. Le serveur ne dort jamais : les démos restent en ligne.
01 PRÉREQUIS_
- un compte Scaleway (ou n'importe quel hébergeur qui donne une machine Ubuntu avec une IP publique fixe — le guide est agnostique) ;
- un nom de domaine dont tu contrôles la zone DNS ;
- une clé SSH sur ton ordi (
ssh-keygen -t ed25519si tu n'en as pas) ; - un compte Claude avec un abonnement Pro ou Max (pour Claude Code), ou une clé API Anthropic ;
- (optionnel) un compte GitHub pour tes repos.
02 LE SERVEUR_
La machine de référence (celle de Jérémy) : Ubuntu 24.04 LTS, 8 vCPU, 32 Go de RAM, ~1 To de disque. C'est confortable. Le minimum raisonnable si tu builds du Next.js : 4 vCPU / 8 Go / 80 Go — les node_modules mangent le disque plus vite que tu ne le crois.
- Console Scaleway → Instances → Create Instance (ou Elastic Metal si tu veux du dédié).
- Image : Ubuntu 24.04 LTS (Noble).
- Ajoute ta clé SSH au projet Scaleway avant la création.
- Security group : ouvre les ports 22, 80 et 443 en entrée. Le 80 est indispensable — Let's Encrypt s'en sert pour valider les certificats. Le 22 sera fermé au public à l'étape 13, une fois Tailscale en place.
- Note l'IP publique : c'est elle que ton DNS pointera.
Première connexion :
ssh ubuntu@IP.DU.SERVEUR
# si l'image te logge en root, crée l'utilisateur ubuntu avec sudo sans mot de passe :
# adduser --disabled-password --gecos "" ubuntu
# usermod -aG sudo ubuntu
# echo "ubuntu ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/90-ubuntu-nopasswd
# mkdir -p /home/ubuntu/.ssh && cp ~/.ssh/authorized_keys /home/ubuntu/.ssh/ && chown -R ubuntu:ubuntu /home/ubuntu/.sshTout le système suppose que ubuntu a sudo sans mot de passe (c'est le défaut cloud-init sur Scaleway). Sinon, voir l'encadré sudoers à l'étape 08.
03 LE DOMAINE_
Dans la zone DNS de ton domaine, crée un seul enregistrement :
C'est tout. *.dev.mondomaine.fr couvre tous les sous-domaines, y compris les sous-sous-domaines (x.preview.app.dev.mondomaine.fr marche aussi — un wildcard DNS matche plusieurs labels). Tu n'auras plus jamais à toucher au DNS pour un nouveau projet.
Optionnel, seulement si tu veux la partie « prod » (https://<projet>.mondomaine.fr) : ajoute aussi A * → IP.DU.SERVEUR.
Seul *.dev pointe vers ce serveur (l'apex du domaine est hébergé ailleurs). Le système gère ce cas : le bloc « prod » de la config Caddy n'est généré que s'il existe des projets dans ~/deploy/.
Vérifie la propagation depuis ton ordi :
dig +short nimportequoi.dev.mondomaine.fr # doit répondre IP.DU.SERVEUR04 BASE SYSTÈME_
sudo apt update && sudo apt upgrade -y
sudo apt install -y git curl wget unzip build-essential jq htop
mkdir -p ~/projects ~/deploy ~/.local/bin
# ~/.local/bin dans le PATH si ce n'est pas déjà le cas :
grep -q '.local/bin' ~/.bashrc || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc~/projects = tes projets de dev (un dossier = une URL). ~/deploy = les builds de prod.
05 NODE + BUN_
# nvm (vérifie la dernière version sur https://github.com/nvm-sh/nvm)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
source ~/.bashrc
nvm install 24 # Node LTS
nvm alias default 24 # important : un default explicite (voir piège au §14)
corepack enable # → pnpm et yarn disponibles
# bun (utilisé par plusieurs projets + par le routeur de previews)
curl -fsSL https://bun.sh/install | bash
# symlink global : indispensable pour que cron et systemd trouvent bun
sudo ln -sf ~/.bun/bin/bun /usr/local/bin/bunLe nvm alias default explicite et le symlink de bun dans /usr/local/bin ne sont pas décoratifs : cron et systemd ne chargent pas ton shell, ils ont besoin de chemins qui existent en dehors d'une session interactive. Jérémy a payé pour le savoir.
06 CLAUDE CODE_
// le cœur de la méthodo : claude tourne SUR le serveur, dans le dossier du projet, avec accès direct aux fichiers, au shell et aux urls de dev.
# Option A (recommandée) : installateur natif, auto-update
curl -fsSL https://claude.ai/install.sh | bash
# Option B (celle du serveur de Jérémy) : npm global + symlink
npm install -g @anthropic-ai/claude-code
ln -sf "$(which claude)" ~/.local/bin/claude # survit aux changements de version nodePremière connexion (interactif, à faire une fois) : lance claude, choisis « Claude account with subscription », ouvre l'URL affichée dans le navigateur de ton ordi, colle le code retourné. Terminé.
GitHub, pour cloner et pousser depuis le serveur :
sudo apt install -y gh # ou via le repo officiel GitHub CLI
gh auth login # interactif : device flow, code à taper sur github.com/login/device
gh auth setup-git # gh devient le credential helper de gitEt tmux, pour que les sessions (claude, serveurs de dev) survivent à ta déconnexion :
sudo apt install -y tmux
tmux new -s dev # nouvelle session | se détacher : Ctrl+b puis d
tmux attach -t dev # se rattacher07 CADDY_
Caddy gère tout le HTTPS automatiquement, y compris l'émission à la demande des certificats Let's Encrypt : premier visiteur d'un sous-domaine → certificat émis en quelques secondes, puis renouvelé tout seul, pour toujours.
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddy
sudo mkdir -p /etc/caddy/sites.d # pour les sites "manuels" hors-systèmeNe configure rien à la main : le Caddyfile sera entièrement généré par l'étape suivante.
08 DEVPROXY_
// le cœur du système. quatre sous-étapes, dans l'ordre.
## 8.1 — le script devproxy-sync
Colle le bloc ci-dessous tel quel dans ton terminal — il crée /usr/local/bin/devproxy-sync. Adapte d'abord les 3 lignes marquées « À ADAPTER » (user, domaine, email) une fois le fichier créé, ou modifie-les à la volée avant de coller.
voir le script complet — devproxy-sync (~170 lignes, s'installe tel quel)
sudo tee /usr/local/bin/devproxy-sync > /dev/null << 'EOF'
#!/usr/bin/env bash
# devproxy-sync — génère le Caddyfile depuis la convention de dossiers.
# Système de kwiss. Lancé par cron toutes les minutes ;
# ne recharge Caddy que si le Caddyfile change.
set -euo pipefail
USER_NAME="ubuntu" # ← À ADAPTER si autre user
HOME_DIR="/home/${USER_NAME}"
WORKSPACE="${HOME_DIR}/projects"
DEPLOYDIR="${HOME_DIR}/deploy"
CADDYFILE="/etc/caddy/Caddyfile"
FALLBACK="/var/www/fallback"
DOMAIN="mondomaine.fr" # ← À ADAPTER
DEV_DOMAIN="dev.${DOMAIN}" # <projet>.dev.mondomaine.fr
PROD_DOMAIN="${DOMAIN}" # <projet>.mondomaine.fr
EMAIL="contact@mondomaine.fr" # ← À ADAPTER (Let's Encrypt)
# matcher_name <host> : remplace . et - par _ pour un identifiant Caddy valide
matcher_name() { echo "$1" | tr '.-' '__'; }
# slug <texte> : minuscules, ne garde que [a-z0-9-]. Vide si label DNS invalide.
slug() {
local s
s="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
if printf '%s' "$s" | grep -qP '^[a-z0-9]([a-z0-9-]*[a-z0-9])?$'; then
printf '%s' "$s"
fi
}
# scan_dir <basedir> <default_port> : émet des lignes "<sousdomaine> <port>"
# Les noms de dossier invalides comme sous-domaine (espaces, etc.) sont ignorés.
scan_dir() {
local basedir="$1" default_port="$2" dir raw name config
[ -d "$basedir" ] || return 0
for dir in "$basedir"/*/; do
[ -d "$dir" ] || continue
raw="$(basename "$dir")"
name="$(slug "$raw")"
if [ -z "$name" ]; then
echo "devproxy-sync: dossier ignoré (nom non valide comme sous-domaine): '$raw'" >&2
continue
fi
config="${dir}.devproxy.json"
if [ -f "$config" ]; then
# cas mono-service : { "port": 3939 }
local simple_port
simple_port="$(grep -oP '"port"\s*:\s*\K\d+' "$config" || true)"
if [ -n "$simple_port" ]; then
echo "${name} ${simple_port}"
else
# cas multi-services : { "web": 3939, "api": 4000 } -> <projet>-web, <projet>-api
local pair key kslug port
while IFS= read -r pair; do
[ -n "$pair" ] || continue
key="$(echo "$pair" | grep -oP '"\K[^"]+')"
port="$(echo "$pair" | grep -oP ':\s*\K\d+')"
kslug="$(slug "$key")"
[ -n "$kslug" ] && [ -n "$port" ] && echo "${name}-${kslug} ${port}"
done < <(grep -oP '"[^"]+"\s*:\s*\d+' "$config" || true)
fi
else
# pas de fichier -> port par défaut
echo "${name} ${default_port}"
fi
done
}
# Émet un bloc wildcard Caddy complet pour un domaine donné.
# args: <wildcard_domain> <base_domain> (entries lues sur stdin)
emit_block() {
local wildcard="$1" base="$2"
echo "*.${wildcard} {"
echo " tls {"
echo " on_demand"
echo " }"
local sub port host m
declare -A seen=()
while read -r sub port; do
[ -n "$sub" ] || continue
host="${sub}.${wildcard}"
m="$(matcher_name "$host")"
if [ -n "${seen[$m]:-}" ]; then
echo "devproxy-sync: sous-domaine en double ignoré: ${host}" >&2
continue
fi
seen[$m]=1
echo ""
echo " @${m} host ${host}"
echo " handle @${m} {"
echo " reverse_proxy localhost:${port} {"
echo " flush_interval -1"
echo " transport http {"
echo " dial_timeout 2s"
echo " }"
echo " }"
echo " }"
done
# fallback : tout sous-domaine inconnu + erreurs -> page statique
echo ""
echo " handle {"
echo " root * ${FALLBACK}"
echo " file_server"
echo " }"
echo " handle_errors {"
echo " root * ${FALLBACK}"
echo " file_server"
echo " }"
echo "}"
}
TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT
# Récupère les entrées dev/prod
DEV_ENTRIES="$(scan_dir "$WORKSPACE" 3000 | sort || true)"
PROD_ENTRIES="$(scan_dir "$DEPLOYDIR" 3001 | sort || true)"
{
echo "# Généré par devproxy-sync — NE PAS ÉDITER À LA MAIN."
echo "# Sites manuels : déposez des *.caddyfile dans /etc/caddy/sites.d/"
echo ""
echo "{"
echo " email ${EMAIL}"
echo " on_demand_tls {"
echo " ask http://localhost:5555"
echo " }"
echo "}"
echo ""
echo "# Endpoint d'approbation TLS on-demand"
echo "http://localhost:5555 {"
echo " respond 200"
echo "}"
echo ""
echo "# Sites manuels hors-système"
echo "import /etc/caddy/sites.d/*.caddyfile"
echo ""
echo "# --- DEV : *.dev.${DOMAIN} (dossiers ~/projects) ---"
emit_block "$DEV_DOMAIN" "$DOMAIN" <<< "$DEV_ENTRIES"
# --- PROD : *.${DOMAIN} (dossiers ~/deploy) ---
# Généré UNIQUEMENT s'il existe des projets de déploiement,
# pour ne pas servir *.mondomaine.fr tant que l'apex est ailleurs.
if [ -n "$PROD_ENTRIES" ]; then
echo ""
echo "# --- PROD : *.${DOMAIN} (dossiers ~/deploy) ---"
emit_block "$PROD_DOMAIN" "$DOMAIN" <<< "$PROD_ENTRIES"
fi
} > "$TMP"
# Recharge SEULEMENT si le Caddyfile a changé
if [ ! -f "$CADDYFILE" ] || ! diff -q "$CADDYFILE" "$TMP" >/dev/null 2>&1; then
sudo cp "$TMP" "$CADDYFILE"
if systemctl is-active --quiet caddy; then
sudo systemctl reload caddy
echo "devproxy-sync: Caddyfile mis à jour, caddy reloadé."
else
sudo systemctl start caddy
echo "devproxy-sync: Caddyfile écrit, caddy démarré."
fi
else
echo "devproxy-sync: aucun changement."
fi
EOF
sudo chmod +x /usr/local/bin/devproxy-syncCe qu'il faut comprendre du script :
- scan — chaque sous-dossier de
~/projectsdevient<nom>.dev.mondomaine.fr→localhost:3000, ou le port du.devproxy.json; - multi-services —
{ "web": 3939, "api": 4000 }donne<projet>-web.dev…et<projet>-api.dev…; - hygiène — noms slugifiés en
[a-z0-9-], dossiers à nom invalide ignorés, doublons dédupliqués ; - tls on-demand — Caddy demande le certificat à la première visite, après accord de l'endpoint
localhost:5555; - sites.d — tout
.caddyfiledéposé dans/etc/caddy/sites.d/est importé : la porte de sortie pour les cas spéciaux (cette page en est un) ; - reload intelligent — Caddy n'est rechargé que si le Caddyfile généré a changé.
L'endpoint :5555 approuve tous les hostnames. Si quelqu'un pointait son propre domaine vers ton IP, Caddy pourrait émettre un certificat pour lui. Pour verrouiller, remplace dans le script le bloc http://localhost:5555 par :
http://localhost:5555 {
@ok query domain=*.dev.mondomaine.fr domain=*.mondomaine.fr
respond @ok 200
respond 403
}## 8.2 — la page fallback
Servie pour tout sous-domaine inconnu ou en erreur. Mets ce que tu veux dedans :
sudo mkdir -p /var/www/fallback
sudo tee /var/www/fallback/index.html > /dev/null << 'EOF'
<!doctype html>
<meta charset="utf-8">
<title>rien ici</title>
<style>body{font-family:monospace;background:#000;color:#fff;display:grid;place-items:center;min-height:100vh}</style>
<p>rien ici (pour l'instant).</p>
EOF## 8.3 — le cron
( crontab -l 2>/dev/null ; echo '* * * * * /usr/local/bin/devproxy-sync >> /home/ubuntu/devproxy-sync.log 2>&1' ) | crontab -Le script fait sudo cp et sudo systemctl reload caddy. Si besoin :
sudo tee /etc/sudoers.d/devproxy > /dev/null << 'EOF'
ubuntu ALL=(root) NOPASSWD: /usr/bin/cp * /etc/caddy/Caddyfile
ubuntu ALL=(root) NOPASSWD: /usr/bin/systemctl reload caddy, /usr/bin/systemctl start caddy
EOF
sudo visudo -c -f /etc/sudoers.d/devproxy## 8.4 — premier test
mkdir -p ~/projects/hello
echo '{ "port": 3999 }' > ~/projects/hello/.devproxy.json
cd ~/projects/hello && python3 -m http.server 3999 &
devproxy-sync # ou attends 1 minute que le cron passe
tail ~/devproxy-sync.logOuvre https://hello.dev.mondomaine.fr : la première visite prend 2–3 secondes (émission du certificat), puis c'est instantané. Le système est en place.
09 AU QUOTIDIEN_
Une session de travail ressemble à ça :
ssh ubuntu@IP.DU.SERVEUR
tmux attach -t dev || tmux new -s dev
mkdir -p ~/projects/nouvelle-app && cd ~/projects/nouvelle-app
echo '{ "port": 3123 }' > .devproxy.json # un port UNIQUE par projet
claudeEt dans Claude : « Crée-moi une app Next.js qui fait X, et lance le serveur de dev sur le port 3123. » Une minute plus tard, https://nouvelle-app.dev.mondomaine.fr est en ligne — sur ton téléphone, chez ton client, partout. Hot reload compris.
Les règles du jeu :
- un port unique par projet — convention : la tranche 30xx (3020, 3025, 3041, 3050…), notée dans le
.devproxy.jsonde chaque projet ; - le serveur de dev écoute en local (
localhostsuffit — c'est Caddy qui expose au monde) :next dev -p 3123,vite --port 3123,bun run dev --port 3123… ; - les serveurs de dev tournent dans tmux (ou en tâche de fond lancée par claude) et survivent à ta déconnexion ;
- renommer / supprimer le dossier = le sous-domaine suit / disparaît à la minute suivante.
10 LA PROD_
// optionnel — nécessite le dns « A * → ip » de l'étape 03.
Pour passer un projet en vraie URL (https://<projet>.mondomaine.fr) avec build de prod et service systemd qui redémarre tout seul, le script new-deploy-project fait tout :
git push deploy main
│
~/git/<projet>.git (repo bare) ──hook post-receive──► checkout dans ~/deploy/<projet>/
install + build (PM auto-détecté)
systemctl restart <projet>.service- auto-détection du package manager (pnpm/bun/npm/yarn via lockfile), de la version node (
.nvmrc, via nvm) et du package d'app en monorepo (--filter) ; - tout est piloté par
~/deploy/<projet>.deploy.conf, éditable (commandes custom install/build/start) — re-push pour ré-appliquer ; - le
.env(non versionné) est copié une fois vers~/deploy/<projet>/.envet y persiste ; - le remote
deployest un chemin local — tu travailles déjà sur le serveur, pas de SSH.
voir le script complet — new-deploy-project (~200 lignes, s'installe tel quel)
sudo tee /usr/local/bin/new-deploy-project > /dev/null << 'OUTER'
#!/usr/bin/env bash
# new-deploy-project <projet> [port] [app_subdir]
#
# Met en place un déploiement git-based pour N'IMPORTE QUEL repo (à la volée).
# Détecte automatiquement (depuis ~/projects/<projet> si présent) :
# - le package manager (pnpm / bun / npm / yarn) via le lockfile
# - la version Node (.nvmrc), utilisée par le build ET le service systemd (nvm)
# - le nom du package de l'app (monorepo) pour cibler le build/start (--filter)
# Génère : repo bare + hook post-receive + service systemd (nvm-aware) + conf éditable.
#
# Tout est piloté par ~/deploy/<projet>.deploy.conf — édite-le puis relance le script
# (idempotent) ou re-push pour ré-appliquer.
set -euo pipefail
USER_NAME="ubuntu"
HOME_DIR="/home/${USER_NAME}"
PROJECT="${1:-}"
PORT="${2:-3001}"
APP_DIR="${3:-}" # sous-dossier de l'app dans un monorepo (vide = racine)
if [ -z "$PROJECT" ]; then
echo "usage: new-deploy-project <projet> [port] [app_subdir]" >&2
echo " ex monorepo : new-deploy-project monapp 3021 apps/nextjs" >&2
exit 1
fi
SRC="${HOME_DIR}/projects/${PROJECT}" # working copy (pour l'autodétection)
GIT_BARE="${HOME_DIR}/git/${PROJECT}.git"
DEPLOY_DIR="${HOME_DIR}/deploy/${PROJECT}"
CONF="${HOME_DIR}/deploy/${PROJECT}.deploy.conf"
RUN="${HOME_DIR}/deploy/${PROJECT}.run.sh"
SERVICE="/etc/systemd/system/${PROJECT}.service"
LOG="${HOME_DIR}/deploy/${PROJECT}.deploy.log"
mkdir -p "${HOME_DIR}/git" "${HOME_DIR}/deploy"
# ── Autodétection depuis la working copy ────────────────────────────────
detect_root="$SRC"; [ -d "$detect_root" ] || detect_root=""
PM="npm"; NODE_VERSION=""; PKG_NAME=""
if [ -n "$detect_root" ]; then
if [ -f "$detect_root/bun.lock" ] || [ -f "$detect_root/bun.lockb" ]; then PM="bun"
elif [ -f "$detect_root/pnpm-lock.yaml" ]; then PM="pnpm"
elif [ -f "$detect_root/yarn.lock" ]; then PM="yarn"
elif [ -f "$detect_root/package-lock.json" ]; then PM="npm"
fi
NODE_VERSION="$(cat "$detect_root/${APP_DIR:+$APP_DIR/}.nvmrc" 2>/dev/null || cat "$detect_root/.nvmrc" 2>/dev/null || true)"
NODE_VERSION="$(echo "$NODE_VERSION" | tr -d '[:space:]')"
if [ -n "$APP_DIR" ] && [ -f "$detect_root/$APP_DIR/package.json" ]; then
PKG_NAME="$(grep -oP '"name"\s*:\s*"\K[^"]+' "$detect_root/$APP_DIR/package.json" | head -1)"
fi
fi
echo "==> Détection : PM=$PM node=${NODE_VERSION:-<défaut nvm>} app=${APP_DIR:-<racine>} pkg=${PKG_NAME:-<racine>}"
# ── Fichier de conf (ne pas écraser s'il existe déjà : éditable par l'user) ──
if [ -f "$CONF" ]; then
echo "==> Conf existante conservée : $CONF (édite-la à la main si besoin)"
else
echo "==> Conf : $CONF"
cat > "$CONF" <<CONF
# Config de déploiement de ${PROJECT} — éditable, relance new-deploy-project ou re-push après modif.
PROJECT="${PROJECT}"
DEPLOY_DIR="${DEPLOY_DIR}"
APP_DIR="${APP_DIR}" # sous-dossier de l'app (vide = racine du repo)
PORT="${PORT}"
NODE_VERSION="${NODE_VERSION}" # version nvm ; vide = défaut nvm / .nvmrc au runtime
PM="${PM}" # pnpm | bun | npm | yarn
PKG_NAME="${PKG_NAME}" # nom du package de l'app (monorepo) pour --filter ; vide = racine
# Commandes : vide = valeurs auto déduites du PM (voir _resolve_cmds dans le hook/run)
INSTALL_CMD=""
BUILD_CMD=""
START_CMD=""
CONF
fi
# ── Bloc commun : résout les commandes auto + active node via nvm ────────
COMMON='
_resolve_cmds() {
local filter=""
[ -n "${PKG_NAME:-}" ] && filter="--filter ${PKG_NAME}"
case "$PM" in
pnpm)
: "${INSTALL_CMD:=pnpm install --frozen-lockfile}"
: "${BUILD_CMD:=pnpm ${filter}${filter:+... }run build}"
: "${START_CMD:=pnpm ${filter} run start}"
;;
bun)
: "${INSTALL_CMD:=bun install --frozen-lockfile}"
: "${BUILD_CMD:=bun run build}"
: "${START_CMD:=bun run start}"
;;
yarn)
: "${INSTALL_CMD:=yarn install --immutable}"
: "${BUILD_CMD:=yarn build}"
: "${START_CMD:=yarn start}"
;;
*)
: "${INSTALL_CMD:=npm ci}"
: "${BUILD_CMD:=npm run build}"
: "${START_CMD:=npm run start}"
;;
esac
}
_use_node() {
export NVM_DIR="'"${HOME_DIR}"'/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
if [ -n "${NODE_VERSION:-}" ]; then nvm use "$NODE_VERSION" >/dev/null 2>&1 || nvm install "$NODE_VERSION" >/dev/null 2>&1 && nvm use "$NODE_VERSION" >/dev/null 2>&1; else nvm use >/dev/null 2>&1 || true; fi
case "$PM" in pnpm|yarn) corepack enable >/dev/null 2>&1 || true ;; esac
}
'
# ── Hook post-receive ───────────────────────────────────────────────────
echo "==> Repo bare + hook : ${GIT_BARE}"
git init --bare "$GIT_BARE" >/dev/null
cat > "${GIT_BARE}/hooks/post-receive" <<HOOK
#!/usr/bin/env bash
set -euo pipefail
source "${CONF}"
${COMMON}
while read -r oldrev newrev ref; do
[ "\$ref" = "refs/heads/main" ] || { echo "skip \$ref"; continue; }
{
echo "===== deploy \$(date -u +%FT%TZ) ====="
mkdir -p "\$DEPLOY_DIR"
GIT_WORK_TREE="\$DEPLOY_DIR" git --git-dir="${GIT_BARE}" checkout -f main
cd "\$DEPLOY_DIR"
_use_node
_resolve_cmds
echo "node=\$(node -v 2>/dev/null) pm=\$PM"
echo "+ \$INSTALL_CMD"; eval "\$INSTALL_CMD"
echo "+ \$BUILD_CMD"; eval "\$BUILD_CMD"
sudo systemctl restart "\${PROJECT}.service"
echo "===== ok \$(date -u +%FT%TZ) → http://localhost:\${PORT} ====="
} 2>&1 | tee -a "${LOG}"
done
HOOK
chmod +x "${GIT_BARE}/hooks/post-receive"
# ── Wrapper de démarrage (utilisé par systemd, nvm-aware) ────────────────
echo "==> Run wrapper : ${RUN}"
cat > "$RUN" <<RUNSH
#!/usr/bin/env bash
set -euo pipefail
source "${CONF}"
${COMMON}
cd "\$DEPLOY_DIR"
_use_node
_resolve_cmds
export PORT
echo "start: \$START_CMD (PORT=\$PORT, node=\$(node -v 2>/dev/null))"
exec \$START_CMD
RUNSH
chmod +x "$RUN"
# ── Service systemd ─────────────────────────────────────────────────────
echo "==> Service systemd : ${SERVICE}"
sudo tee "$SERVICE" >/dev/null <<UNIT
[Unit]
Description=${PROJECT} (devproxy deploy)
After=network.target
[Service]
Type=simple
User=${USER_NAME}
WorkingDirectory=${DEPLOY_DIR}
ExecStart=${RUN}
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable "${PROJECT}.service" >/dev/null 2>&1 || true
# ── .devproxy.json (port prod) + .env best-effort ───────────────────────
mkdir -p "$DEPLOY_DIR"
echo "{ \"port\": ${PORT} }" > "${DEPLOY_DIR}/.devproxy.json"
if [ -f "${SRC}/.env" ] && [ ! -f "${DEPLOY_DIR}/.env" ]; then
cp "${SRC}/.env" "${DEPLOY_DIR}/.env"
echo "==> .env copié depuis la working copy (non-versionné). ADAPTE-le pour la prod si besoin : ${DEPLOY_DIR}/.env"
fi
# ── Permission sudo restart (best-effort ; NOPASSWD global suffit sinon) ──
SUDO_LINE="${USER_NAME} ALL=(root) NOPASSWD: /usr/bin/systemctl restart ${PROJECT}.service"
if [ -f /etc/sudoers.d/devproxy ] && ! sudo grep -qF "$SUDO_LINE" /etc/sudoers.d/devproxy 2>/dev/null; then
echo "$SUDO_LINE" | sudo tee -a /etc/sudoers.d/devproxy >/dev/null && sudo visudo -c -f /etc/sudoers.d/devproxy >/dev/null || true
fi
cat <<DONE
✅ Déploiement "${PROJECT}" prêt (port prod ${PORT}).
Conf éditable : ${CONF}
Logs deploy : ${LOG}
En local, dans ton repo (tu bosses sur le serveur → remote = chemin local, pas de SSH) :
git remote add deploy ${GIT_BARE}
git push deploy main # → build + (re)start → https://${PROJECT}.<TON-DOMAINE>
DONE
OUTER
sudo chmod +x /usr/local/bin/new-deploy-projectUtilisation :
# projet simple à la racine du repo :
new-deploy-project monapp 3021
# monorepo (ex. Turbo) où l'app vit dans apps/nextjs :
new-deploy-project monapp 3021 apps/nextjs
# puis, dans la working copy :
cd ~/projects/monapp
git remote add deploy ~/git/monapp.git
git push deploy mainÀ chaque git push deploy main : checkout → install → build → restart du service. Logs de déploiement dans ~/deploy/<projet>.deploy.log, logs du service via journalctl -u <projet>.service -f.
11 LES PREVIEWS_
// avancé, optionnel — le « système de branches ».
Pour une app donnée, chaque branche poussée sur GitHub obtient sa propre instance sur https://<slug-de-branche>.preview.<app>.dev.mondomaine.fr — buildée automatiquement, mise à jour à chaque push, détruite quand la branche est supprimée. Parfait pour faire valider des variantes à un client sans toucher à master.
Le wildcard DNS de l'étape 03 couvre déjà ces URLs — rien à ajouter côté DNS.
cron (1 min) → autodeploy.sh
├─ master a bougé ? → pull + build + relance du site principal (port fixe)
├─ pour chaque branche sur origin (max 6, les plus récentes) :
│ deploy-branch.sh → git worktree de la branche
│ → build → `next start` sur un port du pool 4101–4120
│ → instances/<slug>.json { branch, port, sha, ... }
├─ branche supprimée sur origin → kill instance + suppression du worktree
└─ self-healing : relance tout ce qui est tombé (site, instances, routeur)
Caddy : *.preview.<app>.dev.… → routeur local :4040
routeur (router.mjs) : lit le sous-domaine → trouve le port dans instances/<slug>.json
→ proxy. Nouvelle branche = zéro édition de Caddy.## mise en place (pour une app « monapp »)
APP=monapp
mkdir -p ~/previews/$APP/{instances,branches}
git clone <URL-DU-REPO> ~/previews/$APP/repo # clone dédié aux worktrees de previewsLe routeur — ~/previews/monapp/router.mjs (remplace monapp dans DIR) :
voir le routeur — router.mjs (~50 lignes)
// Routeur des previews — <slug>.preview.monapp.dev.mondomaine.fr → 127.0.0.1:<port>.
// Résout le port via instances/<slug>.json (écrit par deploy-branch.sh) : les nouvelles
// branches apparaissent sans toucher à Caddy. En écoute locale uniquement (derrière Caddy).
import http from "node:http";
import { readFileSync } from "node:fs";
const DIR = "/home/ubuntu/previews/monapp/instances";
const SLUG_RE = /^[a-z0-9-]{1,100}$/;
function metaFor(hostHeader) {
const host = (hostHeader ?? "").split(":")[0];
const slug = host.split(".")[0];
if (!SLUG_RE.test(slug)) return null;
try {
return JSON.parse(readFileSync(`${DIR}/${slug}.json`, "utf8"));
} catch {
return null;
}
}
const server = http.createServer((req, res) => {
const meta = metaFor(req.headers.host);
const port = meta?.port;
if (!port) {
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
res.end("Preview introuvable — branche supprimée ou pas encore déployée.\n");
return;
}
// Option : la racine redirige vers une page d'atterrissage définie par la branche.
if (req.url === "/" && meta.landing && meta.landing !== "/") {
res.writeHead(302, { location: meta.landing });
res.end();
return;
}
// Host d'origine conservé : Next génère ainsi des redirections correctes (/login…).
const upstream = http.request(
{ host: "127.0.0.1", port, path: req.url, method: req.method, headers: req.headers },
(up) => {
res.writeHead(up.statusCode ?? 502, up.headers);
up.pipe(res);
},
);
upstream.on("error", () => {
res.writeHead(502, { "content-type": "text/plain; charset=utf-8", "retry-after": "30" });
res.end("Preview en cours de démarrage — réessaie dans une minute.\n");
});
req.pipe(upstream);
});
server.listen(4040, "127.0.0.1", () => console.log("routeur previews sur 127.0.0.1:4040"));Le snippet Caddy — /etc/caddy/sites.d/monapp-preview.caddyfile (importé automatiquement) :
# Previews monapp par branche : <slug>.preview.monapp.dev.mondomaine.fr → routeur :4040
*.preview.monapp.dev.mondomaine.fr {
tls {
on_demand
}
reverse_proxy localhost:4040 {
flush_interval -1
transport http {
dial_timeout 2s
}
}
}Le déployeur d'une branche — ~/previews/monapp/deploy-branch.sh. Les lignes marquées # ADAPTE sont les commandes de build/start de ton app (l'exemple : monorepo bun + next.js) :
voir le déployeur — deploy-branch.sh (~90 lignes)
#!/usr/bin/env bash
# Déploie UNE branche en instance preview dédiée (worktree + port du pool).
# Usage: deploy-branch.sh <branche> — appelé par autodeploy.sh
set -euo pipefail
# Sous cron, node (nvm) n'est pas dans le PATH — or next est un script `#!/usr/bin/env node`.
NODE_BIN=$(ls -d /home/ubuntu/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)
export PATH="${NODE_BIN:+$NODE_BIN:}/usr/local/bin:/usr/bin:/bin:$PATH"
BRANCH="${1:?usage: deploy-branch.sh <branche>}"
APP=monapp # ADAPTE
DOMAIN_SUFFIX="preview.${APP}.dev.mondomaine.fr" # ADAPTE
ROOT=/home/ubuntu/previews/$APP
REPO="$ROOT/repo"
MAIN_ENV=/home/ubuntu/projects/$APP/apps/web/.env.local # ADAPTE (ou supprime la copie plus bas)
PORT_MIN=4101
PORT_MAX=4120
[[ "$BRANCH" =~ ^[A-Za-z0-9._/-]+$ ]] || { echo "branche invalide: $BRANCH"; exit 2; }
SLUG=$(printf '%s' "$BRANCH" | sed -E 's/[^a-zA-Z0-9]+/-/g' | tr '[:upper:]' '[:lower:]')
DIR="$ROOT/branches/$SLUG"
META="$ROOT/instances/$SLUG.json"
PIDF="$ROOT/instances/$SLUG.pid"
mkdir -p "$ROOT/instances" "$ROOT/branches"
cd "$REPO"
git fetch --quiet --prune origin
SHA=$(git rev-parse --verify --quiet "origin/$BRANCH") || { echo "branche introuvable sur origin: $BRANCH"; exit 3; }
# Port : celui déjà attribué à ce slug, sinon le premier libre du pool.
if [[ -f "$META" ]]; then
PORT=$(grep -o '"port": *[0-9]*' "$META" | grep -o '[0-9]*')
else
USED=$(cat "$ROOT"/instances/*.json 2>/dev/null | grep -o '"port": *[0-9]*' | grep -o '[0-9]*' || true)
PORT=""
for p in $(seq "$PORT_MIN" "$PORT_MAX"); do
grep -qx "$p" <<<"$USED" || { PORT=$p; break; }
done
[[ -n "$PORT" ]] || { echo "pool de ports plein ($PORT_MIN-$PORT_MAX)"; exit 4; }
fi
echo "=== $BRANCH → $SLUG (port $PORT, ${SHA:0:7}) — $(date -Is) ==="
if [[ ! -d "$DIR" ]]; then
git -C "$REPO" worktree add --detach "$DIR" "$SHA"
else
git -C "$DIR" checkout --quiet --detach "$SHA"
git -C "$DIR" reset --hard --quiet "$SHA"
fi
# Même auth/env que le site principal (les previews restent privées).
[[ -f "$MAIN_ENV" ]] && cp "$MAIN_ENV" "$DIR/apps/web/.env.local" # ADAPTE
cd "$DIR"
bun install 2>&1 | tail -1 # ADAPTE (pnpm/npm…)
bun run --cwd "$DIR/packages/tokens" build >/dev/null # ADAPTE (pré-builds éventuels)
bun run --cwd "$DIR/apps/web" build 2>&1 | tail -3 # ADAPTE (build de l'app)
if [[ -f "$PIDF" ]]; then kill "$(cat "$PIDF")" 2>/dev/null || true; fi
fuser -k "$PORT/tcp" 2>/dev/null || true
sleep 1
cd "$DIR/apps/web" # ADAPTE
# 9>&- : ne pas hériter du fd de verrou d'autodeploy.sh (sinon le lock reste tenu à vie).
PREVIEW_BRANCH="$BRANCH" setsid nohup ./node_modules/.bin/next start -H 127.0.0.1 -p "$PORT" \
>> "$ROOT/instances/$SLUG.log" 2>&1 9>&- </dev/null & # ADAPTE (commande start)
echo $! > "$PIDF"
for _ in $(seq 1 30); do
ss -ltn | grep -q ":$PORT " && break
sleep 1
done
ss -ltn | grep -q ":$PORT " || { echo "instance non démarrée (voir instances/$SLUG.log)"; exit 5; }
# Atterrissage optionnel : un preview.json à la racine de la branche peut définir
# { "landing": "/ma-page" } — le routeur redirige / vers cette page.
LANDING=""
if [[ -f "$DIR/preview.json" ]]; then
LANDING=$(grep -o '"landing": *"[^"]*"' "$DIR/preview.json" | sed 's/.*: *"\(.*\)"/\1/' || true)
fi
printf '{ "branch": "%s", "slug": "%s", "port": %s, "sha": "%s", "landing": "%s", "deployedAt": "%s" }\n' \
"$BRANCH" "$SLUG" "$PORT" "$SHA" "$LANDING" "$(date -Is)" > "$META"
echo "=== OK — https://$SLUG.$DOMAIN_SUFFIX ==="L'orchestrateur — ~/previews/monapp/autodeploy.sh (mêmes # ADAPTE) :
voir l'orchestrateur — autodeploy.sh (~100 lignes)
#!/usr/bin/env bash
# Auto-déploiement — lancé par cron chaque minute.
# push master → rebuild + relance du site principal
# push d'une branche → instance preview dédiée sur <slug>.preview.<app>.dev.<domaine>
# branche supprimée → instance arrêtée, worktree nettoyé
# Relance aussi ce qui est tombé (site, instances, routeur) : self-healing post-reboot.
set -euo pipefail
# Sous cron, node (nvm) n'est pas dans le PATH — or next est un script `#!/usr/bin/env node`.
NODE_BIN=$(ls -d /home/ubuntu/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)
export PATH="${NODE_BIN:+$NODE_BIN:}/usr/local/bin:/usr/bin:/bin:$PATH"
APP=monapp # ADAPTE
DOMAIN_SUFFIX="preview.${APP}.dev.mondomaine.fr" # ADAPTE
ROOT=/home/ubuntu/previews/$APP
MAIN=/home/ubuntu/projects/$APP
MAIN_PORT=3041 # ADAPTE : port du site principal
MAIN_BRANCH=master # ADAPTE : master ou main
MAX_PREVIEWS=6
# Une seule instance à la fois (les builds durent > 1 min).
exec 9>"$ROOT/autodeploy.lock"
flock -n 9 || exit 0
# --- 1. master → site principal ---------------------------------------------
cd "$MAIN"
git fetch --quiet --prune origin
if [[ "$(git rev-parse $MAIN_BRANCH)" != "$(git rev-parse origin/$MAIN_BRANCH)" ]]; then
echo "=== $MAIN_BRANCH → $(git rev-parse --short origin/$MAIN_BRANCH) — $(date -Is) ==="
git merge --ff-only --quiet origin/$MAIN_BRANCH
bun install 2>&1 | tail -1 # ADAPTE
bun run --cwd "$MAIN/packages/tokens" build >/dev/null # ADAPTE
bun run --cwd "$MAIN/apps/web" build 2>&1 | tail -3 # ADAPTE
fuser -k $MAIN_PORT/tcp 2>/dev/null || true
sleep 1
fi
if ! ss -ltn | grep -q ":$MAIN_PORT "; then
cd "$MAIN/apps/web" # ADAPTE
PREVIEW_BRANCH=$MAIN_BRANCH setsid nohup ./node_modules/.bin/next start -H 127.0.0.1 -p $MAIN_PORT \
>> "$ROOT/main-app.log" 2>&1 9>&- </dev/null & # ADAPTE
echo "site principal relancé sur :$MAIN_PORT — $(date -Is)"
fi
# --- 2. previews : une instance par branche poussée --------------------------
mkdir -p "$ROOT/instances" "$ROOT/branches"
cd "$ROOT/repo"
git fetch --quiet --prune origin
mapfile -t BRANCHES < <(git for-each-ref --sort=-committerdate --format='%(refname:short)' refs/remotes/origin \
| grep -vE "^origin(/$MAIN_BRANCH|/HEAD)?$" | head -"$MAX_PREVIEWS" | sed 's#^origin/##')
declare -A WANT=()
for BR in "${BRANCHES[@]}"; do
SLUG=$(printf '%s' "$BR" | sed -E 's/[^a-zA-Z0-9]+/-/g' | tr '[:upper:]' '[:lower:]')
WANT[$SLUG]="$BR"
done
# Réforme : la branche n'existe plus sur origin (ou dépasse le cap).
for META in "$ROOT"/instances/*.json; do
[[ -e "$META" ]] || break
SLUG=$(basename "$META" .json)
if [[ -z "${WANT[$SLUG]:-}" ]]; then
echo "réforme: $SLUG — $(date -Is)"
PIDF="$ROOT/instances/$SLUG.pid"
if [[ -f "$PIDF" ]]; then kill "$(cat "$PIDF")" 2>/dev/null || true; fi
rm -f "$META" "$PIDF" "$ROOT/instances/$SLUG.log" "$ROOT/instances/$SLUG.deploy.log"
git -C "$ROOT/repo" worktree remove --force "$ROOT/branches/$SLUG" 2>/dev/null \
|| rm -rf "$ROOT/branches/${SLUG:?}"
git -C "$ROOT/repo" worktree prune 2>/dev/null || true
fi
done
# Déploie le nouveau / relance ce qui est tombé.
for SLUG in "${!WANT[@]}"; do
BR="${WANT[$SLUG]}"
SHA=$(git rev-parse "origin/$BR")
META="$ROOT/instances/$SLUG.json"
CUR=$(grep -o '"sha": *"[0-9a-f]*"' "$META" 2>/dev/null | grep -o '[0-9a-f]\{40\}' || echo none)
if [[ "$SHA" != "$CUR" ]]; then
if bash "$ROOT/deploy-branch.sh" "$BR" > "$ROOT/instances/$SLUG.deploy.log" 2>&1; then
echo "preview OK: https://$SLUG.$DOMAIN_SUFFIX — $(date -Is)"
else
echo "preview EN ÉCHEC: $BR (voir instances/$SLUG.deploy.log) — $(date -Is)"
fi
else
PORT=$(grep -o '"port": *[0-9]*' "$META" | grep -o '[0-9]*')
if ! ss -ltn | grep -q ":$PORT "; then
cd "$ROOT/branches/$SLUG/apps/web" # ADAPTE
PREVIEW_BRANCH="$BR" setsid nohup ./node_modules/.bin/next start -H 127.0.0.1 -p "$PORT" \
>> "$ROOT/instances/$SLUG.log" 2>&1 9>&- </dev/null & # ADAPTE
echo $! > "$ROOT/instances/$SLUG.pid"
echo "instance $SLUG relancée sur :$PORT — $(date -Is)"
fi
fi
done
# --- 3. routeur previews (127.0.0.1:4040) ------------------------------------
if ! ss -ltn | grep -q ':4040 '; then
setsid nohup bun "$ROOT/router.mjs" >> "$ROOT/router.log" 2>&1 9>&- </dev/null &
echo "routeur previews relancé sur :4040 — $(date -Is)"
fiActivation :
chmod +x ~/previews/$APP/autodeploy.sh ~/previews/$APP/deploy-branch.sh
( crontab -l ; echo "* * * * * /home/ubuntu/previews/$APP/autodeploy.sh >> /home/ubuntu/previews/$APP/autodeploy.log 2>&1" ) | crontab -Pousse une branche feat/nouveau-header → une minute plus tard le build démarre, puis https://feat-nouveau-header.preview.monapp.dev.mondomaine.fr est en ligne. Supprime la branche → l'instance disparaît.
12 LE CONFORT_
// optionnel mais recommandé — d'après l'épisode 2 de kwiss : jeremy.dev.cosmictaco.dev/term
- zsh + prezto + powerlevel10k — prompt et complétion (
chsh -s $(which zsh); config dans~/.zprezto/runcoms/zshrc; lancep10k configureà la première session) ; - tmux avec prefix
ctrl+aet thème catppuccin ; - helix (
hx) comme éditeur ; - outils modernes :
eza(ls),bat(cat),fd(find),rg,fzf,zoxide(z),atuin(ctrl+r),yazi,lazygit,btop,direnv.
nvm n'est chargé que par .bashrc par défaut. Ajoute le bloc nvm au zshrc, sinon node et claude deviennent introuvables dans les nouveaux terminaux :
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"Étapes interactives à faire soi-même (un agent ne peut pas) : p10k configure, gh auth login, atuin login — et chsh ne prend effet qu'à la reconnexion.
13 LA SÉCURITÉ_
// ce qui est réellement en place chez jérémy. à faire dès que le serveur tourne — et dans cet ordre, sinon tu t'enfermes dehors.
La surface publique du serveur se résume à deux ports : 80 et 443, tenus par Caddy. Tout le reste — SSH compris — passe par le VPN Tailscale.
## 13.1 — ssh par clé uniquement
sudo tee /etc/ssh/sshd_config.d/00-hardening.conf > /dev/null << 'EOF'
# Durcissement SSH — authentification par clé uniquement.
# Préfixe 00- : lu AVANT 50-cloud-init.conf (sshd applique la 1re valeur trouvée).
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
EOF
sudo sshd -t && sudo systemctl reload sshLe préfixe 00- n'est pas décoratif : sshd applique la première valeur rencontrée pour chaque option, et cloud-init pose déjà un 50-cloud-init.conf — ton fichier doit être lu avant.
Garde ta session ouverte et teste une nouvelle connexion SSH avant de fermer la première.
## 13.2 — tailscale : ssh et admin via vpn
Tailscale crée un réseau privé chiffré (« tailnet ») entre tes appareils. Le serveur y obtient une IP 100.x.y.z stable : SSH, bases de données, pages d'admin — tout ce qui est privé passe par là, sans jamais être exposé sur internet.
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up # affiche une URL d'auth → ouvre-la, connecte ton compte
tailscale ip -4 # note l'IP 100.x.y.z du serveur
# depuis ton mac/pc (app Tailscale installée et connectée au même compte) :
ssh ubuntu@100.x.y.z # ou le nom MagicDNS de la machine## 13.3 — ufw : rien ne rentre, sauf caddy (et le tailnet)
Active ufw seulement après avoir vérifié que la connexion SSH via Tailscale marche.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 80/tcp comment 'Caddy HTTP (ACME + redirect)'
sudo ufw allow 443/tcp comment 'Caddy HTTPS (sites publics)'
sudo ufw allow 41641/udp comment 'Tailscale direct'
sudo ufw allow in on tailscale0 comment 'Trafic tailnet (dev/admin prives)'
sudo ufw enable
sudo ufw status verboseCe qu'il faut voir dans ces règles :
- le port 22 n'apparaît pas — SSH est invisible depuis internet, accessible uniquement via le tailnet (règle
tailscale0) ; - même si une app de dev écoute sur
0.0.0.0, ufw la bloque de l'extérieur — seul caddy parle au monde ; - transition en douceur : garde
sudo ufw limit 22/tcple temps de la mise en place, puis supprime la règle (sudo ufw delete limit 22/tcp). Tu peux aussi fermer le 22 dans le security group Scaleway — double ceinture.
## 13.4 — fail2ban : bannir les brute-force ssh
Ceinture-bretelles : indispensable tant que le 22 public est ouvert, inoffensif ensuite. Le tailnet (100.64.0.0/10) et le loopback ne sont jamais bannis.
sudo apt install -y fail2ban
sudo tee /etc/fail2ban/jail.local > /dev/null << 'EOF'
# Durcissement fail2ban — protège le SSH public tant que le 22 n'est pas réservé au tailnet.
[DEFAULT]
backend = systemd
banaction = nftables
banaction_allports = nftables[type=allports]
bantime = 1h
findtime = 10m
maxretry = 4
# Ne jamais bannir le réseau Tailscale ni le loopback.
ignoreip = 127.0.0.1/8 ::1 100.64.0.0/10
[sshd]
enabled = true
port = 22
EOF
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd## 13.5 — mises à jour de sécurité automatiques
sudo apt install -y unattended-upgrades
sudo tee /etc/apt/apt.conf.d/20auto-upgrades > /dev/null << 'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOF
sudo systemctl enable --now unattended-upgrades## 13.6 — déjà dans le système
Des couches posées au fil des étapes, pour mémoire :
- le TLS on-demand verrouillable (encadré sécurité de l'étape 08) ;
- les apps écoutent en localhost derrière Caddy — rien d'autre n'est exposé ;
- les
.envne sont jamais versionnés (copiés une fois vers~/deploy/<projet>/.env) ; - sous-domaine inconnu → page fallback statique, pas d'erreur bavarde ;
- option least-privilege : le sudoers de l'étape 08 au lieu du NOPASSWD global.
14 ÇA CASSE ?_
15 CHECKLIST_
// coche au fur et à mesure — c'est sauvegardé dans ton navigateur.
Bon serveur. █