---
title: "Remote Server Dotfiles Bootstrap — Full Stack via SSH Script"
aliases: [remote-dotfiles, ssh-bootstrap, server-setup-script, dotfiles-remote]
tags: [dotfiles, fish, ssh, sshpass, neovim, starship, homelab, bash]
sources:
  - "daily/2026-04-19.md"
created: 2026-04-19
updated: 2026-04-19
---

# Remote Server Dotfiles Bootstrap — Full Stack via SSH Script

When setting up a new remote server to match a local development environment, a single bash script using `sshpass` can install the complete stack (Fish, Starship, Neovim, CLI tools, fnm) and apply existing configs — without ever manually SSHing in. The pattern uses a helper function `r()` that wraps each `ssh` call, making the script readable and idempotent.

## Key Points

- Use `sshpass -p "$PASS" ssh $SSH_OPTS "$SERVER" "$@"` as a helper function `r()` — each command is a one-liner that looks like a local command
- **Idempotency:** every tool install wraps with `command -v tool || install_command` — safe to re-run
- **Fish conf.d/ pattern:** create separate init files in `~/.config/fish/conf.d/` for PATH, Starship, zoxide, fnm — cleaner than one monolithic `config.fish`
- Neovim as AppImage avoids version conflicts with distro packages
- `sshpass` must be installed on the local machine (not the remote): `brew install sshpass` (macOS) or `apt install sshpass` (Linux)

## Details

### The r() Helper Pattern

```bash
SERVER="vs@192.168.1.39"
PASS="yourpassword"
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=30 -o ServerAliveInterval=60"
r() { sshpass -p "$PASS" ssh $SSH_OPTS "$SERVER" "$@"; }

# Usage — clean, readable
r "apt-get update -qq"
r "command -v fish || apt-get install -y fish"
```

`-o StrictHostKeyChecking=no` skips the fingerprint confirmation for new hosts. `ServerAliveInterval=60` prevents connection drops during long-running installs.

### Full Stack Installation

**System packages first:**
```bash
r "echo '$PASS' | sudo -S apt-get install -y git curl wget build-essential tmux unzip python3 python3-pip fontconfig"
```

**Fish shell:**
```bash
r "command -v fish || (echo '$PASS' | sudo -S apt-add-repository -y ppa:fish-shell/release-3 && echo '$PASS' | sudo -S apt-get install -y fish)"
```

**Starship prompt:**
```bash
r "command -v starship || curl -sS https://starship.rs/install.sh | sh -s -- --yes"
```

**Neovim AppImage** (avoids old distro versions):
```bash
r "command -v nvim || (mkdir -p ~/.local/bin && curl -fLo ~/.local/bin/nvim https://github.com/neovim/neovim/releases/latest/download/nvim-linux-x86_64.appimage && chmod +x ~/.local/bin/nvim)"
```

**Rust CLI tools:**
```bash
r "echo '$PASS' | sudo -S apt-get install -y bat fzf ripgrep fd-find"
# eza (apt version may be outdated — install from GitHub release)
r "command -v eza || (EZA=\$(curl -s https://api.github.com/repos/eza-community/eza/releases/latest | grep tag_name | cut -d'\"' -f4) && curl -fLo /tmp/eza.tar.gz https://github.com/eza-community/eza/releases/download/\${EZA}/eza_x86_64-unknown-linux-musl.tar.gz && tar -xzf /tmp/eza.tar.gz -C /tmp/ && sudo mv /tmp/eza /usr/local/bin/)"
r "command -v zoxide || curl -sS https://raw.githubusercontent.com/ajeetdsouza/zoxide/main/install.sh | bash"
r "command -v lazygit || (LGV=\$(curl -s https://api.github.com/repos/jesseduffield/lazygit/releases/latest | grep tag_name | cut -d'\"' -f4 | sed 's/v//') && curl -fLo /tmp/lg.tar.gz https://github.com/jesseduffield/lazygit/releases/download/v\${LGV}/lazygit_\${LGV}_Linux_x86_64.tar.gz && tar -xzf /tmp/lg.tar.gz -C /tmp/ lazygit && sudo mv /tmp/lazygit /usr/local/bin/)"
```

**Node.js via fnm:**
```bash
r "command -v fnm || curl -fsSL https://fnm.vercel.app/install | bash -s -- --install-dir '\$HOME/.local/share/fnm' --skip-shell"
r "export PATH=\"\$HOME/.local/share/fnm:\$PATH\" && fnm install --lts && fnm default lts-latest"
```

### Fish conf.d/ Init Files

Instead of one big `config.fish`, create small init files:

```bash
r "mkdir -p ~/.config/fish/conf.d"
r "printf 'fish_add_path \$HOME/.local/bin\nfish_add_path \$HOME/.cargo/bin\nfish_add_path \$HOME/.local/share/fnm\n' > ~/.config/fish/conf.d/00-paths.fish"
r "printf 'if command -q starship; starship init fish | source; end\n' > ~/.config/fish/conf.d/01-starship.fish"
r "printf 'if command -q zoxide; zoxide init fish | source; end\n' > ~/.config/fish/conf.d/02-zoxide.fish"
r "printf 'if command -q fnm; fnm env --use-on-cdpath | source; end\n' > ~/.config/fish/conf.d/03-fnm.fish"
```

This pattern keeps each concern isolated and makes it easy to disable specific components.

### Set Fish as Default Shell

```bash
r "FISH=\$(which fish) && grep -qF \"\$FISH\" /etc/shells || echo \"\$FISH\" | sudo tee -a /etc/shells"
r "sudo chsh -s \$(which fish) vs"
```

### Verification

```bash
r "export PATH=\"\$HOME/.local/bin:\$HOME/.local/share/fnm:\$PATH\"
for t in fish starship nvim tmux fzf bat rg eza zoxide lazygit; do
    command -v \$t &>/dev/null && echo \"✓ \$t\" || echo \"✗ \$t\"
done"
```

### Assumption: Configs Already Copied

This script assumes dotfiles were already pushed to the server (e.g., via rsync or direct copy from Nextcloud/iCloud sync). The install script only installs tools and creates conf.d init files — it does not touch existing `~/.config/fish/`, `~/.config/nvim/`, or `~/.config/starship.toml`. Those files are expected to be present already.

## Related Concepts

- [[wiki/concepts/fish-abbr-patterns]] — Fish abbreviations configured after this bootstrap
- [[wiki/concepts/fish-shell-path-config]] — Fish PATH management patterns used in conf.d files
- [[wiki/dotfiles/_index]] — full dotfiles topic: Kitty, Fish, WezTerm, LazyVim, Rust CLI tools

## Sources

- [[daily/2026-04-19.md]] — Remote server setup for `vs@192.168.1.39`; full bootstrap script generated with `r()` helper pattern; Fish + Starship + Neovim + Rust CLI tools + fnm; Fish set as default shell; conf.d/ pattern used for initialization
