# Connect VMs with links, groups, and port forwards

> Forward guest ports to host 127.0.0.1, publish ports and link VMs under *.devvm hostnames, and join VMs into mutually reachable groups.

- Repository: devvmhq/devvm
- GitHub: https://github.com/devvmhq/devvm
- Human docs: https://grok-wiki.com/public/docs/devvmhq-devvm-4242986d2853
- Complete Markdown: https://grok-wiki.com/public/docs/devvmhq-devvm-4242986d2853/llms-full.txt

## Source Files

- `internal/cli/ports.go`
- `internal/cli/link.go`
- `internal/cli/group.go`
- `internal/ports/ports.go`
- `internal/vm/links.go`
- `internal/vm/groups.go`
- `internal/vmssh/forwards.go`
- `internal/proxy/links.go`

---

---
title: "Connect VMs with links, groups, and port forwards"
description: "Forward guest ports to host 127.0.0.1, publish ports and link VMs under *.devvm hostnames, and join VMs into mutually reachable groups."
---

`devvm` isolates each project VM's network stack while providing two independent SSH-backed transport mechanisms managed from the host: **host port forwarding** (`devvm ports` and `devvm _ports`) exposes guest TCP listeners on host loopback (`127.0.0.1` and `::1`), and **inter-VM links and groups** (`devvm link`, `devvm group`, and `devvm _links`) route TCP connections from a client VM to published ports on target VMs over host UNIX sockets without bridging guest networks or weakening network tier confinement.

## Transport architecture and isolation boundaries

Both port forwarding and inter-VM links run as detached host processes (`devvm _ports` and `devvm _links`) that authenticate to guest `sshd` instances over host loopback using per-VM SSH keys and start-pinned `known_hosts` records (`~/.local/share/devvm/vms/<hash>/run/known_hosts`). Neither mechanism uses Lima's built-in port forwarder or alters guest firewall confinement.

```mermaid
flowchart LR
  subgraph Host["Host Machine (127.0.0.1 & ~/.local/share/devvm)"]
    Browser["Host Client<br/>127.0.0.1:5173 / [::1]:5173"]
    FwdProc["devvm _ports<br/>(vmssh.Forwarder)"]
    LinkProc["devvm _links<br/>(vmssh.Links)"]
    ProxyProc["devvm _proxy<br/>(proxy.links)"]
    LinkSock[("link-<hash>.sock<br/>0600 UNIX socket")]
    LinkList[("links.json<br/>proxy.LinkList v1")]
  end

  subgraph ClientVM["Client VM (e.g. devvm-app-5e1d7c08)"]
    ClientApp["Guest Process"]
    EtcHosts["/etc/hosts<br/>127.77.0.1 db db.devvm.internal"]
    ClientSSHD["Guest sshd<br/>PermitListen 127.77.*:*"]
  end

  subgraph TargetVM["Target VM (e.g. devvm-db-71a99b10)"]
    TargetSSHD["Target sshd"]
    TargetSvc["Target TCP Server<br/>127.0.0.1:5432"]
  end

  Browser -->|"TCP 127.0.0.1:5173"| FwdProc
  FwdProc -->|"ssh -W 127.0.0.1:5173<br/>(port-<hash>.ctl)"| ClientSSHD
  ClientSSHD -.->|"polls /proc/net/tcp{,6}<br/>(assets/guest/ports.sh)"| FwdProc

  ClientApp -->|"Direct TCP to 127.77.0.1:5432"| ClientSSHD
  ClientApp -.->|"Resolves db / db.devvm.internal"| EtcHosts
  ClientSSHD -->|"ssh -R 127.77.0.1:5432:sock"| LinkSock

  ClientApp -->|"HTTP / CONNECT db:5432"| ProxyProc
  ProxyProc -.->|"Watches"| LinkList
  LinkProc -->|"Writes"| LinkList
  ProxyProc -->|"Dials UNIX socket"| LinkSock

  LinkSock --> LinkProc
  LinkProc -->|"CheckLink + policyEnforced<br/>ssh -W 127.0.0.1:5432"| TargetSSHD
  TargetSSHD -->|"127.0.0.1:5432"| TargetSvc
```

| Capability | Host Port Forwarding (`devvm ports`) | Inter-VM Links (`devvm link`) | VM Groups (`devvm group`) |
| --- | --- | --- | --- |
| **Direction** | Host `127.0.0.1` / `::1` $\rightarrow$ Guest loopback | Client VM $\rightarrow$ Host socket $\rightarrow$ Target VM `127.0.0.1` | Full mesh of links across group members' published ports |
| **Worker daemon** | `devvm _ports -vm <name> ...` | `devvm _links <name>` | Uses `devvm _links <name>` per member |
| **Guest address space** | `127.0.0.1`, `::1`, or `0.0.0.0`/`::` (excluding `127.77.0.0/16` and `127.0.77.1`) | `127.77.0.0/16` (`127.77.0.1` .. `127.77.255.254`), one IPv4 per target VM | Same as links (`Link.Group = true`) |
| **Hostnames in guest** | `localhost` (on host) | `<name>:<port>` and `<name>.devvm.internal:<port>` | `<member-name>:<port>` and `<member-name>.devvm.internal:<port>` |
| **Default behavior** | `all` (every TCP port $\ge 1024$) | None; requires `devvm link publish` on target and `devvm link add` on client | None; requires `devvm group join` and `devvm link publish` |
| **Protocol support** | TCP (IPv4 and IPv6 on host) | TCP over IPv4 (plus HTTP/CONNECT via `devvm _proxy`) | TCP over IPv4 (plus HTTP/CONNECT via `devvm _proxy`) |

---

## Host port forwarding

A project VM forwards listening guest TCP ports to the identical port number on the host's loopback interfaces (`127.0.0.1` and `::1`). Port forwarding is active on all network tiers (`allowlist`, `open`, `internet`, and `none`) and is disabled for image build VMs.

### Configuring forwarded ports

You can set a VM's port forwarding policy at creation time with `devvm --ports <value>`, or inspect and modify it afterward with `devvm ports`.

<CodeGroup>
```bash title="Inspect and replace forwarded ports"
# Show what the current folder's VM forwards (all, none, or a range list)
devvm ports

# Forward only specific ports and ranges (including privileged ports < 1024)
devvm ports 5173,8000-8010

# Stop forwarding all ports
devvm ports none

# Restore default forwarding of all guest ports >= 1024
devvm ports all
```

```bash title="Incrementally add or remove ports"
# Add a single port or range to the VM's list
devvm ports add 5432
devvm ports add 8000-8010

# Remove a listed port or range (must match an entry in the list)
devvm ports rm 5173
```

```bash title="Configure global defaults for new VMs"
# Show the default for new VMs and its configuration source
devvm ports -g

# Set, add, or remove default ports for new VMs in settings.json
devvm ports -g none
devvm ports -g 3000,5173
devvm ports add -g 8080
devvm ports rm -g 8080
```
</CodeGroup>

### Precedence and `devcontainer.json` synchronization

1. **Creation precedence:** When creating a VM, `--ports` overrides `ports` in `~/.config/devvm/config.toml`, which in turn overrides the value saved in `~/.local/share/devvm/settings.json` by `devvm ports -g`. When none is set, the default is `all`. When `config.toml` sets `ports`, `devvm ports -g` refuses to modify the global default and points to `config.toml`.
2. **`devcontainer.json` (`forwardPorts`):** In `internal/vm/ports.go` (`filePorts`), `devvm` tracks which ports came from `.devcontainer/devcontainer.json` (`ports` and `ports_gave` in the `devcontainer` state section). Running `devvm update` removes ports dropped from `forwardPorts` and adds newly listed ports while preserving any ports added or removed manually with `devvm ports`.
3. **Live updates:** On a stopped VM, `devvm ports` updates `vm.json` (`ports.forward`) for the next boot. On a running VM, `devvm` updates `vm.json` and immediately restarts `devvm _ports` (`vmssh.Forwards.Start`), closing connections open through the previous forwarder process.

### How `devvm _ports` and `ports.sh` work

`devvm` never uses Lima's native port forwarding because Lima binds with `SO_REUSEADDR`, which can intercept `localhost` traffic from existing host listeners. Instead, `vmssh.Forwards` spawns `devvm _ports` as a detached daemon per running VM:

1. **Listener discovery (`assets/guest/ports.sh`):** `devvm _ports` opens a control-master SSH session (`~/.local/share/devvm/run/port-<hash>.ctl`) and runs `ports.sh` inside the VM. Every second, `ports.sh` reads `/proc/net/tcp` and `/proc/net/tcp6` for sockets in state `0A` (`TCP_LISTEN`), emitting changed hex `ADDRESS:PORT` pairs (or a heartbeat every 10 seconds) terminated by `.`.
2. **Address filtering (`vmssh.dialAddr`):**
   - Guest listeners bound to `0.0.0.0` (`IPv4Unspecified`) or `127.0.0.1` are forwarded to `127.0.0.1:<port>` in the VM.
   - Guest listeners bound to `::` (`IPv6Unspecified`) or `::1` are forwarded to `[::1]:<port>` in the VM.
   - Listeners on other `127.0.0.0/8` loopback addresses are forwarded to that specific loopback address, **except**:
     - `127.77.0.0/16` (`proxy.IsLinkAddr`): reserved for inter-VM links so links are never exposed on the host's `localhost`.
     - `127.0.77.1` (`proxy.IsLocalProxyAddr`, port `3128`): reserved for `devvm-local-proxy`, which injects the VM's proxy password for guest browsers and must never be reachable from the host.
   - Listeners bound exclusively to non-loopback guest network interfaces are ignored.
3. **Port allocation and collision avoidance (`internal/ports/ports.go`):**
   - `ports.Allocator` allocates host ports for each VM's SSH, host proxy, and VNC desktop under the global claims lock (`state.VM.Claim`), storing them in `vm.json` under `ports.host` (`ssh`, `proxy`, `desktop`). During image or tool builds, temporary SSH ports are recorded and locked in `~/.local/share/devvm/build-ports/<vm>` (`staleAfter = 15m`).
   - `ports.Used(dirs, self)` collects every VM's `ssh`, `proxy`, and `desktop` host ports, active build ports, and all explicitly listed `ports.forward` ranges from other VMs.
   - `devvm _ports` binds `127.0.0.1:<port>` and `[::1]:<port>` using `platform.ListenLoopback` (without `SO_REUSEADDR`). If a host process already holds a port on any address, `devvm _ports` skips it; if the port was explicitly listed on the VM, `devvm` emits a `ports_taken` warning and continues running the VM.
4. **Operational limits (`internal/vmssh/forward.go`):**

| Constant | Value | Behavior |
| --- | --- | --- |
| `minForwardPort` | `1024` | Minimum guest port forwarded when mode is `all`. Explicitly listed ports may be `1`–`65535` (including port `22`). |
| `maxForwards` | `256` | Maximum concurrent ports forwarded per VM. |
| `maxListeners` | `4096` | Maximum listening socket lines accepted in a single `ports.sh` report. |
| `maxRelays` | `256` | Maximum simultaneous active forwarded TCP connections (`ssh -W`) per VM. |
| `logMax` | `1 MiB` | Maximum size of `~/.local/share/devvm/vms/<hash>/logs/ports.log` before truncation on forwarder start; runtime writes are rate-limited (`logBurst = 200`, `1` token per `10s`, `512` bytes/write). |

<Note>
**UDP traffic is not forwarded.** To tunnel low-rate UDP request/response traffic (such as DNS, StatsD, or syslog) over a forwarded TCP port, run `socat` on both sides:
```bash
# Inside the VM (for a UDP server listening on 127.0.0.1:8125, with TCP 9125 forwarded)
socat TCP-LISTEN:9125,fork,reuseaddr UDP:127.0.0.1:8125

# On the host
socat UDP-LISTEN:8125,fork TCP:127.0.0.1:9125
```
</Note>

---

## Inter-VM links (`devvm link`)

An inter-VM link connects a **client VM** to a specific TCP port on a **target VM**. The target VM must explicitly offer the port with `devvm link publish`, and the client VM must add the link with `devvm link add`.

<Warning>
**Links join the trust boundaries of connected VMs.** A service exposed over a link (such as PostgreSQL, Redis, or an unauthenticated HTTP API) can often execute commands or access data in the target VM, giving the client VM indirect access to the target VM's filesystem, allowed network hosts, and bound secrets. Only link VMs that you trust to share capabilities.
</Warning>

### Publishing and unpublishing target ports

Run `devvm link publish` in the target project's folder to expose ports or port ranges to other VMs:

```bash title="Publish and unpublish ports in the target VM folder"
# Show currently published ports (prints "none" or a range list like "5432,8000-8010")
devvm link publish

# Publish port 5432 and range 8000-8010
devvm link publish 5432,8000-8010

# Withdraw port 5432 from links (the server inside the VM keeps running)
devvm link unpublish 5432
```

- `devvm link publish` accepts individual ports (`1`–`65535`) and ranges (`from-to`), never `all` or `none`.
- Publishing a port writes to `links.publish` in the target VM's `vm.json`. It does not forward the port to the host machine.
- Target services must listen on `127.0.0.1` or `0.0.0.0` (all addresses) inside the target VM; `devvm _links` dials `127.0.0.1:<port>` in the target, so servers bound exclusively to `::1` are not reached.
- When you unpublish a port that existing client VMs link to, `devvm` prints a note naming those client VMs (`unpublished_linked`). Existing open connections stay up until closed, and subsequent connection attempts are refused with reason `unpublished`.

### Adding, listing, and removing links

Run `devvm link add TARGET PORT` in the client project's folder:

```bash title="Manage links in the client VM folder"
# Link to port 5432 of the VM for folder "db" (reachable at db:5432 and db.devvm.internal:5432)
devvm link add db 5432

# Override the hostname in the client VM with --as
devvm link add db 5432 --as pg

# Reference the target by relative/absolute folder path, full VM name, or SSH alias
devvm link add ../api 8080
devvm link add devvm-api-71a99b10 8080
devvm link add devvm-api 8080

# List links and their current applicability status
devvm link

# Remove a link by link name, target folder, or target VM name
devvm link rm db 5432
devvm link rm pg 5432
```

#### Target resolution and naming rules

- **Target resolution (`Links.resolve`):** `TARGET` can be:
  1. A path starting with `.` or containing `/` (resolved relative to the working directory and symlink-evaluated via `filepath.EvalSymlinks`).
  2. A full VM name (`devvm-<slug>-<hash>`).
  3. An SSH alias (`devvm-<slug>`).
  4. A project folder name (`<slug>`). If multiple VMs share the same folder name, `devvm` fails with `link_ambiguous_target` and lists every matching `<vm> in <dir>`.
- **Single name per target VM (`linkNameFor`):** A client VM assigns a single hostname to a given target VM (`Target` + `TargetID`). If you add `devvm link add db 5432 --as pg`, a subsequent `devvm link add db 8000` automatically uses `pg` (`pg:8000`); passing a different `--as` flag for the same target fails with `link_target_named`.
- **Hostname validation (`CheckLinkName`):** Link names must match `^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`, cannot be all digits (`strings.Trim(name, "0123456789") != ""`), and cannot be `localhost`, the client VM's own hostname slug, or a name already used by a link to another target VM.
- **Target identity pinning (`TargetID`):** Each `Link` stores the target VM's `state.VM.ID` at creation time. In-place remakes (`devvm layer add`, `devvm layer rm`, `devvm update`, `devvm clone`) preserve `state.VM.ID`, so links continue working. Deleting a VM (`devvm rm`) and creating a new VM in the same folder generates a new `ID`, marking any stale link as `replaced`.

### Guest addressing, `/etc/hosts`, and dual ingress

When a client VM has links, `devvm` configures two ways for guest processes to reach `<name>:<port>` and `<name>.devvm.internal:<port>`:

1. **Direct TCP via `127.77.0.0/16` (`vmssh.Links`):**
   - `linkAddr` assigns each distinct target VM in a client its own IPv4 loopback address in `127.77.0.0/16`, starting at `127.77.0.1` (`proxy.LinkAddr`). Multiple ports on the same target share that target's `127.77.x.y` address; two different targets can publish the same port number because their `127.77.x.y` addresses differ.
   - On launch and whenever links change on a running VM, `syncer.sync` streams `/etc/hosts` entries to `assets/guest/sync.sh`, which updates the block between `# devvm links begin` and `# devvm links end` in place:
     ```text
     # devvm links begin
     127.77.0.1 db db.devvm.internal
     127.77.0.2 api api.devvm.internal
     # devvm links end
     ```
   - On Alpine (`musl`) guests where `/proc/sys/net/ipv4/ip_unprivileged_port_start` defaults to `1024`, `sync.sh` writes `0` so guest `sshd` (running as the unprivileged VM user) can bind remote forwards on ports below `1024` (such as port `80` or `443`).
   - Every image configures `sshd` with `AllowTcpForwarding all`, `GatewayPorts clientspecified`, and `PermitListen 127.77.*:*`. `devvm _links` runs `ssh -O forward -R 127.77.x.y:PORT:/path/to/link-<hash>.sock` on its dedicated master connection.
2. **HTTP and HTTPS `CONNECT` via `devvm _proxy` (`internal/proxy/links.go`):**
   - Link names are intentionally excluded from `NO_PROXY` inside the guest (`DEVVM_NO_PROXY` only covers loopback and local addresses). HTTP clients that honor `HTTP_PROXY` / `HTTPS_PROXY` (such as `curl` or `fetch`) send `CONNECT db:5432` or `GET http://api:8080/` to the host proxy (`devvm _proxy`).
   - `devvm _links` atomically writes `~/.local/share/devvm/vms/<hash>/run/links.json` (`proxy.LinkList`, `version: 1`) containing each link's `hosts` (`["db", "db.devvm.internal"]`), `addr`, `port`, `target`, and expected host socket path.
   - Before evaluating any network policy rule or injecting secrets, `proxy.links.find` checks `links.json` (re-reading on file change) and verifies that `e.Socket == proxy.LinkSocket(k.sockets, k.vm, addr, port)`. Matching requests are spliced (`CONNECT`) or reverse-proxied (`HTTP`) directly to `link-<hash>.sock` with `Proxy-Authorization` and `Proxy-Connection` stripped and no TLS interception.
   - Any request to `*.devvm.internal` (or `devvm.internal`) that does not match an active link of the client VM is immediately rejected by `proxy.links.refuse` with `403 Forbidden` (`not one of this VM's links`) so it never leaks to an upstream DNS resolver or corporate proxy.

<Info>
**Docker containers inside a client VM:** Containers started via the built-in `docker` layer use an isolated bridge network by default, which does not inherit the VM's `127.77.0.0/16` loopback listeners or `/etc/hosts` block. Run containers with `--network host` (`docker run --network host ...`) so they share the VM's network namespace and reach links directly.
</Info>

### Network tier rules and per-connection verification

Links enforce a strict monotonic network-tier rule (`internal/vm/links.go`, `policyRule`): **a client VM can only link to a target VM on the same network tier or a stricter one** (`target.tier.AtLeast(client.tier)`).

| Client VM Tier | Allowed Target VM Tiers | Refused Target VM Tiers |
| --- | --- | --- |
| `none` (strictest) | `none` | `allowlist`, `internet`, `open` |
| `allowlist` | `none`, `allowlist` | `internet`, `open` |
| `internet` | `none`, `allowlist`, `internet` | `open` |
| `open` (least strict) | `none`, `allowlist`, `internet`, `open` | — |

- **State mutation guards (`tierLinked`):** While an active link exists, `devvm network` refuses to lower a target VM's tier below any linked client's tier (`tier_linked_clients`) and refuses to raise a client VM's tier above any linked target's tier (`tier_linked_targets`).
- **Per-connection enforcement (`vm.Links.linkConn`):** `devvm _links` re-verifies every accepted connection on `link-<hash>.sock` before opening an SSH channel to the target:

```mermaid
sequenceDiagram
  autonumber
  participant App as Client Guest Process
  participant Sock as devvm _links (link-<hash>.sock)
  participant State as State & Policy Check (vm.Links.linkConn)
  participant Backend as Target Backend (targetRuns 1s cache)
  participant Target as Target VM sshd (127.0.0.1:PORT)

  App->>Sock: Connect via 127.77.x.y:PORT or devvm _proxy
  Note over Sock: Batch accept loop (5ms settle / 50ms max / 256 cap)
  alt Active connections >= 256 (maxLinkConns)
    Sock-->>App: Close connection (logs event "link_limit")
  else Slot available
    Sock->>State: Check(ctx, link)
    State->>State: Read client & target vm.json<br/>Verify link exists, TargetID matches,<br/>Published(port), and linkPolicy(client, target)
    alt CheckLink fails (removed, gone, replaced, unpublished, policy, ungrouped)
      State-->>Sock: Event "link_refused" + reason
      Sock-->>App: Close connection
    else State valid
      State->>Backend: targetRuns.get(target + ID) [reused for 1s]
      Backend-->>State: Running status, start ID, pinned SSH config
      alt Target not running
        State-->>Sock: Event "link_target_stopped"
        Sock-->>App: Close connection (never auto-starts target)
      else Target running
        State->>Backend: policyEnforced(client, target)
        alt Client confined & target tier "unenforced"
          State-->>Sock: Event "link_refused" (reason: "unenforced")
          Sock-->>App: Close connection
        else Tier enforced
          State-->>Sock: LinkTarget{SSH, Start}
          Sock->>Target: Ensure targetMaster (.ctl) for Start ID<br/>Relay via ssh -W 127.0.0.1:PORT
          Sock->>State: Log event "link"
          Target-->>App: Bidirectional TCP stream
        end
      end
    end
  end
```

### Link applicability states and event log reasons

`devvm link` checks each link against current VM states (`CheckLink`), and `devvm network log` records every connection attempt from `devvm _links` (`~/.local/share/devvm/vms/<hash>/logs/events.jsonl`):

| Status / Reason Code | Displayed in `devvm link` (`APPLIES`) | Logged in `devvm network log` (`event` / `reason`) | Meaning |
| --- | --- | --- | --- |
| `yes` / `""` | `yes` | `event: "link"` | Link is valid and connection was carried to target. |
| `gone` | `gone` | `event: "link_refused"`, `reason: "gone"` | Target VM state no longer exists (`devvm gc --orphans` cleans these up). |
| `replaced` | `replaced` | `event: "link_refused"`, `reason: "replaced"` | Target VM was recreated with a new `state.VM.ID`. |
| `unpublished` | `unpublished` | `event: "link_refused"`, `reason: "unpublished"` | Target VM no longer publishes the port. |
| `policy` | `policy` | `event: "link_refused"`, `reason: "policy"` | Target VM's network tier is less strict than the client VM's tier. |
| `ungrouped` | `ungrouped` | `event: "link_refused"`, `reason: "ungrouped"` | Link was created by a group (`Link.Group == true`), and the VMs are no longer in the same group. |
| `unenforced` | `yes` (warns on add/launch) | `event: "link_refused"`, `reason: "unenforced"` | Client is on a confined tier, and the running target switched to a confined tier without restarting (`devvm ls` shows `unenforced`) or failed its proxy self-check. Restart the target VM with `devvm restart`. |
| `removed` | *(not listed)* | `event: "link_refused"`, `reason: "removed"` | Link was removed from client state before the connection was checked. |
| `not carried` | *(any)* | `event: "link_refused"`, `reason: "not carried"` | Logged by `devvm _proxy` when `link-<hash>.sock` does not answer (e.g., `devvm _links` stopped). |
| `ssh` | `yes` | `event: "link_refused"`, `reason: "ssh"` | Target VM's SSH endpoint or pinned host key could not be verified. |
| *(target stopped)* | `yes` (`STATUS: Stopped`) | `event: "link_target_stopped"` | Target VM is not running (`devvm` never auto-starts a target VM for a link). |
| *(concurrency cap)* | `yes` | `event: "link_limit"`, `reason: "256"` | Client VM already has `256` (`maxLinkConns`) active link connections open. |

---

## Mutually reachable VM groups (`devvm group`)

A group connects multiple project VMs so that every member automatically gets links to every port published by every other member in the group (`internal/vm/groups.go`). A VM can belong to at most one group at a time.

<Steps>
  <Step title="Publish service ports in member VMs">
    In each project folder that runs a service, publish its TCP ports:
    ```bash
    cd ~/code/db && devvm link publish 5432
    cd ~/code/api && devvm link publish 8080
    ```
  </Step>
  <Step title="Join VMs to the group">
    Join each VM to the same group name (`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`, not all digits). The first VM to join creates the group:
    ```bash
    cd ~/code/db && devvm group join web
    cd ~/code/api && devvm group join web
    cd ~/code/app && devvm group join web
    ```
    To override the hostname that other group members use to reach a VM (for example, when two project folders share the same basename), pass `--as`:
    ```bash
    cd ~/work/api && devvm group join web --as api1
    cd ~/old/api && devvm group join web --as api2
    ```
  </Step>
  <Step title="Verify group membership and generated links">
    Inspect the group table with `devvm group` and verify the synthesized links with `devvm link`:
    ```bash
    devvm group
    ```
    ```text
    NAME  VM                        FOLDER               PUBLISHED
    api   devvm-api-0f3c2a91b7d4e6  /Users/you/code/api  8080
    app   devvm-app-5e1d7c08a2b9f4  /Users/you/code/app  none
    db    devvm-db-71a99b1076d92c   /Users/you/code/db   5432
    ```
  </Step>
</Steps>

### Group invariants, locking, and link precedence

- **Global group lock (`paths.Dirs.GroupsLock`):** Every `devvm group join`, `devvm group leave`, `devvm rm` (via `Groups.forget`), and `devvm link publish`/`unpublish` (via `Groups.syncOthers`) acquires an OS file lock on `~/.local/share/devvm/groups.lock` (`lockGroups`). If another `devvm` process holds the lock for more than `200ms` (`groupsNote`), `devvm` prints a waiting notice and re-reads VM state once acquired.
- **Uniform network tier requirement (`checkJoin`):** Because group links are bidirectional (`policyRule(self, other) && policyRule(other, self)`), **all VMs in a group must be on the exact same network tier**. `devvm group join` fails with `group_tier_mismatch` if the joining VM's tier differs from existing members, and `devvm network` refuses to change a VM's tier while it is in a group (` Leave` first).
- **Hostname collision rules (`checkJoinName`):**
  - A member's group name defaults to its folder slug unless `--as NAME` is passed (`links.group_as` in `vm.json`).
  - `devvm group join` refuses `localhost`, refuses a name that matches another member's group name or folder slug (`group_name_taken`), and refuses a join if an existing member's group name matches the joining VM's own hostname slug (`group_name_hostname`), which would shadow the joining VM's own hostname in `/etc/hosts`.
- **Precedence between manual links (`ownLinks`) and group links (`groupLinks`):**
  - Links created via `devvm link add` have `Group: false`; links synthesized by a group have `Group: true`.
  - If a client VM already has a manual link to a group member, all group-generated links to that member adopt the manual link's `Name` and `127.77.x.y` address, and any port already linked manually is not duplicated.
  - If a manual link in a client VM uses the same `Name` for a *different* target VM than a group member (`groupClash`), the manual link wins: the client gets no group links to that colliding member, and `devvm` prints a `group_link_name_clash` warning.
  - Removing a manual link with `devvm link rm` automatically restores the group's link for that member and port (`regained`). Attempting to run `devvm link rm` on an active group-generated link fails with `link_from_group` (use `devvm group leave` or `devvm link unpublish` instead).
- **Leaving a group (`devvm group leave`):** Clears `links.group` and `links.group_as` on the VM, removes all `Group: true` links from the leaving VM and to the leaving VM across all remaining group members, and preserves manual `devvm link add` links. When the last member leaves (or is deleted with `devvm rm`), the group ceases to exist.

---

## State files, runtime sockets, and `--json` schemas

### On-disk layout

`devvm` stores port, link, and group state inside `~/.local/share/devvm`:

:::files
~/.local/share/devvm/
├── groups.lock                              # Global file lock serializing group joins, leaves, and syncs
├── build-ports/
│   └── <build-vm-name>                      # Locked file holding temporary SSH port during image/tool builds
├── run/
│   ├── port-<sha256-prefix-16>.ctl          # SSH control socket for devvm _ports
│   ├── link-<sha256-prefix-16>.ctl          # SSH control sockets for devvm _links (session & target masters)
│   └── link-<sha256-prefix-16>.sock         # Mode 0600 UNIX domain socket per active client link (addr + port)
└── vms/<hash>/
    ├── vm.json                              # Persistent VM state ("ports" and "links" sections)
    ├── logs/
    │   ├── ports.log                        # devvm _ports output log (capped at 1 MiB + token bucket)
    │   ├── links.log                        # devvm _links output log (ssh lines prefixed with "ssh: ")
    │   └── events.jsonl                     # Proxy and link connection events read by devvm network log
    └── run/
        ├── known_hosts                      # Pinned SSH host keys for the VM's current boot
        ├── ports.pid                        # PID file for devvm _ports
        ├── ports.status                     # Readiness status file for devvm _ports
        ├── ports.lock                       # Per-VM lock serializing forwarder stop/start
        ├── links.pid                        # PID file for devvm _links
        └── links.json                       # proxy.LinkList (version 1) watched by devvm _proxy
:::

### `vm.json` state sections and CLI `--json` output

<Tabs>
  <Tab title="vm.json sections">
    In `~/.local/share/devvm/vms/<hash>/vm.json`, `ports` (`internal/ports/ports.go`) and `links` (`internal/vm/state.go`) are stored as top-level sections:

    ```json title="~/.local/share/devvm/vms/<hash>/vm.json (excerpt)"
    {
      "ports": {
        "forward": "5173,8000-8010",
        "host": {
          "ssh": 52104,
          "proxy": 52105,
          "desktop": 52106
        }
      },
      "links": {
        "publish": "5432",
        "group": "web",
        "group_as": "db1",
        "links": [
          {
            "name": "api",
            "port": 8080,
            "target": "devvm-api-0f3c2a91b7d4e6",
            "target_id": "9f8e7d6c5b4a3210",
            "addr": "127.77.0.1",
            "group": true
          }
        ]
      }
    }
    ```
  </Tab>
  <Tab title="devvm ports --json">
    `devvm ports --json` and `devvm ports -g --json` return version 1 JSON payloads (`internal/cli/ports.go`):

    ```json title="devvm ports --json"
    {
      "version": 1,
      "ports": "5173,8000-8010"
    }
    ```

    ```json title="devvm ports -g --json"
    {
      "version": 1,
      "ports": "all",
      "source": "built-in"
    }
    ```
    `source` is one of `"config.toml"`, `"settings.json"`, or `"built-in"`.
  </Tab>
  <Tab title="devvm link --json">
    `devvm link --json` and `devvm link publish --json` return version 1 JSON payloads (`internal/cli/link.go`):

    ```json title="devvm link --json"
    {
      "version": 1,
      "links": [
        {
          "name": "db",
          "port": 5432,
          "target": "devvm-db-71a99b1076d92c",
          "target_folder": "/Users/you/code/db",
          "target_status": "Running",
          "applies": true,
          "reason": ""
        }
      ]
    }
    ```

    ```json title="devvm link publish --json"
    {
      "version": 1,
      "ports": "5432,8000-8010"
    }
    ```
  </Tab>
  <Tab title="devvm group --json">
    `devvm group --json` returns the group name (`""` if not in a group) and sorted member array (`internal/cli/group.go`):

    ```json title="devvm group --json"
    {
      "version": 1,
      "group": "web",
      "members": [
        {
          "name": "api",
          "vm": "devvm-api-0f3c2a91b7d4e6",
          "folder": "/Users/you/code/api",
          "published": "8080"
        },
        {
          "name": "db",
          "vm": "devvm-db-71a99b1076d92c",
          "folder": "/Users/you/code/db",
          "published": "5432"
        }
      ]
    }
    ```
  </Tab>
</Tabs>

---

## Troubleshooting

<AccordionGroup>
  <Accordion title="Link fails to listen in client VM (link_port_taken)">
    If a process inside the client VM is already bound to `0.0.0.0:<port>` with an exclusive socket option that prevents `sshd` from binding `127.77.x.y:<port>`, `ssh -O forward -R` fails for that specific link. `devvm _links` logs `<addr>:<port>: refused by the VM` in `links.log` and `devvm` warns with `link_port_taken` while keeping all other links active. Stop the conflicting listener inside the client VM (or bind it to `127.0.0.1:<port>` instead of `0.0.0.0:<port>`) and run `devvm restart`.
  </Accordion>
  <Accordion title="Forwarded port is skipped on the host (ports_taken)">
    When a listed port is already bound by a host program (on `127.0.0.1`, `::1`, `0.0.0.0`, or `::`) or reserved in `ports.Used` by another VM, `devvm` warns (`ports_taken`) and starts the forwarder for the remaining ports. In `all` mode, ports in use on the host are silently skipped until the guest listener restarts after the host port becomes free. Inspect `~/.local/share/devvm/vms/<hash>/logs/ports.log` to see per-port bind results.
  </Accordion>
  <Accordion title="Connections to a linked target are refused as unenforced">
    When a client VM is on a confined tier (`allowlist`, `internet`, or `none`) and the target VM's tier was changed from `open` to a confined tier while the target VM was already running, the target VM is not yet running behind its confined proxy (`devvm ls` shows `unenforced`). `devvm _links` refuses connections from confined clients with reason `unenforced` until you run `devvm restart` in the target VM's folder.
  </Accordion>
  <Accordion title="Cleaning up orphaned links and stale link sockets">
    If a target VM's state directory was removed outside `devvm rm` or a `devvm _links` process was killed with `SIGKILL`, run `devvm gc --orphans`. This removes dead links (`DeadLink` entries pointing to non-existent target states) and deletes stale `link-*.sock` and `link-*.ctl` sockets in `~/.local/share/devvm/run/` that no longer accept connections (`vm.LeftoverSockets`).
  </Accordion>
</AccordionGroup>

## Related pages

<CardGroup cols={2}>
  <Card title="Network tiers and host proxy" href="/network-tiers-and-proxy">
    Understand traffic confinement across allowlist, internet, open, and none tiers and how devvm _proxy inspects requests and logs events.
  </Card>
  <Card title="CLI reference: network, security, and agent commands" href="/cli-security-network-and-agent-commands">
    Complete command syntax, flags, and examples for devvm link, devvm group, and devvm network.
  </Card>
  <Card title="CLI reference: VM and workspace commands" href="/cli-vm-and-workspace-commands">
    Syntax and flags for devvm ports, devvm size, devvm ls, and devvm restart.
  </Card>
  <Card title="Configuration (config.toml) and --json schema reference" href="/configuration-and-json-reference">
    Default ports settings in config.toml and settings.json, state file layout, and version 1 --json schemas.
  </Card>
</CardGroup>
