Skip to content

VPS / deployment troubleshooting ​

Collection of pitfalls hit while bootstrapping the VPS — provisioning, security hardening, and the Tailscale deployment. Each entry gives the symptom, the actual cause, the fix, and why the fix works — so next time we skip the back-and-forth.

Most of these problems show up when running non-interactive shell (Claude/CI/script) on a stack designed for a human at a TTY. The general rule: anything that asks for a visual prompt has to be disabled explicitly.


1. Docker: permission denied on /var/run/docker.sock after usermod -aG docker ​

Symptom ​

$ docker ps
permission denied while trying to connect to the docker API at unix:///var/run/docker.sock

…even though getent group docker confirms your user is in the group:

$ getent group docker
docker:x:988:kael

…but id doesn't see it in the current session:

$ id
uid=1000(kael) gid=1000(kael) groups=1000(kael),27(sudo),100(users)

Cause ​

A process's Unix groups are fixed at fork()/exec() time and never reloaded afterwards. usermod -aG docker kael modifies /etc/group but has no effect on processes already running.

When bootstrapping the VPS, the typical chain is:

SSH login (groups: kael, sudo, users — no docker, because docker didn't exist yet)
 └─ tmux server (inherits SSH groups)
     └─ bash in a pane (inherits tmux groups)
         └─ here we run sudo apt install docker-ce && sudo usermod -aG docker kael
         └─ then we start Claude (inherits bash groups → still no docker)
             └─ Claude fork()s a sh -c "docker ps" → permission denied

Important: tmux attach from a new SSH client does not respawn any process. It just reconnects your terminal to a tmux server that already exists. The bash shells and child processes keep their original groups. So disconnecting/reconnecting SSH then tmux attach does not fix the problem.

Fix — 3 options ​

(a) Reload for real (clean, but restarts the processes inside tmux)

For a process to see the new group, it has to be launched from a parent that already has it. Concretely:

bash
# 1. Quit Claude/the current tmux shell
exit

# 2. Kill the tmux server entirely (otherwise new panes still inherit the old bash)
tmux kill-server

# 3. Re-ssh to start from a fresh login session
ssh kael@<vps>

# 4. Confirm the groups are there
id | grep docker

# 5. Restart tmux and Claude from this new bash
tmux new -s claude
claude

This is the option to prefer at the end of bootstrap, before starting a long deployment.

(b) sg docker -c "..." — workaround for the current session

sg (substitute group) runs a command in a sub-shell that temporarily inherits an additional group, without touching parent processes.

bash
sg docker -c "docker ps"
sg docker -c "docker compose -f docker/compose.yml up -d --build"

The wrapping applies to each command individually. Useful when you want to keep an in-progress Claude/tmux session without restarting it.

(c) sudo docker ... — discouraged

Always works, but the docker daemon is already privileged and adding sudo on top complicates log reading and breaks scripts that assume non-root execution. Avoid unless urgent.

Verifying the fix ​

bash
id | tr ',' '\n' | grep docker   # must list 'docker' in the groups
docker run --rm hello-world      # must print "Hello from Docker!" without sudo

2. apt upgrade hanging on a whiptail prompt ​

Symptom ​

sudo apt upgrade -y stays frozen indefinitely, no error. No output after the first "Reading package lists…" lines.

Cause ​

Some packages (kernel updates, libc, openssh-server) trigger a whiptail prompt ("Pending kernel upgrade", "Configuration file modified", "Restart services?"). The prompt expects an interactive TTY, but Claude/cron/CI run without one → the prompt waits for a keypress that never comes.

Fix ​

Force non-interactive mode and tell dpkg/needrestart to keep existing configs by default:

bash
sudo DEBIAN_FRONTEND=noninteractive \
     NEEDRESTART_MODE=a \
     apt upgrade -y \
       -o Dpkg::Options::="--force-confdef" \
       -o Dpkg::Options::="--force-confold"
  • DEBIAN_FRONTEND=noninteractive: suppresses whiptail prompts
  • NEEDRESTART_MODE=a: needrestart auto-restarts impacted services instead of asking
  • --force-confdef + --force-confold: if a config file was modified locally, keep the local version

For a full upgrade including a pending kernel:

bash
sudo DEBIAN_FRONTEND=noninteractive apt upgrade -y
sudo reboot   # only if a new kernel got installed

3. gpg --dearmor: invalid radix64 character when piping curl ​

Symptom ​

$ 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 ​

The curl ... | sudo gpg ... pipe can corrupt the encoding of the stream (notably if sudo rewrites stdin, or if a redirection adds control characters). In some cases the shell or a proxy (RTK, etc.) alters the stream between the two commands.

Fix ​

Save the key to a file first, then dearmor from the file:

bash
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /tmp/docker.gpg
file /tmp/docker.gpg   # should say "PGP public key block" — sanity check
sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/docker.gpg /tmp/docker.gpg
rm /tmp/docker.gpg

It's also more debuggable: if curl fails (bad URL, proxy, expired cert) you see it before invoking gpg.


4. gpg --dearmor: cannot open '/dev/tty' ​

Symptom ​

$ sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg /tmp/docker.gpg
gpg: cannot open '/dev/tty': No such device or address

Cause ​

By default, gpg expects to be able to print status messages to the TTY. With no TTY (Claude, CI, cron) it fails early, before even processing the file.

Fix ​

Always invoke gpg with --batch --yes for non-interactive mode:

bash
sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/docker.gpg /tmp/docker.gpg
  • --batch: no prompts, no writes to TTY
  • --yes: assume "yes" for "overwrite the file?" type questions

Apply this to every gpg invocation in a non-interactive script (dearmor, import, sign, etc.).


5. RTK proxy: sudo: rtk: command not found ​

Symptom ​

Some sudo commands fail with:

$ sudo systemctl status fail2ban
sudo: rtk: command not found

…even though which rtk shows rtk is installed for your user.

Cause ​

RTK (Rust Token Killer) is a Claude Code hook that rewrites commands to rtk <cmd> to save tokens. The hook isn't aware of sudo context:

  • User types: sudo systemctl status fail2ban
  • Hook rewrites to: sudo rtk systemctl status fail2ban
  • sudo looks for rtk in root's secure_path (/usr/sbin:/usr/bin:/sbin:/bin by default)
  • rtk is installed in ~/.cargo/bin/rtk or /usr/local/bin/rtk, not in secure_path
  • sudo reports "command not found"

Fix ​

Bypass RTK for sudo commands by using absolute paths:

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 status

The RTK hook does not rewrite commands called by absolute path. More tedious to type but 100% reliable.

Permanent alternative: add the rtk path to sudo's secure_path (sudo visudo, Defaults secure_path=... line). Only do this if you're comfortable with the security implications (any binary in that path can be called as root).


6. gh auth login: Claude refusing to provide the device code ​

Symptom ​

You ask Claude to provide the validation code for gh auth login. Claude responds something like "the code is generated client-side by your browser, I can't give it to you".

Cause ​

Claude confuses this with a purely browser-side OAuth flow. In reality, gh auth login --web runs a device flow:

  1. gh (the CLI, running on the VPS) calls the GitHub API and receives back a device code (XXXX-YYYY) and an activation URL
  2. gh prints both pieces of info to stdout in the VPS terminal
  3. Claude is in the VPS terminal — so Claude sees the code in the command output
  4. The human opens the URL on the laptop, types the code, GitHub validates
  5. On the VPS side, gh polls the API and eventually receives a token

The code is in the command's stdout on the VPS, so Claude has to launch the command and read the result — not assume it's generated elsewhere.

Fix (procedure for Claude) ​

Run gh auth login in background, read the output, extract the code:

bash
# Background, stdout to a file
gh auth login --hostname github.com --git-protocol https --web > /tmp/gh-auth.log 2>&1 &
GH_PID=$!

# Wait for the code to appear (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

# Extract and display the code
grep "one-time code" /tmp/gh-auth.log
# → "! First copy your one-time code: XXXX-YYYY"

Hand the code to the human (who opens https://github.com/login/device in a browser, types the code). Once validated, the background process completes and gh writes its token. Then verify with:

bash
wait $GH_PID
gh auth status

Fallback if the device flow doesn't work ​

Classic Personal Access Token:

bash
gh auth login --with-token < /path/to/token.txt

The token must have at minimum the repo scope (and read:org if you clone from a private org).


General pattern: running shell non-interactively ​

Most of the problems above share one cause: a tool assumes a human at a TTY. When piloting the VPS from Claude/CI/cron, systematically apply:

ToolFlag / variableEffect
aptDEBIAN_FRONTEND=noninteractivesuppresses whiptail prompts
apt-o Dpkg::Options::="--force-confold"keeps local configs without asking
needrestartNEEDRESTART_MODE=aauto-restarts services
gpg--batch --yesno prompts, no writes to TTY
ssh-o BatchMode=yesfails early if interactive auth is required
systemctl--no-pagerno pipe to less
gitGIT_TERMINAL_PROMPT=0fails rather than ask for a password

Keep this in mind when writing a provisioning script or asking Claude to chain steps: if a command is interactive locally, it turns into a silent hang anywhere else.