# https://mydevmachine.sh/getting-started/ devmachine sets up a VPS for you to code on. Each project or client gets its own account on the server, called a workspace, with its own tools and logins. You need a Debian or Ubuntu VPS you can reach as root over SSH, and a Mac or Linux computer. Three commands: ``` brew install mydevmachine/tap/devmachine devmachine setup devmachine workspaces new alice && devmachine sync ``` On Linux without Homebrew, get the binary from the [releases page](https://github.com/mydevmachine/devmachine/releases). `devmachine skills add` teaches your coding agent how to use devmachine. You can run it before `setup`, too. Then work in your new workspace: ``` devmachine ssh alice ``` ## What each command does `setup` asks for your server's address. It shows a code called the fingerprint: check it matches the one in your provider's dashboard, so you know you are talking to your own server. Then it sets up a key and turns off password logins, so only you can get in. `workspaces new alice` creates a workspace called alice. `sync` builds it on the server. A new workspace comes with git, the GitHub CLI, Node LTS (through mise), bun and zsh. The server itself gets nothing else until you add packages to it. Something failed? [Troubleshooting](/troubleshooting/) says what each error really means. ## Set it up to develop - **A coding agent:** `devmachine packages add claude-code --workspace alice`, then `devmachine sync`. - **GitHub, signed in once for every workspace:** `devmachine login gh`, then `devmachine sync`. See [credentials](/concepts/credentials/). - **Docker:** `devmachine packages add docker`, then let the workspace use it with `devmachine workspaces edit alice --set workspace.groups=[docker]`, then `devmachine sync`. - **Anything else:** `devmachine packages list` shows what is available. [Packages](/concepts/packages/) says how to add one or write your own. - **Teach your coding agent this CLI:** `devmachine skills add`. See [agent skills](/concepts/agent-skills/). ## Extras - **Show an app at a URL** — anything listening on a port, a dev server or a Docker container. Add a reverse proxy once with `devmachine packages add caddy`, then `devmachine expose add alice 3000 --host app.example.com` and `devmachine sync`. To reach it only from your own computer, with no public URL, use `devmachine tunnel alice 3000` instead. See [publishing](/concepts/publishing/). - **Keep your configuration in git:** `devmachine setup git`. See [versioning your configuration](/how-it-works/versioning-your-configuration/). Real setups, step by step: [examples](/examples/). --- # https://mydevmachine.sh/agent-setup/ This page is for a coding agent — Claude Code, Codex, Cursor or any other — that a person asked to set devmachine up for them. If you are that agent, follow it in order. Talk to the person in their language, ask one question at a time, and never guess an answer only they know. devmachine sets up a VPS for the person to code on. Each project gets its own account on the server, called a workspace, with its own tools, logins and coding agent. devmachine runs on the person's own computer and reaches the server over SSH. ## Rules - **Only the person confirms the server's identity.** `devmachine setup` shows a code called the fingerprint and asks if it is trusted. The person checks it against their provider's dashboard. Never answer that prompt yourself, never pipe an answer into `setup`, never pass a flag that skips it. - **Only the person types a password or signs in to a service.** When a step needs one, hand them the command. - **Show before you change a server.** Run `devmachine sync --check`, show the person what it will do, and run `devmachine sync --yes` only after they agree. - **Use commands, not files.** Change the configuration with `devmachine` commands. Never edit `config.yml` by hand. - When something fails, read the error, then check [troubleshooting](/troubleshooting/), before trying anything else. ## 1. Install the CLI Check with `devmachine version`. If it is missing: on macOS run `brew install mydevmachine/tap/devmachine`; on Linux, get the binary from https://github.com/mydevmachine/devmachine/releases and put it on the `PATH`. Then run `devmachine skills add`. It teaches you, and any other agent the person uses, the whole CLI. This only takes effect in a new session, so keep following this page either way. ## 2. Ask what you need Ask one question at a time: 1. The server's address — an IP or a hostname. It must run Debian or Ubuntu. 2. Can they reach it as root over SSH — with a key already on the server, or with the root password their provider gave them? 3. A name for the first workspace, for example the project they will work on. 4. Do they want a coding agent inside that workspace? Claude Code is a package called `claude-code`. 5. Only if they want an app visible at a URL: a domain, and whether it is at Hostinger or Cloudflare. Otherwise skip this. ## 3. Hand over `setup` Tell the person to run `devmachine setup` themselves — in Claude Code they can type `! devmachine setup` in this session. Tell them what it will ask: a name for the server, the address, the login (`root`) and port (`22`), and a domain (empty is fine). It also asks how the CLI should log in — the first option, a key of its own, is right when unsure — and shows the fingerprint to check against the provider's dashboard. Once they answer, `setup` sets up a key, checks it works, turns off password logins, and locks in the latest set of packages. When they say it finished, run `devmachine doctor`. Every line should pass. ## 4. Create the workspace ``` devmachine workspaces new devmachine packages add claude-code --workspace devmachine sync --check ``` Skip the second line if they wanted no coding agent. Show them the plan, and once they agree run `devmachine sync --yes`. The first sync takes a few minutes. ## 5. Sign in to GitHub If they use GitHub, hand them `devmachine login gh`. It opens a terminal on the server with GitHub's own sign-in page, which only they can finish. Then run `devmachine sync --yes`, which copies that login into every workspace that uses GitHub. ## 6. Hand it back Tell them to enter the workspace with `devmachine ssh `. If they asked for a coding agent, they run `claude` there once to sign in. Offer what they may want next, each in one short line: [examples](/examples/) — a site with its own domain and HTTPS, a Docker app, Claude Code opened from the phone, an agent in its own workspace. --- # https://mydevmachine.sh/concepts/machines-and-workspaces/ A **machine** is a server, with its own address and login. A **workspace** is one person's account on a machine — usually the thing you actually work in day to day. ``` devmachine workspaces new alice --machine main devmachine sync ``` ## Your computer **Your computer** is the one you run `devmachine` on — usually your laptop. The VPS is never "your computer" in this manual. You can also list your computer itself as a machine, with `self: true`, so the CLI can manage things on it directly, with no address or SSH involved: ```yaml machines: - name: mac self: true ``` This is different from `machines create-local`, which makes a small virtual machine that lives on your computer but is reached like a normal remote server, with its own address and key. See [Your computer as a machine](/how-it-works/your-computer-as-a-machine/) for when to use which. A workspace can't run on a self machine — a workspace is an account reached over SSH, and a self machine has neither an account nor SSH. ## Several machines, several workspaces Each workspace names the machine it runs on: ```yaml machines: - name: main hosts: [203.0.113.10] - name: sandbox hosts: [198.51.100.7] port: 2222 key: /keys/sandbox workspaces: - name: alice machine: main - name: bob machine: sandbox ``` ``` devmachine ssh alice # lands on main devmachine ssh bob # lands on the sandbox ``` Moving `bob` to another machine is one line of configuration; the command you type never changes. `machine:` can be left out only when you have one machine — with several, it's required. ## The Linux account A workspace's account uses its own name by default: `alice` owns the user `alice`. Give it a different name with `user:`, for when that name is already taken: ```yaml workspaces: - name: bob machine: sandbox user: bob-dev ``` ## Making and removing a workspace ``` devmachine workspaces new alice devmachine workspaces new bob --like alice devmachine sync ``` `new` only writes a line in `config.yml` — `sync` is what actually creates the account. A new workspace gets the packages listed in `defaults.workspace`, unless you pass `--packages` or `--like ` to copy another workspace's list. ``` devmachine workspaces rm alice ``` removes the entry from `config.yml`. **The account, its files, and its home folder stay on the machine** — remove those yourself if you want them gone. ## Reaching a workspace by name ``` devmachine aliases --write mosh alice-devmachine ``` writes an SSH shortcut for every workspace into `~/.ssh/config`, inside a marked block it can safely rewrite without touching anything else there: ``` # >>> devmachine — generated, do not edit Host alice-devmachine HostName 100.64.0.5 User alice HostKeyAlias main-devmachine # <<< devmachine ``` `HostKeyAlias` keeps SSH from complaining when the same machine is reached at two different addresses — it tells SSH the two addresses are the same known machine. A `-pub` shortcut appears only when there's a second, fallback address to use. --- # https://mydevmachine.sh/concepts/configuration/ Your configuration is a folder of files that lists your machines, your workspaces, and what's installed where. Look at it with: ``` devmachine config show ``` ## Where it is First match wins: | Order | Rule | | --- | --- | | 1 | `--config ` | | 2 | `DEVMACHINE_CONFIG` | | 3 | `$XDG_CONFIG_HOME/devmachine` | | 4 | `~/.config/devmachine` | ``` $ devmachine config path /Users/alice/.config/devmachine (from default) ``` Run a second, test setup next to a real one with one variable: ``` DEVMACHINE_CONFIG=~/.config/devmachine-test devmachine doctor ``` ## What it holds ``` /config.yml machines, workspaces, domain, DNS provider /secrets.json names of stored secrets, and values the keychain refused /keys/ keys the CLI generated, when it generated any /history.log one line per command that reached a machine ``` ## config.yml ```yaml machines: - name: main hosts: - tailscale:vps # tried first - 203.0.113.10 # the fallback user: root # the account it logs in as port: 22 key: /keys/main # optional; without it the SSH agent serves packages: [base, docker, caddy, firewall, fail2ban, ssh_hardening, git] settings: base.timezone: Europe/Lisbon caddy.email: someone@example.com workspaces: - name: alice machine: main packages: [workspace, dev, zsh, mise] - name: bob machine: sandbox user: bob-dev # optional; the name is used by default packages: [workspace, dev] credentials: gh: own # this one signs in to its own account # What a new workspace gets when no flag says otherwise. `setup` sets this. defaults: workspace: [workspace, dev, zsh, mise] # Whether a login is shared across the machine. A workspace may override it. credentials: gh: machine packages: v0.0.1 # the pinned release the packages come from domain: example.com ``` `user` defaults to `root`, `port` to `22`. Nothing here is a secret — a token goes in `devmachine secrets`, never in this file. ## Settings A package reads its own settings, each with a default. `settings:` overrides one, on a machine (as shown above) or on a workspace, written `.`: ```yaml workspaces: - name: alice packages: [workspace, dev, zsh, claude-plugins] settings: claude-plugins.marketplace: example.com/their-plugins claude-plugins.plugins: [their-plugin] ``` Only the first dot is the split, so `claude-plugins.marketplace.url` is the package `claude-plugins` and the setting `marketplace.url`. A setting is refused, not just ignored, when it has no `.` prefix, or names a package the target doesn't have — so a typo never silently does nothing. ## Credentials A package says how its login works, and whether a copy of it can be shared across a machine. Whether you *want* it shared is your call, set per workspace: ```yaml credentials: gh: machine # the default for this setup workspaces: - name: alice - name: bob credentials: gh: own # bob logs in for himself ``` `machine` means one login, copied into every workspace that wants it. `own` means that workspace signs in for itself. See [Credentials](/concepts/credentials/) for how each kind works, and [Sharing a login](/how-it-works/sharing-a-login/) for what the copy does. ## What is validated `config show`, and every command that touches a machine, refuse a configuration that can't work, and say what to fix: - no machine at all - a machine with no name, no address, or a port outside 1–65535 - two machines, or two workspaces, with the same name - a workspace on a machine that isn't configured - a workspace with no machine when there are several - a setting with no `.` prefix, or for a package the target doesn't have - a credential answer that is neither `machine` nor `own` ## The command log Every command that reaches a machine appends a line to `/history.log`: when, which workspace or machine, whether it worked, and the command. It's the answer to "what did that session do to my machine" — a plain `ssh` session leaves no such trail here. The format is in [commands](/reference/commands/#the-command-log). ## Secrets Tokens don't go in `config.yml`. They live in your operating system's keychain: ``` devmachine secrets set cloudflare_token # asks, without echoing devmachine secrets list # names only, never a value devmachine secrets rm cloudflare_token ``` With no keychain — a headless server, a locked-down container — the value falls back to `secrets.json`, readable by nobody else. --- # https://mydevmachine.sh/concepts/packages/ A package adds something to your server or to a workspace: Claude Code, Docker, your GitHub login. Add one with `devmachine packages add `, then run `devmachine sync` to install it. `devmachine packages list` shows what exists. The published packages live at `github.com/mydevmachine/packages`, and you can write your own. ## Which packages exist ``` devmachine packages list ``` lists every package your configuration can use, with a short description of what each one does. `devmachine packages help ` prints what a package accepts, straight from the package itself. ## Server packages and workspace packages A **server package** installs once and serves everyone on the machine — Docker, the firewall, Caddy. A **workspace package** belongs to one person — Claude Code, `zsh`, a dotfiles setup — and can go on as many workspaces as you want. ``` devmachine packages add docker --machine main devmachine packages add claude-code --workspace alice ``` Adding the wrong kind to the wrong target is refused, and says why. ## Changing a package's settings A package reads its own settings, each with a default. Change one on a workspace with: ``` devmachine workspaces edit alice --set caddy.email=you@example.com ``` An empty value, `--set caddy.email=`, removes the override. On a machine, edit `settings:` directly in `config.yml` — see [Configuration](/concepts/configuration/#settings). ## Writing your own `/packages/` holds packages you write yourself, in the same format as the published ones, and a package there with the same name replaces an official one. A package is an Ansible role plus a `package.yml` file — see [the package format](/reference/package-format/) for what goes in it, and [why packages work this way](/how-it-works/why-nothing-is-embedded/) for the reasoning behind it. --- # https://mydevmachine.sh/concepts/credentials/ A credential is a tool being signed in: `gh`, `claude`, a DNS provider's token. See what's signed in, and what's missing, with: ``` devmachine credentials list ``` ## Three kinds | Kind | Examples | You do it yourself? | | --- | --- | --- | | **manual** | `gh`, `claude`, `tailscale up` | **yes** — a browser or a device code | | **secret** | an API token, an SMTP password | no, the CLI stores it | | **file** | a VPN profile, a kubeconfig | no, the CLI stores it | A manual login can't be done for you. See [why a login can't be automated](/how-it-works/why-a-login-cannot-be-automated/). ## The commands ``` devmachine credentials list what is declared, what is there, and the command that fixes each devmachine login [--workspace w] sit through the tool's own login, in the right place devmachine secrets set hand over a value devmachine credentials push deliver the values and files ``` `credentials list` names, for each row, the exact command that fixes it. ## Your configuration holds no values A value you type goes to `devmachine secrets`, never into a file you'd commit. `devmachine secrets set cloudflare_token` asks for it without echoing it back; `devmachine secrets list` shows only names, never values; `devmachine secrets rm` removes one. ## Sharing a login across workspaces Some tools, like GitHub, work fine signed in once and shared by every workspace. Others, like Claude, need each workspace to sign in for itself. A package suggests which fits its tool, and you can override it per workspace: ```yaml credentials: gh: machine # shared, the default here workspaces: - name: bob credentials: gh: own # bob signs in for himself ``` See [sharing a login](/how-it-works/sharing-a-login/) for what sharing actually copies. ## Where a credential lands - A **machine** credential is copied into every workspace that shares it. - A **workspace** credential lives only in that workspace's own home folder, readable by nobody else. ## A `.env` with several keys One credential is one value. Three keys in one `.env` file are three separate credentials, combined by the package when it writes the file. --- # https://mydevmachine.sh/concepts/dns/ `devmachine dns` points a domain name at your server, so people can reach it by name instead of a bare IP address. ``` devmachine dns add example.com A 198.51.100.10 ``` ## Zone and record A **zone** is a domain: `example.com`. A **record** is one name, one type, and one value: `www.example.com A 198.51.100.10`. `dns add` makes a name hold **exactly** the one value you give it — it replaces whatever was there. A name that needs several values on purpose is out of scope for `dns add`; use `dns list` to see what's there, and your provider's own tools for that case. ## `@` is the apex `@` means the domain itself, with no `www` or other prefix — the way most registrars write it: ``` devmachine dns add @ A 198.51.100.10 --zone example.com ``` Most of the time you type the full name, like `example.com` or `www.example.com`, and the CLI works out the label on its own. ## Supported record types `A`, `AAAA`, `CNAME`, `TXT`. Not `MX` or `SRV` — those need more than one value, and `dns add` only ever sets one. ## A provider is a package Talking to your domain provider — Hostinger, Cloudflare, or another — is a package, installed with `devmachine packages add` like any other. Until one is installed, `dns add` can't write anywhere, and prints the record for you to create by hand instead. See [DNS providers](/how-it-works/dns-providers/) for what each one does. ## Choosing a zone and a provider A command picks which zone a name falls in, then which installed provider handles that zone. Name one directly with `--dns-provider` to skip the guessing; with two providers claiming the same zone, the CLI asks you to pick. Every command prints which provider it picked, and why. The command asks before it writes. In a script, pass `--publish` to confirm you mean to create public DNS; `--yes` alone never does. ## `dns status` is not `dns list` `dns list` asks your **provider** what's configured, whether or not it has reached the rest of the internet yet. `dns status` instead asks **the internet** directly — it resolves the name and checks its certificate, the way anybody else would see it, with no provider or machine needed. A record can be right in `dns list` and still fail `dns status` simply because it hasn't spread yet. The two answer different questions on purpose. --- # https://mydevmachine.sh/concepts/publishing/ Two ways to reach a port running on a machine: `expose` puts it on the internet, `tunnel` reaches it only from your computer. Neither is the default — you pick based on who should reach it. ``` devmachine expose add alice 3000 --host app.example.com devmachine tunnel alice 5432 ``` ## Which one to use | | Anyone | Only you | | --- | --- | --- | | **HTTP** | [`expose`](/reference/commands/#expose) | [`tunnel`](/reference/commands/#tunnel) | | **Anything else** | nothing | [`tunnel`](/reference/commands/#tunnel) | `expose` only works for HTTP, and only puts up an HTTPS address — nothing else goes on the internet. `tunnel` works for anything, HTTP or not, and never touches DNS. Speaking HTTP doesn't mean "safe to publish". A database viewer, a captured-mail inbox, a queue dashboard that can drain a queue: all three speak HTTP, none has its own login, and none should go through `expose`. Use `tunnel` for those — it looks at the thing from your computer, without ever making it public. ## Where a published site is recorded In `config.yml`, on the workspace, under `routes:`. `sync` is what actually writes it to the machine. See [why a published site lives in the configuration](/how-it-works/published-sites/). ## Why `expose` asks first `expose add` tells you, before it does anything, that the port becomes reachable by anybody who learns the hostname. In a script, pass `--publish` to confirm that on purpose — `--yes` alone never does. The same rule protects `dns add`. --- # https://mydevmachine.sh/concepts/agent-skills/ A skill is a folder of know-how your coding agent can read — Claude, Codex, Pi, or OpenCode. Add the official set with: ``` devmachine skills add ``` ## One shared copy Devmachine installs skills once, under `~/.agents/skills//`. Codex, Pi, and OpenCode read that folder directly. Claude gets a link that points to it, so nothing is duplicated: ```text ~/.claude/skills/ -> ../../.agents/skills/ ``` ## Skills on your computer ```bash devmachine skills add # the official set devmachine skills add --package global-skills devmachine skills list devmachine skills update devmachine skills remove ``` `--package` installs skills from one of your own packages instead. None of these commands touch a machine. ## Skills in a workspace A workspace gets skills only when you add the package to it, and run `sync`: ```bash devmachine packages add devmachine-skills --workspace alice devmachine sync --tags devmachine-skills ``` The Claude link is added only for a workspace that also has `claude-code`. Removing the package from your configuration doesn't delete files already on the machine. ## Adding skills from your own package A package can add: ```yaml skills: path: skills ``` Every folder directly inside `skills/` must be a complete skill, with its own `SKILL.md`. See [the package format](/reference/package-format/) for the rest of what a package can declare. --- # https://mydevmachine.sh/how-it-works/ssh-and-authentication/ ## The CLI offers one method, never everything it has When a machine has `key:`, the CLI offers that key and nothing else. Otherwise it offers the SSH agent and nothing else. Never both. This is not tidiness. **An SSH server gives up after a few attempts** — `MaxAuthTries`, six by default. A client that offers every key an agent holds burns those attempts on keys the server does not want, cutting the connection before the method that would have worked is ever tried. The failure is confusing: everything works, then you add two unrelated keys to your agent, and hosts that worked yesterday start refusing you with an error that names no key. It is worse for a password — a server that accepts one will never ask for it if the agent used up the attempts first, so "it never prompts me" looks like the server refusing passwords. The CLI talks to the SSH library directly, instead of running the `ssh` command, so it can decide exactly what to offer. ## Two different programs | Job | What runs | Why | | --- | --- | --- | | commands, checks, stats | the Go SSH library | the CLI controls authentication and reads the output | | `ssh`, `mosh` | the system binary | you want your terminal, your agent, your tmux — not an imitation | `devmachine ssh` hands the terminal over: it does nothing differently from running `ssh` yourself. ## Which account A machine's `user:` is its **admin account** — usually `root` — used for checking and setting the server up. A workspace has a separate account, and `devmachine ssh ` logs into that; with no workspace named, it logs in as the admin. ## Host keys Your key proves who you are to the server. The server's host key proves which server answered you — different keys. During `setup`, the CLI reads the host's public key first, prints its SHA256 fingerprint, and asks before trusting it. Compare that fingerprint with the provider console: accepting without comparing pins the first server reached, but does not on its own prove it was the right one. Approved host keys live in `/known_hosts`, indexed by machine name. Once a key is stored, every command requires that exact identity before it offers authentication — a missing or different key fails closed instead of being learned silently. `ssh`, `mosh`, `login`, `tunnel` and generated aliases all use the same store and the same strict check. See [SSH host keys](/how-it-works/ssh-host-keys/) for migration and mismatches. --- # https://mydevmachine.sh/how-it-works/ssh-host-keys/ SSH checks both sides with different keys. Your client key proves who you are to the server. The server's host key proves which machine answered you. The host **public** key is safe to store; its private half never leaves the server. ## First contact is a decision Before `setup` offers a client key or password, it reads only the server's public host key, prints its type and SHA256 fingerprint, and asks whether to trust it. Compare that fingerprint with the provider console when you need to be sure the address belongs to the right machine. Accepting without comparing is trusting on faith that the first server you reached is the right one. Once accepted, devmachine remembers that key, so a different server can never swap in later without a warning — but faith alone does not prove the first server was the right one. Refusing stops before any login or remote write. ## One store for every connection Approved keys live in `/known_hosts`, in OpenSSH format, indexed by machine name rather than address, so a Tailscale address and a public fallback must present the same identity. Every command reads that file — `ssh`, `mosh`, `login`, `tunnel`, `doctor`, `setup`, generated aliases — checking strictly and only accepting the pinned key. devmachine checks it first with a clear error on mismatch, then the system SSH process checks again before connecting, so nothing can swap the key in between. `known_hosts` holds public keys and is committed by `devmachine setup git`. Private keys under `keys/` stay ignored and guarded from commits. ## Existing configurations Configurations from v0.6 and earlier have no pin. Commands do not learn one silently — they stop and name the fix: ```text devmachine machines trust ``` This reads the presented key without authenticating, prints its type and fingerprint, and asks before writing. `--check` compares without writing. ## A changed key is not automatically a rotation A mismatch can mean a deliberate rebuild, the wrong address, or an attack. devmachine cannot tell those apart, so it prints both fingerprints and refuses. Verify the new fingerprint outside this SSH connection first, then record a deliberate rebuild explicitly: ```text devmachine machines trust --replace ``` `--yes` only skips the local confirmation — it cannot replace a key without `--replace`, and never touches the server. `UpdateHostKeys` is off, so a server can never rotate this decision on its own. Removing a machine from `config.yml` leaves its trust entry in place. Reusing the name must reach the same identity, or go through the same explicit replacement. --- # https://mydevmachine.sh/how-it-works/addresses-and-fallback/ A machine can answer on more than one address: ```yaml machines: - name: main hosts: - tailscale:vps - 203.0.113.10 ``` devmachine tries each in order and uses the first one that answers, printing which on stderr. A public address can stop working for you while it keeps working for everyone else — a provider's network can drop your traffic, your own can block a port. A second address costs one line and turns an outage into a slower connection. ## `tailscale:` entries An address written `tailscale:` is resolved by asking the `tailscale` command where that machine is. When `tailscale` is not installed, or does not know that name, devmachine **drops that entry and tries the next address** — not an error, since that is what the other addresses are for. If you do not use Tailscale, you never have to care this format exists; if you do, your network being down should not lock you out. Only running out of addresses is an error, saying what was dropped and why: ``` no address left to try: tailscale:vps (tailscale is not installed) ``` ## Answered, or refused? These are different problems, and devmachine tells them apart: ``` machine "main": no address answered: 203.0.113.10 (dial tcp: i/o timeout) ``` Nothing is listening, or nothing can reach it — check the network, the port, the firewall. ``` machine "main" answered on 203.0.113.10 but refused the login for "bob": ... ``` The machine is there; the account or key is the problem. Reporting both as "nothing answered" would send you to check a network that was never at fault. --- # https://mydevmachine.sh/how-it-works/choosing-a-target/ ## A command never guesses which machine With one machine set up, a command uses it. With several, a command that acts on a server needs `--machine`: ``` $ devmachine doctor fail configuration several machines are configured (main, sandbox): say which one with --machine skip connection there is no machine to check ``` There is no `default_machine` setting, on purpose. These commands create accounts and update whole servers — landing on a machine nobody named is how the wrong server gets wrecked, and a default set months ago is exactly how that happens. Typing `--machine` is cheap; explaining what happened to production is not. ## Workspaces name themselves A command that acts on a workspace takes it as its first argument: ``` devmachine ssh bob devmachine mosh bob devmachine run --workspace bob "git status" ``` No `--machine` is needed there — the workspace already says where it lives. `run` uses a flag only because its first argument is the command to run. ## Skipping, not failing When a check cannot run because an earlier one failed, devmachine reports it as `skip` with the reason, never as a failure. A machine that is unreachable would otherwise fail every remote check, burying the one line that matters — the connection — in a wall of red. --- # https://mydevmachine.sh/how-it-works/why-nothing-is-embedded/ The CLI ships with no packages built in. Every package — even the ones every machine gets — is fetched from a pinned release of `github.com/mydevmachine/packages`, checked against a checksum, and cached. ## What that buys **Fixing how Docker is installed is a push, not a release.** Nobody upgrades a binary to receive it, and the fix reaches every machine on the next `sync`. **The CLI stays small enough to reason about.** It knows how to fetch a package, send it to a machine and run it — it does not know what Docker is, and never needs to. **One mechanism instead of two.** A package you wrote and one somebody published are the same kind of thing, resolved by the same code — no "built-in" set with different rules to learn. ## What it costs, plainly **A machine cannot be set up for the first time while GitHub is unreachable.** There is nothing embedded to fall back on: ``` error: fetching https://github.com/mydevmachine/packages/releases/download/v1/packages-v1.tar.gz: ... ``` Once a release is in the cache this stops mattering — the packages are on your disk, and bringing a machine up to date needs no network at all. The limit is only the **first** fetch of a **new** pinned version, not every run, and it is worth the simplicity of one mechanism. ## Why the checksum is on a release asset The CLI fetches `packages-v1.tar.gz` from the release, not the tarball GitHub generates for the tag — a tag can be moved, and its generated archive changes with it silently. A release asset is uploaded once, with its checksum published beside it, so `packages.lock` can record what was actually installed and refuse content that changed. Without that, "pinned" would be a word rather than a guarantee. ## Why only one source A package runs as root on a machine you own. Fetching one from anywhere and running it is the shape of a supply-chain attack, so a third-party source is not supported. Your own configuration directory is the exception, and not really one: a package you wrote is a package you already trust. --- # https://mydevmachine.sh/how-it-works/why-a-login-cannot-be-automated/ It is the first question everybody asks, so here is the answer before you go looking. ## Because a person is the point `gh auth login` opens a browser, or prints a device code and waits. `claude /login` does the same. The step in the middle is a human proving they are themselves, to a service that will not accept a script doing it for them. A CLI that automated this would either store your password — what the whole flow exists to avoid — or hold a token it got some other way, the same problem with an extra step. So `devmachine login` does not try. It knows **which command** to run, because the package declared it; **where** it has to happen — that workspace's account, that machine, a real terminal so a prompt or code actually reaches you; and **what to do with the result**: a machine-scoped login is copied to `/etc/devmachine//` and spread from there. You sit through one login; the CLI handles the rest. ## A real terminal, not a captured one `login` runs the system `ssh` with a TTY, the same path `devmachine ssh` takes — a device code you cannot read is one that expires. **One consequence worth knowing:** that path reads `~/.ssh/known_hosts`, which the Go client the rest of the CLI uses does not. So the first `devmachine login` against a machine `ssh` has never seen can fail with `Host key verification failed`, while `doctor` and `sync` worked fine a moment earlier. Connect once with `devmachine ssh` and accept the key, or add it yourself. ## `stored_at` is a claim, not a guarantee A package says where its tool keeps the result: ```yaml stored_at: ~/.claude/.credentials.json ``` That is how `doctor` and `credentials list` can check, and how a shared login knows what to copy. It is a claim by whoever wrote the package, checked against one version of one tool. A tool that moves its session file breaks the claim in the wrong direction: the CLI reports missing what is in fact present, and logging in again changes nothing. Worth having anyway — the alternative is no check at all — and worth knowing before you meet it. A credential with no `stored_at` reports **unknown** rather than missing, because "I cannot tell" and "it is not there" are different claims. --- # https://mydevmachine.sh/how-it-works/sharing-a-login/ One GitHub account usually serves every workspace on a machine. Logging into it six times is six chances to end up on six different accounts, so the login happens once and the result is copied. **The CLI generates the copying** — a package never writes it. The CLI is the only thing that knows both halves: a package says where its tool keeps a session (`stored_at`) and whether a copy works elsewhere (`shareable`); the configuration says which workspaces want the shared one. Put the copying in a package, and every shareable tool grows its own copy task, written again and slightly differently each time. ## What runs For each login that resolves to `machine` scope, `sync` generates three tasks: 1. Look for the master copy under `/etc/devmachine//`. 2. Create every directory on the way to `stored_at`, owned by the account. 3. Copy the master there, owned by the account, mode `0600`. All three skip when the master is not there — running `sync` before anybody has run `devmachine login` is normal, and the next run picks the session up. Every directory is named, not just the last one: Ansible's own `mkdir -p` style would make `~/.config` root-owned, breaking the workspace's own tools for no obvious reason. ## The one that matters: opting out A workspace that says `gh: own` is **not in the loop at all**. ```yaml credentials: gh: machine workspaces: - name: alice - name: bob credentials: gh: own ``` The copy overwrites `stored_at`. A shared login copied into bob's home would replace the account he logged in with, with nothing saying why — so the generated loop carries alice and nobody else, and bob's name never appears near it. ## What beats what | Order | Where | | --- | --- | | 1 | the workspace's `credentials:` | | 2 | the configuration's `credentials:` | | 3 | the package's own `scope:` | A package that says `shareable: false` beats all three: asking for `machine` on one is refused by name, because the copy would land, the tool would reject it, and nothing would say why. A package that has not said `shareable: true` is treated as one that does not. Where each credential lives, and how a non-login value is delivered, is in [Configuration](/concepts/configuration/#credentials). --- # https://mydevmachine.sh/how-it-works/dns-providers/ Every fact here exists so you learn it from this page, not by damaging a zone. Read it before you touch a real one. ## A provider is a package, not a built-in vendor Talking to Hostinger or Cloudflare is an ordinary package: `devmachine packages add hostinger`, run on the machine, never on your computer. There is no vendor list inside the CLI binary. That gives one safety property: **a provider nobody installed cannot be written to.** No code path reaches a registrar's write API unless its package is on the lock file for that machine. Adding a provider is a decision with a diff. ## What the provider gets from the shell `PATH`, `HOME`, and the one token its own credential declares — nothing else. The CLI sources `/etc/devmachine//env` right before running the entrypoint, so nothing else leaks in. A provider that reads a variable its credential does not declare works by accident on one machine and breaks everywhere else. The token never appears in a command argument, where `ps` would show it to every account on the machine. Only the credential's name does, and that name is not a secret. ## Hostinger ### RRsets, not records Hostinger's API has no idea of a single record. It has **RRsets**: one entry per `(name, type)`, holding a list of values. `www A` is one RRset that can carry several addresses at once. `list` splits each RRset into one entry per value, but every write has to work in RRset terms. ### `overwrite: false` appends, and the default is `true` Hostinger's `PUT` takes a whole RRset and an `overwrite` flag. With `false`, the request's values are **added** to what is already there — pointing an existing name at a new address this way leaves both, a round-robin nobody asked for. Leave the field out and Hostinger's own default is `overwrite: true`, the destructive mode, so the provider always sends it explicitly, decided by the code, never left to Hostinger's default. ### Replacing a value: delete, then append — not one call `upsert` must make a name hold **exactly** the new value. Hostinger gives two ways: one `PUT` with `overwrite: true`, or a `DELETE` of the RRset then a `PUT` with `overwrite: false`. The provider uses the second, at the cost of two calls: Hostinger's own docs say `overwrite: true` replaces every record in the payload, so a one-RRset `PUT` would empty the rest of the zone. The cost of the two-call path is real — **the name holds nothing for the gap between them**, so a resolver asking in that window gets NXDOMAIN — but that beats a write whose blast radius is the whole zone. ### A DNS-only token needs its zones configured Hostinger's DNS API answers about one zone at a time and has no list-zones endpoint. Provider selection normally reads the account's domains from the Domains portfolio endpoint, but a least-privilege DNS token gets a 403 there even though it can read its own zone. Set `hostinger.zones` on the machine for that case. ### A delete filter takes the whole RRset Hostinger's `DELETE` removes an entire `(name, type)`, not one value inside it. Deleting one value out of several means: read the RRset, delete it whole, then `PUT` back the values you meant to keep. ### Writes are asynchronous A successful `PUT` or `DELETE` means "request accepted," not "done." A `list` called right after can still show the old state — which is why the provider never checks its own writes immediately. Give it a moment before checking with `dns status` or `dns list`. ## Cloudflare ### `PUT` clears every field it was not given Cloudflare's DNS record `PUT` replaces the whole record: send only `content` and `ttl`, and `proxied`, `comment` and `tags` reset to defaults. The provider always uses `PATCH` instead, which touches only the fields in the request. ### No upsert: every write past a create needs an id Cloudflare has no "make this name hold this value" call. Creating a record returns an id; changing or deleting one needs that id, found by listing first. So `upsert` always lists before it writes: create on no match, `PATCH` by id on one match, refuse to guess on more than one (`ambiguous`). ### An invisible zone is a 200, not a 404 Ask Cloudflare for a zone the token cannot see, and the answer is `200` with an empty `result` array, not an error. The provider treats an empty result as `zone_not_found` itself. ### `proxied` is always `false` Certificates on this machine renew through a challenge that needs the request to reach the machine directly. A proxied record answers from Cloudflare's edge instead, and the challenge fails. ## Neither provider retries a rate limit A `429` is reported as `rate_limited` and the provider stops. Retrying inside the entrypoint would hide how throttled the registrar really is, and could turn one throttled request into a silent hang. Cloudflare blocks a token for five minutes after a single `429`, so retrying would not even help. --- # https://mydevmachine.sh/how-it-works/trust-bootstrap/ Setting up a server you have never logged into is the hardest moment this CLI handles, and the first thing a new user hits. This page explains what `devmachine setup` does, and why the steps run in this order. ## It does not ask which situation you are in Most providers let you paste an SSH key in when you create the server, so a lot of people arrive with a key that already works — asking them for a root password would ask for something they never had. So `setup` finds out for itself: where the machine is, which account and port, which key to use, then **tries the key first**. Only if that fails does it ask for the password. ## The proof is a new connection Installing a key does not mean it works — a wrong file permission, a world-writable home folder, SELinux, any of these can let a key install cleanly and still refuse every login. The connection that installed the key cannot prove it works either, since that connection used the password. So `setup` closes it, opens a **brand new connection using only the key**, and only counts that as proof. ## Locking down comes after the proof, never with it Turning password login off before proving the key works is locking the door with the key still inside. If the proof fails, `setup` stops and says plainly: ``` password login is still on: you can still get in with the password ``` `--no-harden` skips locking the server down; the key is still installed and proved. Locking down means writing one file that turns password login off, checking it is valid, and only reloading SSH if it passes — **an invalid file is deleted**, since a bad file left behind would break the next SSH reload, even a routine reboot. ### Why the file name starts with `00` SSH uses the **first** value it finds for each setting, so a drop-in file only wins if it sorts before others. A cloud image often ships its own settings as `60-cloudimg-settings.conf`; a file named `99-` would read after it and **silently do nothing** — no warning, password login still on despite a successful run. ## The password is never stored It lives in memory for one connection, then is gone — never written to `config.yml`, the lock file, or the log. Typed at a terminal, it is never shown on screen. ## Why this does not just run the `ssh` command If your SSH agent holds several keys, a password login can fail **before you see the prompt**: the agent offers key after key, and the server cuts the connection after too many tries — on a server you are meeting for the first time, that happens every time. `setup` talks to SSH directly in code, so it offers exactly one thing at a time — the key, or the password, never both. Sessions you use yourself, `ssh` and `mosh`, still run the real command, since there you want your own terminal and agent. ## After this step `setup` installs `ansible` and `git` — the last thing done by hand. From then on, every change goes through `devmachine sync`. It installs `ansible` rather than `ansible-core`, since the smaller package leaves out a piece the `firewall` package needs. --- # https://mydevmachine.sh/how-it-works/your-computer-as-a-machine/ A machine can declare `self: true`: your own computer, the one running the CLI. This page explains why that exists, why it is called `self` and not `local`, and what changes about `sync` and `setup`. ## Why `self`, not `local` `machines create-local` already means something else: a small VM made on your computer that stands in for a real server while you learn or test the CLI. It has its own address, port, root login and key, and its own `setup`, exactly like a real server — it only happens to live here. `self` is different: your own computer, right now, with no address to dial and no key to install. Reusing "local" for both would make two ideas share one word, and the first time someone typed the wrong one, they would find out the hard way. ## Why no address fields `hosts`, `user`, `port` and `key` describe how to reach a machine that is not your computer. A self machine has nothing to dial, no login, no key to install, so devmachine refuses all four if you try to set them, naming the field: ``` machine "mac" is your computer (self: true), so it has no hosts: remove it ``` The same reason is why a workspace can never live on a self machine: a workspace is a Linux account reached over SSH with a key the CLI installed, and your own computer has no account system or SSH server for that. ## Why it never needs `sudo` for the work itself Homebrew, mise and Claude all live under your own home folder on a Mac, and none of them need root, so a self machine's setup never asks for extra permissions. It only runs on macOS — nothing about this path has been tried anywhere else — and stops with a clear error elsewhere. ## Why `setup` prints the Homebrew command instead of running it Ansible cannot install itself. On a real server, `setup` bootstraps a key, proves it, locks the server down, then installs Ansible. None of that applies to your own computer, so `setup` here only checks two things: is Homebrew on `PATH`, and is Ansible. If Homebrew is missing, `setup` prints the official one-line install command from [brew.sh](https://brew.sh) and stops rather than running it, since that command asks for `sudo` — and asking for your password on your own computer, without you typing it, is not something this CLI does. Once Homebrew is there, `setup` runs `brew install ansible` itself, since that part needs no extra permission. `sync` follows the same rule: with no `ansible-playbook` on `PATH`, it stops before touching anything and points you to `setup`. ## Where files end up, and why not `/opt` On a real server, devmachine's files live at `/opt/devmachine`, root's territory, which is fine since `setup` already has full control of a server it set up deliberately. On a Mac, `/opt` needs `sudo`, running into the same rule as Homebrew — not this CLI's place to ask. So on a self machine, those files live under your own home instead: ``` $HOME/.local/share/devmachine/bundle ``` --- # https://mydevmachine.sh/how-it-works/versioning-your-configuration/ `devmachine setup git` turns `/` into a git repository, so it can live in a private remote, be reviewed, and be restored. It never runs on its own. ## What is committed, and what never is | Path | Holds | Committed | | --- | --- | --- | | `config.yml` | machines, workspaces, packages, settings | **yes** | | `packages.lock` | the resolved package versions | **yes** | | `known_hosts` | public SSH host identities you approved | **yes** | | `keys/` | private SSH keys | **never** | | `secrets.json` | the keyring fallback, in the clear | **never** | | `cache/` | downloaded package trees | **never** | | `history.log` | what was run, against which host | **never** | | `*.env` | anything a package staged | **never** | `config.yml` holds hostnames and usernames, which is exactly why the remote must be private — and why the command says so out loud rather than assuming you worked it out. ## Why the remote must be private `config.yml` names every machine you run: its address, its admin login, which workspaces live on it. Nothing in it lets somebody in, but it is a map of your infrastructure, and a public repo is a map handed to whoever finds it. `devmachine setup git` offers to create the remote itself with `gh repo create --private`, so the moment somebody chooses to publish this directory is the moment they should not have to remember the flag. ## Why the `.gitignore` comes first The order is: write `.gitignore` → `git init` → `git add` → guard → commit. Written after `git init`, there would be a window where `git add -A` could pick up `keys/id_ed25519` or `secrets.json` before anything excludes them. That window can be milliseconds, and the result is permanent: a private key in a commit is not undone by removing it at the tip. See [troubleshooting](/troubleshooting/#i-already-committed-a-key) if that has already happened. Writing the file first removes the window instead of shrinking it. ## The guard, and why it refuses rather than warns Every commit this feature makes stages everything, checks what actually got staged, and refuses if any of it is a path that must never be committed. A warning printed above a commit that already happened is a warning nobody reads — refusing before the commit is the only version that matters. The check is a closed list of paths — `keys/`, `secrets.json`, `cache/`, `history.log`, `*.env` — not a scan for things that look like a token, because the CLI wrote every one of these paths itself: nothing else lands in the configuration directory. ## Why the CLI writes its own commit messages Every write the CLI makes — `machines add`, `workspaces new`, `sync` locking a machine's packages — commits itself, with a message the command chose, such as `chore(config): add machine box`. A history is only useful if every entry can be trusted to say what actually ran. A commit message a language model wrote by looking at a diff is a guess dressed as a fact; a commit message the CLI wrote is a report — the command that ran is the only thing that could have written that exact line. The auto-commit never fails the command that triggered it: a broken signing key, for instance, is written to `history.log` instead, and the configuration change stands. ## Secrets never enter this history, by a different route A secret's value never lives in the configuration directory — it is kept in the OS keychain (`devmachine secrets set`), with `secrets.json` as the fallback with no keychain, already in the first `.gitignore` this command writes. `devmachine secrets example` lists the `=` a machine's packages need, with no value — the *shape* of what a machine needs, safe to review or hand to somebody else. --- # https://mydevmachine.sh/how-it-works/published-sites/ `expose add` used to write a Caddy file straight onto the machine and reload Caddy. It worked, but it left the only record of the site on the machine: rebuild from the configuration, and the sites `expose` had written never came back. So the record moved. A route is a field of the workspace that owns it: workspaces: - name: alice routes: - {host: app.example.com, port: 8080} `expose add` writes that line. `sync` renders one file per workspace, `alice-routes.caddy`, into the `sites.d` folder the `caddy` package provides, and reloads Caddy when the file changed. `expose rm` deletes the line, and the next `sync` deletes the block. There is one direction — configuration to machine — devmachine never reads the machine to learn what should be published, only to check it agrees. ## What `sync` takes away When the configuration owns a host, `sync` also removes the one-host file the old `expose` wrote for it, since Caddy refuses to reload with one host named twice. A workspace whose last route was removed gets its `-routes.caddy` removed, not emptied — an empty file would still serve. Nothing else in `sites.d` is touched. A file another package added, or written by hand, is not the configuration's to delete; `expose list` reports it as `unmanaged` and says how to adopt it. ## Why DNS is still pointed by `add` The DNS record is not in the configuration, and `sync` does not write it. A name that does not resolve fails minutes later, in Caddy's certificate log, where nobody is looking — so `add` points the name in the same breath, printing the record to create by hand if the machine is unreachable. --- # https://mydevmachine.sh/how-it-works/what-sync-removes/ `sync` only removes a file it recorded writing. Never a guess, never a whole directory swept clean — one path, remembered, removed once the plan stops writing it. ## Two kinds of file, one rule A workspace's routes and a package's `extends` both write into `sites.d`, and `sync` removes both the same way: it keeps a list of exactly what it wrote, and takes away whatever is no longer on that list. - **Routes** come from `expose`. See [why a published site lives in the configuration](/how-it-works/published-sites/). - **Extension files** come from `extends: caddy.sites.d: `, a package's way of adding to a place another package opened. `sync` records every extension file's path in the lock, per machine, and removes one once the package that wrote it drops out of the plan. ## Why not a glob `sites.d` can hold files nothing in the configuration wrote — one dropped in by hand, one from before this feature existed. A glob over the directory would delete those too, so `sync` only removes a path it remembers writing itself. ## The first-run caveat The list of extension paths lives in the lock. A lock from before this feature has no such list, so the first sync after upgrading removes nothing — there is nothing yet to compare against. A file left by a package removed *before* this version never enters the tracked list either, so no later sync catches it. Remove it by hand with `devmachine run`; see [Troubleshooting](/troubleshooting/). --- # https://mydevmachine.sh/reference/commands/ Every command accepts: | Flag | Meaning | | --- | --- | | `--config ` | the configuration directory to use | | `--format table\|json` | how to print; JSON is the stable contract | | `--machine ` | which machine to act on, for commands that act on a server | | `--help` | what this command does | `devmachine help --json` prints the whole tree, including every flag, in one document — read this instead of parsing help text. ## setup ``` devmachine setup [--force] [--no-harden] ``` Connects to your server for the first time and gets it ready to use. With no `config.yml` yet, it asks for a machine name, an address, the admin account, a port, a domain, and how to log in: a key devmachine makes for itself (recommended), a key file already on your computer, or one your SSH agent holds (for a key in a password manager). It shows the server's fingerprint first and asks you to check it against your provider's dashboard. Say no and nothing is sent or changed. Then it gets the server ready, in order: try the key; if that fails, ask for the password (never shown on screen); install the key; open a **new connection using only the key** to prove it works; turn password login off; install Ansible. If the proof step fails, nothing is locked down and the error says where to look. See [setting up a server for the first time](/how-it-works/trust-bootstrap/) for why the order matters. Works on **Debian and Ubuntu** only; elsewhere it names your distro and stops. The password is used once and written nowhere. | Flag | Meaning | | --- | --- | | `--force` | discard the existing configuration and start over | | `--no-harden` | leave password login on; the key is still installed and proved | Run again with a configuration in place, and it just makes sure Ansible is installed — it never rewrites `config.yml`, a key, or SSH settings. **On a self machine** (`self: true`, your own computer — see [`machines`](#machines)), setup only checks Homebrew and installs Ansible; no fingerprint, key or password involved. ## setup git ``` devmachine setup git [--yes] [--check] ``` Turns your configuration directory into a git repository, so you can push it to a private remote. `config.yml`, `packages.lock` and `known_hosts` are committed — see [versioning your configuration](/how-it-works/versioning-your-configuration/) for what never is. In order: writes `.gitignore` **before** `git init`, so a key can never be picked up; `git init -b main`; commits the files above; checks what got tracked and **refuses** if a key is already tracked, with the fix; if `gh` is on `PATH` and logged in, offers to create a **private** repo and push. | Flag | Meaning | | --- | --- | | `--yes` | skip local-write questions; never create or push a remote | | `--check` | say what would happen, write nothing | Run again on an existing repository, and it skips straight to the tracked-files check. ## doctor ``` devmachine doctor [--machine m] ``` Checks whether a machine is healthy and reports what is wrong. Five checks in order: configuration, SSH fingerprint, login, operating system, Ansible installed. A broken fingerprint stops the rest. Then one check per needed credential (`credential: `) and per installed DNS provider (`dns: `). Exits non-zero if anything failed. No credential or DNS provider needed is not a failure. A check that could not run reports `skip` and why — "I cannot tell" is not "it is not there". ## config ``` devmachine config path the directory in use, and the rule that chose it devmachine config show machines, workspaces and their effective values ``` Shows where your configuration lives and what is in it. `show` validates as it prints, so it is the quickest way to find what is wrong. ## machines ``` devmachine machines list each machine, its addresses, port and workspaces devmachine machines add [--no-harden] set up another server and record it devmachine machines add --self add your computer as a machine, with no address devmachine machines trust [name] [--check] [--replace] [--yes] check or update its SSH fingerprint devmachine machines rm [--yes] forget a machine; the server keeps running devmachine machines create-local a machine on your computer devmachine machines start start a local machine devmachine machines stop stop a local machine devmachine machines delete-local [--yes] destroy it and everything on it ``` Manages the list of machines devmachine knows about. `list --format json` prints each machine with `name`, `hosts`, `admin_user`, `port`, `key`, `workspaces`, and `self: true` on your own computer. **Your computer is never picked by default** — a command with no `--machine` still acts on the server, even with a self machine also configured. `add` sets up another server, same as [setup](#setup). `add --self ` instead names the computer devmachine runs on: no address, port or key. Refuses if a self machine already exists, or the name is taken. See [your computer as a machine](/how-it-works/your-computer-as-a-machine/) for how this differs from `machines create-local`. A self machine has no `hosts`, `user`, `port` or `key`, and no workspace can live on one. Any command needing a real SSH address — `ssh`, `mosh`, `tunnel`, `login`, `expose`, `dns`, `machines trust`, `aliases` — refuses on it, naming the reason. `trust` reads the server's public fingerprint without logging in: asks before saving a new one, does nothing if it matches, refuses a changed one unless `--replace`. `--check` compares without writing. JSON fields: `machine`, `address`, `status`, `key_type`, optional `current_fingerprint`, `presented_fingerprint`, `check`, `changed`. `rm` takes a machine out of `config.yml` and **does nothing to the server itself**. Asks first unless `--yes`; refuses to leave a workspace pointing at a gone machine. **Not `delete-local`**: `rm` only forgets a server, `delete-local` erases a machine on your computer. `create-local` builds a machine on your computer, arriving password-only like a bought server — `devmachine setup` still has to run against it. Root password `devmachine`, public on purpose: this VM holds no real data. `start`, `stop` and `delete-local` only act on a local machine. Two limits: needs [Lima](https://lima-vm.io) (`brew install lima`), macOS and Linux only; not reachable from the internet, so `dns`, HTTPS and subdomains do not work on it. ## workspaces ``` devmachine workspaces list devmachine workspaces new [--machine m] [--like w] [--packages a,b] [--user u] [--check] [--yes] devmachine workspaces edit [--machine m] [--user u] [--add p] [--rm p] [--set k=v] [--check] [--yes] devmachine workspaces defaults [--add p] [--rm p] [--check] [--yes] devmachine workspaces rm [--yes] devmachine workspaces destroy [--confirm ] [--check] ``` A workspace is one Linux account on one machine. See [workspaces](/concepts/machines-and-workspaces/). These commands only edit `config.yml` — `devmachine sync` creates or changes the account. `new` takes its package list from `defaults.workspace: [pkg, ...]` in `config.yml`. `--packages` overrides it for one workspace; `--like ` copies another workspace's package list instead — packages only, never the account or the machine. **`new` refuses on a machine with no key**, since a workspace is reached through the copied admin key. Run `devmachine setup` first. With several machines, pass `--machine`. `edit` changes one workspace. `--add`/`--rm` take a package name each, repeatable. `--set .=` writes a package option (read as YAML — see [packages](/concepts/packages/)); an empty value removes it. `--share =own` keeps this workspace's own login instead of the shared one; `=machine` shares it again. A package option for a package the workspace does not install is refused. **Changing `--machine` does not move a workspace.** The next `sync` creates the account on the new machine; the old one keeps everything. **`rm` leaves the Linux account, home and files on the machine** — remove those by hand if you want them gone. **`destroy` deletes for real**: the account, its home, its Caddy routes, and its `config.yml` entry. Asks you to retype the name first (or `--confirm ` from a script); DNS records are left alone. Needs the machine reachable; use `rm` for one that is gone. `defaults` only changes `defaults.workspace`, which new workspaces inherit; existing ones are unchanged. ## skills ```text devmachine skills add [--package name] [--agent claude|codex|pi|opencode] [--yes] devmachine skills list devmachine skills update [--yes] devmachine skills remove [--yes] ``` Manages Agent Skills on your own computer; never touches a machine. Bare `add` installs `devmachine-skills` from the pinned release (or the latest, before `setup` has pinned one). `--package` only accepts a local package. Without `--agent`, it detects installed agent tools and asks. `list` shows each source, its skills and agent tools. `update` reinstalls every recorded source. `remove` takes one exact skill name. Real copy: `~/.agents/skills/`. Claude's is a link: `~/.claude/skills/ -> ../../.agents/skills/`. ## aliases ``` devmachine aliases [--write] [--path p] [--check] [--yes] ``` Prints one SSH `Host` entry per workspace, so `ssh alice-devmachine` and `mosh alice-devmachine` work from an ordinary terminal. ``` # >>> devmachine — generated, do not edit Host alice-devmachine HostName 100.64.0.5 User alice Port 22 IdentityFile /home/you/.config/devmachine/keys/main IdentitiesOnly yes HostKeyAlias main-devmachine StrictHostKeyChecking yes UserKnownHostsFile "/home/you/.config/devmachine/known_hosts" GlobalKnownHostsFile /dev/null UpdateHostKeys no CheckHostIP no VerifyHostKeyDNS no KnownHostsCommand none HostKeyAlgorithms ssh-ed25519 # <<< devmachine ``` `--write` puts this block in `~/.ssh/config`, or the `--path` file, after asking first. **Only the text between the two markers is ever replaced** — the rest of the file may hold hosts devmachine knows nothing about. - `HostKeyAlias` is the same for every alias of one machine, so switching addresses never trips `Host key verification failed`. - `HostName` is the first address that resolves, same as every other command. - `IdentitiesOnly yes` goes with `IdentityFile`, so ssh offers only this key and does not burn login attempts on others in your agent. A `-pub` alias is only written when a second address exists and the first did not already resolve to it. ## stats ``` devmachine stats [--machine m] ``` Prints memory, swap, disk and load. The table rounds; JSON gives raw byte counts. ## ssh, mosh ``` devmachine ssh [workspace] devmachine mosh [workspace] ``` Opens an interactive session: a workspace's account if named, the machine's admin if not. Both run the real `ssh`/`mosh` program, so your terminal, agent and tmux behave normally. `mosh` survives a dropped or roaming connection and needs mosh on both sides. Both check `/known_hosts` first. ## run ``` devmachine run "" [--workspace w] devmachine run --package [--workspace w] -- [args...] ``` Runs one command on a machine and prints its output; exits with the same code. A failed command's output prints before the error, since it usually explains the failure. `--package` calls an installed package's entrypoint directly — everything after `--` goes to the package; `commands:` in its manifest can limit what it accepts. With `--workspace`, it runs as that workspace's own account, for a command that needs that account's own files or logins. See [packages](/concepts/packages/). `run` keeps its SSH connection open for five minutes and reuses it, so a script calling it every few seconds skips the handshake each time. ## dns ``` devmachine dns status [host] devmachine dns providers devmachine dns list [zone] [--dns-provider p] [--zone z] devmachine dns check [--dns-provider p] [--zone z] devmachine dns add [--dns-provider p] [--zone z] [--check] [--publish] devmachine dns rm [value] [--dns-provider p] [--zone z] [--check] [--yes] ``` Manages DNS records through an installed provider package (Hostinger, Cloudflare). See [DNS providers](/how-it-works/dns-providers/) for what differs between them. `status` checks a name from the outside — resolves, certificate accepted, answers a request. Defaults to `domain`. A 4xx counts as serving; a 5xx does not. `providers` lists every installed provider and the zones it can see — run first when a DNS command surprised you. `list`/`check` ask the registrar, unlike `dns status` which asks the public internet. `check` exits non-zero when a name is not set. `--dns-provider` picks a provider directly; `--zone` only for a token that cannot list its own zones. Which provider answered goes to stderr. `add` makes a name hold **exactly** one value, replacing what was there, after showing the zone, provider and value it replaces. `--check` previews; `--publish` is the non-interactive consent (`--yes` alone never grants it). `rm` removes one value, or every value at that name and type with none given — not always one atomic step on the registrar's side, so it warns first. A name is always the full name (or the zone itself for the apex). ## expose **HTTPS only.** Caddy handles HTTPS for you; anything else, or anything only you should reach, uses [`devmachine tunnel`](#tunnel) instead. ``` devmachine expose add --host [--check] [--publish] devmachine expose list devmachine expose rm [--check] [--yes] ``` Publishes a workspace's port to the internet, over HTTPS, at a hostname you choose. `add` records the site in `config.yml`; `devmachine sync` writes the Caddy config. **Refuses if `caddy` is not on the machine.** It asks for confirmation first — the port becomes reachable by anyone who learns the hostname; see [tunnel](#tunnel) for what should not get a yes. `--publish` is the non-interactive way past that; `--check` previews. It also points the hostname at the machine, same as `dns add`. See [why a published site lives in the configuration](/how-it-works/published-sites/). `list` prints every host with its port, workspace, and one of four words: `published` (both agree), `pending`/`differs` (needs `sync`), `unmanaged` (only the machine has it — adopt with the `add` shown). Unreachable machine or missing `caddy`: rows print `unknown`. `rm` takes a host out of the configuration; the next `sync` removes it. A host the configuration does not know is refused, with how to adopt or remove it by hand. See [Publishing](/concepts/publishing/) for the cases this question exists to catch. ## tunnel ``` devmachine tunnel [--local ] ``` Opens an SSH tunnel so a remote port shows up as `localhost:` on your computer. **Nothing is published** — no DNS, no certificate, no Caddy, only you can reach it. Use this instead of `expose` for anything not plain HTTP, or that only you should see: a database tool with real data, an inbox with real mail, a queue dashboard that can drain a queue. | | Anyone | Only you | | --- | --- | --- | | **HTTP** | `expose` | `tunnel` | | **Anything else** | nothing | `tunnel` | `--local` picks the port on your computer, if the remote one is already taken here. Holds your terminal open while the tunnel is up; Ctrl-C closes it, nothing left running. ## machine ``` devmachine machine setup devmachine machine doctor ``` Sets up and checks your own computer — the one thing you still install by hand, once. `doctor` checks `ssh`/`mosh` on `PATH` (mosh is a warning only), an SSH agent or configured key, and whether `~/.ssh/config` matches what `devmachine aliases --write` would produce now. `setup` installs what is missing via Homebrew on a Mac; on Linux it names what to install instead of guessing. Never installs an editor, shell plugins or language runtimes — that stays your choice. ## secrets ``` devmachine secrets set [value] [--stdin] devmachine secrets list devmachine secrets rm devmachine secrets example ``` Stores values packages need that are not logins — API keys, tokens. `set` with no value asks without echoing, so it never reaches your shell history. `list` prints names only. `example` lists which `=` a machine's packages need, no values, always to stdout — never to a file, since `.env.example` sits one typo from `.env`. ## login ``` devmachine login [--workspace w] [--machine m] ``` Runs the login a package declared, in the account it belongs to, over a real terminal session (`ssh -t`) — a device code or browser prompt has to reach a person. A workspace credential needs `--workspace`: no single session to copy between accounts. A machine credential logs in once, as the admin, and copies what the tool wrote to `/etc/devmachine//`; the next `sync` spreads it to workspaces using that package. A `kind: secret` credential is refused — use `devmachine secrets set`, then `devmachine credentials push`. Same strict fingerprint check as every other command. See [SSH host keys](/how-it-works/ssh-host-keys/). ## credentials ``` devmachine credentials list [--machine m] devmachine credentials push [--machine m] [--check] [--yes] ``` Shows what a machine's packages need to authenticate, and what is missing. Every row names the fixing command. `unknown` means the package never said where its tool keeps the result — not the same as missing. Never prints a value. `push` delivers only missing values, skipping logins (nobody can push a browser session) and naming any secret never stored; exits non-zero if it found one. A workspace's own value (`/`) wins over the shared one. `--check` previews and writes nothing. ## packages ``` devmachine packages list devmachine packages add [--machine m | --workspace w] [--check] [--yes] devmachine packages rm [--machine m | --workspace w] [--check] [--yes] devmachine packages new [--scope machine|workspace] [--into ] devmachine packages validate devmachine packages schema [--json] devmachine packages help [--json] devmachine packages pin [release] ``` Manages what is installed on your machines and workspaces. See [the package format](/reference/package-format/). `list` shows each package once with every machine and workspace that uses it; one nothing provides is listed as `missing`. `add`/`rm` only edit `config.yml` — `sync` applies the change. Pass `--machine` or `--workspace`; with one configured machine, that is the target. Comments in `config.yml` survive. `new` writes a package that already passes `validate`; refuses to overwrite one that exists. `validate` reports every problem at once, with file and line. `schema` prints the `package.yml` format this binary reads. `pin` writes `packages: ` — with none given, the latest; a release is a tag such as `v8`, never a branch. `help` asks an installed package what it accepts, by running its own `help`. ## sync ``` devmachine sync [--machine m] [--check] [--yes] [--tags a,b] ``` Applies your configuration to a machine — installs what is missing, updates what changed. In order: checks the configuration, fetches the pinned release if needed, plans what the machine and its workspaces should get, checks your own packages, prints the plan, asks, then runs Ansible on the machine, streaming output. | Flag | Meaning | | --- | --- | | `--check` | dry run: reports what would change, changes nothing | | `--yes` | apply without asking | | `--tags a,b` | only the packages named | One tag beyond package names: `credentials`, which only copies shared logins — run after `devmachine login` instead of a full sync. `--check` never writes the lock file. Only your own packages (in `/packages/`) are checked before the run. With `--format json`, stdout is the result; the plan and machine output go to stderr. On success, `/packages.lock` records what was applied, at which release and checksum, for the machine synced. ## version, help ``` devmachine version devmachine help [command] [--json] ``` ## The command log `run` and `sync` each append one line to `/history.log`, mode `0600`: ``` 2026-09-18T12:00:00Z workspace alice ok "docker ps" 2026-09-18T12:01:00Z machine main failed "sync --tags caddy" ``` UTC time, target, success or failure, then the quoted command. See [configuration](/concepts/configuration/#the-command-log). **`setup`, `machines add` and `login` are not logged** — the first two handle a root password, and `login` runs a command you watch yourself. --- # https://mydevmachine.sh/reference/package-format/ A package is an Ansible role plus one extra file, `package.yml`. Nothing is translated on the way to the machine: what you write is what runs, so a failure points at the exact line you wrote. This page and the validator agree. When they disagree, trust the validator — ask it with `devmachine packages schema --json`. ## The layout ``` / package.yml tasks/main.yml defaults/main.yml handlers/, files/, templates/, vars/ (optional, as in any role) ``` The directory name **is** the package name. `devmachine packages new ` writes a starting skeleton that already passes `devmachine packages validate`. ## The fields ### `format` (required) The shape of the file. This CLI reads format `1`. A validator that meets a format it cannot read says so and stops, instead of misreading fields it does not understand. ### `name` (required) Lower case letters, digits, dashes and underscores, matching the directory name — a package is found by its directory. ### `scope` (required) `machine` or `workspace`, nothing else. A fact about the software, not a preference: Docker installs once and serves everyone, so it is `machine`; a tool with a login per person is `workspace`, running once per workspace that asks for it. ### `summary` (required) One line saying what the package installs. `packages list` prints it. ### `requires.cli` Which version of the CLI can run this package: `">= 0.2.0"`, `"> 0.2.0"` or `"= 0.2.0"`. Different from `format`: `format` says whether the CLI can *read* the file, `requires.cli` says whether it can *run* what it describes — the binary and the packages release on their own schedules, so the two can disagree. A CLI built from source calls itself `dev`, and every constraint allows it. ### `needs` Packages that must run before this one: ```yaml needs: [base, firewall] ``` The only thing that decides run order — the order packages are listed in your own configuration means nothing. A circular dependency is refused, naming the packages involved. ### `provides` Places other packages may write into, as a name and an absolute path on the machine: ```yaml provides: sites.d: /etc/caddy/sites.d ``` ### `extends` Adds a file to a place another package opened: ```yaml extends: caddy.sites.d: files/sharing.caddy ``` The key is `.`, and the value is a path inside this package. It can only add a file there, never change what is already there or reach anywhere else. Extending a place nobody provides is refused while devmachine plans, before anything runs. The file lands as `-`, so two packages adding a same-named file never collide. ### `variables` Values the package reads, each with a summary and a default: ```yaml variables: port: summary: The port the container listens on. default: 53842 ``` The package's Ansible role reads `devmachine__`, so a package called `tunnel` that declares `port` uses `devmachine_tunnel_port` — the package name is part of it since Ansible has one shared namespace, and two packages might both want a `port`. That is the same name used for a target's [settings](/concepts/configuration/#settings): a setting is just a default someone overrode, and the package does not know or care where the value came from. A dash works in a package name but never in a variable name, so `-` becomes `_`, as does the `.` a package name may contain. Names that collide this way are refused, rather than one silently winning. ### `credentials` What the package's tool needs to authenticate, **and how to get it** — how belongs here because the package is the only thing that knows. Each entry has a `name`, a `kind`, and a `scope` (`machine` or `workspace`), plus what its kind needs: | `kind` | also needs | what it means | | --- | --- | --- | | `manual` | `command`, `stored_at` | A person runs `command`; the tool leaves its session at `stored_at`. | | `secret` | `env` or `path` | A value handed over once, delivered there. | | `file` | `path` | A file placed on the machine at that path. | ```yaml credentials: - name: claude kind: manual scope: workspace command: claude /login stored_at: ~/.claude/.credentials.json ``` `stored_at` is a claim, not a guarantee — it is what lets `doctor` check whether the login worked. A `manual` credential can also say `shareable: true`: a copy of `stored_at` works on another account, the way one GitHub login can serve every workspace. This is a fact about the tool, found by testing it — a session file copies fine, a token tied to one device or browser does not. Leave it out and it defaults to `false`. A credential recommending `scope: machine` must say `shareable: true`, since `scope: machine` means "one login, copied into every workspace" — recommending both without it asks for something the package itself says cannot work. Only `manual` can be `shareable`. A `secret` or `file` is delivered fresh to each place that needs it, never copied, so `shareable` on either is refused. `scope` here is only a recommendation — whether a shareable credential is actually shared is the operator's own choice, per workspace — see [Configuration](/concepts/configuration/). ### `requires_files` Files that must already be on the machine before the package runs. ### `skills.path` A package can ship complete Agent Skill directories: ```yaml skills: path: skills ``` The path is relative to the package root, and cannot contain `..`, be absolute, or escape through a symlink. Each direct child must be a lower-case, dash-separated skill directory with a `SKILL.md`, whose frontmatter `name` matches the directory and `description` is not empty. This does not replace the Ansible role — a package with skills still has `tasks/main.yml`, and may also have defaults, handlers, files and templates. ### `kind`, `entrypoint`, `commands` A package can ship an executable the CLI calls on the machine: ```yaml kind: dns entrypoint: bin/provider commands: [zones, list, upsert, delete, help] ``` - `entrypoint` is a path inside the package. It must exist, be executable, and start with `#!/usr/bin/env python3` — Ansible already needs Python on any machine this CLI sets up. - `commands` lists what it accepts: names, or `["*"]` for anything. Mixing `"*"` with named commands is refused. - `kind` is a contract. The only one so far is `dns`, which must accept `zones`, `list`, `upsert`, `delete` and `help`. Nothing calls an entrypoint in this version yet — it is validated now so the first real use cannot invent its own shape later. ## The rules, and what each one says | Rule | The message | | --- | --- | | `format` missing | ``every package needs `format`, and this CLI reads 1`` | | `format` unreadable | `format 99, and this CLI reads 1. Upgrade with brew upgrade devmachine` | | `name` missing | ``every package needs a `name` `` | | `name` malformed | `name "X": use lower case letters, digits, dashes and underscores` | | `name` is not the directory | `name is "X" but the directory is "Y": a package is found by its directory` | | `scope` unknown | `scope must be "machine" or "workspace", got "X"` | | `summary` missing | ``every package needs a one-line `summary` `` | | `requires.cli` unreadable | `requires.cli "X": write it as ">= 0.2.0", "> 0.2.0" or "= 0.2.0"` | | `extends` key has no dot | `an extension point is written .` | | `extends` file is not there | `extends "X" points at Y, which is not in the package` | | `provides` path is relative | `an extension point is an absolute path on the machine` | | no `tasks/main.yml` | `a package is an Ansible role, so it needs tasks/main.yml` | | the `apt` module is used | ``the apt module is not allowed; use `package` so this works beyond Debian`` | | a credential says too little | one line per credential, naming it and what it is missing | | a `machine` login is not `shareable` | ``credential "X" recommends `scope: machine`, so it needs `shareable: true` `` | | a `secret` or a `file` is `shareable` | ``credential "X" is a secret, so it cannot be `shareable` `` | | an entrypoint is not executable | `entrypoint "X" is not executable: chmod +x it` | | an entrypoint is not Python 3 | `an entrypoint is Python 3 and starts with #!/usr/bin/env python3` | | `kind` or `commands` with no entrypoint | ``kind` and `commands` describe an `entrypoint`, and this package declares none`` | `devmachine packages validate` reports every problem at once, not just the first. ## Why `apt` is refused A package that calls `apt` only works on Debian. `package:` picks the machine's own package manager instead, turning a rule people have to remember into an error the validator catches. --- # https://mydevmachine.sh/reference/dns-provider-contract/ What a DNS provider's entrypoint is called with, and what it must answer back. Follow this page and the CLI works with your provider on the first try; guess, and it does not. `devmachine packages new --scope machine --kind dns` writes an entrypoint that already follows this contract, with placeholders to replace rather than writing one from nothing. ## How it is called ``` list|upsert|delete ``` `` is the package's `entrypoint`, for example `bin/provider`. The command is `argv[1]`, the zone is `argv[2]`. `zones` and `help` take no zone: ``` zones help ``` `zones` answers which zones the credential can see, so the CLI can work out which registrar holds a name. A provider that cannot answer it can never be chosen. ```json {"zones": ["example.com", "example.net"]} ``` `help` answers what the entrypoint accepts, and is what `devmachine packages help ` prints. Leave `args` out for a command that takes none. ```json {"commands": [ {"name": "zones", "summary": "The zones this token can see."}, {"name": "list", "summary": "Every record in a zone.", "args": ""}, {"name": "upsert", "summary": "Make a name hold exactly one value.", "args": ""}, {"name": "delete", "summary": "Remove one value from a name.", "args": ""}, {"name": "help", "summary": "This list."} ]} ``` Both `zones` and `help` are required. A manifest listing a command its entrypoint actually refuses is a lie no validator can catch. ## What it receives `list` takes nothing beyond the zone. `upsert` and `delete` take one record as JSON **on stdin**: ```json {"name": "www", "type": "A", "value": "198.51.100.10", "ttl": 300} ``` - `name` is a **label**, never a full name: `www`, or `@` for the apex (the root domain of your server). The provider adds the zone itself. - `ttl` of `0` means "choose": use whatever the registrar defaults to. ## What it must print On success, one JSON document on stdout: - `list` → `{"records": [{"name": "...", "type": "...", "value": "...", "ttl": 300}, ...]}` - `upsert` and `delete` → `{}` ## What a failure looks like Exit non-zero, and print one JSON document on stdout: ```json {"error": {"kind": "zone_not_found", "message": "no zone answers for example.com"}} ``` `kind` must be one of exactly these: | `kind` | When | | --- | --- | | `zone_not_found` | The zone does not exist, or the token cannot see it. | | `unauthenticated` | The token was rejected. | | `forbidden` | The token can see the zone but cannot change it. | | `invalid_record` | The record was rejected — a bad type, a bad value, a name the registrar refuses. | | `rate_limited` | The registrar's API is throttling this token. | | `ambiguous` | The name holds several values and the request does not say which one. | A `kind` outside this list is treated as a bug in the provider, not a new kind the CLI learns. ## What the environment holds Whatever `/etc/devmachine//env` sets for this provider's credential, exported, **and nothing else the CLI adds**. A provider reading a variable its own credential does not declare will work by accident on one machine and break everywhere else. ## Where and as whom it runs On the machine, as root, from `/opt/devmachine/roles//`. Its working directory is not guaranteed — use absolute paths, never a path relative to the entrypoint's location. ## The rules that matter most - `upsert` makes the name hold **exactly** that one value — adding to what is already there is wrong, even if the registrar's API makes that the easy path. - `delete` removes exactly that one value, leaving every other value at that name untouched. - An empty `value` on either call means the whole set, not a value that happens to be an empty string. ## Python 3, standard library only Ansible already needs Python on any machine this CLI sets up, so an entrypoint with no extra dependencies always works. No `requests`, no `pip install`, no shelling out to `jq` — a provider needing a package the machine lacks fails during `sync`, far from where that dependency was declared. `format: 1` in `package.yml` lets an older CLI refuse a package it cannot read cleanly. Leave the number the skeleton already put there. ## Never retry a rate limit A provider that hits a rate limit reports `rate_limited` and stops. Retrying inside the entrypoint hides how throttled the registrar really is, and can turn one slow request into a hang with no visible cause. --- # https://mydevmachine.sh/reference/settings/ Every variable the published packages accept, and what each one defaults to. A setting is written `.` under a machine's or a workspace's `settings:`, and it reaches the recipe as the variable it already reads. **A setting is a default somebody overrode, not a new mechanism** — the recipe cannot tell the difference and does not have to. ```yaml machines: - name: main packages: [base, caddy] settings: base.timezone: Europe/Lisbon caddy.email: someone@example.com ``` Or without opening the file: ``` devmachine workspaces edit alice --set zsh.tmux_config=false ``` A setting for a package the target does not install is refused. A typo in a package name would otherwise be silent: the value would reach nothing, the recipe would keep its default, and the machine would not be what the configuration says it is. **This page is generated** by `make settings`. Do not edit it by hand. | Setting | What it does | Default | | --- | --- | --- | | `base.hostname` | The machine's hostname. Empty leaves the one it already has. | *(empty)* | | `base.swap` | Size of a swapfile at /swapfile, such as 8G. Empty leaves the machine alone. | *(empty)* | | `base.timezone` | The machine's timezone, as tzdata spells it. Empty leaves whatever the machine came with. | *(empty)* | | `base.upgrade` | Upgrade every package already installed. Off, because that is the owner's decision and not a side effect of installing base tools. | `false` | | `caddy.email` | The address the certificate authority writes to about an expiring certificate. Empty means an anonymous account. | *(empty)* | | `caddy.local_certs` | Sign certificates locally instead of asking Let's Encrypt. For a machine no name resolves to — a test VM, a private network — where the ACME challenge can never succeed. Off, because a certificate nobody else trusts is not what a public site wants. | `false` | | `caddy.site` | Serve a one-page site straight from Caddy, no container behind it. It answers 200, which proves the name, the certificate and the proxy in one request. Off leaves the machine serving only what other packages add. | `true` | | `caddy.site_domain` | The name the one-page site answers to, with its own certificate. Empty serves it on port 80 at the machine's address, over plain HTTP, because no public authority signs a certificate for a bare address. It creates no DNS record: pointing the name is `devmachine dns add`. | *(empty)* | | `claude-code.diff_sidebar` | Open the /diff panel. Unset leaves whatever Claude Code has. It is a preference Claude Code keeps in its own state file, so it is amended only where that file, and that preference, already exist. | *(none)* | | `claude-code.env` | Environment variables every Claude Code session runs with, as a map. It is how a model-specific or terminal-specific workaround is turned on without this package having an opinion about it. | `map[]` | | `claude-code.expanded_todos` | Show the task list under the footer. Unset leaves whatever Claude Code has, and the same condition applies. | *(none)* | | `claude-code.home` | Where the account's home is. | `/home/` | | `claude-code.remote_control_at_startup` | Connect every session to Remote Control as it opens, instead of waiting for somebody to type /rc. | `false` | | `claude-code.session_name_prefix` | What each Remote Control session is called in the phone app. Empty means the workspace's name, which is what tells two workspaces' sessions apart. | *(empty)* | | `claude-code.status_line` | The command Claude Code runs to draw its status line. Empty leaves the status line alone. It runs outside a login shell, so give it an absolute path. | *(empty)* | | `claude-plugins.claude` | Where the Claude Code CLI is. It is installed into the account's own ~/.local/bin, so only a workspace that put it somewhere else says so. | `/.local/bin/claude` | | `claude-plugins.home` | Where the account's home is. | `/home/` | | `claude-plugins.marketplace` | Where the plugins come from: a GitHub repository written owner/name, a git URL, or a path on the machine. Empty installs nothing. | *(empty)* | | `claude-plugins.plugins` | Which plugins to install, each written @. The marketplace half is the name the marketplace gives itself, which is what Claude Code installs and reports against. | `[]` | | `claude-plugins.update` | Re-fetch the marketplace and update every plugin on each run. It is off by default because it cannot tell whether anything moved, so a run with it on always reports a change. | `false` | | `claude-remote-control.home` | Where the account's home is. | `/home/` | | `claude-remote-control.restart_seconds` | How long to wait before reconnecting. The server gives up on its own after about ten minutes with no network, so the unit restarts for good. | `30` | | `claude-remote-control.session_name` | What this session is called in the phone app. Empty means the workspace's name, which is what tells two workspaces' sessions apart. | *(empty)* | | `claude-remote-control.working_directory` | The directory a remote session starts in. | `/dev` | | `dev.gh_version` | Which GitHub CLI release to install. It is a release asset rather than a distribution package because most distributions do not carry gh at all. | `2.63.2` | | `dev.home` | Where the account's home is. | `/home/` | | `dev.node_version` | Which Node mise installs globally. | `lts` | | `fail2ban.bantime` | How long a ban lasts, in seconds. | `3600` | | `fail2ban.ignoreip` | The addresses that are never banned, space separated. Loopback only by default: a wider range exempts everyone who shares it. | `127.0.0.1/8 ::1` | | `fail2ban.maxretry` | How many failures from one address before it is banned. | `5` | | `firewall.http` | Open 80 and 443. A machine that hosts nothing can turn this off. | `true` | | `firewall.mosh_interface` | The one interface mosh's UDP range is opened on. Empty means it is not opened at all, which keeps the range off a public edge. | *(empty)* | | `firewall.mosh_ports` | The UDP range mosh is given on that interface. | `60000:61000` | | `git-key.home` | Where the account's home is. | `/home/` | | `glab.home` | Where the account's home is. | `/home/` | | `glab.version` | Which GitLab CLI release to install. It is a release asset rather than a distribution package because no distribution carries glab. | `1.111.0` | | `hostinger.zones` | DNS zones this provider may manage. Set this when the token has DNS permission without Domains portfolio permission; an empty list asks the account portfolio instead. | `[]` | | `mise.home` | Where the account's home is. | `/home/` | | `sentry.home` | Where the account's home is. | `/home/` | | `ssh_hardening.service` | What systemd calls sshd. Empty means the name this distribution family uses, which is `ssh` on Debian and Ubuntu and `sshd` elsewhere. | *(empty)* | | `tailscale.exit_node` | Advertise this machine as an exit node. Off unless asked for. | `false` | | `workspace.admin_home` | The home of the account the CLI provisions with. Whatever reaches that account over SSH is what reaches this workspace. | `/root` | | `workspace.git_email` | The address on this workspace's commits. | *(empty)* | | `workspace.git_name` | The name on this workspace's commits. | *(empty)* | | `workspace.groups` | Extra Linux groups the account joins. The docker group is one of them and it is effectively root, so nobody joins it by accident. | `[]` | | `workspace.home` | Where the account's home is. Debian and Ubuntu put it under /home; a machine whose useradd is configured otherwise says so here. | `/home/` | | `workspace.known_hosts` | The hosts whose SSH host key is trusted in advance, so the first clone does not stop to ask a question nobody is there to answer. | `[github.com]` | | `workspace.shell` | The login shell. Empty means whatever useradd would pick, and the package that installs a shell is the one that sets it. | *(empty)* | | `workspace.sign_commits` | Sign every commit and rebase, once a package such as git-key sets up a key. | `true` | | `zsh.home` | Where the account's home is. | `/home/` | | `zsh.tmux_auto_attach` | Open a tmux session on every SSH login, so a dropped connection loses nothing. A second connection while the first is live gets a session of its own instead of a second view of the same one. | `true` | | `zsh.tmux_config` | Write the account's ~/.tmux.conf. Turn it off to keep a config of your own. | `true` | --- # https://mydevmachine.sh/examples/ Real setups, done in a few steps. Each one links to a concept page for the why; here it is only the commands. - [Try it on your own computer first](/examples/a-local-vm-with-lima/) — a local virtual machine with Lima, used exactly like a VPS. - [Express site with TLS](/examples/express-site-with-tls/) — an Express app, live at your own domain with HTTPS. - [Docker site on 8080](/examples/docker-site-on-8080/) — a container, live at your own domain with HTTPS. - [Claude Code, controlled from your phone](/examples/claude-code-remote-control/) — start a session on your server, drive it from the Claude app. - [Start Claude from your phone](/examples/start-claude-from-your-phone/) — a session that stays up on the server, so you never have to SSH in first. - [An agent in its own workspace](/examples/an-agent-in-its-own-workspace/) — an autonomous agent, kept in its own account. --- # https://mydevmachine.sh/examples/a-local-vm-with-lima/ Run a virtual machine on your computer and use it exactly like a VPS: set it up, make workspaces, add packages, open them. Nothing to buy, and you can throw it away when you are done. **You need:** a Mac or Linux computer and [Lima](https://lima-vm.io), which runs the virtual machine: `brew install lima`. ## 1. Create the machine ``` devmachine machines create-local sandbox ``` It prints the machine's address, its port and the root password: ``` sandbox 127.0.0.1 port 60022 admin: root It has no key on it yet, and the root password is "devmachine". ``` The machine arrives the way a bought server does: reachable as root with a password, and no key yet. The password is public on purpose — this machine holds nothing real. ## 2. Set it up If this is your first machine: ``` devmachine setup ``` If you already have a machine configured, add this one next to it: ``` devmachine machines add ``` Answer with what step 1 printed: address `127.0.0.1`, login `root`, the port it showed, and the password when asked. Everything else is the same as on a real server: the fingerprint check, the key, password logins turned off. ## 3. Use it like a VPS ``` devmachine workspaces new alice --machine sandbox devmachine packages add claude-code --workspace alice devmachine sync --machine sandbox devmachine ssh alice ``` `--machine sandbox` is needed only when you have more than one machine. ## What does not work on a local machine The internet cannot reach a virtual machine on your computer, so `dns`, HTTPS certificates and `expose` do not work there. Everything else does. To see an app you run in the VM, use a tunnel instead of a URL: ``` devmachine tunnel alice 3000 ``` ## Stop it, start it, throw it away ``` devmachine machines stop sandbox devmachine machines start sandbox devmachine machines delete-local sandbox ``` `delete-local` destroys the VM and everything in it. Then take it out of your configuration: first its workspaces with `devmachine workspaces rm alice`, then the machine with `devmachine machines rm sandbox` — it refuses while a workspace still points at it. **Check it:** `devmachine doctor --machine sandbox` passes every line after step 2. Source: [Lima](https://lima-vm.io/docs/) --- # https://mydevmachine.sh/examples/express-site-with-tls/ Run an Express app in workspace `alice`, reachable at `https://app.example.com` with an HTTPS certificate that renews itself. **You need:** workspace `alice` from [Getting started](/getting-started/), and `app.example.com` pointed at your machine (or a DNS provider package installed, so `expose add` points it for you). ## 1. Add Caddy to the machine ``` devmachine packages add caddy devmachine sync ``` Caddy answers every exposed site and gets its own certificate. ## 2. Write and start the app ``` devmachine ssh alice mkdir app && cd app npm init -y && npm install express pm2 cat > server.js <<'EOF' const express = require('express'); const app = express(); app.get('/', (req, res) => res.send('Hello from alice')); app.listen(3000); EOF npx pm2 start server.js --name app ``` pm2 keeps the app running in the background, even after you log out. ## 3. Expose the port Back on your own computer: ``` devmachine expose add alice 3000 --host app.example.com --publish devmachine sync ``` `sync` writes the Caddy route and gets the certificate for the host. ## 4. Point the domain, if it was not automatic If a DNS provider package (`hostinger`, `cloudflare`) is installed, `expose add` already wrote the record — see [DNS](/concepts/dns/). Otherwise it printed the record to create by hand at your registrar. **Check it:** `curl https://app.example.com` returns "Hello from alice", with a valid certificate. Source: [Express — Hello world](https://expressjs.com/en/starter/hello-world.html), [pm2 — Quick start](https://pm2.keymetrics.io/docs/usage/quick-start/) --- # https://mydevmachine.sh/examples/docker-site-on-8080/ Run a site in a Docker container publishing port 8080, at `https://site.example.com`. **You need:** workspace `alice` from [Getting started](/getting-started/). ## 1. Add Docker and Caddy to the machine ``` devmachine packages add docker devmachine packages add caddy devmachine sync ``` ## 2. Put the workspace in the docker group ``` devmachine workspaces edit alice --set workspace.groups=[docker] devmachine sync ``` A workspace in the `docker` group can do almost anything on the server, so this is a deliberate step, not the default — [workspaces](/reference/commands/#workspaces) says why. ## 3. Run the container ``` devmachine ssh alice docker run -d --name site -p 127.0.0.1:8080:80 nginx:alpine ``` Publishing on `127.0.0.1` is enough: Caddy reaches the container locally, and the port itself is never open to the internet. ## 4. Expose it Back on your own computer: ``` devmachine expose add alice 8080 --host site.example.com --publish devmachine sync ``` **Check it:** `curl https://site.example.com` serves the nginx welcome page, with a valid certificate. Source: [Docker — `docker run`](https://docs.docker.com/engine/reference/run/), [nginx image](https://hub.docker.com/_/nginx) --- # https://mydevmachine.sh/examples/claude-code-remote-control/ Run Claude Code in workspace `alice` and open a session from the Claude iPhone or Android app. **You need:** workspace `alice` from [Getting started](/getting-started/). ## 1. Add Claude Code, with Remote Control on by default ``` devmachine packages add claude-code --workspace alice devmachine workspaces edit alice --set claude-code.remote_control_at_startup=true devmachine sync ``` `remote_control_at_startup` connects every session to Remote Control as it opens, instead of waiting for somebody to type `/rc`. ## 2. Log in as alice, once ``` devmachine login alice/claude ``` This opens a real terminal, so a person is there to finish the sign-in — see [credentials](/concepts/credentials/). ## 3. Start a session ``` devmachine ssh alice claude ``` With `remote_control_at_startup` on, Claude Code prints a session URL and a QR code as the session opens. Without it, run `/rc` inside the session to get the same thing. ## 4. Open it from the phone Install the Claude app ([iOS](https://apps.apple.com/us/app/claude-by-anthropic/id6473753684), [Android](https://play.google.com/store/apps/details?id=com.anthropic.claude)), scan the QR code, or find the session by name under **Code** in the app. **Check it:** a message sent from the phone appears in the terminal, and a reply typed in the terminal appears on the phone. For a session that is available from the phone without an SSH session open at all, see [start Claude from your phone](/examples/start-claude-from-your-phone/). Source: [Claude Code — Remote Control](https://code.claude.com/docs/en/remote-control) --- # https://mydevmachine.sh/examples/start-claude-from-your-phone/ Keep a Claude Code Remote Control session up on workspace `alice`, so you can open it from the phone without ever opening an SSH session yourself. **You need:** workspace `alice`, with `claude-code` installed and logged in — see [Claude Code, controlled from your phone](/examples/claude-code-remote-control/). ## What `claude-remote-control` does It runs `claude remote-control` in the background on the server, and starts it again on its own after a crash, a network drop, or a reboot — so a session is always waiting for you, even with nobody logged in. It only starts once the account has signed in to `claude`; with no sign-in yet, `sync` tells you that instead of trying anyway. ## 1. Add the package ``` devmachine packages add claude-remote-control --workspace alice devmachine sync ``` ## 2. Point it at a project, if you want one specific directory ``` devmachine workspaces edit alice --set claude-remote-control.working_directory=/home/alice/app devmachine sync ``` Empty keeps the default, `~/dev`. ## 3. Open it from the phone In the Claude app, tap **Code**, and find the session named `alice` (or the `session_name` you set) in the list — no SSH needed. **Check it:** the session shows a green status dot, online, and a message typed there reaches the machine. Source: [Claude Code — Remote Control](https://code.claude.com/docs/en/remote-control) --- # https://mydevmachine.sh/examples/an-agent-in-its-own-workspace/ Run [OpenClaw](https://openclaw.ai/), an autonomous agent, in a dedicated workspace `agent` — its own account, files and logins, isolated from your own work. **You need:** nothing beyond [Getting started](/getting-started/). ## 1. Create the workspace ``` devmachine workspaces new agent devmachine sync ``` A workspace is its own account. Whatever the agent installs, or breaks, stays inside it and never touches `alice` or any other workspace — see [machines and workspaces](/concepts/machines-and-workspaces/). ## 2. Install OpenClaw ``` devmachine ssh agent curl -fsSL https://openclaw.ai/install.sh | bash ``` The installer sets up Node itself if the account has none. ## 3. Keep it running ``` tmux new -s agent openclaw onboard ``` `openclaw onboard` walks through first-time setup, then runs the Gateway in the foreground. tmux keeps that session alive after you log out — detach with `Ctrl-b d`. To make it survive a reboot too, see OpenClaw's own docs on running it as a background service (`openclaw onboard --install-daemon`). **Check it:** back in the workspace (`devmachine ssh agent`, then `tmux attach -t agent`), the Gateway is still running. Source: [OpenClaw — Install](https://docs.openclaw.ai/install) --- # https://mydevmachine.sh/troubleshooting/ ## "several machines are configured: say which one with --machine" **What it means:** You have more than one server configured, and this command needs to know which one to act on. **What to do:** Add `--machine `, or use a workspace name — that already says which server it lives on. ## "no address answered" **What it means:** Nothing answered at the address devmachine tried. The error lists every address it tried and what happened for each. **What to do:** - Check the port. A server on a non-standard port needs `port:` set in its configuration. - Check for a second address — see [Addresses and fallback](/how-it-works/addresses-and-fallback/). ## "answered on … but refused the login" **What it means:** The server is there, so the network is fine. The account or the key is the problem. **What to do:** - Check that account exists on the server. A workspace in your configuration is not created on the server until you run `sync`. - Check the key is allowed to log in to that account. ## "no key in the configuration and no SSH agent" **What it means:** devmachine has nothing to log in with. **What to do:** Either set `key:` on the server to a private key, or start an SSH agent and load one. ## It used to connect, and now it does not **What it means:** If you recently added keys to your SSH agent, that is very likely the cause. A server gives up after a few tries, and an agent holding many keys can use them all up before it reaches the one that works. devmachine avoids this by offering one key at a time. Plain `ssh`, and anything else on your computer, does not. Setting `key:` on the server makes devmachine immune to whatever your agent is holding. **What to do:** Set `key:` on the server. Full explanation: [SSH and authentication](/how-it-works/ssh-and-authentication/). ## "ansible-playbook is not on the machine" **What it means:** `sync` runs Ansible, the tool devmachine uses to apply packages, on the server — so the server needs it installed. **What to do:** Install it once: `apt install ansible`, or whatever your distribution calls it. Everything after that is `sync`'s job. `doctor` still tells you the truth about everything else without it. ## "ansible-playbook is not on your computer" **What it means:** The same check as above, for your own computer. `sync` and `doctor` both refuse to touch anything when Ansible is not on your `PATH`. **What to do:** Run `devmachine setup --machine `. On your own computer this only checks that Homebrew is there and runs `brew install ansible` — no key, no password, no lock-down involved. ## "machine X is your computer (self: true), so it has no hosts" **What it means:** A server marked `self: true` in `config.yml` also has `hosts`, `user`, `port` or `key` set — whichever the message names. Your own computer has no address, so it cannot carry these. **What to do:** Remove the field the message names. The same error appears, one field at a time, for `user`, `port` and `key`. ## A `tailscale:` address is being ignored **What it means:** devmachine drops it when Tailscale is not installed, or does not know that name, and tries the next address instead. This is by design. **What to do:** Run `tailscale status` and check the server is listed under the name you wrote. ## A setting is accepted, but the package still uses its default **What it means:** A setting reaches the server as `devmachine__`, with dashes and dots turned into underscores. If the package reads a different variable name, your value arrives but nothing looks at it. **What to do:** See what was actually sent: ``` devmachine run --machine main -- "cat /opt/devmachine/host_vars/devmachine.yml" ``` If your variable is there under a different name than the package's own `defaults/main.yml` uses, the package needs fixing — the name in its defaults is the name a setting has to match. ## `devmachine ssh` opens a session as the wrong user **What it means:** `devmachine ssh` with no argument logs you in as the server's **root** account, not a workspace. **What to do:** Name the workspace: `devmachine ssh alice`. ## Changes to config.yml appear to be ignored **What it means:** devmachine may be reading a different file than you think. **What to do:** Check which one: ``` devmachine config path ``` `DEVMACHINE_CONFIG` in your shell beats the default location, and a `--config` flag beats everything. ## `secrets list` shows nothing after storing one **What it means:** The list of names lives in your configuration directory, even though the values themselves live in your keychain. **What to do:** Check you are using the same configuration directory you used when storing it — see `devmachine config path` above. ## `dns status` says a name does not resolve, but it works in the browser **What it means:** The check runs from **your computer**, not the server, and it ignores your browser's cache and any proxy you use. A name that works in the browser but not here usually means DNS has not finished propagating everywhere, or something local — a VPN, an `/etc/hosts` entry — is resolving it just for you. **What to do:** Wait a few minutes and check again, or check your own network settings for something overriding DNS. ## `dns add`/`dns rm`/`dns list`/`dns check` fail with one of these Every DNS provider reports one of a fixed set of errors, shown here in plain words: | Error | What it means | | --- | --- | | `zone not found, or the token cannot see it` | This domain does not exist under this provider, or your token cannot see it. | | `the token was rejected` | Your credential is wrong or expired. Push a fresh one with `devmachine secrets set` and `devmachine credentials push`. | | `the token cannot change this zone` | Your token can read the domain but not write to it. | | `the record was rejected` | The registrar refused the value — a bad type, a bad value, or a name it will not accept. | | `rate limited` | The registrar's API is temporarily blocking this token from making more requests. devmachine never retries this on its own; wait and run the command again. | | `this record type is not supported yet` | Only `A`, `AAAA`, `CNAME` and `TXT` work the same way across every provider. `MX` and `SRV` are refused by name rather than guessed at. | | `the name holds several values` | Two installed providers both claim this domain. Say which one with `--dns-provider`. | Two more lines that are not errors, but change what a command does: - **"no installed provider holds this zone"** — none of the providers you have installed recognized this domain. This is normal for a registrar with no devmachine package yet: the command falls back to `manual` and prints the record for you to create by hand. - **"the provider failed"**, with something that looks like a crash — this is a bug in the provider package, not in devmachine itself. The package sent back something that does not match the [DNS provider contract](/reference/dns-provider-contract/). ## A certificate never arrives after `expose add` **What it means:** The site only reaches the server on the next `sync`. Once it has, Caddy (the reverse proxy) only gets a certificate for a name that already points at the server. Two common causes: - **The name does not resolve yet.** DNS can take a few minutes to catch up, even if `expose add` already wrote the record (or printed it for you to add by hand). Caddy keeps retrying on its own; `devmachine dns status ` tells you when it has caught up. - **Caddy has not noticed the new site yet.** It watches for changes, but can miss one on a busy server. **What to do:** Wait for DNS, or force Caddy to reload: ``` devmachine run --machine -- 'systemctl reload caddy' ``` ## `expose add` served, and the response is "Blocked request" **What it means:** This is your application's own check, not Caddy and not `expose`. Many frameworks refuse a `Host` header they do not recognize by default, as a safety guard — and a name you just published is exactly the kind of header the app has never seen before. **What to do:** Add the published hostname to your application's own list of allowed hosts. `expose` has nothing to do with that list. ## A sync cannot fetch the release ``` fetching https://github.com/.../packages-v1.tar.gz: the server answered 404 ``` **What it means:** The pin in `config.yml` names a release that does not exist. **What to do:** Check `packages:` in your configuration against the tags the packages repository actually has — a pin is a release tag, never a branch. If the URL looks right, the problem is your network: this is a plain anonymous download, so a proxy or firewall that blocks GitHub blocks this too. Once a release is downloaded, it is cached — a later sync at the same pin needs no network at all. ## "the asset changed, which a pin exists to prevent" **What it means:** The file downloaded does not match the checksum the release published. devmachine refuses it and stops, rather than send it to your server. **What to do:** This means the release was changed after it was published, or something altered it in transit. Get the release fixed, or pin a different one. ## "package X needs a CLI >= 0.3.0, and this one is 0.2.1" **What it means:** The package says which CLI version can read it, and yours is older. **What to do:** Upgrade: `brew upgrade devmachine`. Or pin an older release of the packages instead, if upgrading is not your call to make. ## "package X is a workspace package, and main is a machine" **What it means:** Some packages belong on a server (shared by everyone, like Docker); others belong to one workspace (like Claude Code, which needs its own sign-in per person). You tried to add this one in the wrong place. **What to do:** Use the command the error shows, for example: ``` devmachine packages add claude-code --workspace alice ``` ## "package X extends caddy.sites.d, but caddy is not installed on machine main" **What it means:** This package adds files into a place another package manages, so that other package has to be on the same server. **What to do:** ``` devmachine packages add caddy --machine main ``` The same error appears if the package is installed but does not declare that it provides that place — check its `provides:` against the `extends:` that names it. ## A file a removed package left behind is still on the machine **What it means:** `sync` only removes a file it remembers writing — see [What sync removes](/how-it-works/what-sync-removes/). If a package was removed from your configuration before you upgraded to the version that started tracking this, `sync` never recorded that file, so it never cleans it up. **What to do:** Find it under the directory the extending package used (`sites.d` for Caddy) and remove it by hand: ``` devmachine run --machine -- 'rm /etc/caddy/sites.d/-' ``` Then reload Caddy, if it is on the server: ``` devmachine run --machine -- 'systemctl reload caddy' ``` ## `sync --check` fails on a machine nothing has been applied to yet A dry run against a server with no packages applied yet reports failures like: ``` No package matching 'docker-ce' is available Could not find the requested service caddy: host ``` **What it means:** Nothing is actually wrong. A dry run changes nothing, so a package's software repository is never really added, and the package it would have provided genuinely is not there to look at yet. **What to do:** Run `devmachine sync` for real once. After that, `--check` is meaningful, because there is something on the server to compare against. Use a dry run to preview a change to a server you already built — not to preview the first build itself. ## `sync` asks and I answered nothing **What it means:** An empty answer counts as no, and so does closing the input. A command that changes a server defaults to changing nothing. **What to do:** Pass `--yes` to skip the question, or `--check` to see what would happen without being asked at all. ## `ERROR! Invalid options for include_role: devmachine__` **What it means:** This was a bug in how devmachine generated its internal Ansible playbook, and it is fixed. If you see it, your binary predates the fix. **What to do:** Build or install a newer devmachine. Nothing on the server was changed — the run stopped before it started. ## `credential "X" cannot be shared` **What it means:** You asked to share this login (`X: machine`), but the package that declares it says it cannot be — usually because the tool's session file is tied to one device or browser, and a copy of it would not work anywhere else. **What to do:** Ask for `own` instead, and sign in once in each workspace. If you know a copy really does work for that tool, the fix belongs in the package: add `shareable: true` to its declaration. ## The shared login did not reach a workspace **What it means:** Two common reasons, in this order: - **Nobody has signed in yet.** The copy comes from a login already stored on the server, and `sync` skips a workspace rather than fail when there is nothing to copy. - **That workspace keeps its own login.** A workspace with `: own` in its `credentials:` is deliberately left out of the copy, so it never loses the account it signed in with. **What to do:** Run `devmachine login ` then `devmachine sync --tags credentials`, or remove the `own` line if you meant to share after all. ## `credentials list` says `unknown` **What it means:** The package never said where its tool keeps this login, so devmachine has nowhere to look. The login may well be there. **What to do:** Add `stored_at:` to the package's declaration, and the row starts giving a real answer. ## "credential X belongs to a workspace: name it with --workspace" **What it means:** Two workspaces sign in to the same tool with different accounts, so "where do I log in" has no single answer. **What to do:** Name the workspace. The list in the error shows every workspace that uses it. ## The SSH host key is not trusted **What it means:** Configurations made by v0.6 and earlier have no stored server identity. devmachine will not learn one silently. **What to do:** Run `devmachine machines trust `, compare the fingerprint it shows with your provider's dashboard or another source you trust, and approve it. This command reads the key without logging in. ## The SSH host key changed **What it means:** Something about the server no longer matches what devmachine trusted before. This can mean a deliberate rebuild, a configuration mistake, or an attack — devmachine cannot tell which, and will not connect until you decide. **What to do:** Verify the address and both fingerprints yourself. If you just rebuilt the server on purpose, run `devmachine machines trust --replace`. Add `--check` to preview without writing, and `--yes` to skip confirmation — that never substitutes for `--replace`. ## The SSH trust file is malformed **What it means:** The error names `/known_hosts` and the bad line. This file may hold entries for other servers too, so do not delete the whole thing. **What to do:** Fix that one line, or remove just that server's entry, then run `devmachine machines trust ` to approve it again. The file uses plain OpenSSH `known_hosts` syntax. ## "the login left nothing at …" **What it means:** The login command ran, but the file the package promised is not there. Either the sign-in was cancelled, or the tool actually stores its session somewhere else than the package says. **What to do:** Check where the tool really writes its session, and fix the package's `stored_at`. ## `credentials push` says "nothing to deliver" and the value is out of date **What it means:** `push` only writes files that are missing. A server that already has the file keeps its old value. **What to do:** Remove the file on the server first — its path is in `devmachine credentials list` — then push again. ## I already committed a key **What it means:** Removing a file from git's index is not enough. It stays in every past commit — anyone with a clone, or the history itself if the repository is ever made public, can still find it. **What to do:** 1. **Rotate the key first.** Generate a new one and get it authorized wherever the old one was, so the copy in your history stops being able to get in anywhere. 2. Then untrack it: `git rm --cached `, commit that, and add it to `.gitignore` if `devmachine setup git` had not already. 3. If the repository was ever pushed anywhere, rewriting history (with `git filter-repo`, or by deleting and recreating the remote) removes the key from what other people can see — but it was compromised the moment it was committed, whatever you do to the history afterwards. Step 1 is the one that actually fixes anything. `devmachine setup git` refuses to run against a directory that already tracks one of these paths, and says so — see [versioning your configuration](/how-it-works/versioning-your-configuration/). ## `expose add` said "recorded", and the site does not answer **What it means:** `add` only writes to your configuration. The change reaches Caddy on the next `devmachine sync`. **What to do:** Run `devmachine sync`, then `devmachine expose list` should say `published`. ## `expose list` says `unmanaged` **What it means:** The server has a site your configuration does not know about — written by hand, or by an old version of `expose`. It works today, but is lost the day the server is rebuilt. **What to do:** Run the `expose add` the row prints, to adopt it. After that, `sync` writes the workspace's own file and removes the old one, so Caddy sees the host only once. ## `expose list` says `differs` **What it means:** Your configuration and the server disagree on the port or the owner of a host. **What to do:** Run `devmachine sync` — your configuration is treated as the source of truth, and the server is made to match it. ## "workspace X publishes Y, but caddy is not on machine Z" **What it means:** This route has nowhere to go. **What to do:** ``` devmachine packages add caddy --machine Z devmachine sync ``` ## `sync` failed at "reload caddy for the routes" **What it means:** Caddy refused the new set of site files. The most common cause is the same host named in two files — one from another package, or one written by hand — as one the configuration also publishes. **What to do:** Find the duplicate: ``` devmachine run 'caddy validate --config /etc/caddy/Caddyfile' ``` Remove it from whichever side should not own that host. ## `workspaces destroy` failed at userdel **What it means:** Something is still running as that account — a process started outside its login session, or a container. `destroy` already disabled the account and stopped everything it could find, but something outlived that. Your configuration was left untouched, so you can retry. **What to do:** ``` devmachine run "ps -u " ``` Stop what is listed, then run `devmachine workspaces destroy ` again. ## `run` hangs, or answers with a login from a machine that no longer exists **What it means:** `run` keeps its SSH connection open for five minutes and reuses it (see [the `run` reference](/reference/commands/#run)). If the server it was talking to changed address, was rebuilt, or was deleted, that old connection can be left open and never answer again. **What to do:** Close it: ``` ssh -O exit -o ControlPath=/devmachine/cm/%C @
``` If you do not have the exact address, delete the stale connection file directly from `/devmachine/cm/` instead. Either way, the next `run` opens a fresh connection. ## "no package named X, and none is available" **What it means:** Either the name is wrong, or no packages release is pinned. With no `packages:` line in `config.yml`, only your own local packages exist. **What to do:** Run `devmachine packages pin`, which pins the latest release, then `devmachine sync`. A configuration made by `setup` from version 0.7.7 on is already pinned.