9pm.ai CLI
CLI for deploying built applications to 9pm.ai.
This package contains only client-side deploy code. It does not include the 9pm.ai control plane, infrastructure provisioning code, migrations, staging config, operator tokens, or smoke-test scripts.
Install
npm i -g ninepm # persistent `9pm` binary
npx ninepm <args> # run without installing
Configure
Authenticate in your own browser. The CLI stores the token in native protected storage: macOS Keychain, Windows DPAPI, or Linux Secret Service when available. If native storage is unavailable, plaintext file storage is opt-in with --allow-file-credential-store or NINEPM_ALLOW_FILE_CREDENTIAL_STORE=1.
9pm login
9pm whoami
9pm doctor
Running 9pm login again keeps a credential that still works instead of minting another; 9pm login --fresh replaces it and revokes the old one.
For CI, set NINEPM_API_KEY yourself in the CI secret store. --token and NINEPM_API_KEY still override the stored login token.
A key for CI or a server is minted from a signed-in machine with 9pm api-key create --name <name>; the token is printed once. 9pm api-key ls lists your active keys and 9pm api-key revoke <key-id> retires one. Each account holds at most 10 such keys at a time (login tokens do not count). Store the key under any name but NINEPM_* when it lives in a 9pm app's own environment, and pass it with --token.
Inventory
List the apps you have on 9pm.ai before deploying or modifying:
9pm apps
9pm apps --json
The slug column is the handle you pass to access, delete, and files --project.
Versioning
Check installed vs published CLI version:
9pm --version # installed
9pm latest # installed vs latest, prints update one-liner when behind
9pm doctor # surfaces the same drift line alongside credential/account health
Upgrade:
npm i -g ninepm@latest
Deploy
Deploy a static directory:
9pm deploy . --name my-app --slug my-app
Deploying again under the same slug replaces the live app. To try a change without touching it, deploy under a different slug, which creates a separate app at its own address. On Free, your one app slot can't also hold a separate preview app. Deploying to your existing app updates its live version. Practice mode rehearses the current app's workflow. Testing a changed version separately requires another app slot: a plan with more apps (Hobby allows 3).
How long an app's address stays the same
Check this before printing a link or QR code. The live URL is <slug>-<suffix>.9pm.ai, and the suffix comes from the app's internal id, not from the slug:
- Redeploying under the same slug keeps the address.
- Deleting the app and creating it again under the same slug gives it a new address, because the new app gets a new id. Anything printed with the old one stops working.
- There is no rename: a different address means a different app.
- A custom domain you own, added on the dashboard's Domains page, gives the app an address of your own. Not every plan includes custom domains.
Pages that do not exist
A static site or --entry worker app answers a path that has no file in one of two ways. Every deploy prints which, as a Missing pages: line.
--no-spa(the default for new apps): the path returns status 404, with the deploy's own404.htmlwhen it has one./still servesindex.html, and extensionless links work:/menuservesmenu.html, or redirects to/menu/when onlymenu/index.htmlexists, and/menu/servesmenu/index.html. Right for a multi-page site, where a removed or mistyped page should say "not found" rather than show the homepage.--spa: every path with no file servesindex.html, so a single-page app that routes in the browser loads on a deep link or a refresh. That includes dotted paths such as/users/jane.smithand missing files such as/missing.json, which get the app's shell rather than a 404.
9pm deploy ./dist --name my-spa --spa
The app remembers the choice: a deploy without either flag keeps it, and a deploy that fails to publish leaves the previous choice in place. Apps created before this choice existed keep serving index.html for every unmatched path until a deploy passes one of the flags. A worker app's own Response, a 404 included, is always returned as it is. The flags do not apply to --bundle or container apps.
Deploy a built framework output directory:
npm run build
9pm deploy ./dist --check
9pm deploy ./dist --name my-app --slug my-app
Deploy with a managed SQL database:
9pm deploy ./dist --name my-app --slug my-app --with-db
Attach an optional short description to the deploy (shown next to the version in the dashboard; up to 200 characters):
9pm deploy ./dist --name my-app --slug my-app --description "Fix login redirect"
9pm deploy ./dist --name my-app --slug my-app --owner acme # into an organisation you are an admin of
The CLI prints the public app URL after upload and finalization.
Store and share working files:
9pm files put ./report.pdf
9pm files share <file-id> --expires 24h
9pm files put ./notes.md --project my-app --path notes/release.md
9pm files share <file-id> --project my-app --public
Personal files and project files both count toward the account file storage limit. Share links expire and are capped at 7 days.
Share links are owner-only by default: the link opens only for you, signed in. Add --public when the recipient has no account — that link needs no sign-in, so anyone who obtains the URL can download the file until it expires. Do not use --public for anything containing personal data, credentials or a database export.
Deploy a first-class Worker runtime app:
9pm deploy ./examples/worker-app --name worker-app --slug worker-app --runtime worker --entry src/index.js --assets public --with-db
The --runtime worker entry must be a bundled fetch-compatible JavaScript module with a default export. With --with-db, the app receives env.DB and should use SQLite-compatible prepared statements.
Choose which files are public with --assets <dir>: only the files under that directory are served, from the app's root (public/index.html answers on /), and nothing else in the deploy directory is uploaded. Use --no-assets when the entry serves every response itself. The app remembers the choice, so later deploys need not repeat the flag; pass the other flag, or another directory, to change it — there is no way back to publishing everything. A worker app that has never chosen serves every file in the deploy directory except the entry publicly, server helpers and leftovers like index.js.orig included; every deploy of such an app prints a warning naming those files, and 9pm deploy --check lists every file a deploy would make public. Its summary counts the files scanned apart from the files selected for upload (the entry and the public files); --check itself uploads nothing.
Deploy a container app from a local Docker build:
9pm deploy . --check --runtime container --port 8080
9pm deploy . --name container-app --slug container-app --runtime container --port 8080 --health-path /health --region auto
Container deploys require Docker locally. The CLI builds a linux/amd64 image, exports it, checks image size against the per-app limit and account retained-image quota, uploads through a short-lived session, and finalizes a queued deployment. The container must listen on 0.0.0.0:$PORT - always read the PORT env var rather than hardcoding the selected --port, since on some placements the platform holds the selected port and hands the app a different one; set --health-path when the app has a cheap readiness endpoint.
Container CPUs are shared by default: each one can burst well above its baseline for a while, then is throttled to a fraction of a core once that burst allowance is spent. That suits a web app. Sustained CPU work - video or audio encoding, transcoding, ML inference - wants dedicated cores: add --cpu-kind performance. Each performance CPU comes with at least 2 GB of memory, costs noticeably more, and is available only on plans that include it; a plan without it refuses the deploy with plan_container_performance_cpus_unavailable.
Use --with-db when the container needs managed SQL. The app receives NINEPM_SQL_URL and NINEPM_DB_BINDINGS; send POST requests to NINEPM_SQL_URL with { "binding": "DB", "sql": "SELECT 1", "params": [] }. Some placements also set NINEPM_SQL_TOKEN — when it is present, send Authorization: Bearer <that token> on every request, or the bridge refuses them all with 401 {"error":"unauthorized"}. Read the URL and the token from the environment on each call rather than hardcoding either: the URL differs between placements, and the token is reissued on every deploy. For local development, use the app's normal local database adapter behind a repository layer and keep deployed SQL compatible with SQLite.
Regions are provider-neutral: auto, us-east, us-west, europe, asia, south-america, middle-east, oceania, africa.
When a container app fails to come up or you want to investigate it after the fact, retrieve its recent logs:
9pm logs my-app # recent container logs + the failure reason, if any
9pm logs my-app --lines 50 # last N lines
9pm logs my-app --json # machine-readable
9pm logs reports the latest container deployment's status, health, the failure reason, and the captured startup output when a deploy failed to come up. It is read-only and does not affect deploys.
Delete an app, its managed database, managed container app, and retained image:
9pm delete my-app --confirm my-app
The app's address goes with it: creating an app again under the same slug gives it a new address, so printed links and QR codes stop working. 9pm delete prints this warning before it deletes anything.
9pm delete also prints Its address stops serving the app within about a minute. when the delete went the way whose timing has been measured: a static or Worker app, or a container app (including one with a volume, always-on or custom CPU/memory), with no custom domain. Not every container app qualifies, so go by the line, not the app type. When the line is missing (an app with a custom domain, some container apps, or a cleanup step that reported an error), how long the address keeps serving has not been measured, so check the address yourself.
Access Modes
Apps are public by default.
Changing an app's access mode needs --switch-mode. Without it the command changes nothing and prints what the change would do (who loses access, what happens to the app's custom domains) and the command to re-run. Service-token mode locks every person out and removes the app's custom domains; leaving it sets them up again with new DNS records to publish. Creating another service token on an app already in service-token mode needs no flag.
One-time PIN with email/domain allowlist:
9pm access allow email my-app [email protected]
9pm access allow domain my-app example.com
9pm access otp my-app --switch-mode
9pm access revoke email my-app [email protected]
9pm access revoke domain my-app example.com
Identity provider access:
9pm access idp my-app --provider google --switch-mode
Machine access:
9pm access service create my-app --name backend --switch-mode
Return to public:
9pm access public my-app --switch-mode
Create an app-scoped runtime token for server-side allowlist management:
9pm access token create my-app --name production-app-server
Use 9pm_app_... runtime tokens only from server-side code. They can list, add, and remove email/domain grants for one app. They cannot deploy, create databases, create API keys, or manage another app.
Service token secrets returned by access service create are also server-side secrets.
Custom Domains
Connect a domain you own to an app:
9pm domains add my-app www.example.com
This prints the DNS records to publish at your DNS provider, with names relative to your
domain (www, _acme-challenge.www) and what DNS shows for each right now. When a TXT value
is missing, add it as its own TXT record; other TXT values on that name can stay. The root of
your domain (@) cannot hold a CNAME, so a root domain is shown as an ALIAS / ANAME record when
your DNS host is not one the CLI recognises (a recognised host's own name for it otherwise): add it as
ALIAS or ANAME (or a CNAME your provider flattens). A correct ALIAS answers with addresses, which
cannot be compared from outside, so that row follows the platform's own routing check. Then:
9pm domains wait my-app www.example.com
wait exits 0 once the domain is live, 10 if it timed out while you have something to do (a
record is missing or could not be checked, or a CAA record blocks the certificate even though every
record is found), 11 if it timed out with every record checked and found and nothing left for you
(the platform is still finishing; wait again), 20 if the domain failed or expired, and 1 on a
usage, sign-in or API error. Since 0.74.0 a CAA block exits 10; earlier versions exited 11. Since 0.77.0 a bare domain whose DNS host cannot point a root
domain at a hostname (GoDaddy, Route 53, IONOS, Wix) makes wait stop at once with 10: its next
is use_www with the www hostname to add instead, since no record at the root can finish it.
9pm domains status <app> <hostname> --json prints {hostname, app, state, reason, next, routing, tls, provider, providerId, nameservers, zone, records, advisories, error, expiresAt, checkedAt}, where provider is
the DNS host (where the nameservers point, not necessarily where the domain was bought), reason says why
(reason.providerMessage carries the provider's own words when it reported any, with source
saying whether they are the certificate authority's),
next says who acts next and what to do, and state is live, ready_to_switch,
waiting_for_dns, dns_unverified (routing is active but the records could not be checked),
issuing_certificate, points_elsewhere, failed or expired. Each record's ok is null when the check cannot
say, and its check says why (not_checked, or apex_addresses). A record whose value was found
at the name with the domain typed twice (_acme-challenge.www.example.com.example.com) has
check: "doubled" and doubledAt: enter only the short name shown. Since 0.79.0, against a server
that sends them: a record answered by a wildcard record of the DNS host (*.example.com) rather
than one of its own has check: "wildcard" and wildcard, and the row says to add a record at that
name (the wildcard can stay; a TXT value is always added beside the values already there);
resolvers lists what each public resolver answered when two were asked, and when one shows the
expected value and another does not, the record is check: "resolvers_disagree" with ok: null:
neither cache proves which answer is current, so confirm the record at your DNS host and check again.
A record that does not match carries ttlSeconds, how long that answer may stay cached. In
9pm domains check output (0.95.0 or later; the server reads the host directly only for one
domain's Re-check, so list, status and wait never carry it), a record whose DNS host is
GoDaddy, Namecheap, Google Cloud DNS or NS1 can carry authoritative: what the host's own nameserver answered when asked directly
(outcome: "answered" with nameserver, current and matches, or outcome: "unverified" when
that read failed). The row then says "your DNS host publishes …", or "your DNS host: unable to
verify directly"; a failed read never means the record is missing. It only explains the row: ok,
check and the state stay the public resolvers' verdict either way. Other hosts get no such
field. A root
answering with addresses while routing is pending asks you to confirm the ALIAS or ANAME target,
since a parking page answers with addresses too. With every record found, the text names the
platform's next automatic check from next.nextCheckAt. advisories lists hints that
the hostname may be a typo, such as a domain that is not in DNS; the domain is added either way.
Since 0.78.0 each record also carries typeAtProvider, nameAtProvider ("" means leave the Host
field empty) and valueAtProvider: the record as a recognised DNS host's editor wants it.
typeAtProvider is null for a root record the host cannot hold (GoDaddy, Route 53, IONOS, Wix);
the text output then suggests connecting www instead. list, status and wait
only read; add, check, restart, remove and move need permission to configure the app.
Since 0.89.0, against a server that sends them, two states cover moving a domain between hosts.
ready_to_switch: routing and the certificate are active, so the platform could serve the domain,
but its routing record still leads to another host and visitors still reach that host. The text
names the record and the value to change it to, next is replace_record, and wait keeps
waiting until it is live. points_elsewhere: the domain was live and its DNS now leads to another
host, so the provider stopped routing it. next is remove: remove the domain here once visitors
reach the new host. If the move was a mistake, change the record back and run 9pm domains check.
wait stops at once with 10 on this state. The provider removes the domain from its configuration if it
stays moved for 7 days. This does not affect your domain registration.
Since 0.101.0, against a server that sends it, waiting_for_reconnection ("DNS points back to 9pm;
waiting for reconnection") is the way back: the routing record leads here again but the provider
still has the hostname disconnected after the move. Nothing in your DNS needs changing; the text
prints check first, then wait, which keeps polling through this state (timing out with 11),
and then restart as a fallback for a domain that stays disconnected, not a required step. A
server from before then calls the same domain failed with reason.code moved_needs_restart;
this CLI reads that as waiting_for_reconnection when the record is found. When the record could
not be confirmed, the state stays failed with moved_needs_restart and next.action recheck.
That doesn't show whether you want the domain back: if you want to reconnect it, make sure the
record points here and re-check; if you moved it to another host on purpose, remove it once
visitors reach the new host. wait keeps polling through that too (timing out
with 10). A blocked or deleted hostname never gets either. Either state's routing record carries elsewhere, the host it leads to. The platform reports
these only on strong evidence: two public resolvers agree on a record of the name's own that leads,
through its whole CNAME chain, to a host outside both your zone and the platform. A root answering
with addresses, a wildcard's answer, resolvers that disagree, or a check that could not run leave
the domain in the state it had (live, or failed for one the provider stopped routing). An older
CLI prints these state names as they are.
9pm domains remove prints only the records the API says the domain used, each to delete only if
its value is unchanged: a name that now holds a different value (the domain points somewhere new, or
another service's certificate check uses _acme-challenge) is not ours, so leave it. Against a server
that sends no such list it prints no deletion advice at all. When the removal is still finishing
upstream (the API answers 202 with releasing: true), it says so, names no record to delete, and
prints the 9pm domains list command: once the domain is no longer listed, it has stopped serving
from 9pm. Such a domain shows as removing in list and status ("removing": true in --json),
and wait on it exits 20. Since 0.80.0.
Since 0.86.0, a removal that finished (from remove or 9pm delete) also says that after removal,
visitors may see a TLS or routing error while the domain's DNS still points at 9pm. It names no
record and adds no deletion advice of its own: what may be deleted is only the cleanup list printed
above it, each record only if its value still matches. A removal still finishing gets no such line,
since it is told to leave its records in place. A server that sends no cleanup list still gets it.
Since 0.87.0, that line is followed by the order the staging rehearsal (#2182) demonstrated for a
move to another host: point DNS at the new host, wait for the DNS TTL to pass, then remove the domain
from 9pm. While caches update, some visitors may still reach 9pm or briefly see an error from the new
host, and once DNS points away 9pm shows the domain as pointing elsewhere (before 0.89.0, or
against an older server, as failed), which is expected. It links the
custom-domain guide's "Moving a domain off 9pm" section on the site beside the control plane it
talked to (staging's guide for --api-url https://api-staging.9pm.ai). A move to your own Cloudflare
DNS zone was not rehearsed, so no order is printed for it; the guide says to contact support first.
Since 0.84.0, 9pm domains removed [--owner <organisation>] lists the DNS records you may delete
after a removal that finished in the background: a remove that answered releasing, or a domain of
a deleted app, finished later by the platform. It is the same list remove prints when it finishes
itself, under the same rule (delete a record only while it still has the value shown), and the
printed hint after a still-finishing removal or 9pm delete points at it, with --owner and
--api-url kept. A removal is listed for 30 days after it finishes, only to the account that owned
the domain, and not at all once the hostname has been added again anywhere: the server checks the
certificate provider for it when you run the command, and a domain it couldn't check is named without
its records, with the command to ask about it again. The server checks ten domains per page; the
command reads up to 20 pages and says so if more remain. --hostname <hostname> asks about one
domain only. --json prints the API's answer with its pages merged (next is null once every page
was read). Against a server without the route it says it can't list any, prints no record and exits 1.
Since 0.83.0, 9pm domains cleanup <app> <hostname> lists the setup TXT records 9pm issued for a
live domain and no longer needs, such as an earlier _acme-challenge value or the
_cf-custom-hostname ownership record (--json prints the API's answer). Each may be deleted only
while it still has the value shown; a different value at that name is not ours. The list is checked
against the certificate provider when you run the command: a value the provider still asks for is
never in it, a domain that is not live lists nothing, and if the provider cannot be read, or the
domain changes while it is being checked, the command fails and lists nothing; run it again. Only values the API issued are candidates, never anything read from public
DNS. 9pm domains status on a live domain prints the command. 9pm domains remove lists these values
with the rest of the domain's records, since once the domain is removed none of them is needed.
After 9pm domains restart, the values it says no longer count are worded the same way. Against a
server without the route, cleanup says it can't list any and exits 1. Values replaced before the
server kept them cannot be listed.
9pm domains move <hostname> --to <app> moves a domain to another app of the same account. It keeps
its certificate and the DNS records already published, so nothing is validated again. Without a
source app the CLI looks it up in the account; name it (9pm domains move <app> <hostname> --to <app>) to skip that. Each refusal is printed with what to do: the domain or the app is not found,
the destination is at its custom-domain cap, a removal of the domain is still finishing, the domain
was disconnected under a service-token app or was never provisioned (remove it and add it to the
other app), or the destination is in another account. remove --json and move --json print the
API's answer as it came. Since 0.80.0.
Every 9pm deploy lists the app's custom domains under its Deployed line: Also serving at https://<domain> for a live one, and for any other its state and the 9pm domains status
command to run. If the domains cannot be read within a few seconds, the deploy says so in one
line instead; the deploy itself is never held back by it. 9pm delete names the custom domains
it removed and, since 0.80.0, the records the API says each one used (under the same
value-unchanged rule); a domain still being released is reported as such, with no records to delete,
and none can be listed for it later.
Commands
9pm --version
9pm deploy [directory] --name <name> [--slug <slug>] [--spa | --no-spa] [--with-db]
9pm deploy [directory] --check [--runtime worker --entry <path> (--assets <dir> | --no-assets)]
9pm deploy [directory] --runtime container --port 8080 [--health-path /health] [--region auto] [--with-db]
9pm signup
9pm login [--fresh]
9pm logout [--api-url <url>] [--local]
9pm doctor [--json]
9pm whoami
9pm apps [--json]
9pm latest [--json]
9pm files list [--project <site>]
9pm files put <local-path> [--path <remote-path>] [--project <site>]
9pm files get <file-id> [--output <path>] [--project <site>]
9pm files share <file-id> [--expires 24h] [--public] [--project <site>]
9pm files delete <file-id> [--project <site>]
9pm deploy [directory] --runtime worker --entry <path> (--assets <dir> | --no-assets) [--spa | --no-spa] [--with-db]
9pm delete <site> --confirm <slug>
9pm domains list [<app>] [--owner <organisation>] [--json]
9pm domains add <app> <hostname> [--json]
9pm domains status <app> <hostname> [--json]
9pm domains check <app> <hostname> [--json]
9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--json]
9pm domains restart <app> <hostname> # after a certificate failed, or as a fallback for a moved domain that stays disconnected; record values may change
9pm domains remove <app> <hostname> --confirm <hostname> [--json]
9pm domains move [<app>] <hostname> --to <app> [--json]
9pm domains removed [--hostname <hostname>] [--owner <organisation>] [--json] # records to delete after a removal that finished in the background
9pm access show <site>
9pm access public <site> [--switch-mode]
9pm access otp <site> [--switch-mode]
9pm access idp <site> [--provider google|github|entra] [--switch-mode]
9pm access allow email <site> <email>
9pm access allow domain <site> <domain>
9pm access revoke email <site> <email>
9pm access revoke domain <site> <domain>
9pm access service create <site> [--name <name>] [--duration 8760h] [--switch-mode]
9pm access service revoke <site> <service-token-id>
9pm access token create <site> [--name <name>]
9pm access token revoke <site> <token-id>
Options:
--api-url <url>overridesNINEPM_API_URL--token <key>overridesNINEPM_API_KEY--allow-file-credential-storepermits plaintext fallback storage forloginandsignupwhen native protected storage is unavailable--name <name>sets the app display name--slug <slug>sets the URL-safe app slug--with-dbrequests managed storage for the app--with-authgives the app 9pm-managed end-user sign-in (email one-time codes) and implies--with-db. Once enabled it stays on for every later deploy of the app, with or without the flag; it can't be turned off--database-name <name>customizes the requested database name--binding <name>customizes the database binding name--environment <name>sets deployment environment metadata--runtime <static|worker|container>selects static asset hosting, app-owned Worker code, or local Docker container deployment--entry <path>selects the Worker runtime server entry file--assets <dir>makes only that directory of a worker deploy public (remembered for later deploys)--no-assetsmakes no file of a worker deploy public (remembered for later deploys)--spaservesindex.htmlfor every path that has no file, for single-page apps (remembered for later deploys)--no-spareturns 404 for a path that has no file, with the deploy's404.htmlwhen present; the default for new apps (remembered for later deploys)--port <port>selects the HTTP port exposed by a container app--health-path <path>optionally lets 9pm.ai check container health before marking it healthy--region <region>optionally selects provider-neutral container placement--cpus <n>and--memory <size>(512m,2048,4g) size a container app--cpu-kind <shared|performance>picks the container's CPU class:shared(default) bursts and is throttled under sustained load;performanceis dedicated cores, on plans that include it--project <site>scopes file commands to an app--path <remote-path>stores a file at a specific path--output <path>writes a downloaded file to disk--expires <duration>sets share-link expiry, capped at 7 days--publicmakes a share link downloadable by anyone with the URL, no sign-in (share links are owner-only without it)--confirm <slug>confirms app deletion by requiring the slug--provider <google|github|entra>selects a configured identity provider--duration <duration>sets a service token duration
Access command options:
--api-url <url>overridesNINEPM_API_URL--token <key>overridesNINEPM_API_KEY--name <name>names an app-scoped runtime token--expires-at <iso-date>optionally expires an app-scoped runtime token
Safe API-key rotation
Use a full-access personal login. 9pm api-key rotate <key-id> creates a linked
replacement and prints its token once. Store it securely and switch every consumer,
then run 9pm api-key rotation complete <rotation-id> --yes to revoke the original.
rotation show inspects deadlines; rotation cancel revokes the replacement.
All support --json. Seven days is the maximum overlap, further capped by the
original fixed expiry. Forgetting Complete disables the replacement and leaves
the original's existing deadlines unchanged.
Ordinary api-key revoke warns if a replacement survives. For incident containment,
9pm api-key revoke <original-key-id> --both --rotation <rotation-id> revokes exactly
that pair, even after Complete. Independently minted credentials and later rotations
remain separate: review all keys created during a compromise. Unsupported servers
fail clearly, with no mint/revoke fallback. Never put tokens in reports or logs.