Skip to content

devlanes is in beta and not ready for production use.Report an issue

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.

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 has infra.
  • The devlanes binary on the PATH that a non-interactive SSH session gets, or at ~/.devlanes/bin/devlanes. remote connect finds it with sh -c 'command -v devlanes', which does not read .bashrc or .zshrc. On a Windows box it asks PowerShell (Get-Command), with the PATH sshd 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:

Terminal window
curl -fsSL https://devlanes.run/install.sh | sh -s -- --dir ~/.devlanes/bin

install.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:

Terminal window
mkdir -p ~/.devlanes/bin && ln -sf "$(command -v devlanes)" ~/.devlanes/bin/devlanes

remote 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:

Terminal window
ssh hetzner
git clone git@github.com:acme/acme.git && cd acme
devlanes start # or devlanes start --no-ui
devlanes setup # optional: for curl and browsers running on the box itself

Services 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.

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:

Terminal window
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: false

A 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.

The box serves only machines it paired with. Before a machine’s first connection, get a one-time code on the box:

Terminal window
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: false

A 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: false

Clients 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.

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.

services:
admin:
run: ./admin
hidden: true
infra:
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.

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.

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.

A Windows machine is a box through Windows’ own OpenSSH Server, an optional feature. In an elevated PowerShell on the box:

Terminal window
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
Set-Service sshd -StartupType Automatic
Start-Service sshd
Get-NetFirewallRule -Name OpenSSH-Server-In-TCP # the feature adds this rule for port 22

If 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.

The client runs macOS, Linux or Windows (see Windows clients).

  • A Host entry in ~/.ssh/config. devlanes runs the system ssh, so ProxyJump, ProxyCommand, certificates, IdentityFile and User apply. The name you give remote connect is that Host. 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 ssh master 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’s SSH_AUTH_SOCK. If the agent socket changes (new login, 1Password restart), devlanes daemon restart picks up the new one.
  • devlanes setup, once. The box’s hostnames are served by your local proxy with your local CA.
  • A different ports.range than 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.
Terminal window
devlanes remote connect hetzner # stays attached: shows state, relays SSH prompts
devlanes remote connect hetzner --background
devlanes remote ls # state, mirrored ports, skipped lanes, warnings
devlanes remote disconnect hetzner

Passphrase, 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.

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:

Terminal window
devlanes remote connect hetzner --code K7QM-3XWD
devlanes remote connect hetzner
# [hetzner] pairing code from `devlanes daemon pair` on the box: K7QM-3XWD

Case 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.

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:

Terminal window
devlanes remote ls
# items:
# state: needs_auth
# error: ssh to hetzner failed (255): ... Permission denied (publickey).
# host: hetzner
# fix: devlanes remote connect hetzner
devlanes remote connect hetzner # shows SSH prompts in this terminal, then reconnects

While 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.

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 yes

devlanes 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.

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.

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.

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:

Terminal window
Get-Service ssh-agent | Set-Service -StartupType Automatic
Start-Service ssh-agent

Then, 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.

Terminal window
devlanes remote ls # connected, reconnecting, needs_auth or needs_pairing (with the error), failed
devlanes daemon clients # on the box: paired machines and the ones connected now
devlanes url ls # the box's hostnames next to your local ones
devlanes status --json | jq .clients # on the box: who is connected and what each mirrors
devlanes url print web # on the box: the URL each connected client opens
curl -sS https://api.acme.localhost/healthz # from the client, once the box lane is ready
MessageCauseFix
devlanes is not installed on hetznernot 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. Set remote.allow_connections to true thereremote.allow_connections: false on the boxset 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 offdevlanes doctor on the box shows it; fix it, then connect again
the devlanes daemon is not running on hetzner, and remote.start_daemon is false thereremote.start_daemon: false on the box and its daemon is stoppeddevlanes 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 boxan open_failed warning; remote ls shows the same as the port’s last_errorstart the unit on the box
... failed because nothing on the box accepted it (connection refused)the unit runs but does not listen on the box’s loopbackmake it listen on 127.0.0.1, ::1 or a wildcard address
... failed because the box did not answer in time or the link dropped while it openeda slow or dropped linktry again; remote ls shows the link state
... failed because box hetzner is not connected: ...the link stayed down for 10 secondsthe error after the colon says why
the box runs devlanes 0.4.1 and this machine 0.4.3. Install the same release on both.different releases, same protocolworks; update the box when convenient
the box runs ... (protocol 5) but this client speaks protocol 4releases too far apartdevlanes 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 firstdevlanes update on both machines
a mux or handshake error right after one machine was updatedthis client predates protocol 4 and the box is newer: the client cannot read the line the box’s tunnel starts withdevlanes 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 onedevlanes 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 followsfix the shell’s startup files, or the ForceCommand or RemoteCommand for this host
the box printed more than 64 KiB before `devlanes tunnel --stdio` starteda profile file prints a lot on every loginmake it quiet for non-interactive sessions
port 5432 is not mirrored because postgres (pid 812) uses it herea local process holds the portstop it, or mark the box container hidden
lane acme@1 is not mirrored because this machine's acme@1 is in usea stopped local lane of the same project has that hostnamedevlanes 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 machinea running local lane has that hostname, and it always winsdevlanes 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 useanother connected box runs the same lane or serves the same hostname, because it connected first or you chose itdevlanes remote use <box> acme@0 switches (several boxes)
port 20012 is not mirrored because box hetzner (acme@0:api) uses it hereanother connected box’s mirror holds the portgive the boxes different ports.range values
the box’s ports.range 20000-32767 overlaps box hetzner (20000-32767). Lanes that only one of them runs can get the same ports, and then only one is mirrored here. Give each box its own ports.range before such lanes get their ports.two connected boxes hand out ports from the same pool, as forks of one image doNothing 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 ...). Open its hostnames on port 443.another process holds the box’s proxy port on the clientstop 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). Open its hostnames on port 8443.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 offLinux box without loginctl enable-lingerrun it on the box
the box daemon stops when this SSH session endsa Windows box whose SSH server does not let the daemon leave the sessionstart the daemon outside SSH, as Windows boxes describes
cmd.exe on hetzner cannot run devlanes from C:...\devlanes.exe because the path holds ...the box’s shell is cmd.exe and the path holds % or !install devlanes in another folder
ssh to hetzner failed (255): Permission deniedno agent or key for the daemon’s sshload the key, devlanes daemon restart, connect again
needs_auth in remote ls, a sign-in needed page in the browsera reconnect asked for a password, passphrase or host key with no remote connect attached, or ssh rejected credentials that worked beforedevlanes 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 boxthe first connection, with no code given or typeddevlanes 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 8pair 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 nowa new code from devlanes daemon pair
box hetzner paused pairing after 5 wrong codes in 10 minutes. The box takes codes again in 7 minutesfive wrong codes within 10 minutes; daemon pair on the box refuses with the same waitwait, 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: ... Host key verification failedthe box’s host key changedcheck the new key, ssh-keygen -R hetzner, then devlanes remote connect hetzner
a box connects again after a daemon restartyou connected it and have not run remote disconnect for it sincedevlanes remote disconnect hetzner when you no longer want it
no ssh client foundWindows without OpenSSH Clientadd the optional feature, or put ssh.exe on PATH

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).

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.