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.

// convention, partout dans ce guide

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-boutiques

La convention tient en deux lignes :

dossierurl publiqueport par défaut
dev~/projects/<projet>/ → https://<projet>.dev.mondomaine.fr3000
prod~/deploy/<projet>/ → https://<projet>.mondomaine.fr3001

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_

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.

  1. Console Scaleway → Instances → Create Instance (ou Elastic Metal si tu veux du dédié).
  2. Image : Ubuntu 24.04 LTS (Noble).
  3. Ajoute ta clé SSH au projet Scaleway avant la création.
  4. 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.
  5. 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/.ssh
// note sudo

Tout 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 :

typenomvaleur
A*.devIP.DU.SERVEUR  (TTL 300–3600)

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.

// chez jérémy

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.SERVEUR

04 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/bun

Le 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 node

Premiè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 git

Et 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 rattacher

07 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ème

Ne 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-sync

Ce qu'il faut comprendre du script :

// sécurité, optionnel

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 -
// encadré sudoers — seulement si ton user n'a pas NOPASSWD global

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.log

Ouvre 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
claude

Et 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 :

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
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-project

Utilisation :

# 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 previews

Le 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)"
fi

Activation :

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

// piège connu si tu passes à zsh

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 ssh

Le 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.

// règle d'or

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)

// attention

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 verbose

Ce qu'il faut voir dans ces règles :

## 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 :

14 ÇA CASSE ?_

l'url ne monte pas après 1 mintail ~/devproxy-sync.log ; nom de dossier valide ? ([a-z0-9-], pas d'espaces) ; cron actif ? (crontab -l)
page fallback au lieu de l'apple sous-domaine n'est pas dans le caddyfile (grep monapp /etc/caddy/Caddyfile) ou mauvais port
502 en ~5 snormal : l'app n'écoute pas sur son port. relance ton serveur de dev
première visite lente (2–3 s)normal : émission du certificat let's encrypt, une seule fois par sous-domaine
certificat refusé / erreurs acmeport 80 fermé dans le security group, ou dns pas propagé (dig +short x.dev.mondomaine.fr)
node/claude introuvable sous cron ou systemdnvm n'existe pas hors session interactive — d'où les path reconstruits dans les scripts, et bun symlinké dans /usr/local/bin
nvm charge une version fantômemets un default explicite : nvm alias default 24 (un alias vers une version non installée casse le chargement)
caddy en vracjournalctl -u caddy -f ; caddy validate --config /etc/caddy/Caddyfile
un deploy prod échouetail -50 ~/deploy/<projet>.deploy.log puis journalctl -u <projet>.service -f
une preview échouecat ~/previews/<app>/instances/<slug>.deploy.log
après reboottout revient tout seul : caddy et les services sont enabled, le cron relance devproxy-sync et les previews (self-healing)
enfermé dehors après ufw enableconsole out-of-band de l'instance (console scaleway, onglet console) → sudo ufw disable, répare la règle, réactive

15 CHECKLIST_

// coche au fur et à mesure — c'est sauvegardé dans ton navigateur.

Bon serveur.