Remote setup
A box runs its own devlanes and its own lanes. Your machine connects over SSH and gets the box’s hostnames and ports locally, nothing else: lanes are started and stopped on the box, and the connection never carries your environment or secrets there. Remote boxes describes mirroring once connected; this page is about getting both machines ready.
On the box
Section titled “On the box”devlanes on the box is an ordinary install. It needs:
- Linux with glibc 2.35 or newer (Debian 12, Ubuntu 22.04 and later, Fedora), x86_64 or arm64; or macOS; or Windows 11 24H2 or later (see Windows boxes). Alpine and other musl systems have no release build.
git, and Docker if the project hasinfra.- The
devlanesbinary on thePATHthat a non-interactive SSH session gets, or at~/.devlanes/bin/devlanes.remote connectfinds it withsh -c 'command -v devlanes', which does not read.bashrcor.zshrc. On a Windows box it asks PowerShell (Get-Command), with thePATHsshd builds from the registry.
Install the latest release from an SSH shell on the box, and keep the client on the latest release too (devlanes update), so both run the same one. install.sh can put it in ~/.devlanes/bin, where remote connect always looks:
curl -fsSL https://devlanes.run/install.sh | sh -s -- --dir ~/.devlanes/bininstall.sh installs only the latest release. To match a client that stays on an older one, install that version on the box with npm or PyPI (npm install -g devlanes@X.Y.Z or uv tool install devlanes==X.Y.Z, where X.Y.Z is what devlanes --version prints on the client), though staying on an older release is not recommended.
Without --dir it installs to ~/.local/bin, which a non-interactive SSH session may not have on its PATH; check with ssh hetzner 'command -v devlanes' from the client. Homebrew, npm and cargo binstall work too (the README lists them). When their directory is not on that PATH, as with Homebrew’s /opt/homebrew/bin on macOS, link the binary into ~/.devlanes/bin from an interactive shell on the box:
mkdir -p ~/.devlanes/bin && ln -sf "$(command -v devlanes)" ~/.devlanes/bin/devlanesremote connect never installs or updates devlanes on a box. Without a binary the first connection fails with devlanes is not installed on hetzner and stops retrying.
Then, on the box:
ssh hetznergit clone git@github.com:acme/acme.git && cd acmedevlanes start # or devlanes start --no-uidevlanes setup # optional: for curl and browsers running on the box itselfServices on the box must listen on loopback or a wildcard address. The box dials 127.0.0.1:<port> for the mirror, as the proxy does locally.
Keep the box daemon alive
Section titled “Keep the box daemon alive”The daemon starts on the first command and detaches from the SSH session, so it survives logging out. What kills it is systemd’s cleanup of your user session on Linux. Enable linger once:
sudo loginctl enable-linger "$USER"Without it, devlanes remote ls on the client warns linger off and the lanes vanish when your last SSH session ends. devlanes daemon install on the box adds a systemd user unit that restarts the daemon at boot, which is useful on top of linger but does not replace it: a user unit is stopped with the session too when linger is off.
On a macOS box, turn on Remote Login (System Settings, General, Sharing) so the box runs sshd. There is no linger. The daemon keeps running after the SSH session closes. devlanes daemon install writes a LaunchAgent for login; it needs a GUI login session to load, so run it in a session where you are logged in on the console as well.
A client that connects while no daemon runs on the box starts one there. To keep the box daemon under your own control, set this in the box’s ~/.devlanes/config.yaml:
remote: start_daemon: falseA client that connects while the daemon is stopped then shows the devlanes daemon is not running on hetzner in remote ls and keeps retrying. It connects once you run devlanes daemon start on the box. A daemon started on demand like that exits about 15 seconds after the last client or TUI leaves while nothing runs, so run devlanes daemon install to keep it running, or set daemon.exit_when_idle: false in the same file. An invalid value, any other problem under remote outside remote.boxes, or a file that cannot be read, is not YAML or has too many problems to keep any setting, counts as false. The setting is read at each connection, so it needs no restart.
Pairing clients
Section titled “Pairing clients”The box serves only machines it paired with. Before a machine’s first connection, get a one-time code on the box:
devlanes daemon pair# pairing code: K7QM-3XWD# it works once and expires in 10 minutes# on your other machine, run: devlanes remote connect <box> --code K7QM-3XWD<box> is the name the client reaches this machine by. Without --code, remote connect asks for the code. In the TUI on the box, B opens the boxes view, and p there shows a code with how long it stays valid.
The client enters it once (first connection) and connects with a token from then on, one per box name it connects by. devlanes daemon clients lists the paired machines and the ones connected now; devlanes daemon revoke <name> removes one and cuts its connection. The boxes view of the TUI on the box (B) shows the same list under this machine, and x there revokes the selected client. A wrong code voids every code you handed out, and five wrong codes within 10 minutes pause pairing for up to 10 minutes. To let every machine that reaches the box over SSH connect without a code, set this in the box’s ~/.devlanes/config.yaml:
remote: require_pairing: falseA problem anywhere under remote outside remote.boxes, or one that makes the whole file fall back to the defaults, turns require_pairing on (and start_daemon off). The setting is read at each connection, so it needs no restart. Pairing has the details.
A machine that no other machine should connect to can turn connections off:
remote: allow_connections: falseClients then fail with box hetzner does not accept connections. Set remote.allow_connections to true there and stop retrying, and devlanes daemon pair gives no code. Connecting from that machine to its own boxes still works. Like the settings above it is read at each connection, and a problem under remote counts as false.
PATH for Docker and tools
Section titled “PATH for Docker and tools”The box daemon inherits the environment of whatever started it. Over SSH that is the non-interactive PATH (/usr/bin:/bin:/usr/sbin:/sbin on macOS, the sshd default on Linux), and Compose, psql, aws and the rest are looked up there. Homebrew’s /opt/homebrew/bin is not in it. Either put additions in a file the non-interactive shell reads (~/.zshenv for zsh, ~/.ssh/environment with PermitUserEnvironment, or /etc/environment), or start the daemon from an interactive shell once (devlanes daemon restart), or install it as a service with the right PATH in the unit.
Hidden units
Section titled “Hidden units”services: admin: run: ./admin hidden: trueinfra: hidden: [postgres]Hidden units stay on the box: clients do not see them, their ports are not mirrored, the box refuses streams to them and their sync folders are not synced. Everything else that is running or starting is mirrored.
Ports outside the config
Section titled “Ports outside the config”A server you run on the box outside devlanes, such as python -m http.server 8000 in an SSH shell, is not mirrored until the box’s config declares it. Add it to devlanes.local.yaml next to the project’s devlanes.yaml on the box:
infra: external: files: address: 127.0.0.1:8000 hosts: "files.${{ lane.domain }}"The box’s lanes then list the unit files, and clients mirror port 8000 and https://files.<lane domain> while a lane of the project runs. External ports has the details.
URLs to give out
Section titled “URLs to give out”On the box, devlanes url print web prints the box’s URL and, in a terminal, on <client>: <url> for each connected client, the URL that opens there, or why the unit does not open there (a port taken on that machine, a lane it skips). Agents working on the box read the same from devlanes url print web --json under clients; the bundled devlanes skill tells them to give you the client’s URL. When no client is connected, an agent runs devlanes daemon pair on the box and asks you to run devlanes remote connect <box> on your machine with the code it got; a machine that is paired already needs no code.
Windows boxes
Section titled “Windows boxes”A Windows machine is a box through Windows’ own OpenSSH Server, an optional feature. In an elevated PowerShell on the box:
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0Set-Service sshd -StartupType AutomaticStart-Service sshdGet-NetFirewallRule -Name OpenSSH-Server-In-TCP # the feature adds this rule for port 22If that rule is missing, add it with New-NetFirewallRule -Name OpenSSH-Server-In-TCP -DisplayName 'OpenSSH Server (sshd)' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22. Only the SSH port needs a rule: mirrored ports and synced files travel inside the SSH connection. Install devlanes as the user you log in as, as in the Windows guide. install.ps1 puts %LOCALAPPDATA%\Programs\devlanes on your user PATH, which sshd reads from the registry for every session, so remote connect finds it; it also looks for %USERPROFILE%\.devlanes\bin\devlanes.exe (-Dir $env:USERPROFILE\.devlanes\bin). Log in with a standard account rather than an administrator: an administrator’s SSH session is elevated, so its daemon would run every unit as administrator, which devlanes doctor warns about. For key authentication, sshd reads a standard account’s keys from %USERPROFILE%\.ssh\authorized_keys and every administrator’s from C:\ProgramData\ssh\administrators_authorized_keys.
sshd runs commands with cmd.exe unless the DefaultShell value under HKLM:\SOFTWARE\OpenSSH names another shell. remote connect works with cmd.exe, Windows PowerShell, PowerShell 7 and Git Bash: when its sh probe finds no sh, it asks PowerShell for devlanes and the shell, and quotes the tunnel command for that shell. A Windows client therefore authenticates twice on its first connection to a Windows box after its daemon starts, and once from then on. With cmd.exe as the shell, the path to devlanes must not hold % or !, which cmd.exe would expand. On a locked-down box where PowerShell runs in Constrained Language Mode, or security software blocks powershell -EncodedCommand, that probe gets no answer, and remote connect fails with unexpected probe output from the box, followed by what PowerShell printed on stderr. Such a box can still be reached as a box reached by a command that names devlanes itself, such as [ssh, acme-box, C:\Users\acme\.devlanes\bin\devlanes.exe, tunnel, --stdio].
The daemon a connection starts leaves the SSH session and keeps running after it ends. It runs in the SSH server’s background session, not on the desktop, even when you are signed in there, so a unit that opens a window shows nothing, and devlanes setup there fails as it does over any SSH session; the client serves the box’s hostnames with its own CA, so the box needs no trust. A daemon already started on the desktop (for example by devlanes daemon install) is used instead. With key authentication the daemon started over SSH has no network credentials, so units cannot reach SMB shares or use credentials from the Windows Credential Manager; when that matters, devlanes daemon restart from the desktop moves the daemon there. devlanes daemon install starts the daemon when you sign in on the desktop, not when the machine boots. A box nobody signs in to gets its daemon from the first connection or the first devlanes command over SSH, or from a Task Scheduler task that runs devlanes.exe daemon run as you at startup. When an SSH server other than Windows’ own keeps the daemon inside the session, remote ls warns the box daemon stops when this SSH session ends; start the daemon in one of those ways instead.
A project with infra needs Docker Desktop running, which starts only when you sign in on the desktop; over SSH docker reaches it while it runs.
Synced folders come from a Windows box with / in their paths and the names as the box stores them. Windows keeps only a read-only attribute, so the box sends new files as 0o644, or 0o444 when read-only, and leaves the exec bits of files the client already holds alone.
On the client
Section titled “On the client”The client runs macOS, Linux or Windows (see Windows clients).
- A
Hostentry in~/.ssh/config. devlanes runs the systemssh, soProxyJump,ProxyCommand, certificates,IdentityFileandUserapply. The name you giveremote connectis thatHost. Boxes on a tailnet or behind Cloudflare Zero Trust have their own pages: Tailscale and Cloudflare. A container, pod, WSL distribution or codespace without SSH is reached by a command instead; see boxes reached by a command. - Your key in an agent. The
sshmaster process is a child of the local daemon and uses the agent the daemon was started with; a daemon spawned from your shell inherits that shell’sSSH_AUTH_SOCK. If the agent socket changes (new login, 1Password restart),devlanes daemon restartpicks up the new one. devlanes setup, once. The box’s hostnames are served by your local proxy with your local CA.- A different
ports.rangethan the box in~/.devlanes/config.yaml, so lanes created on both machines before they first connected do not overlap. Overlapping ports are reported, not mirrored, and the local allocator avoids the box’s ports from then on. While the ranges overlap, every connection warns about it. With several boxes, give each box its own range as well.
devlanes remote connect hetzner # stays attached: shows state, relays SSH promptsdevlanes remote connect hetzner --backgrounddevlanes remote ls # state, mirrored ports, skipped lanes, warningsdevlanes remote disconnect hetznerPassphrase, one-time-password and host-key prompts appear in the terminal running remote connect, prefixed with the box name; type yes to a host-key question even though the input is hidden.
The TUI connects boxes too: B opens the boxes view, n asks for the box’s SSH name or, for a box reached by a command, its command, and Enter connects. The same prompts, and the pairing code, come up in its modal. Enter on a box in the list connects it again, resumes one that waits for a sign-in or a code, or asks before it disconnects a connected one.
First connection
Section titled “First connection”The first connection to a box pairs this machine with it. Run devlanes daemon pair on the box (pairing clients), then either pass the code or type it when asked:
devlanes remote connect hetzner --code K7QM-3XWDdevlanes remote connect hetzner# [hetzner] pairing code from `devlanes daemon pair` on the box: K7QM-3XWDCase and the dash do not matter. --background asks for the code the same way before it returns; without a terminal, as for an agent, pass --code. The box gives this machine a token, kept in its state database, that every later connection presents, also the ones the daemon makes on its own after sleep or a restart. Without a code, as in a script with no terminal, or with a wrong one, the connection waits in needs_pairing and remote connect says why; run it again with a new code. The same happens when the box revoked this machine’s pairing: its ports stay bound and its hostnames answer with a pairing needed page until you pair again.
The connection belongs to your local daemon, not to the terminal: Ctrl-C detaches, the daemon reconnects with backoff after sleep or network loss and keeps the mirrored ports bound meanwhile. It also survives a restart of the local daemon (devlanes daemon restart, a reboot with devlanes daemon install, an update): the new daemon connects every box that connected since you last ran remote disconnect for it, in the background. It keeps retrying such a box even when the box is gone for good or its name no longer resolves; only remote disconnect ends that.
Unattended reconnects
Section titled “Unattended reconnects”Reconnects after sleep, after a network change and after a daemon restart usually run with no terminal attached. Keys in an agent, or a key without a passphrase, let them run without a prompt. When a reconnect needs you anyway, because ssh asks for a password, a passphrase or a new host key, or rejects credentials that worked before, the box waits instead of retrying:
devlanes remote ls# items:# state: needs_auth# error: ssh to hetzner failed (255): ... Permission denied (publickey).# host: hetzner# fix: devlanes remote connect hetznerdevlanes remote connect hetzner # shows SSH prompts in this terminal, then reconnectsWhile it waits and the daemon keeps running, the box’s ports stay bound and its hostnames answer with a sign-in needed page; after a daemon restart they appear once the box connects. status --json lists it under hosts with the same fix. A box that never connected is different: on the first remote connect, rejected credentials fail it for good, unless ssh asked something nobody answered.
Slow links
Section titled “Slow links”devlanes compresses synced files itself: the box sends their contents with zstd wherever that makes them smaller. Mirrored connections (HTTP, database and other TCP traffic) cross the SSH connection as they are. On a slow or high-latency link, turn on SSH’s own compression in the box’s Host block of ~/.ssh/config:
Host hetzner HostName 203.0.113.10 Compression yesdevlanes doesn’t set it for you, because options on the ssh command line would override what your ~/.ssh/config says for that host. Compression costs CPU on both machines and gains nothing on a fast link, so leave it off there. It applies from the next SSH connection to the box: run devlanes remote disconnect hetzner, then remote connect again.
Several boxes
Section titled “Several boxes”Connect to each box by its own name. Give every machine its own ports.range, for example 20000-24999 on the client, 25000-29999 on one box and 30000-32767 on the other, then devlanes daemon restart on each.
Lanes that already have ports keep them until devlanes lane clean. Forks of one image share their range, since they start from the same user config, so give a fork its own range right after it is made if its new lanes must not share ports with the other forks’ (overlapping port ranges).
When two boxes run the same project, this machine mirrors one box’s lane, the box connected first unless you choose another with devlanes remote use <box> <lane>, and lists the other under skipped_lanes in remote ls. Several boxes with the same project has the order and how to switch. When the ranges of two connected boxes overlap, remote ls shows a warning on both while they do.
Box proxy on another port
Section titled “Box proxy on another port”A box whose proxy could not bind 443 (a VPS where unprivileged users cannot bind low ports) serves HTTPS on 8443 and puts that port in its URLs. The client then listens on 8443 too, on loopback only, and serves that box’s hostnames there, so https://web.acme.localhost:8443 opens unchanged, with nothing to configure. When the client cannot bind that port, because something else holds it or it is a low port the client’s user may not bind, remote ls says so; the box’s hostnames still open on the client’s own HTTPS port.
Windows clients
Section titled “Windows clients”devlanes uses Windows’ own OpenSSH client (C:\Windows\System32\OpenSSH\ssh.exe, the “OpenSSH Client” optional feature), or the first ssh.exe on PATH when that is not installed. It reads %USERPROFILE%\.ssh\config and known_hosts like any ssh you run yourself.
Windows’ OpenSSH has no ControlMaster, so every connection and every reconnect authenticates again. Keep the key in the ssh-agent service so reconnects need no prompt. Once, in an elevated PowerShell:
Get-Service ssh-agent | Set-Service -StartupType AutomaticStart-Service ssh-agentThen, as yourself, ssh-add $env:USERPROFILE\.ssh\id_ed25519. The agent keeps the key across logins, and the daemon finds it without SSH_AUTH_SOCK. Passphrase and host-key prompts still appear in the terminal running remote connect (PowerShell, cmd or Windows Terminal; Git Bash’s mintty has no console to prompt in).
Synced folders land under %USERPROFILE%\.devlanes\sync. Windows keeps only a read-only attribute, so exec bits are not synced. Files whose names Windows cannot hold (con.txt, a:b, a name ending in a dot or space) and the second of two names that differ only in case (README.md and Readme.md) are skipped and listed in remote sync. A box port inside a Windows reserved port range (netsh int ipv4 show excludedportrange protocol=tcp) cannot be mirrored and is reported with that hint.
Checking
Section titled “Checking”devlanes remote ls # connected, reconnecting, needs_auth or needs_pairing (with the error), faileddevlanes daemon clients # on the box: paired machines and the ones connected nowdevlanes url ls # the box's hostnames next to your local onesdevlanes status --json | jq .clients # on the box: who is connected and what each mirrorsdevlanes url print web # on the box: the URL each connected client openscurl -sS https://api.acme.localhost/healthz # from the client, once the box lane is ready| Message | Cause | Fix |
|---|---|---|
devlanes is not installed on hetzner | not on the SSH session’s PATH or in ~/.devlanes/bin (%USERPROFILE%\.devlanes\bin on Windows) | install it on the box, or move the binary there, then connect again |
box hetzner does not accept connections. | remote.allow_connections: false on the box | set it to true on the box, then devlanes remote connect hetzner again |
box hetzner does not accept connections because the remote settings in its user config are invalid . | a problem under remote in the box’s config.yaml, which turns connections off | devlanes doctor on the box shows it; fix it, then connect again |
the devlanes daemon is not running on hetzner, and remote. | remote.start_daemon: false on the box and its daemon is stopped | devlanes daemon start on the box, or devlanes daemon install to keep it running; the client keeps retrying |
hetzner: a connection to port 20012 of acme@0:api failed because the unit is not running on the box | an open_failed warning; remote ls shows the same as the port’s last_error | start the unit on the box |
. | the unit runs but does not listen on the box’s loopback | make it listen on 127.0.0.1, ::1 or a wildcard address |
. or the link dropped while it opened | a slow or dropped link | try again; remote ls shows the link state |
. | the link stayed down for 10 seconds | the error after the colon says why |
the box runs devlanes 0. | different releases, same protocol | works; update the box when convenient |
the box runs . | releases too far apart | devlanes update on both machines, then devlanes daemon restart on the box |
`devlanes tunnel --stdio` did not answer within 20s. If the box runs an older devlanes release, ... | this client is newer than the box: the box’s devlanes predates protocol 4, whose tunnel announces itself first | devlanes update on both machines |
| a mux or handshake error right after one machine was updated | this client predates protocol 4 and the box is newer: the client cannot read the line the box’s tunnel starts with | devlanes update on both machines |
the box daemon runs an older devlanes than `devlanes tunnel` | the box’s binary was updated but its daemon still runs the old one | devlanes daemon restart on the box |
the box closed the connection before `devlanes tunnel --stdio` started. The box printed: ... | the login shell exited or ran something else; its output follows | fix the shell’s startup files, or the ForceCommand or RemoteCommand for this host |
the box printed more than 64 KiB before `devlanes tunnel --stdio` started | a profile file prints a lot on every login | make it quiet for non-interactive sessions |
port 5432 is not mirrored because postgres (pid 812) uses it here | a local process holds the port | stop it, or mark the box container hidden |
lane acme@1 is not mirrored because this machine's acme@1 is in use | a stopped local lane of the same project has that hostname | devlanes remote use <box> acme@1, or give one side another project name (several boxes) |
lane acme@1 is not mirrored because acme@1 runs on this machine | a running local lane has that hostname, and it always wins | devlanes stop --lane acme@1 and the containers the message names, then devlanes remote use <box> acme@1 (several boxes) |
lane acme@0 is not mirrored because hetzner's acme@0 is in use | another connected box runs the same lane or serves the same hostname, because it connected first or you chose it | devlanes remote use <box> acme@0 switches (several boxes) |
port 20012 is not mirrored because box hetzner (acme@0:api) uses it here | another connected box’s mirror holds the port | give the boxes different ports.range values |
the box’s ports. | two connected boxes hand out ports from the same pool, as forks of one image do | Nothing for lanes both boxes run. For lanes only one box runs, set another ports.range in ~/.devlanes/config.yaml on one box before those lanes get ports, then devlanes daemon restart there (overlapping port ranges) |
this machine cannot use the box’s HTTPS port 8443 (in use by . | another process holds the box’s proxy port on the client | stop that process; the client tries the port again every 30 seconds, or at once on devlanes remote connect for the box. Until then use the client’s own HTTPS port |
this machine cannot use the box’s HTTPS port 443 (permission denied). | the box’s proxy runs on a low port that the client’s user may not bind (on macOS the extra port binds loopback only, which needs root below 1024) | on Linux, allow low ports as Ports 443 and 80 describes, then devlanes daemon restart; otherwise use the client’s own HTTPS port |
linger off | Linux box without loginctl enable-linger | run it on the box |
the box daemon stops when this SSH session ends | a Windows box whose SSH server does not let the daemon leave the session | start the daemon outside SSH, as Windows boxes describes |
cmd. | the box’s shell is cmd.exe and the path holds % or ! | install devlanes in another folder |
ssh to hetzner failed (255): Permission denied | no agent or key for the daemon’s ssh | load the key, devlanes daemon restart, connect again |
needs_auth in remote ls, a sign-in needed page in the browser | a reconnect asked for a password, passphrase or host key with no remote connect attached, or ssh rejected credentials that worked before | devlanes remote connect hetzner in a terminal; load the key into an agent so later reconnects need no prompt |
box hetzner needs a pairing code from devlanes daemon pair on the box | the first connection, with no code given or typed | devlanes daemon pair on the box, then devlanes remote connect hetzner --code <code> |
box hetzner no longer accepts this machine’s pairing and needs a new code . | devlanes daemon revoke or devlanes daemon reset-state on the box, or the box dropped this pairing as the oldest of 8 | pair again with a new code from devlanes daemon pair |
box hetzner: the pairing code is wrong, expired or already used . | a typo, a code older than 10 minutes or one used before; every outstanding code is void now | a new code from devlanes daemon pair |
box hetzner paused pairing after 5 wrong codes in 10 minutes. | five wrong codes within 10 minutes; daemon pair on the box refuses with the same wait | wait, then use a new code; the box logs the refused attempts with their client id and SSH address |
box hetzner needs you to sign in again: . | the box’s host key changed | check the new key, ssh-keygen -R hetzner, then devlanes remote connect hetzner |
| a box connects again after a daemon restart | you connected it and have not run remote disconnect for it since | devlanes remote disconnect hetzner when you no longer want it |
no ssh client found | Windows without OpenSSH Client | add the optional feature, or put ssh.exe on PATH |
Updating
Section titled “Updating”devlanes never updates a box. Run devlanes update on both machines: it installs the latest release and restarts the daemon, so the box’s daemon matches devlanes tunnel. Machines with different protocol versions do not connect (checking).
Limits
Section titled “Limits”TCP only: UDP ports are not mirrored. The connection is client to box only; the box never dials you. Commands are not forwarded (devlanes --host), so manage lanes in an SSH shell or an editor’s remote terminal on the box.