Command reference
Every console command: flags, defaults, and the errors you may see. This is the lookup table; the topic pages are the tour.
Running commands
Every console command runs over SSH as a one-shot from your own computer. There is nothing to install; the ssh client you already have is the whole tool:
$ ssh dashboard@ironwire.sh list NAME STATUS IMAGE IP FLAGS URL web running ubuntu 10.77.0.2 - https://web-sam.ironwire.xyz
ssh dashboard@ironwire.sh <command…> runs one console verb and returns; running ssh dashboard@ironwire.sh with no command opens the interactive dashboard instead. See connecting.
--flag=value form. --disk 10G with a space is not accepted. (env additionally accepts -m=<name> as shorthand for --machine=.)JSON mode: set IRONWIRE_FMT=json in the command's environment. Typed shapes are noted below; every other command wraps its text as {"ok":true,"message":"<text>"}, and errors come back as {"ok":false,"error":"<msg>"}. See fleet access & orders.
Running a command inside a machine, and copying files
The commands above manage your fleet. To run a one-off command inside a machine (a build, a test, a script), address the machine in the SSH username and pass the command, exactly like plain SSH. The command's exit code becomes the SSH exit code, so an agent reads success or failure directly; stdout and stderr stream back:
$ ssh <machine>@ironwire.sh 'cd /root/app && make test' $ echo $? # the command's own exit code
Copy files with a tar stream over that same SSH channel: one continuous stream, so a whole tree moves in a single round trip. Push a local path in:
$ tar c -C ./build . | ssh <machine>@ironwire.sh 'tar x -C /root/app'
Pull a remote path out:
$ ssh <machine>@ironwire.sh 'tar c -C /root/app dist' | tar x -C ./local
tar is present in every machine image. See connecting for the machine address form.Machines
$ ssh dashboard@ironwire.sh create web --disk=10G --drive created web url: https://web-sam.ironwire.xyz ssh: ssh web@ironwire.sh
create <name> [--disk=<size|max>] [--restart=…] [--drive] [--image=<id>] [--port=<guest|none>] [--publish=<public:guest>]
Alias: new
Create a machine. <name> is required: lowercase, starting with a letter, then letters/digits/hyphens, 2 to 31 characters; dashboard is reserved. Disk is the only per-machine size: CPU and memory are pooled across your account, and every machine sees the full pool. Disk accepts 5G, 512M, a bare MiB number, or max (the whole storage pool, the same ceiling a blank size gets; sizes are ceilings, only stored data counts against the pool). The retired --cpu/--mem flags are rejected with CPU and memory are pooled across your account (every machine sees the full pool) — only --disk is set per machine.
| Flag | Value | Default | Meaning |
|---|---|---|---|
--disk= | size or max | platform default | Disk. |
--restart= | policy | platform default | Restart policy. |
--image= | image id | ubuntu | Guest image; see images. |
--drive | (boolean) | off | Attach your shared drive, creating it at the default size if you don't have one. |
--port= | guest port or none | 8000 | Guest port behind the machine's main HTTPS URL; none (or off) = no public URL. |
--publish= | <public>:<guest> | — | Extra HTTPS port mapping, public port 8100-9999; repeatable. |
Errors you may see: invalid name (lowercase, a-z0-9-, 2-31 chars), name "<name>" is taken. Requests over your budget are rejected with the limit and what you asked for.
JSON: {"ok":true,"machine":{…}} with name, ip, status, policy, image, url, ports, ssh, and sizes.
More: machines & lifecycle.
copy <source> <new-name>
Alias: clone
Clone a machine's disk into a new machine. The source must be paused or stopped first; the copy inherits size, restart policy, and drive attachment. Missing arguments give copy: need <source> <new-name>; a busy source gives cannot copy "<name>" while it is <status> — pause or stop it first (copy clones a settled disk).
JSON: {"ok":true,"machine":{…}}. More: machines & lifecycle.
images
List the guest images you can create machines on: one block per image with its id, label, on-disk size, and description. No arguments or flags.
JSON: {"ok":true,"images":[{"id","label","bytes","info"}]}. More: machines & lifecycle.
list
Alias: ls
List your machines as a table: NAME STATUS IMAGE IP FLAGS URL. The FLAGS column shows F for fleet access, P for delete-protected, N for a machine that takes no orders, - for none of them. With no machines it prints no machines.
JSON: {"ok":true,"machines":[…]}. More: machines & lifecycle.
info <name>
Show one machine: status, IP, size, restart policy, URL, SSH endpoint, the fleet-access / takes-orders / delete-protection switches, published ports, and reachability hints. Missing name: info: need a machine name.
JSON: {"ok":true,"machine":{…},"network":{…},"system_version":…}. More: machines & lifecycle.
resize <name> [--disk=<size|max>] [--restart=<policy>]
Grow a machine's disk and/or change its restart policy. The new disk size applies on the next restart; a new restart policy is live at once. Disk is the only per-machine size (CPU and memory are pooled) and it can only grow.
| Flag | Value | Default | Meaning |
|---|---|---|---|
--disk= | size or max | keep current | Disk (grow only). |
--restart= | always, on-failure, never | keep current | Crash-recovery policy. |
At least one flag is required: resize: pass --disk=<size> and/or --restart=<policy> (disk is the only per-machine size — CPU/RAM are pooled). An unknown policy gives unknown restart policy "<p>" (use always, on-failure, or never). Shrinking disk gives disk can only grow (<cur> MiB now, requested <n> MiB). --cpu/--mem are rejected with the pooled-resources notice.
JSON: {"ok":true,"machine":{…},"note":"resized <name>: 10 GiB disk (applies on next restart)"}. More: machines & lifecycle.
stats <name> [--range=1h|24h|7d|30d]
Show a machine's current CPU, memory, and disk usage plus a history table.
| Flag | Value | Default | Meaning |
|---|---|---|---|
--range= | 1h, 24h, 7d, 30d | 1h | History window. |
An unknown window gives unknown range "<w>" (use 1h, 24h, 7d, 30d). If the machine isn't running or metrics are still warming up, the current block says now: no data (machine not running or metrics still warming up).
JSON: {"ok":true,"stats":{"range","from","to","series":[…],"current":{…}}}. More: machines & lifecycle.
Power & lifecycle
$ ssh dashboard@ironwire.sh stop web stopped web $ ssh dashboard@ironwire.sh start web started web
stop <name>
Power a machine off; its name, IP, and disk are kept. Stopping a machine that isn't running gives <name> is not running (it is <status>). More: machines & lifecycle.
start <name>
Boot a stopped machine on its same IP. Starting a running machine gives <name> is already running. More: machines & lifecycle.
restart <name>
Alias: reboot
Stop then start the machine. This is how a pending resize, env-var change, or drive attach/detach takes effect. More: machines & lifecycle.
pause <name>
Freeze the whole machine: RAM and processes are snapshotted to disk. A paused machine consumes nothing from your pool. More: machines & lifecycle.
resume <name>
Restore a paused machine exactly where it left off. Resuming a machine that isn't paused gives <name> is not paused (it is <status>). More: machines & lifecycle.
rm <name>
Delete a machine permanently; the disk is gone. Delete protection blocks everyone, including you, until you turn it off: machine "<name>" is delete-protected; turn protection off first. JSON: {"ok":true,"deleted":"<name>"}. More: machines & lifecycle.
Networking
$ ssh dashboard@ironwire.sh ports web main 3000 443: https://web-sam.ironwire.xyz → vm port 3000 published: (none)
connect <name>
Alias: ssh
Open an interactive shell into a machine. Unknown machines give machine "<name>" not found. Only a running machine accepts a connection; anything else is refused with the reason, e.g. <name> is stopped — start it first, <name> is paused — resume it first, or <name> is still booting — try again in a moment. You can also SSH straight in with ssh <machine>@ironwire.sh; see connecting.
ports <name> [main <guest|none> | publish <public:guest> | unpublish <public>]
Manage a machine's HTTPS ports. With no subcommand it lists them. Subcommand aliases: add = publish; rm = remove = unpublish.
ports <name>- Show the main URL mapping and every published port.
ports <name> main <guest-port|none>- Point the machine's bare HTTPS URL at a guest port, or
noneto turn the public URL off. ports <name> publish <public>:<guest>- Publish an extra HTTPS port. The public port must be between 8100 and 9999; the guest port can be anything.
ports <name> unpublish <public>- Remove a published port.
Changes apply live without a restart. Errors you may see: port <n> is not published on <name>, public port <n> is reserved by the platform. More: networking.
domain <name> [status | set <fqdn> | rm]
Attach your own domain to a machine. Subcommand aliases: rm = clear = remove; with no subcommand, status is shown. set requires a subdomain you control; after setting, create a CNAME to the shown target and it goes live automatically.
Errors you may see: "<fqdn>" is not a valid domain name, "<fqdn>" must be a subdomain (e.g. app.example.com) — an apex domain can't be a CNAME. More: networking.
Shared drive
$ ssh dashboard@ironwire.sh drive drive: 10 GiB — attached to app (mounts at /mnt/share)
drive [attach <machine> | detach <machine>]
Your account's shared drive is always there at a fixed 10 GiB (one per account; mounts at /mnt/share on attached machines). There is nothing to create, size, or delete; just attach it where you want it.
drive- Show the drive's size and where it's attached.
drive attach <machine>/drive detach <machine>- Mount or unmount it on a machine; takes effect on the machine's next boot.
More: shared drive.
Environment variables
$ ssh dashboard@ironwire.sh env set API_KEY sk-123 --machine=web set API_KEY — restart the machine to apply
env [list | set <NAME> <VALUE> | rm <NAME>] [--machine=<name>]
Manage env vars, account-global by default or scoped to one machine with --machine=. Subcommand aliases: ls = list; rm = delete = unset; with no subcommand, list runs.
| Flag | Value | Default | Meaning |
|---|---|---|---|
--machine= (or -m=) | machine name | account-global | Scope the command to one machine's vars. |
set is an upsert; everything after the name becomes the value (spaces included). Changes apply on the machine's next restart. Names starting with IRONWIRE_ are rejected: names starting with IRONWIRE_ are reserved by the control plane. Invalid names give invalid variable name "<n>" (use letters, digits, and underscore; must not start with a digit).
JSON: env list → {"ok":true,"env":[{"name","value"}]}; set → {"ok":true,"message":…,"set":"<NAME>"} and rm → {"ok":true,"message":…,"removed":"<NAME>"}, each with "machine" when scoped by --machine=. More: environment variables.
Egress proxies
$ ssh dashboard@ironwire.sh proxy attach stripe web attached stripe to web — live now; env vars (PROXY_STRIPE) on next restart
proxy [list | add <label> --target <host> --header 'N: v' | set-header <label> <n> <v> | rm-header <label> <n> | attach <label> <machine> | detach <label> <machine> | rm <label>]
Manage egress proxies: machines call third-party APIs through a per-destination hostname and the platform injects your stored headers in flight, so the secret never exists inside the VM. With no subcommand, list runs (ls = list; delete = rm).
add takes one --target (a public hostname; IP literals, private addresses, and platform domains are refused), any number of --header 'Name: value' entries (the secrets to inject; optional: a headerless proxy still allowlists the destination, and headers can be added later with set-header), and optional --env 'NAME=template' entries where $PROXY_URL expands to the proxy URL, which is how stock SDKs get their base-URL variables. Labels are lowercase a-z0-9-, up to 32 chars, unique per account.
Header values are write-only: list shows them redacted and nothing ever prints one in full; set-header is the rotation path (an upsert) and also how you add further headers after creation.
attach/detach take effect at the proxy immediately; the PROXY_<LABEL> env vars land on the machine's next restart. Attaching two proxies with the same target to one machine is refused. rm while attached is refused, naming the machines: proxy "x" is attached to: web — detach first. Limits: 20 proxies per account, 8 headers per proxy, 4 KiB per value.
From inside a machine the CLI may proxy list (redacted), but every mutation is refused: a machine cannot manage proxies — proxy settings are owner-only.
JSON: proxy list → {"ok":true,"proxies":[{"label","target","url","env_name","headers":[{"name","value_redacted"}],"envs","attached"}]}. More: egress proxies.
conn [list | attach <name> <machine> [--repo owner/name | --all-repos] | detach <name> <machine> | rm <name> | request <service> [--repo owner/name] | tools <name> | call <name> <tool> '<json>']
Third-party connections: your machines' agents use your Linear and GitHub with no key in the VM. A service is connected once in the ironwire app; these commands manage and use what is connected.
list shows your connections and the machines each is attached to. attach and detach take effect immediately. GitHub attachments name the repositories the machine gets with --repo owner/name (repeatable); a repository outside the app installation is refused. --all-repos gives the machine every repository the connection can reach, including ones added to it later. rm detaches a connection everywhere and revokes it at the service in one step. A machine may hold one connection per service host, and not alongside an egress proxy for the same destination.
From inside a machine, list shows only what that machine has, and attach, detach and rm are refused: only the owner can manage connections. A machine can ask instead: request notifies your phone and waits (up to four minutes) for your answer; run again, it keeps waiting on the same request without notifying twice. tools lists the connected service's own tools and call runs one, printing the service's result. Those three run only on a machine, as that machine.
JSON: conn list → {"ok":true,"connections":[{"name","service","display","status","attachments":[{"machine","repos","calls","last_used_at"}]}]}; conn tools → {"ok":true,"connection","tools":[…]} with each tool's argument schema; conn call → {"ok":true,"result":{…}}, the service's result unchanged. More: connections.
tool list | tool call <name> '<json>'
The agent tool surface, as JSON. Every machine runs an MCP server for its agents, and these two commands are what it serves: tool list returns each tool with its argument schema, and tool call runs one. The tools cover connections and the everyday fleet commands (list, create, copy, start, stop, restart, pause, resume, delete, env); each one runs the same command documented on this page, with the caller's identity and exactly its permissions, so a machine without fleet access is refused in the same words. There is nothing here that the commands above cannot do; you will rarely call it by hand.
email <machine> [--new | on | off | get <id> | rm <id>]
A machine's inbound email. email <machine> on turns on its catch-all address, *@<machine>-<handle>.ironwire.xyz, and mail sent there is held for the machine (even while it is stopped). Off by default; turning it on when the host isn't receiving gives inbound email is not enabled on this host.
With no subcommand, list the mailbox (id · from · to · subject · size · auth · age; * = unseen). --new lists only messages never fetched. get <id> prints the full raw message (headers + body; pipe it to a parser) and marks it seen; reading never deletes. rm <id> deletes one message: deleting after processing is the ack, and stored mail counts against your account's mail cap (see quotas); when the cap is full, new mail is refused with a retry-later error until you clear space. A missing id gives message not found.
The auth column is the platform's sender-authentication verdict (SPF/DKIM/DMARC), checked when the message arrived: trust it, not the message's own headers, before acting on who a message claims to be from. Receive-only: machines cannot send or reply to email.
JSON: email <machine> → {"ok":true,"messages":[{"id","from","rcpt_to","subject","size_bytes","auth","seen","received_at"}]}; get → {"ok":true,"message":{…,"raw_base64"}}.
Outposts
$ # on the machine: curl -sSL https://ironwire.sh/connect | sh && sudo ironwire-outpost — it shows a code $ ssh dashboard@ironwire.sh outpost approve KXRT-4419 outpost hamids-mac-mini approved
outpost [list | approve <code> [--name <n>] [--replace] | add <name> | info <name> | port <name> <web|port> public|internal | relink <name> | domain <name> <fqdn> | rm <name>]
Connect a machine you own (a Raspberry Pi under the desk, a Mac mini in a closet, a GPU box) so it appears in your machine list beside your VMs, serves a real HTTPS URL, and is reachable from your VMs by name. With no subcommand, list runs (ls = list; remove = rm). Walkthrough: outposts.
approve <code> links the machine showing that code to this account. Run sudo ironwire-outpost on the machine to get a code. The outpost is named after the machine unless you pass --name; names share one namespace with your machines, so a name a machine already uses is refused. --replace makes this machine an existing offline outpost of that name (it keeps its URL, custom domain and private address); an online one gives <name> is online — `outpost rm <name>` first. A code nobody is showing, or one that expired, gives that code is not waiting for approval; after ten wrong codes in ten minutes you are told to wait. The account's outpost limit and billing state apply here, as for add.
port <name> <web|port> public|internal sets who can reach one share: internal is your own machines only, at <name>.internal:<port>; public adds the HTTPS URL (web) or public port. A newly shared port starts internal. It chooses among what the machine shares and cannot add a port: <name> does not share port <n>. In info an internal share reads internal — <name>.internal:<port>; in JSON its port carries "internal":true.
A freshly joined machine shares nothing. What it shares (a public URL, extra ports, a shell and file manager) is decided on the machine, with sudo ironwire-outpost or sudo ironwire-outpost share, and takes effect without a new code. Nothing on this side can widen it.
add <name> is for scripts: it claims the name and prints a one-time code for sudo ironwire-outpost link <code> [--http <port>] [--publish <public>:<local>] [--shell] [--files <dir>] on the machine. That code is good for 30 minutes, works once, and (re)links the machine it is typed on. relink issues a fresh one for an existing outpost.
An outpost has three states of its own: pending (a code was issued and no machine has used it), online, and offline with the time it was last seen and, when known, why: stopped answering, disconnected, or refused: …. A machine at home sleeping, losing power, or losing its uplink is normal: its URL then serves a page saying so, and it comes back on its own when it reconnects. Nothing is restarted or escalated by us.
info shows its state, URL, published ports, whether a shell was offered, the machine it runs on, and uptime over 24 hours / 7 days / 30 days.
rm drops the row, cuts the tunnel, and frees the name and URL. The agent stays on your machine and shows that it was removed; join again there with sudo ironwire-outpost, or remove it with sudo ironwire-outpost disconnect. We do not run commands on hardware we do not own.
JSON: outposts appear in list with "kind":"outpost"; an offline one carries "offline_reason" when known, an internal web share "web_internal":true, and an internal published port "internal":true.
Outposts are included in the price: they draw no CPU, RAM or disk from your pool and appear on no invoice line. A per-account limit exists as an abuse control; see quotas.
Account
$ ssh dashboard@ironwire.sh usage 2/50 machines · 0/3 outposts · pool 2 vCPU · pool 4 GiB · 20/50 GiB disk · 0/100 MiB email
whoami
Show your handle: you are <handle>. JSON: {"ok":true,"handle":…,"admin":…,"machine":…}. More: getting started.
usage
Show your machine slots, disk, and stored mail against your budget, plus your account's CPU/RAM pool (shared by all your machines; see quotas). JSON: {"ok":true,"usage":{"machines","disk_mib","mail_mib"},"budget":{"machines","disk_mib","mail_mib"},"pool":{"vcpu","mem_mib"}}.
keys
List your SSH keys: fingerprint, label, and the date each was added. More: connecting.
link
Get a short-lived code to add another device: on the new machine, run ssh dashboard@ironwire.sh, choose "link a device", and enter the code. More: connecting.
unlink <fingerprint-prefix>
Remove one of your SSH keys by fingerprint prefix. An ambiguous prefix gives ambiguous prefix "<prefix>" (matches <n> keys), and you can never remove your last way in: you can't remove your only way in — add another key or passkey first (a passkey on the account counts). More: connecting.
billing
Show your plan: how many units your pool holds, what that entitles, when it renews, and a link to the billing page where you manage your payment method, invoices and cancellation. If your subscription has lapsed, this also shows the date after which suspended machines are deleted.
subscribe
Reactivate an account whose subscription has lapsed. Prints a link to a checkout page; once you have paid, your suspended machines start again by themselves and the deletion deadline is cancelled. Everything comes back as it was: same handle, same keys, same disks.
units <n>
Change how many units your pool holds. Growing takes effect immediately and settles on your next invoice. Shrinking is refused when your current machines and disk would not fit the smaller pool; the message names what you would need to release first, because changing your plan never deletes your machines.
help
Aliases: -h, --help
Print the built-in command summary. Running with no command at all prints the same text.