CLI utility to create and manage VMs
Supports DigitalOcean, Vultr, and Google Cloud Platform hosting platforms.
The animation above is a real, unedited run against a live DigitalOcean account; it is recorded by demo/record-demo.sh.
This project uses uv for dependency management and builds.
Install uv:
curl -LsSf https://astral.sh/uv/install.sh | sh
uv sync
This creates a .venv virtual environment and installs all dependencies (including dev tools like ruff).
Run the CLI during development:
uv run machine --help
Run the linter:
uv run ruff check machine/
Build a self-contained executable using shiv:
./sh/build-package.sh
This produces build/machine, a single-file Python zipapp.
Install directly from the GitHub repository using uv:
uv tool install git+https://github.com/stirlingbridge/machine.git
Alternatively, download the machine binary from the releases page, make it executable, and place it on your PATH:
chmod +x machine
sudo mv machine /usr/local/bin/
Access token and other settings configured in the file ~/.machine/config.yml. The config file contains a provider section (digital-ocean, vultr, or gcp) and a machines section.
If only one provider section is present, it is auto-detected. If multiple provider sections exist, add a provider: key to select one explicitly:
provider: vultrdigital-ocean:
access-token: dop_v1_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
ssh-key: my-ssh-key-name
dns-zone: example.com
machine-size: s-4vcpu-8gb
image: ubuntu-22-04-x64
region: nyc3
project: Infrastructure
machines:
example:
new-user-name: alice
script-dir: /opt/setup-scripts
script-url: https://raw.githubusercontent.com/example/setup-machine.sh
script-path: /opt/setup-scripts/setup-machine.sh
script-args: "-y"| Key | Required | Description |
|---|---|---|
access-token |
Yes | DigitalOcean API access token |
ssh-key |
Yes | Name of an SSH key in your DigitalOcean account to use for new machines, or a list of names (see Multiple SSH keys) |
dns-zone |
No | DNS zone for automatic DNS record creation/deletion |
machine-size |
Yes | Default machine size slug (e.g. s-4vcpu-8gb), or a list of sizes to try in order (see Multiple regions and sizes) |
image |
Yes | Default image name (e.g. ubuntu-22-04-x64) |
region |
Yes | Default region code (e.g. nyc3), or a list of regions to try in order (see Multiple regions and sizes) |
project |
No | DigitalOcean project name to assign new machines to |
Supported regions: NYC1, NYC3, AMS3, SFO2, SFO3, SGP1, LON1, FRA1, TOR1, BLR1, SYD1, ATL1, RIC1, MKC1, MEM1
vultr:
api-key: ${VULTR_API_KEY}
ssh-key: my-ssh-key-name
dns-zone: example.com
machine-size: vc2-1c-1gb
image: 2136
region: ewr
machines:
example:
new-user-name: alice
script-dir: /opt/setup-scripts
script-url: https://raw.githubusercontent.com/example/setup-machine.sh
script-path: /opt/setup-scripts/setup-machine.sh
script-args: "-y"| Key | Required | Description |
|---|---|---|
api-key |
Yes | Vultr API key (from https://my.vultr.com/settings/#settingsapi) |
ssh-key |
Yes | Name of an SSH key in your Vultr account to use for new machines, or a list of names (see Multiple SSH keys) |
dns-zone |
No | DNS zone for automatic DNS record creation/deletion |
machine-size |
Yes | Vultr plan slug (e.g. vc2-1c-1gb), or a list of plans to try in order (see Multiple regions and sizes). Use machine list-plans or the Vultr API to list available plans |
image |
Yes | Vultr OS ID (numeric, e.g. 2136 for Ubuntu 24.04). Use machine list-os or the Vultr API to list available OS IDs |
region |
Yes | Vultr region code (e.g. ewr), or a list of regions to try in order (see Multiple regions and sizes) |
Supported regions: ewr, ord, dfw, sea, lax, atl, ams, lhr, fra, sjc, syd, nrt, cdg, icn, mia, sgp, sto, mex, mad, sao, del, hnl, yto, blr, jnb, bom, tlv
Note: Vultr does not have a "projects" concept, so the project config key and the projects command are not applicable when using the Vultr provider.
gcp:
project-id: my-gcp-project
credentials-file: ~/.config/gcloud/service-account.json
ssh-key: alice
dns-zone: example.com
machine-size: e2-standard-2
image: projects/debian-cloud/global/images/family/debian-12
region: us-central1-a| Key | Required | Description |
|---|---|---|
project-id |
Yes | GCP project ID where all resources (VMs, DNS, etc.) live |
credentials-file |
No | Path to a service account JSON key file. If omitted, Application Default Credentials are used (e.g. from gcloud auth application-default login) |
ssh-key |
Yes | Username GCP associates with an SSH key in the project's ssh-keys metadata (see SSH key notes below), or a list of usernames (see Multiple SSH keys) |
dns-zone |
No | DNS name of a Cloud DNS managed zone (e.g. example.com) |
machine-size |
Yes | GCE machine type (e.g. e2-standard-2), or a list of machine types to try in order (see Multiple regions and sizes) |
image |
Yes | Image self-link or family path (e.g. projects/debian-cloud/global/images/family/debian-12) |
region |
Yes | GCE zone (e.g. us-central1-a). Despite the key name, GCP requires a fully-qualified zone, not a region. A list of zones to try in order is also accepted (see Multiple regions and sizes) |
Authentication:
machine supports two ways to authenticate with GCP:
- Application Default Credentials (ADC) — omit
credentials-fileandmachinefalls back to ADC. The simplest way to set this up is to rungcloud auth application-default loginonce; it stores credentials under~/.config/gcloudthatmachinepicks up automatically. This is the easiest option for interactive use on a workstation that already has thegcloudCLI, and avoids creating and managing a long-lived key file. - Service account key file — set
credentials-fileto the path of a service account JSON key. This is appropriate for unattended environments or when you want a dedicated, tightly-scoped identity.
SSH keys:
GCP doesn't have a named SSH key registry like DigitalOcean or Vultr. Instead, public keys live in the project's ssh-keys metadata, and each one is associated with a username. The value of ssh-key in the config must match one of those usernames.
Two ways to add a key:
-
Console (Compute Engine → Metadata → SSH Keys → Add SSH Key): paste a raw public key, e.g.
ssh-rsa AAAAB3Nza... alice@laptopGCP derives the username from the comment portion at the end — everything before the first
@. Soalice@laptopandalice@example.comboth produce the usernamealice. Setssh-key: alicein the config. -
gcloud — write each line in the explicit
<username>:<key>form to a file (no auto-derivation here), then upload:echo "alice:ssh-rsa AAAAB3Nza... alice@laptop" > keys.txt gcloud compute project-info add-metadata --metadata-from-file ssh-keys=keys.txt
The tool reads existing keys but does not create them.
Other notes:
- The
projectconfig key and re-assignment to projects are not applicable when using GCP — every resource is owned by the project specified inproject-idat creation time. - The
regionfield is interpreted as a GCE zone. Validation only checks for plausibility; any zone string with a hyphen is accepted.
machines section:
Each entry under machines: defines a machine type that can be referenced with create --type:
| Key | Required | Description |
|---|---|---|
new-user-name |
Yes | Username for the non-root user created on the machine |
script-url |
No | URL to download an initialization script from |
script-dir |
No | Directory to store the initialization script |
script-path |
No | Full path for the initialization script |
script-args |
No | Arguments passed to the initialization script (supports variable expansion). Either a single string, or a list where each item is passed to the script as exactly one shell argument |
firewall |
No | Cloud firewall rules applied to machines of this type (DigitalOcean only, see Firewalls) |
If script-url, script-dir, and script-path are all provided, the script is downloaded and executed as the new user during cloud-init.
When script-args is a list, each item reaches the script as one argument regardless of any spaces it contains. This composes well with runner scripts such as combine.sh, which accepts one "<script> [args...]" entry per script to run:
script-args:
- packages.sh build-essential
- podman.sh
- k3s-node.sh -y --letsencrypt-email user@example.comThe following variables are available for expansion in script-args (both forms):
$MACHINE_SCRIPT_URL— URL of the initialization script$MACHINE_SCRIPT_DIR— directory path for the script$MACHINE_FQDN— fully qualified domain name of the machine (if DNS is configured)
A machine type can define cloud firewall rules, which the provider enforces outside the machine itself:
machines:
acme-host:
new-user-name: bpi
firewall:
inbound:
- protocol: tcp
ports: 2222
sources: [203.0.113.10/32, 198.51.100.7/32]
outbound:
- protocol: udp
ports: 53
destinations: [0.0.0.0/0, "::/0"]
- protocol: udp
ports: 123
destinations: [0.0.0.0/0, "::/0"]
- protocol: tcp
ports: 80
destinations: [0.0.0.0/0, "::/0"]
- protocol: tcp
ports: 443
destinations: [0.0.0.0/0, "::/0"]
- protocol: tcp
ports: 22
destinations: [192.0.2.20/32, 192.0.2.21/32]Each rule has a protocol (tcp, udp or icmp), ports for tcp/udp (a port, a range like 8000-9000, or all, which is the default), and sources (inbound) or destinations (outbound): a list of IPv4/IPv6 addresses or CIDRs.
- Inbound traffic not matched by a rule is dropped. An empty or missing
inboundblocks all inbound traffic, including ssh. - If
outboundis omitted, all outbound traffic is allowed. If it's present, everything else is dropped, and an empty list blocks all outbound traffic.
create sets up a firewall named machine-<type>, attached to the machine type's tag (machine:type:<type>), before creating the machine, so the machine is protected from first boot. Because the firewall is attached by tag, it covers every machine of that type in the account. Each create overwrites the firewall's rules with those in the config, so editing the config and creating another machine updates the rules for all of them. destroy leaves the firewall in place.
The DigitalOcean access token needs the firewall and tag scopes in addition to those create already uses. Firewalls aren't yet supported for Vultr or GCP: a machine type with a firewall section fails to create on those providers.
The ssh-key config value accepts either a single key name or a list of names. When a list is given, every key is installed on new machines (both on the provider account's default user and, when --initialize is used, on the new user created by cloud-init).
digital-ocean:
access-token: ${DO_API_TOKEN}
ssh-key:
- alice-laptop
- bob-laptop
machine-size: s-4vcpu-8gb
image: ubuntu-22-04-x64
region: nyc3The single-name form is unchanged and remains fully supported:
ssh-key: my-ssh-key-nameProviders sometimes run out of a machine size in a region for a while (a "stockout"), or stop offering it there. To ride that out, the region and machine-size config values each accept a list in order of preference, and create makes the machine in the first region, with the first size, that has capacity:
digital-ocean:
access-token: ${DO_API_TOKEN}
ssh-key: my-ssh-key-name
machine-size:
- s-4vcpu-8gb
- s-4vcpu-8gb-intel
image: ubuntu-22-04-x64
region:
- nyc3
- nyc1
- tor1Every size is tried in a region before moving on to the next region, since where a machine runs usually matters more than which of several equivalent sizes it gets. Before creating, machine asks the provider which combinations it currently offers (DigitalOcean and Vultr can say; GCP can't) and tries the ones it doesn't last, in case the answer is out of date. If the create itself fails because the provider is out of capacity, the next combination is tried; any other error stops there. The region and size used are printed, and are in the --output json result. Only list regions and sizes you would be equally happy with: a machine in a different region has different latency to whatever it talks to.
--region and --machine-size can each be given more than once on the command line to override the lists for one machine.
Config values support environment variable substitution using ${VAR} syntax, similar to Docker Compose files. This is useful for keeping secrets like API tokens out of the config file.
Supported forms:
${VAR}— substitutes the value ofVAR; errors if the variable is not set${VAR:-default}— substitutes the value ofVAR, ordefaultif the variable is not set
Examples:
# DigitalOcean
digital-ocean:
access-token: ${DO_API_TOKEN}
ssh-key: ${SSH_KEY_NAME:-my-ssh-key}
dns-zone: example.com
machine-size: ${MACHINE_SIZE:-s-4vcpu-8gb}
image: ubuntu-22-04-x64
region: ${DO_REGION:-nyc3}
project: Infrastructure# Vultr
vultr:
api-key: ${VULTR_API_KEY}
ssh-key: ${SSH_KEY_NAME:-my-ssh-key}
dns-zone: example.com
machine-size: ${MACHINE_SIZE:-vc2-1c-1gb}
image: 2136
region: ${VULTR_REGION:-ewr}Substitution is applied to all string values throughout the config file, including the machines section.
Examples of advanced machine setup scripts can be found in the machine-provisioning repository.
Each invocation of machine uses a session ID (auto-generated and stored in ~/.machine/session-id.yml). Machines are tagged with their session ID on creation. By default, list, status, list-domain, and destroy only operate on machines from the current session. Use the --all flag to include machines from other sessions or machines not created by this tool.
The session ID can be overridden with the global --session-id option.
Machines created by this tool are automatically tagged with:
machine:created— identifies the machine as created by this toolmachine:type:<type-name>— the machine type from the configmachine:session:<session-id>— the session that created the machine
$ machine --help
Usage: machine [OPTIONS] COMMAND [ARGS]...
Options:
--debug Enable debug output
--quiet Suppress all non-essential output
--verbose Enable verbose output
--dry-run Run but do not do anything
--config-file <PATH> Specify the config file (default
~/.machine/config.yml)
--session-id <ID> Override the default session ID
-h, --help Show this message and exit.
Commands:
create Create a machine
destroy Destroy one or more machines
domains List dns domains
list List machines
list-domain List domain records
projects List projects
ssh-keys List ssh keys
status Machine status
types List configured machine types
version Display version
Create a new machine on the configured provider. By default, the machine is initialized with cloud-init (using the specified --type from config) and a DNS A record is created.
$ machine create --help
Usage: machine create [OPTIONS]
Create a machine
Options:
-n, --name <MACHINE-NAME> Name for new machine [required]
-t, --tag <TAG-TEXT> Tag to be applied to new machine
-m, --type <MACHINE-TYPE> Machine type from config (required if --initialize)
-r, --region <REGION-CODE> Region (overrides config default); repeat to
give regions to try in order
-s, --machine-size <MACHINE-SLUG>
Machine size (overrides config default);
repeat to give sizes to try in order
-i, --image <IMAGE-NAME> Image (overrides config default)
--wait-for-ip / --no-wait-for-ip Wait for IP address assignment (default: off)
--progress / --no-progress Animate a progress indicator while waiting
for the IP address (default: off)
--update-dns / --no-update-dns Create DNS A record (default: on)
--initialize / --no-initialize Initialize with cloud-init (default: on)
-o, --output <FORMAT> Output format (json)
-h, --help Show this message and exit.
Output formats:
- Default: human-readable status messages
--output json: JSON object with id, name, tags, region, size, ip, type
JSON output example:
{
"id": "12345678",
"name": "my-machine",
"tags": ["machine:created", "machine:type:example", "machine:session:abc12345"],
"region": "nyc3",
"size": "s-4vcpu-8gb",
"ip": "203.0.113.10",
"type": "example"
}When --update-dns is enabled (the default), the command waits for the instance's IP address and creates an A record in the configured dns-zone with a 5-minute TTL.
That wait takes tens of seconds and is silent by default. --progress animates a spinner with an elapsed-second count on stderr for the duration, so an interactive run does not look like it has hung. It is ignored when stderr is not a terminal, and --verbose (which reports the same wait in words) takes precedence over it, so scripted and piped output is unaffected either way.
When --initialize is enabled (the default), a cloud-config user-data payload is generated that creates a non-root user with sudo access, installs the configured SSH key(s), and optionally downloads and runs an initialization script.
If a project is configured (DigitalOcean only), the machine is automatically assigned to that project.
Destroy one or more machines by instance ID. By default, requires confirmation and deletes associated DNS records.
$ machine destroy --help
Usage: machine destroy [OPTIONS] [MACHINE-IDS]...
Destroy one or more machines
Options:
--confirm / --no-confirm Require confirmation (default: on)
--delete-dns / --no-delete-dns Delete associated DNS records (default: on)
--all Include machines not created by this tool
or by other sessions
-h, --help Show this message and exit.
Confirmation requires typing exactly YES (not "y", "yes", or "Yes"). Use --no-confirm to skip.
Safety checks prevent destroying machines that were not created by this tool or that belong to a different session, unless --all is specified.
List machines with optional filtering.
$ machine list --help
Usage: machine list [OPTIONS]
List machines
Options:
--id <MACHINE-ID> Filter by id
-n, --name <MACHINE-NAME> Filter by name
-t, --tag <TAG-TEXT> Filter by tag
-m, --type <MACHINE-TYPE> Filter by type
-r, --region <REGION> Filter by region
-o, --output <FORMAT> Output format (json)
-q, --quiet Only display machine IDs
--unique Return an error if more than one match
--all Include all machines from all sessions
-h, --help Show this message and exit.
Output formats:
- Default:
name (id, region, type): ip_address --quiet: machine IDs only--output json: JSON array of machine objects
JSON output example:
[
{
"id": "12345678",
"name": "my-machine",
"tags": ["machine:created", "machine:type:example", "machine:session:abc12345"],
"region": "nyc3",
"size": "s-4vcpu-8gb",
"ip": "203.0.113.10",
"type": "example"
}
]Check the status of machines, including querying a custom status endpoint.
$ machine status --help
Usage: machine status [OPTIONS]
Machine status
Options:
--id <MACHINE-ID> Filter by id
-n, --name <MACHINE-NAME> Filter by name
-t, --tag <TAG-TEXT> Filter by tag
-m, --type <MACHINE-TYPE> Filter by type
-r, --region <REGION> Filter by region
-o, --output <FORMAT> Output format (json)
--status-check <CHECK> Status check to perform (default: cloud-init-status)
-q, --quiet Only display machine IDs
--all Include all machines from all sessions
-h, --help Show this message and exit.
Output formats:
- Default: human-readable status line
--output json: JSON array of status objects
JSON output example:
[
{
"name": "my-machine",
"id": "12345678",
"machine-status": "active",
"cloud-init-status": "done"
}
]In addition to the provider-reported instance status, this command queries each machine at http://<ip>:4242/cgi-bin/<status-check> (default: cloud-init-status) for custom status information. If the endpoint is unreachable, the status is reported as UNKNOWN.
List DNS records within a domain zone.
$ machine list-domain --help
Usage: machine list-domain [OPTIONS] [ZONE]
List domain records
Options:
-n, --name <RECORD-NAME> Filter by record name
-m, --type <RECORD-TYPE> Filter by record type (default: A and AAAA, use * for all)
-o, --output <FORMAT> Output format (json)
-q, --quiet Only display record names
--all Include all records from all sessions
-h, --help Show this message and exit.
If ZONE is omitted, uses the dns-zone from config. By default, only shows A and AAAA records associated with machines from the current session.
Output formats:
- Default:
name\ttype\tdata --quiet: record names only--output json: JSON array of record objects
JSON output example:
[
{
"id": "98765432",
"machine": {
"id": "12345678",
"name": "my-machine",
"tags": ["machine:created"],
"region": "nyc3",
"size": "s-4vcpu-8gb",
"ip": "203.0.113.10",
"type": "example"
},
"name": "my-machine",
"fqdn": "my-machine.example.com",
"zone": "example.com",
"data": "203.0.113.10",
"ttl": 300,
"type": "A"
}
]List all DNS domains in your provider account.
Options:
-o, --output <FORMAT>: Output format (json)
JSON output example:
["example.com", "example.org"]List SSH keys in your provider account. Output format: id: name (fingerprint)
List project names (DigitalOcean only).
Options:
-o, --output <FORMAT>: Output format (json)
JSON output example:
["Infrastructure", "Web Apps"]List all machine types defined in the config file (from the machines section).
Options:
-o, --output <FORMAT>: Output format (json)
JSON output example:
["example", "webserver"]