Appearance
Troubleshooting VPS / déploiement
Recueil des pièges rencontrés en bootstrappant le VPS — provisioning, durcissement sécurité, et déploiement Tailscale. Chaque entrée donne le symptôme, la cause réelle, le fix, et pourquoi le fix marche — pour que la prochaine fois on évite l'aller-retour.
La plupart de ces problèmes apparaissent quand on exécute du shell non-interactif (Claude/CI/script) sur une stack pensée pour un humain devant un TTY. La règle générale est : tout ce qui demande un prompt visuel doit être désactivé explicitement.
1. Docker : permission denied sur /var/run/docker.sock après usermod -aG docker
Symptôme
$ docker ps
permission denied while trying to connect to the docker API at unix:///var/run/docker.sock…alors que getent group docker confirme que ton user est bien dans le groupe :
$ getent group docker
docker:x:988:kael…mais id ne le voit pas dans la session courante :
$ id
uid=1000(kael) gid=1000(kael) groups=1000(kael),27(sudo),100(users)Cause
Les groupes Unix d'un processus sont fixés au moment de son fork()/exec() et ne sont jamais rechargés ensuite. usermod -aG docker kael modifie /etc/group mais n'a aucun effet sur les processus déjà lancés.
Quand on bootstrappe le VPS, la chaîne typique est :
SSH login (groups: kael, sudo, users — pas docker, parce que docker n'existait pas encore)
└─ tmux server (hérite des groupes du SSH)
└─ bash dans un pane (hérite des groupes de tmux)
└─ on lance ici sudo apt install docker-ce && sudo usermod -aG docker kael
└─ on lance ensuite Claude (hérite des groupes du bash → toujours pas docker)
└─ Claude fork() un sh -c "docker ps" → permission deniedImportant : tmux attach depuis un nouveau client SSH ne respawn aucun processus. Ça ne fait que reconnecter ton terminal à un serveur tmux qui existe déjà. Les bash et processus enfants gardent leurs groupes d'origine. Donc se déconnecter/reconnecter SSH puis tmux attach ne corrige pas le problème.
Fix — 3 options
(a) Recharger pour de vrai (propre, mais relance les processus dans tmux)
Pour qu'un processus voie le nouveau groupe, il faut qu'il soit lancé depuis un parent qui l'a déjà. Concrètement :
bash
# 1. Quitter Claude/le shell tmux actuel
exit
# 2. Tuer le serveur tmux entièrement (sinon les nouveaux pane héritent encore de l'ancien bash)
tmux kill-server
# 3. Relancer ssh pour repartir d'une session de login fraîche
ssh kael@<vps>
# 4. Vérifier que les groupes sont là
id | grep docker
# 5. Relancer tmux et Claude depuis ce nouveau bash
tmux new -s claude
claudeC'est l'option à privilégier en fin de bootstrap, avant de commencer un long déploiement.
(b) sg docker -c "..." — workaround pour la session courante
sg (substitute group) lance une commande dans un sous-shell qui hérite d'un groupe additionnel temporairement, sans toucher aux processus parents.
bash
sg docker -c "docker ps"
sg docker -c "docker compose -f docker/compose.yml up -d --build"Le wrapping s'applique à chaque commande individuellement. Utile quand on veut continuer une session Claude/tmux en cours sans la redémarrer.
(c) sudo docker ... — déconseillé
Ça marche toujours, mais le démon docker tourne déjà privilégié et ajouter un sudo par-dessus complique la lecture des logs et casse certains scripts qui supposent l'exécution non-root. À éviter sauf urgence.
Vérification après fix
bash
id | tr ',' '\n' | grep docker # doit lister 'docker' dans les groupes
docker run --rm hello-world # doit afficher "Hello from Docker!" sans sudo2. apt upgrade qui hang sur un prompt whiptail
Symptôme
sudo apt upgrade -y reste figé indéfiniment, sans erreur. Aucun output après les premières lignes "Reading package lists…".
Cause
Certains paquets (mises à jour kernel, libc, openssh-server) déclenchent un prompt whiptail ("Pending kernel upgrade", "Configuration file modified", "Restart services?"). Le prompt s'affiche sur un TTY interactif, mais Claude/cron/CI tournent sans TTY → le prompt attend une touche qui ne vient jamais.
Fix
Forcer le mode non-interactif et dire à dpkg/needrestart de garder les configs existantes par défaut :
bash
sudo DEBIAN_FRONTEND=noninteractive \
NEEDRESTART_MODE=a \
apt upgrade -y \
-o Dpkg::Options::="--force-confdef" \
-o Dpkg::Options::="--force-confold"DEBIAN_FRONTEND=noninteractive: supprime les whiptailNEEDRESTART_MODE=a:needrestartredémarre automatiquement les services impactés au lieu de demander--force-confdef+--force-confold: si un fichier de conf en place a été modifié localement, on garde la version locale
Pour un upgrade complet incluant un kernel pending :
bash
sudo DEBIAN_FRONTEND=noninteractive apt upgrade -y
sudo reboot # uniquement si un nouveau kernel a été installé3. gpg --dearmor : invalid radix64 character quand on pipe curl
Symptôme
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
gpg: invalid radix64 character XX skipped
gpg: invalid radix64 character XX skipped
...
gpg: no valid OpenPGP data found.Cause
Le pipe curl ... | sudo gpg ... peut corrompre l'encoding du flux (notamment si sudo réécrit la stdin, ou si une redirection ajoute des caractères de contrôle). Dans certains cas le shell ou un proxy (RTK, etc.) altère le flux entre les deux commandes.
Fix
Sauver la clé dans un fichier d'abord, puis dearmor à partir du fichier :
bash
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /tmp/docker.gpg
file /tmp/docker.gpg # doit dire "PGP public key block" — sanity check
sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/docker.gpg /tmp/docker.gpg
rm /tmp/docker.gpgC'est aussi plus debuggable : si le curl rate (mauvaise URL, proxy, certif expiré), on le voit avant d'invoquer gpg.
4. gpg --dearmor : cannot open '/dev/tty'
Symptôme
$ sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg /tmp/docker.gpg
gpg: cannot open '/dev/tty': No such device or addressCause
Par défaut, gpg attend de pouvoir afficher des messages d'état sur le TTY. Sans TTY (Claude, CI, cron) il échoue tôt, avant même de traiter le fichier.
Fix
Toujours invoquer gpg avec --batch --yes pour le mode non-interactif :
bash
sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/docker.gpg /tmp/docker.gpg--batch: pas de prompt, pas d'écriture sur TTY--yes: assume "oui" pour les questions de type "écraser le fichier ?"
À appliquer à toute invocation gpg dans un script non-interactif (dearmor, import, sign, etc.).
5. RTK proxy : sudo: rtk: command not found
Symptôme
Certaines commandes sudo échouent avec :
$ sudo systemctl status fail2ban
sudo: rtk: command not found…alors que which rtk montre que rtk est bien installé pour ton user.
Cause
RTK (Rust Token Killer) est un hook Claude Code qui réécrit les commandes en rtk <cmd> pour économiser des tokens. Le hook n'est pas conscient du contexte sudo :
- L'utilisateur tape :
sudo systemctl status fail2ban - Le hook réécrit en :
sudo rtk systemctl status fail2ban sudocherchertkdans lesecure_pathde root (/usr/sbin:/usr/bin:/sbin:/binpar défaut)rtkest installé dans~/.cargo/bin/rtkou/usr/local/bin/rtk, pas danssecure_pathsudorapporte "command not found"
Fix
Bypasser RTK pour les commandes sudo en utilisant des chemins absolus :
bash
/usr/bin/sudo /usr/bin/systemctl status fail2ban
/usr/bin/sudo /usr/bin/docker run --rm hello-world
/usr/bin/sudo /usr/sbin/fail2ban-client statusLe hook RTK ne réécrit pas les commandes appelées par chemin absolu. Plus chiant à taper mais 100 % fiable.
Alternative permanente : ajouter le chemin de rtk au secure_path de sudo (sudo visudo, ligne Defaults secure_path=...). À ne faire que si tu es à l'aise avec les implications sécurité (tout binaire dans ce path peut être appelé en root).
6. gh auth login : Claude qui refuse de fournir le device code
Symptôme
Tu demandes à Claude de te fournir le code de validation pour gh auth login. Claude répond quelque chose du genre "le code est généré côté client par ton navigateur, je ne peux pas te le donner".
Cause
Claude confond avec un flow OAuth purement côté navigateur. En réalité, gh auth login --web lance un device flow :
gh(le CLI, qui tourne sur le VPS) appelle l'API GitHub et reçoit en réponse un device code (XXXX-YYYY) et une URL d'activationghaffiche ces deux infos sur stdout dans le terminal du VPS- Claude est dans le terminal du VPS — donc Claude voit le code dans la sortie de la commande
- L'humain ouvre l'URL côté laptop, tape le code, GitHub valide
- Côté VPS,
ghpoll l'API et finit par recevoir un token
Le code est dans la stdout de la commande sur le VPS, donc Claude doit la lancer et lire le résultat — pas s'imaginer qu'il est généré ailleurs.
Fix (procédure pour Claude)
Lancer gh auth login en background, lire la sortie, extraire le code :
bash
# Background, stdout dans un fichier
gh auth login --hostname github.com --git-protocol https --web > /tmp/gh-auth.log 2>&1 &
GH_PID=$!
# Attendre que le code apparaisse (max 30s)
for i in {1..30}; do
if grep -q "First copy your one-time code" /tmp/gh-auth.log 2>/dev/null; then
break
fi
sleep 1
done
# Extraire et afficher le code
grep "one-time code" /tmp/gh-auth.log
# → "! First copy your one-time code: XXXX-YYYY"Présente le code à l'humain (qui ouvre https://github.com/login/device dans son navigateur, tape le code). Une fois validé, le processus background se termine et gh écrit son token. Vérifie ensuite avec :
bash
wait $GH_PID
gh auth statusFallback si le device flow ne marche pas
Personal Access Token classique :
bash
gh auth login --with-token < /path/to/token.txtLe token doit avoir au minimum les scopes repo (et read:org si tu clones depuis une org privée).
Pattern général : exécuter du shell non-interactif
La majorité des problèmes ci-dessus partagent une cause : un outil suppose un humain devant un TTY. Quand tu pilotes le VPS depuis Claude/CI/cron, applique systématiquement :
| Outil | Flag / variable | Effet |
|---|---|---|
| apt | DEBIAN_FRONTEND=noninteractive | supprime les prompts whiptail |
| apt | -o Dpkg::Options::="--force-confold" | garde les configs locales sans demander |
| needrestart | NEEDRESTART_MODE=a | redémarre les services automatiquement |
| gpg | --batch --yes | pas de prompt, pas d'écriture sur TTY |
| ssh | -o BatchMode=yes | échoue tôt si une auth interactive est demandée |
| systemctl | --no-pager | pas de pipe vers less |
| git | GIT_TERMINAL_PROMPT=0 | échoue plutôt que de demander un mot de passe |
À garder en tête au moment d'écrire un script de provisioning ou de demander à Claude d'enchaîner des étapes : si une commande est interactive en local, elle se transforme en hang silencieux ailleurs.