Skip to main content

Remote Recipes

Provisioning snippets that get a Linux host into a state where dot-agent-deck remote add will succeed. The deck itself doesn't ship a provisioner — these recipes are starting points you adapt to your environment.

For prerequisites the host must satisfy see Remote Environment Requirements. For lifecycle and connection semantics see Remote Environments. The Kubernetes-as-host recipe lives in PRD #81 and is not yet shipped.

Status. Validated on a fresh Ubuntu 24.04 LTS UpCloud VM (the M0.2 reference host). Other providers should work given the same OS and SSH posture, but have not been independently re-tested. If a provider's image needs different bootstrap steps, the differences are typically in the cloud-init / first-login section — the deck-side flow (remote add) is identical once SSH and a non-root user with the agent toolchain are in place.

Common shape

Every recipe converges on the same end state:

  1. A Linux VM running Ubuntu 24.04 LTS (or equivalent), reachable over ssh.

  2. A non-root user with ~/.local/bin on PATH and the agent CLI installed.

  3. Outbound HTTPS to the LLM provider, package registries, and your git remote.

  4. From your laptop:

    dot-agent-deck remote add <name> <user>@<host>

The recipes below differ only in steps 1–3.

Multipass (local VM, macOS or Linux)

For a fully local dev setup with no cloud account.

# Launch an Ubuntu 24.04 LTS VM with sensible defaults.
multipass launch 24.04 --name dad-dev --cpus 2 --memory 2G --disk 20G

# Get into the VM as the default `ubuntu` user.
multipass shell dad-dev

Inside the VM:

# Make sure ~/.local/bin is on PATH for future shells.
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
mkdir -p ~/.local/bin

# Install Node.js (for npm-distributed agents like Claude Code).
sudo apt-get update
sudo apt-get install -y nodejs npm

# Install your agent. Example: Claude Code.
npm install -g @anthropic-ai/claude-code

# Set the agent's API key in your shell rc.
echo 'export ANTHROPIC_API_KEY=sk-ant-...' >> ~/.bashrc

# Enable systemd lingering so user services survive your shell exit.
sudo loginctl enable-linger $USER
exit

Back on the laptop:

# Multipass exposes the VM's IP via `multipass info`.
IP=$(multipass info dad-dev | awk '/IPv4/ {print $2; exit}')

# Multipass installs your laptop's authorized key by default; if not, use
# `multipass exec dad-dev -- bash -c 'echo <pubkey> >> ~/.ssh/authorized_keys'`.
dot-agent-deck remote add dad-dev ubuntu@$IP
dot-agent-deck connect dad-dev

Hetzner Cloud

Cheap, reliable, simple API. Replace <your-ssh-key-name> with the key registered in Hetzner Cloud Console.

# Create the server. CX22 is the smallest tier that comfortably runs an
# agent + the workspace; bump to CX32 for parallel agents or heavier tools.
hcloud server create \
--name dad-dev \
--type cx22 \
--image ubuntu-24.04 \
--ssh-key <your-ssh-key-name>

# Wait for it, then read the public IP.
IP=$(hcloud server ip dad-dev)

First login as root (Hetzner's default for cloud images) — create a non-root user, install the toolchain, then never log in as root again:

ssh root@$IP
adduser --disabled-password --gecos "" deck
usermod -aG sudo deck
mkdir -p /home/deck/.ssh
cp ~/.ssh/authorized_keys /home/deck/.ssh/
chown -R deck:deck /home/deck/.ssh
chmod 700 /home/deck/.ssh
chmod 600 /home/deck/.ssh/authorized_keys

# Disable password auth and root login (sshd hardening).
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
systemctl restart ssh
exit

Then as deck:

ssh deck@$IP
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
mkdir -p ~/.local/bin
sudo apt-get update
sudo apt-get install -y nodejs npm git
npm install -g @anthropic-ai/claude-code
echo 'export ANTHROPIC_API_KEY=sk-ant-...' >> ~/.bashrc
sudo loginctl enable-linger deck
exit

Back on the laptop:

dot-agent-deck remote add hetzner-1 deck@$IP
dot-agent-deck connect hetzner-1

If your ssh identity isn't at one of ssh's default search paths, pass it explicitly:

dot-agent-deck remote add hetzner-1 deck@$IP \
--key ~/.ssh/dot-agent-deck

UpCloud

UpCloud is the M0.2 reference host. The flow is identical to Hetzner once the VM exists; the differences are at the IaaS layer.

# Create the VM via the upctl CLI (or the web console). Pick whichever
# template / plan fits — anything ≥ 2 vCPU / 2 GiB RAM running Ubuntu
# 24.04 LTS is sufficient.
upctl server create \
--hostname dad-dev \
--plan 2xCPU-2GB \
--os "Ubuntu Server 24.04 LTS" \
--ssh-keys "$(cat ~/.ssh/id_ed25519.pub)" \
--zone <your-zone>

Then bootstrap the VM the same way as the Hetzner recipe (non-root user, ~/.local/bin on PATH, Node.js + agent install, enable-linger). The only UpCloud-specific note: cloud-init sets root as the default user; create a non-root user before running remote add so the daemon doesn't run as root.

Bare metal / desk-side box

Any always-on Linux box on your network works — a homelab server, a Raspberry Pi 5, an old laptop. The flow is just the bootstrap section of the cloud recipes minus the IaaS step:

  1. Install Ubuntu 24.04 LTS (or your distribution of choice — see Remote Environment Requirements for what's required).
  2. Create a non-root user, add your laptop's ssh key to its ~/.ssh/authorized_keys.
  3. Install Node.js + the agent CLI; set the agent's API key in the user's environment.
  4. sudo loginctl enable-linger $USER.
  5. From the laptop: dot-agent-deck remote add desk-pi user@hostname.local.

mDNS (hostname.local) is convenient on a home LAN. For routed access from outside the LAN, set up a tunnel (Tailscale, ZeroTier, or a port-forwarded ssh) before running remote add.

Reaching networks only your laptop can see

Sometimes the remote is less connected than your laptop: the laptop holds a corporate VPN that grants access to internal git and private registries, and the VM — on your home LAN, or in a restricted-egress segment — cannot reach them. Agents spawn fine and then the first git clone fails.

connect runs plain ssh and does not pass -F or otherwise override your ssh configuration, so a Host block in ~/.ssh/config applies to the deck exactly as it does to any other ssh invocation. You can lend the VM your laptop's network access with a reverse tunnel, with no deck-side configuration at all.

Status. The tunnel mechanism below is verified end to end (a service reachable only from the laptop's loopback, fetched from a remote through the tunnel). It has not been validated against a real corporate VPN — whether your internal git host is reachable this way depends on your network and your IT policy. See issue #97.

Prerequisite on the remote. Its sshd must permit TCP forwarding — AllowTcpForwarding yes, which is the OpenSSH default but is disabled in some distributions' packages (Alpine's, for one) and by most hardening baselines. Check with sshd -T | grep allowtcpforwarding. If it is off, every forward fails with remote port forwarding failed for listen port N, which looks identical to a port collision. Note that sshd_config takes the first value it finds for a keyword, so appending AllowTcpForwarding yes to the end of the file does nothing if the key is already set above — rewrite the existing line.

Covers HTTPS git, private package registries, and internal APIs in one rule, and preserves hostnames end to end. On the laptop, in ~/.ssh/config:

Host deck-vm.example
RemoteForward 1080
ExitOnForwardFailure yes

RemoteForward with a port and no destination is reverse dynamic forwarding: ssh opens a SOCKS proxy on the VM's loopback at port 1080 and forwards whatever it requests out through your laptop. Requires OpenSSH 7.6 or newer on the laptop. Then on the VM:

git config --global http.proxy socks5h://127.0.0.1:1080

Use socks5h, not socks5 — the h sends hostname resolution through the proxy, so the name is resolved at your laptop (the VM has no DNS for it) and TLS still sees the real hostname, so certificate validation and SNI work normally.

Single host over ssh

Simpler when you only need one git host and it speaks the ssh protocol. On the laptop:

Host deck-vm.example
RemoteForward 2222 git.company.com:22
ExitOnForwardFailure yes

On the VM, give the tunnel a name so host-key checking stays meaningful:

Host company-git
HostName 127.0.0.1
Port 2222
User git
HostKeyAlias git.company.com

Then git clone company-git:team/repo.git. HostKeyAlias records the real host's key under its real name in known_hosts, instead of filing it under [127.0.0.1]:2222 where it would collide with any other host you tunnel to that port.

DynamicForward is the wrong direction. DynamicForward (and ssh -D) opens a SOCKS listener on your laptop that egresses via the remote — useful for reaching the remote's network from the laptop, which is the opposite of the problem here. Use RemoteForward <port> with no destination.

Authentication

The tunnel carries packets, not credentials. A reachable git endpoint still needs to authenticate, and the options are not equally good:

  • A deck-specific deploy key on the VM, registered with your git host — recommended. Scoped to the repositories it needs, revocable on its own, and it leaves an audit trail distinct from your personal account. Usually needs a request to whoever administers the git host.
  • A PAT in the VM's environment. Works for HTTPS-only flows, but the token is now durable on a machine that may be less protected than your laptop.
  • ForwardAgent yes — avoid. It is the least effort and the worst trade: every agent on the VM can use your laptop's ssh-agent for as long as you are connected, with no per-agent scoping and no way to revoke one agent's access short of disconnecting.

Limits worth knowing before you rely on this

The tunnel lives and dies with the ssh session; your agents do not. Agents survive detach by design — their access to laptop-tunneled resources does not. An agent that pushes while you are disconnected fails; one that clones, pulls, or fetches from a private registry blocks on bytes that will never arrive. Reads in particular are not deferrable. If a task needs the tunnel mid-flight, stay connected. On reconnect the forward comes back up with the new session.

The Host block applies to every ssh the deck makes to that host — the version probe, remote add, remote upgrade, and each automatic reconnect attempt, not just connect. Mostly harmless, but it interacts badly with ExitOnForwardFailure yes: if a previous session's listener is still held on the remote, the next connection fails to bind and exits, and the deck reports it as an unreachable host. Two mitigations, and you want both: set ClientAliveInterval 15 / ClientAliveCountMax 3 in the remote's sshd_config so it reaps dead sessions on roughly the same ~45s budget the deck's client-side keepalive uses (sshd's default is to never probe, so a listener orphaned by a laptop sleeping can linger for a long time), and do not run two connect sessions to the same remote with the same forward port.

Forward ports are per-remote, not per-laptop. Two laptops connecting to the same VM with the same RemoteForward 1080 will collide — the second one's forward fails to bind. Give each laptop its own port.

Three options the deck sets explicitly override your config. connect passes ConnectTimeout, ServerAliveInterval, and ServerAliveCountMax on the command line, and ssh gives command-line -o precedence over the config file, so setting those in your Host block has no effect. Forwarding options are untouched.

What to watch for

If remote add fails, the deck distinguishes three failure classes; see Remote Environments → Failure modes for what each one means and how to recover.

The most common first-time failures are:

  • Wrong user. If the cloud image's default user isn't root, the install steps above need to run under the right account. Check the provider's image documentation.
  • ~/.local/bin not on PATH. The remote-side install lands the binary there, but a fresh non-interactive ssh session may not source ~/.bashrc. The deck handles this — remote add invokes the binary by absolute path during install — but later commands assume a login shell with PATH set.
  • Node.js too old. Ubuntu's apt Node.js is sometimes lagging; if your agent's CLI requires a newer version, install via NodeSource or nvm instead of apt.

See also