capstan
v0.2.1
Published
Multi-provider VPS lifecycle library — Hetzner, DigitalOcean, Linode, Vultr behind one TypeScript interface.
Maintainers
Readme
capstan
Multi-provider VPS lifecycle library — Hetzner, DigitalOcean, Linode, Vultr behind one TypeScript interface.
npm i capstanimport { HetznerProvider } from 'capstan'
const p = new HetznerProvider({ token: process.env.HETZNER_API_TOKEN! })
// Provision
const vps = await p.createVPS({
name: 'my-server',
size: 'cx33',
region: 'fsn1',
sshKeyIds: [(await p.uploadSSHKey({ name: 'mykey', publicKey })).id],
userData: '#cloud-config\nruncmd:\n - echo hello > /work/hello\n',
})
// ... do stuff over SSH to vps.publicIPv4 ...
// Tear down
await p.destroyVPS(vps.id)Same code, different provider:
import { DigitalOceanProvider, LinodeProvider, VultrProvider } from 'capstan'
// Or pick at runtime:
import { createProvider } from 'capstan/registry'
const p = createProvider('digitalocean', { token: process.env.DO_TOKEN! })What it is
One typed Provider interface with four working backends. Every implementation:
- Authenticates via API token; reports a normalized
Account - Lists
Sizes andRegions with monthly pricing in EUR/USD cents - Uploads / lists / deletes SSH keys
- Creates / gets / lists / destroys VPSes with cloud-init
userData - Surfaces a normalized
ProviderError(withcode,status,retryable) - Quotes monthly cost up-front via
estimateMonthlyCost()
That's it. No deployment, no bootstrap-stage orchestration, no Worker semantics. Layer those on top.
What it isn't
- Not Terraform / Pulumi / OpenTofu. No state file, no diff, no plan. Just imperative provider calls.
- Not a CLI. Library only. (CLI tools that consume capstan: groundflare — extracting to depend on this; future internal Creek tooling.)
- Not a deployment tool. It provisions the box. What you do over SSH after is your problem.
Providers
| Provider | Account | Sizes/Regions | SSH keys | VPS lifecycle | User-data | Tests | |---|---|---|---|---|---|---| | Hetzner | ✓ | ✓ | ✓ | ✓ | ✓ | 33 | | DigitalOcean | ✓ | ✓ | ✓ | ✓ | ✓ | 17 | | Linode | ✓ | ✓ | ✓ | ✓ | ✓ | 22 | | Vultr | ✓ | ✓ | ✓ | ✓ | ✓ | 27 |
107 unit tests, all passing. No fetch goes out during tests — providers accept a fetchImpl injection.
Provider interface
interface Provider {
readonly name: ProviderName
readonly displayName: string
authenticate(token: string): Promise<Account>
listSizes(region?: string): Promise<readonly Size[]>
listRegions(): Promise<readonly Region[]>
uploadSSHKey(opts: SSHKeyOptions): Promise<SSHKey>
listSSHKeys(): Promise<readonly SSHKey[]>
deleteSSHKey(id: string): Promise<void>
createVPS(opts: ProvisionOptions): Promise<VPS>
getVPS(id: string): Promise<VPS | null> // null when missing
listVPS(): Promise<readonly VPS[]>
destroyVPS(id: string): Promise<void>
estimateMonthlyCost(opts: { size: string; region: string }): number
}See src/types.ts for the value types (Account, Size, Region, SSHKey, VPS, ProvisionOptions, ProviderError).
Ephemeral sessions (v0.2+)
For lab tooling and benchmark scripts — anything that spins up a VPS, does some work, and tears down — the boilerplate is the same every time: upload SSH key, create VPS, poll for IP, remember to clean both up on every code path including SIGINT. openEphemeralSession() collapses that into one call with automatic cleanup via await using:
import { openEphemeralSession, HetznerProvider } from 'capstan'
const provider = new HetznerProvider({ token: process.env.HETZNER_API_TOKEN! })
await using session = await openEphemeralSession(provider, {
name: `bench-${Date.now()}`,
size: 'cx43',
region: 'fsn1',
publicKey: myPublicKey, // you generate the keypair locally
userData: '#cloud-config\n...', // optional cloud-init
})
const ip = await session.publicIP() // polls if necessary
// ... ssh root@ip, run your work ...
// On scope exit: VPS is destroyed, SSH key is deleted.
// On createVPS failure: SSH key is rolled back automatically.
// Both cleanup steps are best-effort and never throw.If your codebase can't use await using (older targets, REPL), call await session.dispose() from a finally block.
Requires Node 22+ and TypeScript 5.2+ — same as capstan core.
Why "capstan"?
A capstan is the rotating drum on a ship used to hoist heavy things — anchors, sails, cables. This library hoists servers up and down. The metaphor lands.
Roadmap
0.1.x— provider abstraction (foundation)0.2.x— ephemeral session helper (this release):openEphemeralSession()+await usingcleanup0.3.x— cloud-init profile registry (generic Go/Node/Python boxes vs runtime-specific YAMLs)0.4.x— optional bootstrap-stage orchestrator (auth → ssh-key → provision → wait-ssh → cloud-init), lifted from groundflare- Provider additions opportunistic — Scaleway, OVH, Backblaze Compute, Fly Machines, etc.
License
MIT. See LICENSE.
Credit
Extracted from groundflare. The provider abstraction was built there, then split out to be useful beyond the "deploy a Cloudflare Worker on a VPS" use case.
