# Ghostdock CLI

Command-line access to your Ghostdock account — built for agents, but usable by humans too.
Authenticate once, then call Ghostdock's tools without leaving the terminal.

This document is written for an agent (or human) driving the CLI directly. It is also published at
`https://ghostdock.ai/cli.md` so an agent can `curl`/fetch it without cloning this repo.

## Install

One binary for macOS, Linux and Windows. Pick one way:

```bash
brew install brainchimps/tap/ghostdock                         # Homebrew (macOS, Linux)
curl -fsSL https://ghostdock.ai/install.sh | sh   # install script: ~/.local/bin
npm install -g ghostdock                                       # npm (also pnpm, bun, yarn)
```

Or run it without installing: `npx ghostdock --help`.

Every way installs `gdock` too, a short name for the same CLI. Help and hints use whichever name
you typed. With `npx`, use `npx ghostdock`: `npx gdock` does not work. Homebrew sets up tab
completion; with the others, run `ghostdock completion install` (`ghostdock login` offers it once).

### Update

At most once a day the CLI checks for a newer version and, if there is one, prints the command
for how you installed it: `brew upgrade ghostdock`, `ghostdock update` (install script) or
`npm install -g ghostdock` (or pnpm's, bun's, yarn's). `npx` always runs the latest version.

### Uninstall

Leave the private network first, if this machine joined it: that removes the WireGuard tunnel and
the split DNS. Remove the CLI's Docker contexts too (`docker context ls`, then `docker context rm
ghostdock-<org>-<vm>`).

```bash
ghostdock network disconnect
```

Then remove the CLI the way you installed it:

```bash
brew uninstall ghostdock
```

```bash
rm ~/.local/bin/ghostdock ~/.local/bin/gdock ~/.local/bin/.ghostdock-install   # install script
```

```bash
npm uninstall -g ghostdock   # or: pnpm remove -g, bun remove -g, yarn global remove
```

None of these touch your login and settings. To remove them too, and the CLI's cache:

```bash
rm -rf ~/.config/ghostdock ~/Library/Caches/ghostdock   # macOS
rm -rf ~/.config/ghostdock ~/.cache/ghostdock           # Linux
```

If you ran `ghostdock completion install`, delete the line ending in `# ghostdock tab completion`
from `~/.zshrc`, `~/.bashrc` or `~/.bash_profile` (left there, it does nothing without the CLI), or
for fish the files `ghostdock.fish` and `gdock.fish` in `~/.config/fish/completions`.

## Output: for people, and `--json` for scripts and agents

Every command prints for people by default: one line per event, a symbol, then the message.

```
$ ghostdock vm list
  ⚓ Marketing site  quiet-harbor  running
  ◌ teal-jetty  stopped
$ ghostdock site publish ./dist
  ↗ https://foggy-teal-bollard.static.ghostdock.app
  ✓ Published site foggy-teal-bollard.
```

`⚓` ready, `✓` done, `↗` a URL, `◌` stopped or gone, `✗` failed, `▂▄▆` in progress. Every line
carries the name and id the next command needs.

Commands that wait show a progress line on stderr while they run. `vm up` says the create's
step on it (`Downloading the OS image: 312 of 820 MB`, `Starting the system…`); off a terminal
each new step is a line of its own, and `--json` prints none. Starting a VM
(`vm up`, `vm start`) prints how long each step took, measured on the server, each
step with its own time:

```
$ ghostdock vm start quiet-harbor
  ⚓ quiet-harbor  running
  ⚓ VM started in 125ms
  ✓ container started in 3.0s
```

The container line is missing when no container was started, or when the host is older and did
not report it. `vm status` prints the same lines for the last start. With `--json` they are
the `boot` field: `{ trigger, vm: { startedAt, readyAt, ms }, container: { … } | null }`, or
`null` when the command started nothing.

**Scripts and agents pass `--json`.** It prints the result as one JSON document on stdout, with
every field, and nothing else there. It works on every command and anywhere on the line:

```bash
ghostdock vm list --json | jq -r '.vms[].name'
```

- **stdout is the result, stderr is everything else**: notices (`Using organization acme …`),
  hints and errors. Piping stdout never picks up a notice.
- **`✦` is a hint**: what you could do next, set apart by a blank line, with the command to run on
  its own line below. Never with `--json`.
- **Errors** print as `✗ <what went wrong and what to try>` on stderr and exit non-zero, with or
  without `--json`.
  With `--json`, a failed tool call also prints its error as the result on stdout:
  `{"error":{"code":"vm_not_found","message":"…","retryable":false,"params":{…}}}`. `code` is
  stable (the catalog in the API's error docs); `retryable: true` means the same command can work
  later (capacity, a busy VM, an interrupted create), and the CLI then exits `75` instead of `1`.
- **Colour** only on a terminal: piped output and `NO_COLOR=1` get the same text without colour
  codes. `FORCE_COLOR=1` turns it back on.

## Global flags

```
ghostdock --version
ghostdock --base-url <url> <command>
ghostdock <command> --json
```

- `--version` / `-v` — print CLI version and exit
- `--base-url <url>` — API origin for this invocation (wins over `GHOSTDOCK_BASE_URL`). The stored
  login goes only to the server that issued it (see [Credentials file](#credentials-file))
- `--json` — print the result as one JSON document on stdout (see Output above)

## Commands

Commands are grouped by resource, like `gh` or `kubectl`: `ghostdock <resource> <action>`.

<!-- generated:command-groups:start (bun run sync:cli; do not edit) -->
```
ghostdock completion     Tab completion for commands, flags, VM names, containers and organizations.
ghostdock docker         Docker over the private network: a VM's Docker engine from this machine.
ghostdock domain         
ghostdock env            Manage organization environment variables and secrets.
ghostdock login          Sign in and store a device access token for this machine and server…
ghostdock logout         Revoke the device session and remove stored credentials.
ghostdock network        The private network: reach your VMs by address and name from this machine.
ghostdock organization   Organizations you belong to.
ghostdock project        Deployable projects, their deployments and environment.
ghostdock provider       Providers of an organization.
ghostdock registry       Log in to the organization container registry.
ghostdock site           Sites: static files on the CDN.
ghostdock skill          List and read Agent Skills (same catalog as MCP skill://index.json).
ghostdock snapshot       VM snapshots.
ghostdock ssh-key        SSH keys of your account, for logging in to Servers.
ghostdock template       
ghostdock tools          List available agent tools.
ghostdock update         Update this CLI to the latest release.
ghostdock vm             Create, run and manage VMs.
ghostdock volume         Volumes: storage that outlives VMs, attached to them as Docker volumes.
ghostdock whoami         Show the account this CLI is currently authenticated as.
```
<!-- generated:command-groups:end -->

Run `ghostdock <command> --help` at any level for its subcommands or flags, e.g.
`ghostdock vm --help` or `ghostdock vm url set --help`.

Most commands are generated from the tool catalog — see **Invoking a tool** below. Every command
is listed in the [command index](#command-index); [`cli-reference.md`](cli-reference.md) has each
one's full description and flags.

### Choosing an organization

Every organization-scoped command acts on one organization, taken from the first of:

1. `--org <id-or-slug>` on the command
2. the `GHOSTDOCK_ORG` environment variable
3. the default set with `ghostdock organization use <id-or-slug>`, stored in
   `~/.config/ghostdock/settings.json`

With one organization there is nothing to do: `login` makes it the default, and every command,
in any directory, uses it.

```bash
ghostdock organization use acme      # the default (login sets one when it can)
ghostdock vm list              # stderr: Using organization acme (default from ~/.config/ghostdock/settings.json)
ghostdock vm list --org beta   # --org always wins
ghostdock organization current       # which organization, and where it comes from
ghostdock organization use --clear
```

When the organization does not come from `--org`, the command says which one it uses on stderr,
so a default never acts silently. The default is stored next to the credentials, so it belongs to
the same login. **Scripts and CI should pass `--org` or set `GHOSTDOCK_ORG`** rather than depend on
(or change) a stored default, and use the organization id: renaming an organization changes its
slug, not its id.

### On a Server

`ghostdock` is already there on a Server: the image has a launcher that installs the latest release
at first use and updates it once a day (`GHOSTDOCK_NO_AUTO_UPDATE=1` keeps the installed version).
It connects to the app the VM belongs to (`GHOSTDOCK_BASE_URL`, set by the platform). Log in with
`ghostdock login`, and open the link it prints in your own browser (an agent runs
`ghostdock login start`, shows you the link, then runs `ghostdock login finish`).
Tab completion works in bash, with nothing to set up.

### Scripts and CI: `GHOSTDOCK_TOKEN`

An organization API token (Organization settings → API tokens, for owners and admins) runs the CLI
without a person's login. Set it as `GHOSTDOCK_TOKEN`, with `GHOSTDOCK_ORG`:

```bash
GHOSTDOCK_TOKEN=ext_ot_… GHOSTDOCK_ORG=acme ghostdock vm list
```

It replaces the stored login for every command and is never refreshed. It acts only in its
organization and within its scopes: a command its scopes do not cover fails with the scope it
needs, and a revoked or expired token fails with `GHOSTDOCK_TOKEN was refused`. The private network
and Docker access (`network`, `docker`) need a person's login.

### `ghostdock login`

Ghostdock uses a device-authorization flow: no password is ever typed into the terminal.

**Default — print the link and wait:**

```bash
ghostdock login
# or with a custom timeout (seconds, default 300):
ghostdock login --timeout 120
```

This prints a URL to open in a browser, then polls the server every few seconds until the login is
approved, denied, or the timeout elapses. Exits `0` on success, non-zero otherwise.

**`login start` / `login finish` — for agents:** an agent sees a command's output only once it
ends, so a waiting `login` would hide the link from the user until it times out. Agents log in in
two steps:

```bash
ghostdock login start    # prints the URL and exits 0 at once: show it to the user
ghostdock login finish   # once they approve: completes that same login
```

`login start` stores the started login next to the credentials (`pending-login.json`, readable by
you only) while it is valid (about 15 minutes). Run again before finishing, it prints the same URL.
`login finish` waits for that login's approval (at once when it is already approved) and never
starts a new one: with no login started, or an expired one, it exits `1` and says to run
`login start` again. Plain `login` reuses a started login too. `--wait` from earlier versions is
still accepted and changes nothing.

**`--code` — paste-code fallback:**

If the CLI can't poll (e.g. the browser approval happened on a different machine), the browser's "Having trouble? Show code" link reveals a
short one-time code. Redeem it directly:

```bash
ghostdock login finish --code <paste-code>
```

On success, credentials are written and the command exits `0`. If the code hasn't been approved
yet, it exits `1` — approve it in the browser first.

Credentials are stored per machine and per server, in `~/.config/ghostdock/servers/<server>/credentials.json` (see
[Credentials file](#credentials-file)), so one login works in every directory. It is one login for
one server: logging in to another server replaces it, and `login` says so.

**Default organization after login.** Once logged in, `login` sets the
default organization when that is unambiguous:

- `--org <id-or-slug>` sets that one, e.g. `ghostdock login --org acme`. If you are not a
  member of it, no default is set and `login` exits `1`.
- A default that is still one of your organizations stays as it is.
- With exactly one organization, that one becomes the default.
- With several, a person at a terminal picks one from a numbered list (Enter skips). Without a
  terminal (agents, CI) nothing is asked: `login` lists them and points to
  `ghostdock organization use`.

See [Choosing an organization](#choosing-an-organization) for how commands pick it up.

### `ghostdock logout`

```bash
ghostdock logout
```

Revokes the device session on the server (best-effort — still clears local credentials even if the
server call fails, e.g. when offline) and deletes the credentials file. The default organization
stays. With a `--base-url` or `GHOSTDOCK_BASE_URL` other than the server that issued the login, it
sends nothing, keeps the file and exits `1`, naming the command that ends that login.

### `ghostdock whoami`

```bash
ghostdock whoami
```

Prints who the stored access token belongs to, by name and email, and which server it talks to
(`--json`: `{ "userId": ..., "email": ..., "name": ..., "baseUrl": ... }`).
Transparently refreshes an expired access token using the stored refresh token before failing.
Exits `1` with a message if not logged in or the session can't be refreshed.

### `ghostdock tools`

Lists the tool catalog for the authenticated account:

```bash
ghostdock tools          # name + description, one per line
ghostdock tools --json   # the full catalog (including each tool's JSON Schema) as JSON
```

Requires login — prints a hint and exits `1` if there's no valid session.

### `ghostdock skill`

Lists and reads **Agent Skills** for the authenticated account — the same catalog MCP
clients discover at `skill://index.json` (SEP-2640 / Agent Skills discovery 0.2.0).
Prefer resources. The MCP tools `skill_list` and `skill_get` are fallbacks for clients that
cannot use `resources/list` and `resources/read` — skip them if those work.

```bash
ghostdock skill list                          # name, description, and skill:// URI per skill
ghostdock skill list --json                   # official discovery index ($schema + skills[])
ghostdock skill get skill://github/SKILL.md   # print SKILL.md
ghostdock skill get github                    # same, by name
ghostdock skill get org_1/release-notes       # organization skill
```

`--json` on `get` prints `{ uri, name, description, markdown }`. If the same name exists
in more than one organization, pass the `skill://` URI from the index.

Requires login — prints a hint and exits `1` if there's no valid session.

### Invoking a tool

Every catalog tool is a command at its own path, with one `--flag` per schema property
(`autoWake` -> `--auto-wake`). The one thing a command acts on is positional instead:
`ghostdock vm stop <vm>`, `ghostdock vm url delete <vm> [container:]port`,
`ghostdock env get <name>`. The organization is always the `--org` flag, by id or slug: a slug is easier to type, an id keeps
working after the organization is renamed (renaming changes the slug). The MCP tool name is the same path joined with
underscores: `ghostdock vm url set` calls the `vm_url_set` tool. Run
`ghostdock <command> --help` to see a command's full description, its flags and which are
required; a group's `--help` lists its commands with one line each. Output is a line per item
for people; `--json` prints the tool's result as it comes back over MCP.

```bash
ghostdock organization list
#   • Acme  org_123
ghostdock organization list --json
# {"organizations": [{"id": "org_123", "name": "Acme", "slug": "acme"}]}
```

Number/boolean flags are parsed from their string form; array/object flags take a raw JSON string
(e.g. `--tags '["a","b"]'`). Requires login — prints a hint and exits `1` if there's no valid
session, or the server's error message (e.g. "Not a member of this organization") on a `4xx`.

The tool list embedded in the CLI binary is generated from the server's tool catalog (`bun run
sync:cli`), so `--help` and flag parsing work offline; invoking a
tool still requires a network call and a valid session.

Command descriptions are short on purpose. Concepts and workflows live in the built-in Agent
Skills, the same documents MCP agents read:

```bash
ghostdock skill get vm       # sources, manifests, lifecycle, providers, URLs and TCP
ghostdock skill get vm-boot   # processes that must come back after idle-stop
ghostdock skill get environment    # organization entries, bindings, local entries, secrets
ghostdock skill get publishing     # sites publish vs projects publish, outcomes, redeploys
```

### VMs

Read `ghostdock skill get vm` before your first VM: what it can boot from, manifests,
idle-stop and auto-delete, providers, URLs.

Every VM has a name, unique within its organization, and every command that takes a VM
accepts it with the organization (`--org`, `GHOSTDOCK_ORG` or the default). Choose it with
`--name` (`api`, `my-web-app`: 3–30 lowercase letters, digits and single hyphens), or leave
it out for a generated one like `quiet-harbor`. The long `vmId` in `--json` output works too,
in the same organization. A name never changes while the VM exists, so scripts, `ssh` logins
(`api--acme`) and private addresses (`api.acme.ghostdock.internal`) can keep it; a name in use
is refused. Once a VM is deleted, a name you chose can be taken again at once; a generated one
is held back for 30 days.

The display name is a separate free label, shown next to the name in `vm list`; change it any
time. For example, create a VM, expose a URL, then relabel it:

```bash
ghostdock vm up --org org_123 --name api --display-name "Marketing site"
ghostdock vm url set api 8080 --access public --wake
ghostdock vm update api --display-name "Marketing site (staging)"
ghostdock vm update api --display-name null   # clear it
```

#### Containers in a VM

On Ghostdock Cloud a VM runs one Docker engine; everything in it is an ordinary container.
Containers are reached the way Docker reaches them, on either kind of VM: `vm container exec`
as `docker exec`, `vm container cp` as `docker cp`.

```bash
ghostdock vm container list quiet-harbor
ghostdock vm container exec quiet-harbor web                  # a shell, with a terminal
ghostdock vm container exec quiet-harbor web -- ls /app        # one command
ghostdock vm container cp quiet-harbor ./dist web:/app         # into the container
ghostdock vm container cp quiet-harbor web:/app/log.txt .      # out of it
ghostdock vm container unlock quiet-harbor web
```

`vm container exec` without a command opens bash (else sh) with a terminal; with one after
`--` it runs it with this stdin, without a terminal (as `docker exec -i`), and exits with its
code. It runs as `docker exec` does: as the container's `ghostdock.login.user`, else the image's
`USER`, else root. Both go straight to the container through Ghostdock, no `ssh` needed; with
`--json` a command's output comes back as data. `vm container cp` copies files and folders both
ways, of any size, in a stopped container too: into an existing folder a copy keeps its name,
elsewhere it takes the name given, a source ending in `/.` copies what is in it, and `-` is a
tar on stdin or stdout. A container path without a leading `/` is from the container's `/`.
What goes in belongs to the container's root, as with `docker cp`. Tab completion offers the
VM's containers as `web:` and the paths in them.

`vm command` and `vm shell` land on a Server itself unless a container is named. On a
Container host they land in a container: the one named after the VM
(`vm command run quiet-harbor web`), else the one running container. With several running the
CLI asks which one at the keyboard; without a terminal it fails and lists the names.

A shell session lives in its container: name the same container on every `shells` and
`commands` call on it.

#### Files on a VM

One filesystem, two places. No container is the Server, as its login user (the same person `ssh`
would be). A container name is that container.

```bash
ghostdock vm file list quiet-harbor /srv/app
ghostdock vm file read quiet-harbor /etc/hostname
ghostdock vm file write quiet-harbor /srv/app/note.txt --content hello
ghostdock vm cp quiet-harbor ./dist :/srv/app            # into the Server
ghostdock vm cp quiet-harbor :/var/log/syslog ./         # out of it
ghostdock vm cp quiet-harbor web:/app ./out              # out of a container
```

`vm file` carries at most 256 KiB per call, because a tool call is JSON. A larger file, or a
folder of any size, is `vm cp`. As with `vm container cp`, the VM comes first and one side is
this machine; the other is `:path` for the Server or `web:path` for a container. A relative path,
or `~/…`, starts where a login does: the login user's home on the Server, the container's start
folder (its login user's home) in a container. Answers name a path in the home as `~/…`, and
take it back. Into an existing folder a copy keeps its name, elsewhere it takes the name given,
a source ending in `/.` copies what is in it (`:.` is what is in the home), and `-` is a tar on
stdin or stdout.

```bash
ghostdock vm file write quiet-harbor notes/todo.txt --content hi   # ~/notes/todo.txt
ghostdock vm cp quiet-harbor ./site :site                          # into ~/site
```

A Server path goes through Ghostdock's guest agent as the login user, so it works while sshd is
down. A write keeps an existing file's mode, a move never replaces what is at its destination,
and a delete needs `--recursive` for a folder and never removes `/`, the login user's home or a
folder above it. A link to a file is written through, as `docker cp` does. A VM on an older base
image takes `vm file` over sshd and answers `vm cp` of a Server path with `guest_needs_newer`:
stop and start the VM. `vm container cp` stays `docker cp`: a relative path is from the
container's `/`, and it works in a stopped container. A secret belongs in `vm env set`, not in
a file these commands write.

#### Add a container to a VM

`vm container up` runs a template, an image, a Compose file or a Dockerfile in a running VM, as
its own Compose project next to the VM's. An image or a Dockerfile is one container named after
its service (`nginx`, `redis`), or `--name`; a name in use gets the next free number (`nginx-2`).
The source's URLs are made after it runs; a port that already has a URL is refused.

```bash
ghostdock vm container up quiet-harbor nginx                 # a template
ghostdock vm container up quiet-harbor --image redis:7       # an image
ghostdock vm container up quiet-harbor --compose ./compose.yaml --name stack
ghostdock vm container up quiet-harbor --dockerfile ./Dockerfile --name worker
```

A Compose project or a Dockerfile that needs other files (a build context) goes through the Docker
context ([`ghostdock docker context`](#ghostdock-docker-context)).

#### SSH into a Server

SSH is for Servers (`vm up`, any system but `container-host`): you log in to the VM's own sshd, as `ghost`, with
passwordless `sudo`. `vm ssh` starts the VM when needed and connects: with your SSH key when one on
this machine is in your account, otherwise with a temporary login for 60 minutes. It never asks, so
agents and scripts run the same command.
Arguments after `--` go to `ssh`: a port forward, or a command to run instead of a shell.

```bash
ghostdock vm ssh quiet-harbor                                 # a shell on the Server
ghostdock vm ssh quiet-harbor -- -L 5432:localhost:5432       # forward a private port
ghostdock vm ssh quiet-harbor -- uname -a                     # run one command
ghostdock vm ssh quiet-harbor --print                         # show the ssh command
ghostdock vm ssh quiet-harbor --config                        # the ~/.ssh/config block, for VS Code
ghostdock vm ssh quiet-harbor --temp                          # a temporary login, even with a key
ghostdock vm ssh quiet-harbor --revoke                        # end the temporary login
```

With `--json`, `vm ssh` prints the access as data and does not connect. A Container host has no
SSH: `vm ssh` says so, and `vm container exec` reaches its containers.

With an SSH key in your account, plain `ssh` logs in to the Servers of every organization you
belong to, without `vm ssh` and without a token: the username is `<vm>--<org>`, or
`<user>--<vm>--<org>` for another Linux user (never `root`). `vm ssh --print` shows that command
once you have a key, and `--config` the block that lets VS Code Remote-SSH and `scp` use the VM's
name. Without a file, `ssh-key add` asks which key on this machine to add, or generates a new one.

```bash
ghostdock ssh-key add                                          # choose a key, or generate a new one
ghostdock ssh-key add ~/.ssh/id_ed25519.pub --name laptop      # ed25519, ecdsa, RSA ≥ 3072
ghostdock ssh-key list
ssh quiet-harbor--acme@<host>                                # the host vm ssh --print shows
ghostdock ssh-key delete <id>
```

#### VMs that create VMs, sites and volumes

A VM can get its own identity (ADR 0058): `GHOSTDOCK_TOKEN` and `GHOSTDOCK_ORG` in its environment,
so this CLI works inside it unchanged. The identity sees and manages only what it creates, and may
create only the kinds you allow, within their budget:

- `--vms-budget cpu=8,memory=32,disk=200` lets it create VMs. `cpu` and `memory` cap the VMs it
  runs at once: stopped VMs do not count, and every start is checked (ADR 0066). `disk` caps all of
  them (memory and disk in GiB); `count=4` caps their number too.
- `--sites-budget count=20` lets it publish up to 20 sites; `--can-create sites` alone sets no limit.
- `--volumes-budget size=500` lets it create volumes whose sizes add up to at most that, attached
  or not (GiB, or `500G`, `2T`, as `volume create --size`); `count=10` caps their number too.
- `--can-create vms,sites,volumes` names the kinds (vms still needs `--vms-budget`, volumes
  `--volumes-budget`).
- `--on-auto-delete delete|release` is required with an identity: what happens to what it created
  when the VM is removed without anyone deciding (an ephemeral VM's end, auto-delete, its host gone).
  `release` keeps them as ordinary VMs and sites of the organization.
- `--no-identity` gives the VM none, even when its template gives one. `--identity` takes the whole
  identity as JSON instead of the flags.

`vm up` and `vm update` take the same flags; on `vm update` they replace the VM's whole identity.
`vm budget <vm>` shows what it may create, what it uses, and what is left (inside the VM, `vm budget`
alone shows its own).

```bash
ghostdock vm up runner-manager --vms-budget cpu=8,memory=32,disk=200 --on-auto-delete delete
ghostdock vm up agent --can-create vms,sites --vms-budget cpu=4,memory=16,disk=80,count=2 --sites-budget count=20 --on-auto-delete release
ghostdock vm up db-agent --volumes-budget size=200G,count=4 --on-auto-delete release
ghostdock vm budget agent
ghostdock vm release runner-1                     # an ordinary VM of the organization from now on
ghostdock site release bright-otter               # the same for a site it published
ghostdock vm delete agent --owned-resources release     # or delete: what happens to what it owns
ghostdock vm update agent --no-identity --owned-resources delete
```

Deleting a VM whose identity still owns VMs or sites, or taking its identity away, needs
`--owned-resources delete` (they go too, down the whole tree) or `--owned-resources release`.

#### Environment in a VM

A VM exports **nothing** by default (unless you boot from an organization snapshot or a
source whose labels supply defaults). `environment` has three parts: `organization` (plain same-name opt-ins from the
catalogue, see [`ghostdock env`](#ghostdock-env)), `bindings` (slot ← org entry remaps), and `local` (VM-only variables/secrets). A local entry overrides an
organization export of the same name. Prefer the CLI when setting secret values so they are not
pasted into the agent conversation.

```bash
# At create time, in one call:
ghostdock vm up --org org_123 \
  --environment '{"organization":["VITE_API_URL","BUILD_TOKEN"],"bindings":[{"name":"DATABASE_URL","organization":"PROD_DATABASE_URL"}],"local":[{"name":"API_KEY","value":"sk_live","kind":"secret"}]}'

# Afterwards:
ghostdock vm env get quiet-harbor
ghostdock vm env select quiet-harbor --environment '["VITE_API_URL"]' \
  --bindings '[{"name":"DATABASE_URL","organization":"PROD_DATABASE_URL"}]'
ghostdock vm env select quiet-harbor --environment '[]'   # clears plain + remaps (omit bindings)
ghostdock vm env set API_KEY --vm quiet-harbor --value sk_live --kind secret
ghostdock vm env delete API_KEY --vm quiet-harbor
```

Values arrive as real environment variables, so read them with `process.env` / `$VAR` inside the
VM. **Do not pass secrets in `vm command run` strings** — the command text is retained and
readable through `vm command logs`. `vm env get` reports every organization entry
(selected + overriddenByLocal) and the VM's own local entries; `vm status` reports
just the resolved names.

New shell commands and SSH sessions see a change immediately. Processes already running keep the
values they booted with until restarted (pass `--restart` to `vm env select` /
`vm env set` / `vm env delete` to restart a running VM
instead).

#### Volumes

A volume is the organization's storage that outlives VMs (ADR 0061). A **disk** (the default) is
a block device one VM has at a time, for databases and anything with locks; a **shared folder**
(`--shared`) is mounted by many VMs at once. In the VM a volume is mounted at its path (default
`/mnt/volumes/<name>`) and is a Docker volume of its name, so `docker run -v pgdata:/x` and
Compose's `external: true` use it. Deleting a VM never deletes its volumes.

```bash
ghostdock volume create pgdata                     # a disk, 10 GiB
ghostdock volume create assets --shared --size 50  # --size in GiB, or 50G, 1T
ghostdock volume list
ghostdock volume show assets
ghostdock volume resize pgdata --size 40           # grow only
ghostdock volume delete scratch                    # refused while attached, naming the VMs
```

```
NAME     KIND    SIZE    USED     ATTACHED TO
pgdata   disk    20 GiB  3.1 GiB  shop-db (rw)
assets   shared  50 GiB  12 GiB   web-1 (rw), web-2 (rw), worker (ro)
scratch  disk    10 GiB  –        –
```

Both kinds attach and detach live in a VM that runs; a stopped VM takes the change at its next
start. A running VM on an older base image, one started before disks attached live, or one on a
Docker host takes a disk at its next start too: the answer says why, the list shows
`(rw, next start)` or `(rw, detaches at next start)`, and `ghostdock vm restart <vm>` makes it so
now. A detach whose path a process in the VM still uses waits for it, and says who:

```
assets is in use by node (pid 812) in web-1: it detaches when they stop using it, or at the VM's next start
```

Shared folders are slower than a disk for many small files and are not for databases: a read-write
one at a database's directory, `/var/lib/docker` or a `node_modules` folder comes with a warning.
On Ghostdock Cloud an organization's shared folders are served by its file service on the host, a
small VM of its own that starts with the first VM that needs it and stops 10 minutes after the last
one; `volume show` says whether it runs.

A **host folder** (`--host-folder <name>`, only on a Docker or Incus host) is a folder of the
machine itself that its owner listed when the host was set up (`--host-paths`), such as
`/srv/media`. `provider list` shows each host's folders. It attaches like a shared folder, to any
number of VMs; one listed read-only only attaches read-only. It has no size, one volume per host
folder, and Ghostdock never deletes its files: `volume delete` only forgets it. Files of the
machine's uid 1000 (usually its first user) belong to `ghost` in the VM.

```bash
ghostdock provider list                              # host folders: media (/srv/media, read-only)
ghostdock volume create media --host-folder media
ghostdock vm volume attach web-1 media:/srv/media:ro
```

Attach them as Docker does: `-v`/`--volume` is the short form (`volume[:/path][:ro]`), `--mount`
the long one (`source=`, `name=` for its name in the VM, `target=`, bare `readonly` and `shared`,
`size=`). Both repeat. A volume that does not exist is created on first use, a 10 GiB disk unless
`shared` or `size` say otherwise; those two only matter then. A `--mount` with a template's volume
`name` replaces the template's, so `--mount source=shop-db-pgdata,name=pgdata` gives a new VM an
existing database.

```bash
ghostdock vm up --volume pgdata                      # /mnt/volumes/pgdata, read-write
ghostdock vm up --volume assets:/srv/assets:ro
ghostdock vm up --mount source=assets,shared,size=50,target=/srv/assets
ghostdock vm up postgres --name shop-db-2 --mount source=shop-db-pgdata,name=pgdata

ghostdock vm volume attach web-3 assets:/srv/assets:ro
ghostdock vm volume detach web-3 assets
```

A volume's files can be looked into without a VM of yours, as on the Volumes page (ADR 0065):
list a folder, read a file, copy one out, and write one into a shared folder. A disk is read-only
here: its one writer is its VM. Paths are inside the volume, `/` its root.

```bash
ghostdock volume file list assets /config
ghostdock volume file read assets /config/app.yml
ghostdock volume file write assets /config/flag.txt --content on   # a shared folder only
ghostdock volume cp assets ./app.yml :/config/                     # into /config/app.yml
ghostdock volume cp pgdata :/PG_VERSION -                          # to stdout
```

On Ghostdock Cloud a shared folder is read through its file service (started for the call when it
is stopped) and a disk through the running VM it is attached to: a disk whose VM is stopped, or
attached to none, is refused with `volume_files_vm_stopped` or `volume_files_needs_vm`. On a
Docker or Incus host the host reads both itself. One call carries a file of up to 384 KiB out and
256 KiB in; a larger one goes through a VM that has the volume attached (`vm cp`). `volume cp`
copies one file: a volume path ending in `/` is a folder it goes into under its own name, and the
folder must exist.

Every attach says whether it created the volume or attached an existing one; check it, since a
typo makes a new, empty volume:

```
  ✓ Attached existing volume assets (shared, 50 GiB) to web-3 at /srv/assets, read-only. Live now.
  ✓ Created volume pgdata (disk, 10 GiB) and attached it to web-1 at /mnt/volumes/pgdata. Live now.
  ✓ Attached existing volume cache (disk, 10 GiB) to old-1 at /mnt/volumes/cache. old-1 runs an older base image that changes disks only at a start. Attaches at old-1's next start: ghostdock vm restart old-1
  Warning: size=80 ignored: assets has 50 GiB. Grow it with ghostdock volume resize assets --size 80
```

### Systems, workloads and templates

`vm up` takes two independent choices: the **workload** (what runs, optional) and the
**system** (what boots). Name one of our systems where a template goes (`vm up desktop`), or any
system with `--system`.

| System | What boots |
|---|---|
| `headless` | Server · Headless: the VM's own system, based on Ubuntu 26.04, with sshd, Docker and systemd; `vm ssh` lands on it. **The default with nothing to run** |
| `desktop` | Server · Desktop: the same Server with an XFCE desktop in the browser, on its private Desktop URL (2 CPU / 4 GiB by default) |
| `container-host` | The platform's lean read-only base that only runs the workload's containers; no SSH. Needs a workload. **The default with something to run**, and on a provider that runs no Servers |
| an image ref (`--system`) | Any image labelled `ghostdock.os=true` is a system (a Server); one without the label is refused |

The defaults follow one rule: nothing to run is a Server, something to run is a Container host.
A template may name its own system, and `--system` wins over both. `headless`, `desktop` and
`container-host` are systems, never templates: no template may be called so.

`headless` and `desktop` follow their images' stable tags (`ghostdockhq/headless:ubuntu-26.04`,
`ghostdockhq/desktop:ubuntu-26.04`), as `nginx` follows `nginx:latest`. To pin one build, give
the image's ref instead: `--system ghostdockhq/headless:ubuntu-26.04-<date>-<sha>-<run>`.

The workload is at most one of a template (`vm up nginx`), `--image`, `--compose`,
`--dockerfile` or `--source`. On a Server it runs as its own Compose project in
`/srv/ghostdock/<project>`, exactly as `vm container up` adds one (its URLs, MCPs and forwards
with the create's `--urls`, `--mcps`, `--forwards`). On a Container host it is the VM's own project. An image labelled `ghostdock.os=true` is
a system, never a workload: `vm up --image <it>` says to use `--system`.

```bash
ghostdock vm up                                # an empty Server (headless)
ghostdock vm up desktop                        # the same with a desktop
ghostdock vm up nginx                          # nginx on a Container host
ghostdock vm up --compose ./compose.yaml       # the project on a Container host
ghostdock vm up nginx --system headless        # nginx inside a full Server
ghostdock vm up desktop --image redis          # redis on a desktop Server
```

`--snapshot` is a whole VM and brings its own system (not with `--system`). A template brings its
source (and maybe its system): the other flags change everything else — `--system`, size,
lifecycle, idle time, environment, `--env`, `--urls`, `--forwards`, `--mcps`, `--restart`,
`--provider` — but a template together with `--image`, `--compose`, `--dockerfile` or `--source`
is refused. A system's name with one of those is fine: `vm up desktop --image redis`.

`vm up --source` takes an OCI ref (`{"type":"image","ref":"..."}`, any image, its
`ghostdock.*` labels read from its registry), Dockerfile text, inline Compose files, a Compose
reference (`oci://…`), a snapshot, or a template by name. What an organization wants to keep —
a system, an image, a Dockerfile, a Compose project — is a template (`ghostdock template create`,
or the Templates page in the app), and `ghostdock vm up <template>` brings it up. `ghostdock vm up`
alone is `ghostdock vm up headless`: an empty Server.

### Waiting for a create, and retrying it

`vm up` waits until the VM is up, however long that takes: the server answers after at most two
minutes with `status: creating` and the CLI follows the create with `vm status` until the VM runs
or the create failed. A create goes on when the command is interrupted (Ctrl-C, a dropped
connection): `ghostdock vm status <vmId> --wait-seconds 120` picks it up again.

- `--no-wait` returns at once with `{ vmId, name, organizationId, status: "creating" }` (exit `0`).
- `--idempotency-key <key>` makes the create safe to retry: the same key with the same arguments,
  within 24 hours, answers the same VM (or its running create) instead of making a second one. A
  create that failed with a retryable error (exit `75`) frees its key: running the command again
  starts a new attempt. A final failure (exit `1`) answers again for that key. The same key with
  other arguments fails with `idempotency_key_reused`. Keys belong to the caller (a
  person or an API token) within the organization.
- `vm status <vm> --wait-seconds N` (0–120) waits up to N seconds while the VM is still being
  created, then answers `creating`, `running` (the VM's status as usual) or `failed` with
  `failure: { code, message }`.

```bash
ghostdock template list --org org_123
ghostdock vm up postgres-pgadmin --org org_123 \
  --environment '{"local":[{"name":"POSTGRES_PASSWORD","kind":"secret","value":"..."}]}'
ghostdock vm up --org org_123 --source '{"type":"image","ref":"ghcr.io/acme/app:latest"}'
```

Booting with `--source '{"type":"dockerfile","content":"..."}'` parses labels inline. That JSON
source is Dockerfile text only: COPY/ADD of local files fails.
`--source '{"type":"compose","files":{"compose.yaml":"..."}}'` runs `docker compose up` on those
inline files (must include `docker-compose.yml`/`yaml` or `compose.yml`/`yaml`);
`ghostdock.*` service labels become the create-time manifest: `ghostdock.url=80` (an HTTP
URL with Organization access), or `ghostdock.urls.<name>.port` with `.protocol`, `.access`, `.title`, `.autoWake` and
`.required`, reaches that service's container on its own port (no `ports:` needed);
`ghostdock.forward=8080:80` makes VM port 8080 reach it. In `--urls`, a URL's `target` names
the service (omitted: the host network). `--image` and `--dockerfile` run as `docker run -d`
would: one container on Compose's own network, no TTY; its URLs and forwards reach the
container's own ports, and an MCP is forwarded from the same VM port. A directory or ZIP — Dockerfile
build context, or a Compose project with extra files — is
[`ghostdock vm build`](#ghostdock-vm-build), not this command.

### `ghostdock site publish`

Publish a local directory or ZIP to the CDN (durable public URL, no VM). Each publish without
`--site-id` creates a new site with its own URL; `--site-id` republishes into an existing site and
keeps its URL (its files are replaced in place, it never goes offline):

```bash
ghostdock site publish --org org_123 ./dist
ghostdock site publish --org org_123 --zip ./site.zip
ghostdock site publish --org org_123 --site-id foggy-teal-bollard ./dist   # same URL, new files
ghostdock site list --org org_123                    # each site's id, URL and owner
ghostdock site delete --org org_123 foggy-teal-bollard   # files gone, URL answers 404, name free again
ghostdock site release --org org_123 foggy-teal-bollard  # no longer owned by the identity that published it
```

MCP `site_publish` is for small inline payloads. Use this command for a real directory or large ZIP.

### `ghostdock project publish`

Inspect a local source tree and publish (CDN when pre-built static, otherwise Compose from a DeploymentSpec).
Required secrets without values return `awaiting_configuration` instead of a live URL.
Re-invoke with `--environment` (same JSON shape as `vm up`) after configuring.
On `build_failed`, `start_failed`, or `verify_failed`, inspect the kept VM (unless verification tore it down), then re-invoke with `--spec` (JSON `DeploymentSpec`). The spec is executed on a fresh VM and stored for later replay. Do not pass `--spec` with `--replan`. Every terminal outcome stores the upload and returns authenticated `fileUrls`. Detected tests always run after the stack is up; pass `--require-verify` to refuse a deploy when they fail.

```bash
ghostdock project publish --org org_123 ./app
ghostdock project publish --org org_123 --idle-stop-minutes -1 --zip ./app.zip
ghostdock project publish --org org_123 --project-id proj_123 ./app
ghostdock project publish --org org_123 ./app \
  --environment '{"organization":["DATABASE_URL"],"local":[{"name":"API_KEY","value":"sk_live","kind":"secret"}]}'
ghostdock project publish --org org_123 ./app --spec '{"version":1,"run":{"kind":"static","outputDir":"."},"env":[]}'
ghostdock project publish --org org_123 --require-verify ./app
ghostdock project publish --org org_123 --provider prov_123 ./app
```

MCP `project_publish` is for small inline payloads. Use this command for a real directory or large ZIP.

### `ghostdock vm build`

Create a VM from a local Dockerfile or Compose directory. The CLI zips the directory (or sends
`--zip`) and the server builds it. MCP `vm_up` stays inline text only.

```bash
ghostdock vm build --org org_123 --kind dockerfile ./app
ghostdock vm build --org org_123 --kind compose --zip ./stack.zip
ghostdock vm build --org org_123 --kind dockerfile \
  --provider prov_123 --display-name "API" ./app
```

The Dockerfile (`Dockerfile` or `dockerfile`) or a compose file (`docker-compose.yml`/`yaml`,
`compose.yml`/`yaml`) must sit at the directory root, or at the root of the ZIP. A ZIP that wraps
the project in one folder is unwrapped. Pass one directory or `--zip`, not both.

### `ghostdock env`

Manage the environment variables and secrets an organization offers to its VMs and projects.
A VM inherits nothing automatically — select the entries it needs on that VM.

```bash
ghostdock env list --org org_123
ghostdock env get VITE_API_URL --org org_123
ghostdock env set VITE_API_URL --org org_123 --value https://api.example.com
ghostdock env delete VITE_API_URL --org org_123
```

**Variables vs secrets.** A variable is readable by anyone who can see the organization; `env list`
prints its value inline. A secret can be set and replaced but never read back — it shows as
`<secret>` in `env list` and `env get` refuses it. Pass `--secret` to create one:

```bash
# Keeps the value out of shell history and the process list
printf %s "$STRIPE_KEY" | ghostdock env set STRIPE_SECRET_KEY --org org_123 --secret --value-stdin
```

`--secret` is only required when creating; updating an existing entry keeps its kind. To convert one,
pass `--kind variable` or `--kind secret` explicitly along with the new value:

```bash
ghostdock env set STRIPE_SECRET_KEY --org org_123 --kind variable --value not-secret-anymore
# Warning: STRIPE_SECRET_KEY was a secret and is now a variable — its value is readable by everyone in the organization.
```

Converting never reveals the old secret: `set` always replaces the value, so the value you supply in
that call is the one that becomes readable. The command prints what changed either way.

`env get` prints the bare value so it can be captured in a shell (`API=$(ghostdock env get
VITE_API_URL --org org_123)`). It also returns values too large for `env list`, which
withholds anything over 1024 characters rather than truncating it — a printed value is always the
complete value.

With `--json`, `list`, `get` and `set` print the tool's result instead. These commands wrap the
`env list`, `env get`, `env set` and
`env delete` tools, which are also available directly (and over MCP).

Names must be valid environment variable names, and names the VM runtime owns (`PATH`,
`HOME`, anything starting with `GHOSTDOCK_`, …) are rejected. Prefix a name with `VITE_`
only when browser code must read it: Vite inlines those into the published bundle, so a `VITE_`
secret is public.

### `ghostdock network`

Reach your organizations' VMs from this machine by private address and name, over
WireGuard ([ADR 0027](../../docs/adr/0027-private-sandbox-network.md)). Everything on a
VM's address is reachable: a container that publishes port 80 answers on
`http://quiet-harbor.acme.ghostdock.internal/`, as it would on `localhost` inside the VM.
Nothing reaches your machine from a VM.

```bash
ghostdock network connect              # your organization's default provider; sudo once
ghostdock network connect helsinki     # or name a provider (name or id)
ghostdock network vms --org acme       # VMs, addresses, names, and what is reachable
curl http://quiet-harbor.acme.ghostdock.internal:8080/
open http://quiet-harbor.acme.ghostdock.internal:8080/  # a browser lands on the HTTPS name
ghostdock network status
ghostdock network disconnect
```

- **One provider at a time.** Each provider is its own gateway, and a device reaches
  only the VMs on the provider it connected through. Without an argument, `connect` takes
  the organization (`--org`, `GHOSTDOCK_ORG`, the default from `organization use`) and its
  default provider, else the platform default. Connecting through another provider
  replaces the connection: same interface and address, new gateway. `network status` says which
  provider you are connected through; `network vms` marks VMs elsewhere as
  `not reachable: on <provider>` with the `connect` command that reaches them. A provider
  without a private network is refused by the server; one your organization
  may not use answers 403.
- **Needs `wg-quick`** (`brew install wireguard-tools`, `apt install wireguard-tools`). macOS
  and Linux. On Windows, run the CLI in WSL: then only tools inside WSL reach VMs.
- **The gateway's name.** The config names `net.<provider's domain>` when the provider has an
  active domain, its address otherwise. WireGuard looks the name up only when the tunnel starts,
  so `ghostdock network connect` restarts a running tunnel when the name has moved to a new
  address. That restart asks for sudo; otherwise connecting again does not.
- **Tunnel MTU 1280**, so the tunnel works on networks with a smaller path than 1500 bytes
  (DS-Lite, VPNs, mobile). A config written before 2026-10-20 has 1420: `network status` and
  `network vms` then say *This machine's tunnel has MTU 1420, the network now uses 1280*. Run
  `ghostdock network connect` again; until then TCP works, other large packets can be lost.
- **One key per machine**, kept in `~/.config/ghostdock/network/`.
  Only the public key is sent. The device belongs to the login it was connected with:
  `ghostdock logout` takes it out of the network, and so does not connecting for 90 days.
- **Names**: `<vm>.<org>.ghostdock.internal`, for every organization you are a member
  of. A name always says its organization, so a browser origin never changes hands between
  organizations. Only AAAA records exist: the network is IPv6, and the VM sees IPv4.
- **In a browser**, the HTTPS name `https://<vm>--<org>.ghostdock.net:<port>` serves the VM
  (`network vms --json` has it as `httpsName`; opening the `.internal` name redirects there): any port, a real certificate, so the page is a secure context
  (clipboard, `Secure` cookies, service workers). The host's `gdo-ingress` terminates TLS and
  forwards to the VM's port; it serves only devices of the VM's organization, and
  refuses background requests (fetch, forms, iframes, websockets) from pages outside that
  organization; pages on another HTTPS name of the same organization, any port, may call it. The
  VM sees the request from `198.18.0.0`, and your device's address in
  `X-Forwarded-For`. The desktop image's Desktop opens only this way, never on the raw
  address.
- **Browsers use the HTTPS name; tools keep the raw address.** A browser that opens
  `http://quiet-harbor.acme.ghostdock.internal:3000/app` (or the raw IPv6 address) gets a
  `308` to `https://quiet-harbor--acme.ghostdock.net:3000/app`. Any other browser request on
  the raw address (`fetch`, images, scripts, forms, websockets) gets `403` with
  `X-Ghostdock-Blocked: browser-https-only` and the HTTPS address in the body. This keeps
  websites you visit while connected from using your connection against your VMs,
  including through DNS rebinding.
  - **What counts as a browser:** a `User-Agent` that starts with `Mozilla/`, which every
    browser sends on its own requests. Browsers send no `Sec-Fetch-*` headers over plain
    `http://` to an address like this, so those cannot be the signal. (Firefox lets a page
    set its own `User-Agent` on a `fetch`; the `Host` check below stops that one.) Tools that send a
    browser's `User-Agent` (Playwright, Puppeteer, their request APIs) count as browsers too:
    point them at the HTTPS name. A frontend outside the VM (a dev server on your laptop)
    calls the VM's API by its HTTPS name as well.
  - **Tools** (`curl`, SDKs, test runners, Prometheus) pass untouched, and every other
    protocol does too, as long as they address the VM by its IP address, its
    `.internal` name (or a container's, `web.quiet-harbor.acme.ghostdock.internal`), or
    `localhost`: any other `Host` gets `421` (`X-Ghostdock-Blocked: host-not-allowed`). That
    check is what stops DNS rebinding for a page that sets its own `User-Agent`. A name of
    your own in `/etc/hosts` gets 421 too.
  - **One difference for tools:** a port where nothing listens accepts the connection and
    then resets it, so port checks such as `nc -z` report it open; check with a real request
    instead.
  - **In the VM,** firewall rules for these clients belong in the input path
    (`iptables -A INPUT`, an nft `hook input` chain): `FORWARD` and `DOCKER-USER` rules no
    longer see them, because the guard connects to a published container port from inside
    the VM.
- **The network pause** ([ADR 0033](../../docs/adr/0033-gdo-net-dead-mans-switch.md)): a
  device that sent no traffic to a VM for its idle timeout (default 4 h), or whose session
  since the last connect reached its maximum (default 12 h), is paused at the gateway. Your
  organizations set both (Settings → Security). If you are in several, the strictest value
  applies. `connect` ends with this device's own numbers and names the organization that set a
  stricter one.
  - **While paused**, names still resolve, but every port of a VM answers `Connection
    refused`, and UDP and ping get *administratively prohibited*. Browsers get a pause page
    with a *Reconnect* button, and Docker gets `Ghostdock: network access for this device is
    paused (idle 4 h)`. The `network` and `docker` commands print `Network access for this
    device is paused (…). To resume: ghostdock network connect` once; other commands work the
    same while paused and say nothing.
  - **To resume**, run `ghostdock network connect`, or click *Reconnect* in the web app (User
    settings → Network devices). With the tunnel up and nothing changed, `connect` only
    re-arms: no sudo, *Re-armed: network access for this device runs until 22:14 (pauses after
    4 h idle).* *Pause now* in the web app pauses a device at once.
  - DNS, WireGuard keepalives and ping do not count as traffic. A long stream does (a `docker
    logs -f`, an open desktop tab).
- `ghostdock network devices` lists your devices with their state (*Active, pauses in 3 h 12
  min*, *Paused: idle 4 h*, *Paused: 12 h session limit*, *Paused by you*, *Not connected
  (…)*). `ghostdock network devices remove <id>` removes one.
- **`ghostdock network disconnect`** also removes the split-DNS files a reboot or a crashed
  `wg-quick` left on macOS (`/etc/resolver/<zone>`), even when the tunnel is already down.
  Without that, names of the private network hang while you are disconnected. `network status`
  says when such a file is still there.

### `ghostdock docker context`

Point your own `docker` and `docker compose` at a VM's Docker engine, over the private
network with mutual TLS ([ADR 0032](../../docs/adr/0032-docker-api-over-gdo-net.md)). Connect
first: the context uses this machine's device.

```bash
ghostdock network connect
ghostdock docker context quiet-harbor --org acme
#   ✓ Docker context ghostdock-acme-quiet-harbor (in use)
#       tcp://quiet-harbor.acme.ghostdock.internal:2376
#       Certificate for this device, valid until 2026-11-20.
#       → docker ps
docker ps
docker compose up -d
ghostdock docker context north-pier --no-use   # create only: docker --context ghostdock-acme-north-pier ps
```

- **One Docker context per VM**, `ghostdock-<org>-<vm>`, for
  `tcp://<vm>.<org>.ghostdock.internal:2376`. Running the command again updates it;
  without `--no-use` it also becomes Docker's current context (`docker context use`). A
  `DOCKER_HOST` or `DOCKER_CONTEXT` in your shell wins over that: the command says so.
- **One client certificate per organization and machine**, in
  `~/.config/ghostdock/docker/<organizationId>/`: `key.pem` (ECDSA P-256, made on this machine,
  0600; only a certificate request is sent), `cert.pem` and `ca.pem`. The certificate names your
  user, this machine's device and the organization, and lives 30 days. A new one replaces the
  earlier one of this device for that organization: the old one stops working at once.
- **Renewal:** `ghostdock network connect` and `ghostdock docker context` renew every
  certificate of this machine with less than 10 days left, and update the contexts they wrote
  (`~/.config/ghostdock/docker/contexts.json`). An expired certificate fails Docker's TLS
  handshake with Docker's own error: run `ghostdock docker context <vm>` again.
- **`no such host`** on a `ghostdock.internal` address means this machine is not connected:
  run `ghostdock network connect`. The context's description in `docker context ls` says so
  too.
- **Renamed organization:** the context's host keeps the old name, which stops resolving. Run
  `ghostdock docker context <vm>` again to update the host.
- **Only from this device:** the VM checks every request with the app (membership, device,
  the network pause). A copied key and certificate do not work from another machine; a refusal
  comes back as Docker's `Error response from daemon: Ghostdock: …` with what to run.
- **Warnings:** a VM on another provider than this machine's connection (with the
  `network connect` command that reaches it), and a VM whose base image is too old for
  Docker over the network (restart it to update). A VM that cannot have a Docker engine
  over the network (its provider has no private network, it has no address there yet, or a
  name that is not a DNS label) gets no context, and the command says why.
- **A stopped VM** gets its context too: the first connection to it starts it (wake on
  SYN), and its Docker engine answers once it runs. A start that takes longer than Docker's
  32 s dial timeout fails that one command with Docker's `i/o timeout`; the next one works.
- **Needs Docker's CLI** (`docker` on `PATH`: Docker Desktop, OrbStack, or the CLI alone). The
  Docker engine itself runs in the VM.

## Command index

Every command with its arguments and what it does. Run `ghostdock <command> --help` for its flags,
or read [`cli-reference.md`](cli-reference.md) for every command's full description and flags.

<!-- generated:command-index:start (bun run sync:cli; do not edit) -->
#### completion

| Command | What it does |
| --- | --- |
| `ghostdock completion bash` | Print the bash completion script. |
| `ghostdock completion fish` | Print the fish completion script. |
| `ghostdock completion install [shell] [flags]` | Set up tab completion for your shell: shows the one line it adds to the… |
| `ghostdock completion powershell` | Print the powershell completion script. |
| `ghostdock completion zsh` | Print the zsh completion script. |

#### docker

| Command | What it does |
| --- | --- |
| `ghostdock docker context <vm> [flags]` | Point Docker at a VM's Docker engine over the private network: plain `docker`… |

#### domain

| Command | What it does |
| --- | --- |
| `ghostdock domain attach <domain> --provider <string> [flags]` | Attach a custom domain to a provider. |
| `ghostdock domain check <domain> [flags]` | Check the DNS records of an attached custom domain against its provider. |
| `ghostdock domain delete <domain> [flags]` | Delete a custom domain and throw its certificate away. |
| `ghostdock domain detach <domain> [flags]` | Detach a custom domain from the provider that uses it. |
| `ghostdock domain list [flags]` | List the organization’s custom domains and their certificates. |

#### env

| Command | What it does |
| --- | --- |
| `ghostdock env delete <name> [flags]` | Delete a variable or secret. |
| `ghostdock env get <name> [flags]` | Print one variable value in full (secrets cannot be read). |
| `ghostdock env list [flags]` | List variables and secrets. |
| `ghostdock env set <name> [flags]` | Create or replace a variable or secret. |

#### login

| Command | What it does |
| --- | --- |
| `ghostdock login <command> [flags]` | Sign in and store a device access token for this machine and server… |
| `ghostdock login finish [flags]` | Agent step 2: finish the login `login start` began. |
| `ghostdock login start` | Agent step 1: print the sign-in URL and exit. |

#### logout

| Command | What it does |
| --- | --- |
| `ghostdock logout` | Revoke the device session and remove stored credentials. |

#### network

| Command | What it does |
| --- | --- |
| `ghostdock network connect [provider] [flags]` | Put this machine into the private network through one provider: WireGuard and… |
| `ghostdock network devices <command>` | Your devices in the private network. |
| `ghostdock network devices remove <id>` | Remove one of your devices from the private network. |
| `ghostdock network disconnect` | Take this machine out of the private network, and remove split DNS an earlier… |
| `ghostdock network status` | Whether this machine is connected, through which provider, and as which device. |
| `ghostdock network vms [flags]` | VMs of an organization with their private addresses and names, and which are… |

#### organization

| Command | What it does |
| --- | --- |
| `ghostdock organization current [flags]` | Show which organization commands use, and where it comes from. |
| `ghostdock organization list` | List the organizations you belong to, with their ids and slugs. |
| `ghostdock organization use [org] [flags]` | Set the default organization for commands run without --org (in every… |

#### project

| Command | What it does |
| --- | --- |
| `ghostdock project create --display-name <string> [flags]` | Create an empty project. |
| `ghostdock project delete <project-id> [flags]` | Permanently delete a project and everything it runs and stores. |
| `ghostdock project deployment get <deployment-id> --project-id <string> [flags]` | Get one deploy attempt, with links to its stored files. |
| `ghostdock project deployment list <project-id> [flags]` | List a project’s deploy attempts, newest first. |
| `ghostdock project env delete <name> --project-id <string> [flags]` | Delete one of a project’s own variables or secrets. |
| `ghostdock project env get <project-id> [flags]` | Get a project’s stored environment. |
| `ghostdock project env select <project-id> --environment <array> [flags]` | Replace the organization entries a project exports. |
| `ghostdock project env set <name> --project-id <string> --value <string> --kind <string> [flags]` | Create or replace one of a project’s own variables or secrets. |
| `ghostdock project get <project-id> [flags]` | Get a project with its latest attempt and live deployment. |
| `ghostdock project list [flags]` | List an organization’s projects. |
| `ghostdock project publish [path] [flags]` | Inspect local source with the deploy agent and publish to the CDN or a VM. |
| `ghostdock project redeploy <project-id> [flags]` | Deploy a project’s stored source again, with its current environment. |
| `ghostdock project update <project-id> --display-name <string> [flags]` | Rename a project. |

#### provider

| Command | What it does |
| --- | --- |
| `ghostdock provider connect <kind> [flags]` | Start connecting your own server to Ghostdock as an Incus host or a Docker host. |
| `ghostdock provider disconnect <provider> [flags]` | Disconnect one of the organization’s own providers. |
| `ghostdock provider list [flags]` | List the providers an organization can create VMs on. |
| `ghostdock provider new-join <provider> [flags]` | Get a new setup command for a Docker or Incus host that is not connected. |
| `ghostdock provider rename <provider> --name <string> [flags]` | Rename one of the organization’s own providers. |
| `ghostdock provider set-address <provider> --address <string> [flags]` | Set the address an organization’s Docker or Incus host is reached at. |
| `ghostdock provider set-auto-update <provider> --enabled <value> [flags]` | Turn automatic updates of a provider on or off. |
| `ghostdock provider set-default <provider> [flags]` | Set the provider used when a create omits provider. |
| `ghostdock provider status <provider> [flags]` | Show how a provider is doing: what is wrong now and where its release stands. |
| `ghostdock provider update <provider> [flags]` | Update a provider to its newest release. |
| `ghostdock provider update-list <provider> [flags]` | List a provider’s last 20 updates, newest first. |
| `ghostdock provider update-log <provider> <update> [flags]` | Show one of a provider’s updates with the host’s log of it. |
| `ghostdock provider verify <provider> [flags]` | Check or finish connecting a Docker or Incus host. |

#### registry

| Command | What it does |
| --- | --- |
| `ghostdock registry credentials [flags]` | Print registry host, username, and the one-hour password. |
| `ghostdock registry login [flags]` | Fetch a one-hour password and run docker login --password-stdin. |

#### site

| Command | What it does |
| --- | --- |
| `ghostdock site delete <site-id> [flags]` | Permanently delete a site and its files. |
| `ghostdock site list [flags]` | List the sites of an organization, last published first, with their URLs. |
| `ghostdock site publish [path] [flags]` | Publish a site: local files, a directory or a ZIP, to the CDN. |
| `ghostdock site release <site-id> [flags]` | Release a site from the identity that created it. |

#### skill

| Command | What it does |
| --- | --- |
| `ghostdock skill get <uri>` | Print one SKILL.md (URI from the discovery index, or a skill name). |
| `ghostdock skill list` | List the Agent Skills discovery index. |

#### snapshot

| Command | What it does |
| --- | --- |
| `ghostdock snapshot create <vm> --name <string> [flags]` | Save a VM’s filesystem as a reusable organization snapshot. |
| `ghostdock snapshot delete <snapshot-id> [flags]` | Permanently delete an organization snapshot (provider artifact and record). |
| `ghostdock snapshot get <snapshot-id> [flags]` | Get one snapshot with its manifest and required environment, URLs and MCPs. |
| `ghostdock snapshot list [flags]` | List the snapshots an organization can boot VMs from. |
| `ghostdock snapshot update-manifest <snapshot-id> --manifest <object> [flags]` | Replace a snapshot’s manifest (URLs, environment, MCPs). |

#### ssh-key

| Command | What it does |
| --- | --- |
| `ghostdock ssh-key add [file] [flags]` | Add an SSH public key to your account, for logging in to Servers. |
| `ghostdock ssh-key delete <id>` | Remove an SSH key from your account. |
| `ghostdock ssh-key list` | List the SSH public keys of your account. |

#### template

| Command | What it does |
| --- | --- |
| `ghostdock template create --name <string> --title <string> --spec <object> [flags]` | Save a VM create request as a template of the organization. |
| `ghostdock template delete <template> [flags]` | Delete a template of the organization by its name. |
| `ghostdock template get <template> [flags]` | Get one template by name, with what its source asks of a VM. |
| `ghostdock template list [flags]` | List what to create a VM from: systems and templates. |
| `ghostdock template update <template> [flags]` | Change a template of the organization by its name. |

#### tools

| Command | What it does |
| --- | --- |
| `ghostdock tools` | List available agent tools. |

#### update

| Command | What it does |
| --- | --- |
| `ghostdock update [flags]` | Update this CLI to the latest release. |

#### vm

| Command | What it does |
| --- | --- |
| `ghostdock vm budget [vm] [flags]` | An identity’s budget by kind, what it uses, and what is left. |
| `ghostdock vm build [path] --kind <value> [flags]` | Create a VM from a local Dockerfile or Compose directory. |
| `ghostdock vm command get <command-id> --vm <string> --session-id <string> [flags]` | Get a shell command’s status and exit code. |
| `ghostdock vm command kill <command-id> --vm <string> --session-id <string> [flags]` | Interrupt a running shell command. |
| `ghostdock vm command logs <command-id> --vm <string> --session-id <string> [flags]` | Get a page of a shell command’s stdout and stderr. |
| `ghostdock vm command run <vm> [container] --command <string> [flags]` | Run a shell command in a VM. |
| `ghostdock vm compose down <vm> <project> [flags]` | Remove a Compose project’s containers and networks (docker compose down). |
| `ghostdock vm compose restart <vm> <project> [flags]` | Restart a Compose project’s containers (docker compose restart). |
| `ghostdock vm compose start <vm> <project> [flags]` | Start a Compose project’s stopped containers (docker compose start). |
| `ghostdock vm compose stop <vm> <project> [flags]` | Stop a Compose project’s containers (docker compose stop). |
| `ghostdock vm container cp <vm> <src> <dst> [flags]` | Copy files and folders between a container and this machine, as docker cp does. |
| `ghostdock vm container exec <vm> <container> [flags]` | Run a command in a container, or open a shell in it, as docker exec does. |
| `ghostdock vm container list <vm> [flags]` | List the containers of a VM, running or not. |
| `ghostdock vm container lock <vm> <container> [flags]` | Lock a container against removal. |
| `ghostdock vm container logs <vm> <container> [flags]` | Read a container’s output, as docker logs does. |
| `ghostdock vm container remove <vm> <container> [flags]` | Remove a container (docker rm -f). |
| `ghostdock vm container restart <vm> <container> [flags]` | Restart a container, as docker restart does (stopping as docker stop does). |
| `ghostdock vm container restart-policy <vm> <container> <policy> [flags]` | Set a container’s restart policy (docker update --restart). |
| `ghostdock vm container start <vm> <container> [flags]` | Start a stopped container, as docker start does. |
| `ghostdock vm container stop <vm> <container> [flags]` | Stop a container, as docker stop does. |
| `ghostdock vm container unlock <vm> <container> [flags]` | Unlock a container so Docker can remove or recreate it. |
| `ghostdock vm container up <vm> [template] [flags]` | Add a container to a VM from a template, an image, a Compose file or a… |
| `ghostdock vm cp <vm> <src> <dst> [flags]` | Copy files and folders between this machine and a VM, any size. |
| `ghostdock vm delete <vm> [flags]` | Permanently delete a VM and its files. |
| `ghostdock vm env delete <name> --vm <string> [flags]` | Delete one of a VM’s own variables or secrets. |
| `ghostdock vm env get <vm> [flags]` | Get a VM’s environment: selections, bindings and local entries. |
| `ghostdock vm env select <vm> --environment <array> [flags]` | Replace the organization entries a VM exports. |
| `ghostdock vm env set <name> --vm <string> --value <string> [flags]` | Create or replace one of a VM’s own variables or secrets. |
| `ghostdock vm file delete <vm> <path> [flags]` | Delete a file, or with recursive a folder and what is in it. |
| `ghostdock vm file list <vm> <path> [flags]` | List one level of a folder. |
| `ghostdock vm file mkdir <vm> <path> [flags]` | Create a folder, and the folders above it. |
| `ghostdock vm file move <vm> <path> <destination> [flags]` | Move or rename a file or folder. |
| `ghostdock vm file read <vm> <path> [flags]` | Read one file: utf8 text, or base64 when the bytes are binary. |
| `ghostdock vm file write <vm> <path> --content <string> [flags]` | Write one file, creating its parent folders. |
| `ghostdock vm forward delete <vm> <port> [flags]` | Remove the forward of a VM port. |
| `ghostdock vm forward list <vm> [flags]` | List a VM’s forwards: VM ports that reach a container’s port. |
| `ghostdock vm forward set <vm> <port> [flags]` | Forward a VM port to a container’s port, or point its forward elsewhere. |
| `ghostdock vm list [flags]` | List the VMs of an organization, with their lifecycle and deadlines. |
| `ghostdock vm mcp delete <vm> <name> [flags]` | Delete a VM’s MCP endpoint, by name. |
| `ghostdock vm mcp list <vm> [flags]` | List the MCP endpoints a VM exposes (name, port, path, autoWake). |
| `ghostdock vm mcp set <vm> <name> [flags]` | Expose an MCP server in a VM by name, or change the endpoint of that name. |
| `ghostdock vm release <vm> [flags]` | Release a VM from the identity that created it. |
| `ghostdock vm resize <vm> [flags]` | Change a VM’s CPU and/or memory. |
| `ghostdock vm resize-disk <vm> --disk-gib <number> [flags]` | Grow a VM’s disk. |
| `ghostdock vm restart <vm> [flags]` | Restart a VM, keeping it: an ephemeral VM is not deleted. |
| `ghostdock vm share-link delete <vm> <port> <name> [flags]` | Revoke a share link at once. |
| `ghostdock vm share-link list <vm> [port] [flags]` | List a VM’s share links, or those of one port. |
| `ghostdock vm share-link replace <vm> <port> <name> [flags]` | Give a share link a new address (token). |
| `ghostdock vm share-link set <vm> <port> <name> [flags]` | Make a named share link to an HTTP port, or change the link of that name. |
| `ghostdock vm shell close <session-id> --vm <string> [flags]` | Close a shell session. |
| `ghostdock vm shell create <vm> [container] [flags]` | Open a shell session that keeps cwd and environment between commands. |
| `ghostdock vm ssh <vm> [flags]` | Log in to a Server over SSH, starting the VM when needed, as the VM's login… |
| `ghostdock vm start <vm> [flags]` | Start a stopped VM, or reset the idle-stop countdown of a running one. |
| `ghostdock vm status <vm> [flags]` | Get a VM’s status and lifecycle deadlines without waking it. |
| `ghostdock vm stop <vm> [flags]` | Stop a running VM. |
| `ghostdock vm storage delete <vm> --kind <string> --name <string> [flags]` | Delete a volume, a leftover folder or a removed project’s files. |
| `ghostdock vm storage list <vm> [flags]` | List a VM’s volumes, bind mounts and what removed containers and projects left. |
| `ghostdock vm tool call <name> --vm <string> --arguments <object> [flags]` | Call a tool on an MCP server inside a VM. |
| `ghostdock vm tool list <vm> [flags]` | List the tools of the MCP servers a VM exposes. |
| `ghostdock vm up [template] [flags]` | Bring up a new VM: what runs on it (optional) and the system it boots. |
| `ghostdock vm update <vm> [flags]` | Change a VM’s display name, idle-stop interval or lifecycle. |
| `ghostdock vm url delete <vm> <port> [flags]` | Delete a port’s URL. |
| `ghostdock vm url list <vm> [flags]` | List the URLs that expose a VM’s ports, with protocol, access and title. |
| `ghostdock vm url move <vm> <port> <to-container> [flags]` | Move a port’s URL to another container, keeping its address and settings. |
| `ghostdock vm url new-name <vm> <port> [flags]` | Give a port’s URL a new name (slug). |
| `ghostdock vm url set <vm> <port> [flags]` | Give a VM port, or a container’s port, its URL, or change the one it has. |
| `ghostdock vm volume attach <vm> <volume> [flags]` | Attach a volume to a VM, as docker run --mount does. |
| `ghostdock vm volume detach <vm> <volume> [flags]` | Detach a volume from a VM, by its name in the VM or the volume’s name. |

#### volume

| Command | What it does |
| --- | --- |
| `ghostdock volume cp <volume> <src> <dst> [flags]` | Copy one file between this machine and a volume, without a VM. |
| `ghostdock volume create <name> [flags]` | Create a volume. |
| `ghostdock volume delete <volume> [flags]` | Delete a volume, and a disk’s or shared folder’s data with it. |
| `ghostdock volume file list <volume> [path] [flags]` | List one folder of a volume: files (with size) and folders. |
| `ghostdock volume file read <volume> <path> [flags]` | Read one file of a volume. |
| `ghostdock volume file write <volume> <path> --content <string> [flags]` | Upload one file into a Shared folder or a Host folder. |
| `ghostdock volume list [flags]` | List the organization’s volumes (ADR 0061): storage that outlives VMs. |
| `ghostdock volume release <volume> [flags]` | Release a volume from the identity that created it. |
| `ghostdock volume resize <volume> --size <GiB> [flags]` | Grow a volume (never shrink). |
| `ghostdock volume show <volume> [flags]` | One volume by name or id, with its attachments. |

#### whoami

| Command | What it does |
| --- | --- |
| `ghostdock whoami` | Show the account this CLI is currently authenticated as. |
<!-- generated:command-index:end -->

## CLI settings

Environment variables read by the CLI process itself. For an organization's environment variables
and secrets, see [`ghostdock env`](#ghostdock-env).

| Variable                       | Purpose                                                      | Default                          |
| ------------------------------- | ------------------------------------------------------------- | --------------------------------- |
| `GHOSTDOCK_BASE_URL`           | API origin the CLI talks to. Override for local dev / self-hosted instances. | Local/dev builds default to `http://localhost:3000`. npm-published builds default to the production API origin baked at publish time. Override with `--base-url` or this env var. |
| `GHOSTDOCK_ORG`                | Organization (id or slug) for commands run without `--org`. Takes precedence over the default from `organization use`. | unset |
| `XDG_CONFIG_HOME`              | Base directory for the CLI's per-machine state (`ghostdock/servers/`, `ghostdock/network/`, `ghostdock/docker/`). | `~/.config` (native Windows: `%APPDATA%`) |
| `GHOSTDOCK_CREDENTIALS_PATH`   | Absolute path to the credentials file, overriding the default. `settings.json` (the default organization) sits in the same directory, so each credentials file keeps its own default — handy for a second account. | `~/.config/ghostdock/credentials.json` |

Example for local development against a Nuxt dev server:

```bash
ghostdock --base-url http://localhost:3000 login
# or
GHOSTDOCK_BASE_URL=http://localhost:3000 ghostdock login
```

## Credentials file

Credentials are stored per machine and per server as JSON in
`~/.config/ghostdock/servers/<server>/credentials.json`, for example `servers/https_ghostdock.ai/`
(`$XDG_CONFIG_HOME/ghostdock/` when that is set, `%APPDATA%\ghostdock\` on native Windows; WSL
counts as Linux), with file permissions restricted to the owner (`0600`). So a login to staging
leaves your production login alone, and each server keeps its own default organization.
`GHOSTDOCK_CREDENTIALS_PATH` points the CLI at one file for every server, e.g. for a second account.
The file looks like this:

```json
{
  "origin": "https://ghostdock.example",
  "accessToken": "ext_at_...",
  "refreshToken": "ext_rt_...",
  "accessExpiresAt": "2026-07-30T12:00:00.000Z"
}
```

Access tokens are short-lived (1 hour); the CLI refreshes them automatically using the refresh token
(30-day rolling expiry) whenever a request gets a `401`. If both tokens are invalid or revoked (e.g.
after `ghostdock logout` from another session), re-run `ghostdock login`.

`origin` is the server that issued the login: the API origin at login, with scheme and host in lower
case and without a default port, query or trailing slash. The tokens go to that server only. When
`GHOSTDOCK_CREDENTIALS_PATH` names a file with another server's login, the CLI sends neither token,
leaves the file as it is and exits `1`. A refresh that the issuing server refuses with a `401` forgets
the login. A file without `origin` counts as no login: run `ghostdock login` again.
`GHOSTDOCK_TOKEN` is unaffected: it replaces the stored login and goes to whatever server the command
talks to.

## Exit codes

- `0` — success (including the default `login` after only printing the URL — approval is still
  pending, that's expected).
- `1` — failure: not logged in (also when the stored login belongs to another server), session expired and unrefreshable, login denied/expired/timed out,
  a missing/invalid tool flag, or a tool invocation error (e.g. not a member of the organization).
- `75` — a failure the API marks `retryable` (capacity, a busy VM, an interrupted create): the same
  command can succeed later. Retry it with a backoff; `vm up` with the same `--idempotency-key`
  then starts a new attempt and never makes two VMs.
