Install/run via npx (no global install required). Three bins are registered: launchpad,
launch-pad, and the short alias lpd.
npx @agentsystemlabs/launch-pad <command>Commands: init · doctor · deploy · destroy ·
scale · config · status · logs · job ·
secret · dns · node · project · cluster
Available on every command (before or after the subcommand):
| Flag | Description |
|---|---|
--profile <name> |
AWS profile to use |
--region <region> |
AWS region (defaults to AWS config) |
--cluster <name> |
Target cluster (default: your local default cluster, else default) |
--json |
Machine-readable JSON (no banner/spinners) |
--verbose |
Verbose output; stack traces on error |
--no-color |
Disable colored output |
-V, --version |
Print version |
-h, --help |
Command help |
AWS-touching commands print a cluster: <id> banner so you always know which cluster a
command targets.
In --json mode a failing command emits {"error": "...", "hint": "..."} to stdout
(plus the non-zero exit code) — human-readable stderr logging is suppressed, so this is
the only way automation sees why a command failed.
Create a launch-pad.toml in the current directory.
launchpad init [options]| Flag | Description |
|---|---|
--name <name> |
Project and service name |
--domain <domain> |
Public domain (makes this a web service) |
--port <port> |
Container port |
--dockerfile <path> |
Path to Dockerfile (default ./Dockerfile) |
--cpu <shares> |
CPU in vCPU shares (1024 = 1 vCPU) |
--memory <mb> |
Memory in MB |
-f, --force |
Overwrite an existing config |
Run interactively, init detects your project to seed smarter defaults: it reads the
Dockerfile's EXPOSE port and your package.json for a known web framework (Express, Next.js,
Fastify, NestJS, Astro, …), then defaults the "is this a web service?" and port prompts
accordingly (the Dockerfile EXPOSE wins over a framework default). Pass the flags above to skip
prompts entirely (CI / scripted use).
Preflight your environment before the first deploy (and before any spend). Runs read-only
checks and reports pass / warn / fail for each — it provisions nothing.
launchpad doctor # check the default region
launchpad doctor --region us-west-2 # check a specific region
launchpad doctor --json # machine-readable (for CI)| Check | What it verifies |
|---|---|
| Docker + buildx | docker buildx is installed and the daemon is reachable |
| AWS credentials & region | your identity resolves (STS) and a region is configured |
| S3 state bucket | the per-account/region state bucket is reachable (warn if it doesn't exist yet — it's created on first deploy) |
| ECR access | an ECR authorization token can be obtained |
| default VPC | a default VPC exists in the region (custom networking isn't supported yet) |
| golden AMI | a golden AMI is available for the region (warn if not — nodes full-bootstrap AL2023, a slower first boot) |
If AWS credentials/region can't be resolved, the AWS-dependent checks are skipped (not
failed). Exit code is non-zero when any check fails (warnings and skips don't fail), so
launchpad doctor is safe to gate a CI pipeline on.
Run with no subcommand for the guided first-run bootstrap; the subcommands generate
copy-paste IAM + CI templates so you don't have to attach AdministratorAccess.
launchpad setup # guided default-cluster bootstrap (interactive)
launchpad setup --region us-west-2 --yes # scriptable, no prompts
launchpad setup --cluster prod --region us-east-1 --yes # also set up a named cluster| Flag | Description |
|---|---|
--region <region> |
Region to bootstrap (skips the region prompt) |
--cluster <name> |
Set up a named cluster (saves a ~/.launch-pad target + cluster.json); default is the implicit default cluster |
--yes |
Skip the confirmation prompt (required in CI / non-interactive) |
It resolves your AWS account (via STS), picks a region (prompted on a TTY, or --region),
and creates the account+region state bucket if missing (idempotent — your app data is
untouched), then prints next steps. For the implicit default cluster it saves nothing
locally (it runs on ambient AWS creds); its value is ensuring the bucket exists so the first
deploy doesn't 403. For a --cluster <name> it also saves the local target and writes
cluster.json (equivalent to cluster create). Interactive on a TTY; fully scriptable with
flags + --yes.
Print a least-privilege IAM policy for the operator (the human or CI principal that runs
launch-pad). It grants exactly the permissions deploy/provision/manage need, scoped to the
launchpad state bucket, ECR repos, the launch-pad-node-* IAM roles, /launch-pad/* secrets,
CloudWatch Logs, and a single region (an aws:RequestedRegion condition on EC2).
launchpad setup iam-policy # for your current account + region
launchpad setup iam-policy --json > operator-policy.json # just the document
launchpad setup iam-policy --account 111122223333 --region us-west-2 # offline (no AWS call)
# Then create + attach it:
aws iam create-policy --policy-name launch-pad-operator \
--policy-document file://operator-policy.json
aws iam attach-user-policy --user-name <you> \
--policy-arn arn:aws:iam::<account>:policy/launch-pad-operatorWith both --account and --region it runs fully offline; otherwise it resolves them from
your current identity (STS). The policy is region-scoped — generate one per region you
deploy to. It is sized to fit a single managed policy (≤ 6144 chars). The policy is for a
trusted operator: it can create/manage the launch-pad-node-* roles and pass them to EC2,
so don't hand it to an untrusted principal without adding an IAM permissions boundary.
Verified end-to-end against real AWS:
pnpm e2e:operator-iammints a temp IAM user with only this policy and runs a full provision → deploy → destroy under it (and asserts it can't act outside its scope or region).
Print a GitHub Actions OIDC trust policy + a ready-to-commit deploy workflow, for keyless CI deploys (GitHub Actions assumes an IAM role via OIDC — no long-lived access keys in repo secrets).
launchpad setup github-oidc --repo acme/widgets # branch main (default)
launchpad setup github-oidc --repo acme/widgets --branch release
launchpad setup github-oidc --repo acme/widgets --json # both artifacts as one JSON object| Option | Effect |
|---|---|
--repo <owner/name> |
(required) the GitHub repo allowed to assume the role |
--branch <name> |
pin the role to one branch (default main) — only that branch's workflow can assume it |
--all-branches |
allow any ref (branches, tags, and pull requests, including from forks) — broader; prefer a pinned branch |
--role-name <name> |
IAM deploy-role name (default launch-pad-deploy) |
The trust policy pins the audience to sts.amazonaws.com and the subject to your repo/branch,
so no other repository can assume the role. Pair the role with setup iam-policy for its
permissions:
aws iam create-open-id-connect-provider --url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com # once per account
aws iam create-role --role-name launch-pad-deploy \
--assume-role-policy-document file://launch-pad-oidc-trust.json
aws iam attach-role-policy --role-name launch-pad-deploy \
--policy-arn arn:aws:iam::<account>:policy/launch-pad-operatorThe generated .github/workflows/deploy.yml is keyless (OIDC), runs on a push to your
branch (and workflow_dispatch for manual runs), and is concurrency-guarded — deploy is
CAS-protected against concurrent writers, so the workflow runs one deploy per ref at a time and
cancels a superseded run rather than racing it. The steps are: checkout → assume the role via
OIDC → set up Docker Buildx → npx @agentsystemlabs/launch-pad deploy --yes.
Caching guidance (CI build speed):
- Pin the CLI version for reproducible deploys — replace
@agentsystemlabs/launch-padwith@agentsystemlabs/launch-pad@<version>in thenpxstep (an unpinnednpxfloats to latest). - Cache the CLI download if your repo has a
package-lock.json: addcache: npmto theactions/setup-nodestep (the generated workflow leaves it commented because the cache needs a detectable lockfile). - Docker build time usually dominates.
deployruns Buildx internally, so the biggest lever is your Dockerfile layer order — copy dependency manifests and install deps before copying source, so an unchanged-deps build reuses cached layers. (An unchanged app still produces the same content-addressed ECR tag, so the push is a no-op, but the local image is rebuilt.) - For heavy/frequent builds, a self-hosted runner (warm Buildx + Docker layer cache on disk) or a registry-backed Buildx cache is the next step.
Build Docker images, push to ECR, and publish desired state to S3. Auto-provisions missing
nodes and resumes paused ones (with confirmation unless --yes), repairs EC2 console drift
before publishing, and waits for the agent to report convergence.
Placement is automatic: the scheduler bin-packs services across the cluster's app nodes
by free CPU/memory, and every web domain routes through the cluster's dedicated edge node
(every cluster is at least 2 nodes — the edge + ≥1 app node). Deploy handles the node-pool
gaps itself: it bootstraps an empty cluster (the edge-1 edge, default t4g.nano, plus
a first auto-sized app node) and auto-adds app nodes (generated <noun>-<verb>-<adverb>
names) when the current pool can't
fit the deploy (e.g. after a replica scale-up) — both spend-gated like any provision, and
both disabled by --no-create.
launchpad deploy [options]| Flag | Description |
|---|---|
--service <name> |
Deploy only this service |
--changed <ref> |
Deploy only services whose build context/Dockerfile changed since this git ref (monorepo CI) |
--env <name> |
Named environment: projects domains + namespaces the footprint |
--ttl <duration> |
Env lifetime (30m/72h/7d) — destroy --prune-expired tears the env down after it. Requires --env |
--no-create |
Fail if a needed node is missing (disables edge/app bootstrap + capacity auto-add) |
--no-repair |
Fail on EC2 console drift instead of repairing |
--no-recreate |
Repair stopped nodes but fail on terminated instances |
--no-wait |
Don't wait for agent convergence |
--timeout <seconds> |
Convergence timeout (default 180) |
--yes |
Skip confirmation prompts (required for auto-provision in CI) |
--dry-run |
Plan only — no image push, S3 writes, or node creation |
--ami <id> |
AMI id for auto-provisioned/recreated nodes |
--restart |
Skip build/push; re-publish desired state and roll containers |
--allow-new-services |
Permit new [[service]] blocks (e.g. add admin to an existing footprint) |
--image <uri> |
Skip build/push; redeploy an existing ECR tag of one --service (rollback / promote) |
--remote-build |
Build images on AWS CodeBuild instead of local docker (slim CI runners) |
launchpad deploy
launchpad deploy --service web --no-wait
launchpad deploy --changed origin/main --yes # CI: deploy only what changed
launchpad deploy --remote-build --yes # CI runner without a docker daemon
launchpad deploy --env staging
launchpad deploy --env pr-123 --ttl 72h --yes # PR preview that auto-expires
launchpad deploy --yes # CI
launchpad deploy --dry-run
launchpad deploy --restart --service api # roll containers after a secret rotation
launchpad deploy --allow-new-services --service admin # add a new service to an existing footprint
launchpad deploy --service web --image <uri> # redeploy an existing tag (rollback)--changed <ref> is first-class "deploy changed services only" for monorepos. It runs
a git diff between <ref> and your working tree (committed, uncommitted, and untracked files
all count, because they all land in the image a rebuild would push) and deploys only the
services whose build inputs changed — i.e. a changed file lives under the service's docker
context directory, or is its dockerfile. Unchanged services keep their previously-published
image. Wire it into CI as launchpad deploy --changed origin/main --yes (or --changed ${{ github.event.before }}). With no service changed it's a clean no-op that exits 0, so
a docs-only commit doesn't fail the deploy job. Config-only edits (cpu/replicas/env in
launch-pad.toml) are not build inputs — use scale / config set
or a full deploy for those. Mutually exclusive with --service, --image, and --restart.
A --changed (or --service) deploy is a partial deploy: it upserts into each node's
desired state, preserving the project's other services co-located on the same node (it does not
republish the whole footprint), so deploying one service never tears down its siblings.
--image <uri> redeploys an existing immutable ECR tag of one --service without
building — for rolling back to a known-good build or promoting a tested one. The URI must be a
tagged image in that service's own ECR repo (<project>/<service>:<tag>) and the tag must
already exist; the service must already be deployed (it re-rolls in place, health-gated).
Container config (cpu/memory/replicas/env/secrets) still comes from the current
launch-pad.toml, so the config lock applies as usual.
Re-running with the same image is an idempotent no-op (no container churn). Mutually exclusive
with --restart. ECR keeps every immutable tag, so any prior build is always available to roll
back to — this is why destroy deliberately leaves images in place.
--remote-build builds every image on AWS CodeBuild instead of local docker — for slim
CI runners (or laptops) with no docker daemon. Per service, deploy packs the build context into
a tarball, uploads it under the footprint's builds/ prefix in the state bucket, and runs one
build in a per-cluster CodeBuild project (launch-pad-build-<cluster>) that produces the
same immutable, content-addressed architecture-matched tag the local buildx path would
(linux/arm64 on Graviton, linux/amd64 on x86). Everything
after the build — merge, publish, convergence watch — is identical, and an image already in ECR
skips its build the same way.
ARM builds run on CodeBuild's ARM container environment so Dockerfile RUN steps execute
natively instead of trying to emulate the target platform.
The tarball honors .dockerignore for what gets uploaded: literal paths, root-level globs
(*.pem, .env*), and any-depth **/-prefixed patterns are excluded from the upload — so the
glob patterns people guard secrets with keep those files out of S3, exactly as docker keeps
them out of the build. Unsupported glob shapes (and everything, when a !negation makes
exclusion unsafe for the build) upload anyway but are still ignored by docker remotely — the
full .dockerignore ships in the tarball. Anything truly sensitive should not live in the
build context at all (use secrets).
First use creates the CodeBuild project plus a least-privilege service role
(launch-pad-codebuild-<cluster>) that can only read its own cluster's builds/ tarballs
(never desired.json/status.json), push to ECR, and write its own build logs;
cluster destroy removes project, role, and log group. The uploaded tarball is deleted after
each build. The dockerfile must live inside its build context (the tarball is all
CodeBuild sees). On a failed build the CLI prints the failing command's log context.
429 Too Many Requests on FROM node:…). The
buildspec retries the build up to 3× with backoff, but for reliable remote builds prefer AWS's
mirror of the official images — e.g. FROM public.ecr.aws/docker/library/node:24-alpine —
which has no rate limit from CodeBuild.
CodeBuild bills per build minute (small Linux instances; expect ~$0.01–0.03 per typical
build). Mutually exclusive with --restart / --image, which skip building entirely. Wire it
into CI as launchpad deploy --remote-build --yes.
--env <name> is a named (parallel) environment — staging, develop, a PR preview. The footprint becomes
<project>-<env> (coexisting with prod on the same nodes), every web domain is projected
— via the service's domainPattern ({env}/{service} tokens), or by suffixing the first
label (app.example.com → app-pr-123.example.com). DNS stays yours to configure: one
wildcard DNS-only A record at the edge's Elastic IP (e.g. *.example.com → <edge EIP>)
covers every projected env subdomain — the deploy's DNS panel prints the exact targets (and
the wildcard, when a domainPattern makes one possible), and
dns verify checks them. Each --env deploy also writes an env marker
(projects/<project>-<env>/preview.json) recording the env's domains and — with --ttl —
an expiry deadline. destroy operates
on those markers; --ttl on a later re-deploy re-arms the deadline (a re-deploy without
--ttl keeps the existing one).
The inverse of deploy: remove a deployment — the whole base footprint, one of its services,
or a named environment. The agent on each node stops the containers on its next poll.
Infrastructure teardown stays separate: node destroy for EC2 nodes,
cluster destroy for whole clusters.
This is also the sanctioned way to drop a service the
config lock otherwise freezes: deleting a [[service]] block
and re-deploying aborts with "service removed", but destroy removes it cleanly and trims
the config baseline so a follow-up deploy of the edited launch-pad.toml passes the lock.
launchpad destroy [options]| Flag | Description |
|---|---|
--service <name> |
Destroy only this service (default: the whole project footprint) |
--env <name> |
Destroy a named environment created by deploy --env: containers + S3 state |
--project <name> |
With --env: scope the env teardown (when several projects share the env name). Alone: destroy all of the project's components — every base + env footprint and the component registry (TOML-less). Also filters --list-envs |
--component <name> |
Component the env belongs to (when one project's components share an env name; also filters --list-envs) |
--list-envs |
List the cluster's environments (project, component, env, expiry, domains) instead of destroying |
--prune-expired |
Destroy every env whose deploy --ttl deadline has passed (dry-run without --yes; cron-able) |
--purge-secrets |
Also delete the removed services' SSM secrets (irreversible; off by default) |
--no-wait |
Don't wait for the agent to stop the containers |
--timeout <seconds> |
How long to wait for the containers to stop (default 120) |
--yes |
Skip the confirmation prompt (required with --json for --env / --prune-expired) |
launchpad destroy --service worker # remove one service, keep the rest
launchpad destroy # remove the whole base footprint
launchpad destroy --env pr-123 --yes # tear a PR env down (containers + state)
launchpad destroy --list-envs # what environments exist?
launchpad destroy --prune-expired --yes # reap every TTL-expired env (cron/CI)
launchpad destroy --service api --purge-secrets
launchpad destroy --project shop --yes # whole logical project: every component + env
launchpad destroy --project shop --env pr-7 --yes # one env across ALL of shop's componentsBase footprint (no --env) requires a launch-pad.toml in cwd (or a parent):
- Single service: drops it from every node's
desired.jsonand trims the baseline to the remaining services. Afterward, delete its[[service]]block fromlaunch-pad.toml. - Whole footprint (no
--service): removes every service and clears the baseline, so the nextdeployis a fresh first deploy with identity unlocked again. - DNS is never touched — it's yours to manage at your provider.
Named environment (--env <name>) is marker-driven — it works without a
launch-pad.toml in cwd, so a PR-close job can run it from anywhere:
- Undeploys the env's whole footprint, waits for the drain, and sweeps its
projects/state (marker, deploy events, baseline). DNS is never touched — a wildcard record keeps covering the envs that remain, and per-env records are yours to remove at your provider. - Only marker-backed environments are eligible — the base project's footprint can never be
destroyed via
--env, and other footprints co-located on the same nodes are never touched (the teardown reuses the ownership-scoped undeploy planner). --service <name> --env <name>removes one service from the env's footprint (needs thelaunch-pad.toml, like a base partial; leaves the marker alone).- With components (federated multi-repo deploys), the env teardown is scoped to the cwd
TOML's component by default;
--component <name>disambiguates without a TOML, and an explicit--project <name> --env <name>destroys that env across all components.
Whole project (--project <name>, no --env) is registry-driven and TOML-less: it
reads the project's component index (written by every deploy), destroys each component's
env footprints (marker-driven) and base footprint, sweeps their projects/ state, and
finally deletes the index. A partial failure keeps the registry so a retry can finish.
Other projects' services on shared nodes are never touched.
--prune-expired is one cron-able reconcile pass (no daemon — same model as
autoscale run): destroy every env whose --ttl deadline has passed, keep the rest. Without
--yes it's a dry run that only lists the expired envs. Envs deployed without --ttl never
expire. A failed teardown keeps the env's marker, so the next pass retries it. In --json
mode it requires --yes (it destroys environments — automation must be explicit).
- ECR images are kept in every mode — immutable + content-addressed, they cost almost
nothing and preserve rollback. SSM secrets are kept unless you pass
--purge-secrets. - Another project's services on the same node are never touched (ownership-scoped merge).
- Typical PR wiring: the PR workflow deploys with
--env pr-<n> --ttl 72h; a scheduled workflow runsdestroy --prune-expired --yes --json; the PR-close job runsdestroy --env pr-<n> --yes.
Redeploy a service's previous image — or a specific --to <tag> — without rebuilding.
A thin, ergonomic wrapper over deploy --image: it reads the service's currently
published image, finds the build pushed just before it (by ECR push time), and re-rolls in
place (health-gated, zero-downtime).
launchpad rollback [options]| Flag | Description |
|---|---|
--service <name> |
Service to roll back (required when the project has multiple) |
--to <tag> |
Roll to a specific immutable tag (can roll forward) instead of the previous build |
--env <name> |
Target a named environment footprint (same as deploy --env) |
--no-wait |
Don't wait for the agent to report convergence |
--timeout <seconds> |
Convergence timeout |
--dry-run |
Show the from → to roll without deploying |
--yes |
Skip the confirmation prompt |
launchpad rollback --service web # to the previous build
launchpad rollback --service web --to sha-abc123
launchpad rollback --service web --dry-run # preview from → to- The auto-pick is the most-recent build strictly older than what's deployed; if there's
nothing older it asks for
--to <tag>(rollback never silently rolls forward). - Container config (
cpu/memory/replicas/env/secrets) comes from the currentlaunch-pad.toml, and the chosen image is re-validated to the service's own ECR repo — so a rollback can only ever re-point a service at one of its own immutable builds.
Show the project's deploy history. Every deploy appends an append-only event to S3 (per
footprint) recording who deployed, when, which image per service, how it ran (build /
restart / image), and whether it converged.
launchpad history [options]| Flag | Description |
|---|---|
--service <name> |
Only deploys that touched this service |
--env <name> |
Target a named environment footprint (same as deploy --env) |
--limit <n> |
How many deploys to show (default 10) |
launchpad history # the last 10 deploys
launchpad history --service web --limit 20
launchpad history --env stagingHistory is advisory — an audit trail and a hint for which tags rollback can target — and
is never read by the node agents. Events hold only image tags + the caller ARN (no secret
values), and live under …/projects/<footprint>/events/.
Show service status from each node's status.json in S3.
launchpad status [options]| Flag | Description |
|---|---|
--node <nodeId> |
Only this node (default: the nodes the footprint is deployed on) |
--env <name> |
Only this environment's footprint (<project>-<env>) |
--watch |
Re-poll until interrupted |
A scheduled (cron) service reports a cron rollup per service — lastRunAt,
lastExitCode, nextRunAt — and stays state running while armed between fires (a failed
run surfaces through the exit code and message, not an error state).
Stream a service's logs from CloudWatch, merged across all nodes/replicas. Run from the
project directory (launch-pad.toml resolves the project).
BYOS nodes enrolled with node init use the Rust agent's direct CloudWatch Logs shipper; no
separate CloudWatch Agent is required. Older external nodes enrolled before direct logging can
still be inspected locally with journalctl -u launch-pad-agent and docker logs until they
are upgraded/re-enrolled with the journald forwarder.
launchpad logs <service> [options]| Flag | Description |
|---|---|
--env <name> |
Read the named environment's footprint |
--since <window> |
How far back: 15m, 1h, 24h, 7d (default 15m) |
--tail <n> |
Only the last N lines of the window |
--follow |
Keep streaming new lines (like tail -f) |
--filter <pattern> |
CloudWatch filter pattern |
Run a top-level [[job]] from launch-pad.toml exactly once. Jobs are ignored by normal
deploy; they are intended for migration tasks, data backfills, and other CI-gated one-off
work.
launchpad job run migrate --wait --yes| Flag | Description |
|---|---|
--env <name> |
Run against a named environment footprint |
--no-wait |
Publish the run request and return without waiting for exit |
--timeout <seconds> |
How long --wait waits for completion (default 300) |
--yes |
Reserved for CI symmetry with deploy/provisioning commands |
--remote-build |
Reserved; job runs currently require local Docker buildx |
job run builds and pushes the job image, finds the already-deployed footprint node (preferring
the sticky managed-database node when one exists), writes a transient run request into that
node's desired.json, and waits for the agent to report the matching run id in status.json.
The agent starts one --restart no container and records the exit code. A second run of the
same job will not overlap a still-running prior run.
For database migrations, deploy the database first so the job has a node and service DNS target, then gate the API deploy on migration success:
launchpad deploy --service primary --yes
launchpad job run migrate --wait --yes
launchpad deploy --service api --yesStore sensitive values in SSM Parameter Store (SecureString). Key names are registered
in launch-pad.toml; values never land in git or S3 desired.json.
launchpad secret set DATABASE_URL --service api # hidden prompt (or stdin / --value)
launchpad secret list --service api # names only, never values
launchpad secret get DATABASE_URL --service api # decrypt for local scripting (see below)
launchpad secret rm DATABASE_URL --service api
launchpad secret import .env.prod --service api # bulk-load (production / base)
launchpad secret import .env.staging --service api --env staging
cat .env.prod | launchpad secret import - --service api # from stdin| Flag | Description |
|---|---|
--service <name> |
Service or job from launch-pad.toml (set / rm / import require this) |
--env <name> |
Same footprint as deploy --env |
--no-register |
SSM only — do not add/remove the key in launch-pad.toml |
--value <value> |
(set) value inline (prefer the hidden prompt or stdin in scripts) |
--format <mode> |
(get) value (default), shell (export KEY=…), or json |
--quiet |
(get) skip the warning when printing raw secret values |
--dry-run |
(import) show what would be created/overwritten (names only) without writing |
SSM path layout: /launch-pad/<cluster>/<ownerProject>/<service>/<KEY> — <cluster> comes
from the active --cluster and <ownerProject> is <project>-<env> (just <project> for the
base/prod footprint), so secrets are isolated per cluster and per environment automatically.
The same DATABASE_URL key holds a different value in each.
Reads a .env-style file (KEY=VALUE per line; # comments, blank lines, and an export
prefix are ignored) and writes every pair as a SecureString under the service's per-env SSM
tree, registering each key in launch-pad.toml (skip with --no-register). This is the
per-environment config mechanism: the TOML [service.env] table is shared across all envs,
so to give an environment its own values you import its .env.<env> into that --env.
- Values can be any string — URLs containing
#, JSON, or multi-line private keys (wrap multi-line / space-padded values in single or double quotes; unquoted values are taken verbatim to end-of-line, so a#in a password is preserved). Only the key must be a valid env-var name (UPPER_SNAKE). - All-or-nothing: the whole file is validated first; an invalid key, an empty value, or a
non-
KEY=VALUEline aborts the import with a line-numbered list and writes nothing. - Existing keys are overwritten (import is a sync from the file); existing keys not in the file are left untouched (it never deletes).
⚠️ A key registered inlaunch-pad.tomlis required by every environment's deploy (the agent errors on a missing SSM ref). Keep the key set consistent across envs — import the matching.env.<env>into each environment so every env's tree has the full set.
After rotating a secret, roll containers without rebuilding:
launchpad deploy --restart --service apiDecrypts one SSM SecureString and prints it to stdout for local scripting (e.g.
eval "$(launchpad secret get DATABASE_URL --service api --format shell)" before
pnpm db:seed). Avoid CI logs and shared terminals — anyone with your operator IAM can
already read these values in the AWS console.
| Flag | Description |
|---|---|
--format value |
Raw value only (default; prints a warning unless --quiet) |
--format shell |
export KEY='…' safe for eval |
--format json |
{"key":"…","value":"…"} |
Operator IAM (not auto-provisioned): your local AWS profile needs ssm:PutParameter,
ssm:GetParameter, ssm:GetParameters, ssm:GetParametersByPath, ssm:DeleteParameter,
and ssm:DescribeParameters on arn:aws:ssm:<region>:<account>:parameter/launch-pad/*.
Node IAM: app agents need ssm:GetParameter + ssm:GetParameters on the same
prefix. New nodes get this automatically; on existing nodes run
launchpad node upgrade-agent (refreshes the IAM policy) before the first secrets deploy.
Change the operational fields the config lock allows
after the first deploy — replicas (horizontal), cpu and memory (vertical). scale
edits launch-pad.toml in place, then runs deploy --service <name> so the change rolls
out health-gated and zero-downtime.
launchpad scale replicas web 3 # scale to 3 replicas and roll it out
launchpad scale cpu web 512 --yes # 512 vCPU shares (1024 = 1 vCPU)
launchpad scale memory worker 1024 # 1024 MB
launchpad scale replicas web 5 --no-deploy # edit launch-pad.toml only
launchpad scale replicas web 5 --dry-run # preview; change nothing| Flag | Description |
|---|---|
--no-deploy |
Edit launch-pad.toml only — don't deploy |
--dry-run |
Show the change without editing the file or deploying |
--yes |
Skip confirmation prompts (e.g. for provisioning a scale-up needs) |
--no-wait |
Don't wait for the agent to report convergence |
--timeout <seconds> |
How long to wait for convergence |
scale replicas refuses a scheduled (cron) service — a cron job runs exactly one
container per fire (scale cpu/memory work normally).
A scale-up that needs more room than the node has fails the capacity admission check (the
same one deploy runs) — raise the node's instance type or move services first.
Edit a service's non-secret env table (then deploy). Same allowlisted-mutation model
as scale; for secrets use secret, for replicas/cpu/memory use
scale.
launchpad config set web FEATURE_FLAGS=beta # set an env var + roll it out
launchpad config set web LOG_LEVEL=debug --yes
launchpad config unset web FEATURE_FLAGS # remove it + roll it out
launchpad config set web LOG_LEVEL=debug --no-deploy # edit only| Flag | Description |
|---|---|
--no-deploy |
Edit launch-pad.toml only — don't deploy |
--dry-run |
Show the change without editing the file or deploying |
--yes |
Skip confirmation prompts |
--no-wait |
Don't wait for the agent to report convergence |
--timeout <seconds> |
How long to wait for convergence |
An env change is part of a container's config fingerprint, so the deploy rolls the
containers (health-gated) to apply it — no rebuild, since the image is unchanged. Setting an
env key that's also declared as a secret aborts the deploy (keep secret values in SSM).
Replan all of a footprint's services across the current app pool and republish to match — reusing each service's already-published image (no rebuild). Use it after adding an app node (to spread load onto it) or before removing one. The one exception is a volume-bearing service: its placement is sticky (its data lives on one node's disk), so it never moves.
launchpad rebalance --dry-run # preview the moves
launchpad rebalance --yes # apply them
launchpad rebalance --drain node-prod-2 # evacuate the footprint OFF a node| Flag | Description |
|---|---|
--drain <node> |
Exclude this node from the pool — evacuate the footprint off it (same as node evacuate) |
--env <name> |
Environment footprint (same as deploy --env) |
--dry-run |
Show the moves without writing any state |
--yes |
Skip the confirmation prompt |
Run from the project directory. Rebalance is config-lock-safe: the launch-pad.toml must
match the deployed baseline — only the placement (re-planned over the live pool) changes. It
re-runs the same scheduler deploy uses, so a planned move always passes the capacity
admission check.
Convergence is eventual: rebalance republishes desired state and each node's agent
reconciles on its next poll (it publishes nodes that gain replicas before nodes that shed them,
but doesn't health-gate across nodes the way a single-node rolling update does). Don't run it
concurrently with a deploy/scale of the same footprint; a re-run reconciles any interleaving
safely (it's idempotent — a balanced footprint reports "already balanced" and writes nothing).
--drain refuses if a volume-bearing service lives on the node (its data can't move) and
refuses to drain the last app node.
Reactive node-pool autoscaling: a declarative policy (min/max app nodes + CPU/memory
utilization thresholds) stored in the cluster's cluster.json, applied by a one-shot
reconcile pass — there is no daemon, matching the no-control-plane design. Cron
autoscale run (locally, CI, or a scheduled workflow) for hands-off scaling.
launchpad autoscale set --min 1 --max 3 # save the policy (cluster.json)
launchpad autoscale show # print it
launchpad autoscale run --dry-run # what would happen right now?
launchpad autoscale run --yes # apply at most ONE scale action
launchpad autoscale off # disable (clears the policy)| Flag | Description |
|---|---|
--min <n> |
Minimum app nodes — maintained even when idle (required) |
--max <n> |
Maximum app nodes — utilization never grows past this (required) |
--scale-out-percent <p> |
Scale out when average pool CPU or memory ≥ this % (default 80) |
--scale-in-percent <p> |
Scale in when every node's CPU and memory are below this % (default 30) |
--cooldown <seconds> |
Minimum seconds between utilization-driven actions (default 300) |
The thrash guard requires scale-in % < scale-out %. Policy lives in cluster.json, so
autoscale needs a named cluster (the implicit default cluster has none).
One reconcile pass: read the policy, observe the live pool (registry + each node's
status.json host utilization sample, which the agent embeds every stats interval),
ask the pure planner for at most one action, apply it, record lastScaleAt, exit.
- Scale out — provisions a new app node with a generated name (sized like the largest node already
in the pool, defaulting to
t4g.microfor an empty ARM pool and inheriting an existing x86 pool's architecture; always roleappbehind the cluster's edge) and rebalances the project's services onto the new pool. Triggered by theminNodesfloor (which bypasses the cooldown) or by average utilization ≥ the scale-out threshold (never pastmaxNodes). - Scale in — drains the least-utilized node via the rebalance machinery, waits
for the survivors to converge, then gives the victim's agent a drain-grace window
(graceful container stop + upstream-shard retraction) before terminating it. It
refuses to touch the cluster's edge, refuses if the node still hosts any
service after the drain (another project's, or a volume-bearing one — autoscale never
orphans workloads), refuses to act when any pool node is missing fresh metrics (a node it
can't see is assumed busy), and only picks a victim whose reserved footprint the
survivors can absorb — low live utilization never overrides the capacity admission
check (
cpu/memoryare reservations); when no drainable node fits, the pass reports why and does nothing.
Before applying an action, the pass CAS-claims lastScaleAt in cluster.json
(conditional PUT): two overlapping runs can't both act — the loser aborts having changed
nothing — and a pass that fails mid-action leaves the cooldown in place, so a cron retry
can't launch instances every interval. Like rebalance, avoid running it concurrently
with a deploy/scale of the same footprint.
| Flag | Description |
|---|---|
--env <name> |
Environment footprint (same as deploy --env) |
--dry-run |
Report the planned action without changing anything |
--yes |
Skip the confirmation prompts — required for billable/destructive actions in --json/cron mode |
--timeout <seconds> |
Scale-in drain convergence timeout (default 300) |
Run it from the project directory (the rebalance step needs launch-pad.toml); --dry-run
and no-op passes work from anywhere. Example cron line:
*/5 * * * * cd /path/to/project && launchpad autoscale run --yes --cluster prod
Utilization comes from the host sample each agent publishes in its status.json
(CPU busy % and memory used % of the whole host, refreshed every LAUNCHPAD_STATS_INTERVAL_MS,
60s default). Samples older than 5 minutes — or from a node whose heartbeat is stale — are
ignored, which blocks scale-in entirely (conservative) and simply shrinks the scale-out average.
DNS is the most common thing standing between a deploy and working HTTPS: Let's Encrypt's
HTTP-01 challenge only succeeds if the domain's A record points directly at the node's
Elastic IP. DNS is yours to configure — point each web domain (or a wildcard like
*.example.com, which covers every deploy --env subdomain too) as an A record at your
edge node's Elastic IP, at any DNS provider. Every
deploy prints the exact targets in its DNS panel; dns verify is the CI-friendly check that
you got it right.
launchpad dns verify app.example.com # look up the expected EIP from the project
launchpad dns verify app.example.com --expect 54.210.10.20 # check any domain, no project needed
launchpad dns verify app-staging.example.com --env staging| Flag | Description |
|---|---|
--service <name> |
Which service the domain belongs to (disambiguates the expected node) |
--env <name> |
Environment footprint (same as deploy --env) |
--expect <ip> |
Compare against this IPv4 directly (skips the cluster registry lookup) |
Run from the project directory so the expected Elastic IP can be looked up from the cluster registry (the edge node that fronts the domain). It reports one of:
| Status | Meaning |
|---|---|
ok |
The A record points at the node's Elastic IP — HTTPS can issue. |
wrong-ip |
Resolves, but to a different IP than the edge's EIP (e.g. a proxy/CDN sits in front — the record must resolve directly to the edge). |
no-records |
No A record (NXDOMAIN or not created yet). |
no-expected-ip |
Resolved fine, but the expected EIP couldn't be determined (run from the project dir or pass --expect). |
Exit code is non-zero for wrong-ip and no-records so it's scriptable
in CI. Every deploy now also prints a DNS panel with each domain's A-record target.
Manage EC2 nodes — the machines that run your services. Most nodes are managed EC2
instances Launch Pad provisions for you; you can also bring your own server (BYOS) and
enroll an existing Linux host with node init.
Enroll an operator-owned server you already have (a VPS, a bare-metal box, an EC2
instance you manage yourself) as a Launch Pad node, without Launch Pad ever provisioning any
EC2. The CLI provisions only AWS credentials for the box (a per-node IAM user with the
same least-privilege node policy an EC2 node gets via its instance profile), SSHes in once to
bootstrap it (install Docker for app or Caddy for edge, drop the agent binary + agent.json + an EnvironmentFile
with the AWS keys, register a launch-pad-agent systemd unit), and writes an external
node registry entry. From then on an external app box
polls S3, reconciles Docker, and publishes its upstream shard to the cluster's edge; an
external edge box polls upstream shards and reconciles Caddy.
The box must be Linux with systemd and a dnf or apt-get package manager, reachable over
SSH with passwordless sudo. External edge hosts must expose public TCP 80/443 and have a
stable --public-ip; Launch Pad records that IP for DNS guidance but does not allocate or
manage it.
| Flag | Description |
|---|---|
--host <user@host> |
(required) SSH target for the box, e.g. ubuntu@203.0.113.10 |
--role <role> |
app (default) or edge |
--edge <nodeId> |
Pin a specific edge for this app node (required for external app nodes; omitted for edge nodes) |
--advertise-ip <ip> |
The IP the edge dials to reach this app box's container host ports (auto-detected over SSH when omitted) |
--public-ip <ip> |
(required for edge) Stable public IP users point DNS at; optional display IP for app nodes |
--cpu <shares> |
Schedulable CPU shares (1024 = 1 vCPU) |
--memory <mb> |
Schedulable memory in MB |
--name <id> |
Node id (generated <noun>-<verb>-<adverb> when omitted) |
--ssh-key <path> |
Identity file for SSH (-i) |
--ssh-port <port> |
SSH port (default 22) |
--agent-version <semver> |
Agent version to install |
--timeout <seconds> |
Bootstrap timeout (default 180) |
--show-secrets |
Print the generated IAM access key (otherwise redacted as ***) |
--dry-run |
Show the plan (IAM + bootstrap) without changing anything |
--yes |
Skip the confirmation prompt |
The generated IAM access key is shown only once, at creation. It is redacted by
default (and in --json); pass --show-secrets to print it. It is also written onto the
box (/etc/launch-pad/agent.env, mode 600) so the agent can authenticate.
Before creating the IAM user/access key, node init runs an SSH preflight:
sudo -n true over the provided SSH target. This catches a wrong key/user, unreachable host,
or sudo that would prompt for a password before Launch Pad creates long-lived credentials.
After the IAM user is created, node init writes the provisioning node.json entry before
running the bootstrap so a partial failure can still be cleaned up with node destroy.
# Enroll an existing Ubuntu box as an app node in the prod cluster
launchpad node init --host ubuntu@203.0.113.10 \
--cpu 2048 --memory 4096 \
--cluster prod --yes
# Override the detected address when the edge must dial a private/VPN address
launchpad node init --host ubuntu@203.0.113.10 \
--advertise-ip 10.0.1.50 --cpu 2048 --memory 4096 \
--cluster prod --yes
# Enroll an operator-owned ingress box as the cluster edge
launchpad node init --host ubuntu@edge.example.com \
--role edge --public-ip 203.0.113.20 --cpu 512 --memory 512 \
--cluster prod --yesdeploy still never SSHes into nodes — only node init does, and only at enrollment.
Once enrolled, the box is driven exactly like an EC2 node: entirely through S3 (desired →
status), self-healing, idempotent.
When --advertise-ip is omitted for an external app node, node init runs
ip -o -4 route get 1.1.1.1 over SSH, extracts the source IPv4 address, and asks you to
confirm it before writing agent.json / node.json. Use --advertise-ip explicitly when the
edge should dial a VPN, VPC, or peered private address instead of the host's default route
address.
Before declaring success for an external app node, node init performs a best-effort edge
reachability probe: it starts a one-shot temporary listener on the BYOS host at TCP 20000,
then asks the EC2 edge over SSM to connect to the chosen advertise IP on port 20000. A
failure is a warning, not an enrollment failure, but it means web services will 502 or fail
edge health checks until the network path is fixed. External edge enrollment skips this
app-port probe.
If enrollment times out waiting for the first heartbeat, the bootstrap may still be healthy
but slow. Re-run the same node init command with --name <nodeId>; when the existing entry
is an external node still in provisioning, Launch Pad resumes the heartbeat wait and marks
the node ready once the agent reports, without creating another IAM key or re-running the
bootstrap.
BYOS app networking checklist:
- Open edge → BYOS host TCP
20000-29999in the host firewall, security group, VPN ACL, or routed LAN policy. - Confirm or override the detected advertise IP with an address reachable from the edge, not merely from your laptop.
- Avoid NAT hairpin paths where the edge dials the box's public IP from inside the same NAT; use a private/VPC/VPN/peered address instead.
- Re-run
launchpad doctorafter deploying a web service; it probes the edge to live external host ports and reports unreachable nodes.
BYOS edge networking checklist:
- Point service A records at the external edge node's
--public-ip(one wildcard DNS-only A record is usually enough for environment subdomains). - Open public TCP
80/443to the edge host so Caddy can serve traffic and complete ACME HTTP/TLS challenges. - Keep the public IP stable outside Launch Pad; unlike EC2 edge nodes, Launch Pad does not allocate an Elastic IP for operator-owned hosts.
Resolved open decisions (BYOS Phase 1):
- One IAM user per external node (not a shared user) — same least-privilege node policy an EC2 node gets, so a compromised box can't read anything beyond its own state. Teardown deletes just that user.
- A single
node initcommand does both credential provisioning and one-shot SSH bootstrap — there is no separate "register" + "bootstrap" split. advertiseIpis stored in bothagent.jsonandnode.json— the box reads it fromagent.json(envLAUNCHPAD_ADVERTISE_IPoverrides), the CLI/edge read it fromnode.json.- Target hosts are Linux + systemd with a
dnforapt-getpackage manager; anything else fails the bootstrap with a clear error. SSH access and passwordless sudo are checked up front withsudo -n true. doctorchecks external-node heartbeat freshness and warns when a BYOS node is missing or stale. When a web service is running on a BYOS node,doctoralso asks the edge to probe that node's live host ports. External nodes are not SSM-managed, so EC2 lifecycle commands still skip them with a warning.
External nodes are not SSM-managed. AWS Systems Manager Run Command, Session Manager,
node install-logging, EC2 pause/resume/resize, and the EC2 live-sampling path in
node monitor --watch do not reach an operator-owned host. Use the Launch Pad command when
one exists (node upgrade-agent <external>, node rotate-creds <external>, node destroy)
and use SSH for emergency host inspection:
# Agent health + recent logs
ssh ubuntu@203.0.113.10 'sudo systemctl status launch-pad-agent --no-pager'
ssh ubuntu@203.0.113.10 'sudo journalctl -u launch-pad-agent -n 200 --no-pager'
# App nodes: Docker/container state
ssh ubuntu@203.0.113.10 'sudo docker ps --filter label=launchpad.managed=true'
ssh ubuntu@203.0.113.10 'sudo docker logs <container-id> --tail 200'
# Edge nodes: Caddy state
ssh ubuntu@203.0.113.20 'sudo systemctl status caddy --no-pager'
ssh ubuntu@203.0.113.20 'sudo journalctl -u caddy -n 200 --no-pager'
# Restart after manual host repair
ssh ubuntu@203.0.113.10 'sudo systemctl restart launch-pad-agent'
ssh ubuntu@203.0.113.20 'sudo systemctl restart caddy launch-pad-agent'Credential and lifecycle break-glass:
- To refresh a suspected-stale key, run
launchpad node rotate-creds <name> --host <user@host> --ssh-key <path> --yes; it writes/etc/launch-pad/agent.env, restarts the agent, and deletes superseded IAM keys after the SSH push succeeds. - If a key is compromised and the host cannot be reached, revoke it from AWS IAM immediately
(delete the
launch-pad-node-<cluster>-<node>access keys or the whole user), then runlaunchpad node destroy <name> --force --yesto remove Launch Pad registry/S3 state. - If the host is repaired after a heartbeat timeout, re-run
node init --name <nodeId>with the same SSH options to resume the heartbeat wait instead of minting another key.
Provision an EC2 instance, bootstrap the agent, and register the node. The name is
optional — when omitted, a generated <noun>-<verb>-<adverb> id (e.g. dog-runs-fast,
unique within the cluster) is used, so you never have to invent node names.
| Flag | Description |
|---|---|
--instance-type <type> |
EC2 instance type (default t4g.micro) |
--role <role> |
app or edge (default app) |
--edge <nodeId> |
For an app node: pin a specific edge (defaults to the cluster's edge) |
--key-name <keypair> |
EC2 key pair for SSH (omit to disable SSH) |
--ami <id> |
AMI id (default: Launch Pad golden AMI, falling back to latest Amazon Linux 2023) |
--agent-version <semver> |
Agent version to install |
--amount <n> |
Create n nodes — generated names when [name] is omitted; sequential ids from an explicit base (app → app-1…app-n) |
--dry-run |
Show plan without creating anything |
--yes |
Skip launch confirmation |
An app node routes through an edge, resolved in order: explicit --edge → the cluster's
defaultEdge (set via cluster set-edge) → the cluster's
single edge-role node. So in a cluster that already has an edge you don't pass --edge at
all — the new app node auto-attaches to it. The create is refused only when the cluster has
no edge yet (create one first with --role edge) or has more than one edge and no
default (disambiguate with --edge or cluster set-edge). App nodes are VPC-private (no
public IP); an edge node gets the public 80/443 + Elastic IP.
List registered nodes with capacity, heartbeat age, and the project/service footprints
scheduled on each node (replicas shown as ×N, scheduled jobs tagged (cron); app nodes
with nothing placed show no services, as do edge nodes which never run containers). Prefixes
in S3 with no node.json show as missing node.json — leftover state from a partial destroy
or failed provision. --json adds a services array to each node entry.
Remove orphaned S3 node prefixes that have objects but no node.json registry entry. Safe to
run after destroying nodes; it only sweeps state that node list would show as
missing node.json.
| Flag | Description |
|---|---|
--yes |
Skip the confirmation prompt |
launchpad node prune --yesRe-running node destroy <name> on an already-destroyed node also sweeps any leftover prefix.
Show registry entry, desired state, and live status for one node. Capacity is broken
out as total (instance size), used (scheduled services from desired.json),
and free (allocatable minus used — allocatable is total minus the host reserve for
the OS and agent).
Fully tear down the node(s) (comma- or space-separated ids): terminate the instance, release
the Elastic IP, delete the security group, delete the per-node IAM role + instance profile,
and remove its full S3 prefix (node.json, desired.json, status.json, the agent binary,
upstream/*, etc.). Deleting IAM is best-effort + idempotent and only ever touches the
launch-pad-node-<cluster>-<node>-named resources (a legacy shared role is left alone).
| Flag | Description |
|---|---|
--yes |
Skip the confirmation prompt |
--force |
Destroy even if the node still hosts services (they will be orphaned) |
--delete-data |
Acknowledge permanent data loss — required to destroy a node holding a persistent volume (a database). --force does not bypass this |
--evacuate |
First move the current project's services off the node(s), wait for them to come up elsewhere, then destroy |
--env <name> |
Target a named environment footprint for --evacuate (same as deploy --env) |
--timeout <seconds> |
How long --evacuate waits for the moved replicas to converge (default 300) |
Data-loss guard: a node hosting a persistent volume (a [[database]] or any
[[service.volumes]] service) refuses to be destroyed — terminating the instance wipes the
volume, which is irreversible. This gate is checked independently of --force (data loss is
not the same as orphaning a stateless container) and evacuation can't move volume data off the
node. Two ways forward: remove the data service first (launchpad destroy --service <svc>) so its
volume is gone, then destroy the node; or pass --delete-data to accept that the volume's
data is destroyed with the instance. A node whose desired.json is present but unparseable is
treated as possibly holding a volume and refuses the same way (fail-closed).
Safety: node destroy refuses by default when a node still hosts scheduled services —
destroying it would orphan their containers (no node reconciles them anymore). The error lists
which project/services are at risk. Three ways forward:
--evacuate(one-shot, recommended) — run it from your project directory and it auto-moves this project's services onto the rest of the app pool (=node evacuate), waits for them to be running there, then tears the node down. Volume-bearing services (their data lives on the node's disk) and other projects' services can't be auto-moved; if any remain the destroy still refuses (evacuate those projects too, or add--force). Draining every node — or a node whose drain would leave the cluster with no app nodes — can't relocate the replicas, so it refuses. It never terminates the node until the footprint is confirmed up elsewhere (a stuck convergence aborts with nothing torn down).- manual —
node evacuate <name>first, watchlaunchpad status, then re-run destroy. --force— destroy now and orphan whatever is still scheduled there.
launchpad node destroy app-2 --evacuate --yes # evacuate this project's services, then destroy
launchpad node destroy app-2 --force --yes # destroy now, orphan its servicesExternal (BYOS) nodes: for a node enrolled with node init
(provisioning: "external"), destroy deletes the per-node IAM user (its access keys +
inline policy) and removes the node's S3 prefix — but makes no EC2 calls and never touches
the server itself. The box keeps running; the agent simply loses its credentials and stops
reconciling. Tear the host down (or stop the launch-pad-agent service) yourself.
Before teardown, destroy best-effort marks the external node terminating, which cordons it
out of future placement while IAM/S3 cleanup runs. For a dead BYOS node that still has desired
services, use node destroy <name> --evacuate --yes: Launch Pad first republishes the current
project's volume-free services onto surviving app nodes, then deletes the stale node state.
node pause / node resume / node resize manage EC2 instances and are unsupported for
external nodes — they error out telling you to manage the server yourself.
To tear down a whole cluster at once, use cluster destroy.
Move the current project's services OFF a node — the safe pre-step to
node pause/destroy/resize. Run from the project directory; it replans the footprint across
the rest of the app pool (reusing each service's published image) so the node is freed. It is
exactly rebalance --drain <name> scoped to the current project.
launchpad node evacuate node-prod-2 --dry-run
launchpad node evacuate node-prod-2 --yesVolume-bearing services can't be evacuated — their placement is sticky (the data
lives on that node's disk), so evacuate refuses if one lives on the node (destroy it or
recreate the footprint to move it). A node hosting other projects needs each of them evacuated too (run
it from each project dir). Once launchpad status shows the node drained, node destroy/pause
will accept it. To evacuate and destroy in one step (with the drain wait built in), use
node destroy --evacuate.
Stop the EC2 instance to save cost / start it back up. The edge node keeps its Elastic IP and disk while paused.
Change a node's EC2 instance type. EC2 can only retype a stopped instance, so a plain
resize is stop → modify → start — the node's services are briefly down during the swap. A
paused node stays paused at the new size; shrinking is blocked when the node's scheduled
services (plus rollout surge) no longer fit; an edge node's Elastic IP survives the cycle.
Cross-architecture resize is refused up front (t3.* ↔ t4g.*): add a node of the target
architecture, rebalance/evacuate onto it, then destroy the old node instead.
--evacuate makes it non-disruptive for the current project's services
(run from the project directory): it drains them onto the rest of the app pool (=
node evacuate), waits for them to be confirmed running
elsewhere, resizes the emptied node, then rebalances back and waits again — so a replica is
never down with the instance. It needs another app node with room; it refuses a paused node
(nothing running to protect) and a node hosting a volume-bearing service (its data can't
move — plain resize is the path there). Other projects' services on the node still ride the
brief stop/start, and resizing the edge node still blips ingress while Caddy restarts.
| Flag | Description |
|---|---|
--instance-type <type> |
Target instance type (required) |
--evacuate |
Drain this project's services first, resize, rebalance back (no downtime for them) |
--env <name> |
Environment footprint for --evacuate (same as deploy --env) |
--timeout <seconds> |
How long --evacuate waits for each convergence (default 300) |
--dry-run |
Show the from→to change (and whether it would drain) only |
--yes |
Skip confirmation |
launchpad node resize node-prod-1 --instance-type t4g.large # brief downtime
launchpad node resize node-prod-1 --instance-type t4g.large --evacuate --yes # rolling, no downtimeUpload the role-specific Rust agent binary to S3 and install it on running nodes. EC2
nodes are restarted via SSM (with manual fallback). A named external (BYOS) node is restarted
over SSH: pass --host when you need a user/hostname override, otherwise the CLI uses
node.json's publicIp / advertiseIp and forwards --ssh-key / --ssh-port to ssh.
With no name, upgrades every EC2 node in the cluster and skips external nodes so SSH details
stay explicit. Each node gets the binary for its role (edge → Caddy router, app → Docker
reconciler), and the registry records agentType: "rust". A node still on the legacy
TypeScript agent is migrated in place — the systemd unit is rewritten to run the binary, the
old bundle is removed, and an edge node also stops its now-unneeded Docker daemon — no
re-provisioning. (Build the binaries first: pnpm build:agent.)
| Flag | Description |
|---|---|
--upload-only |
Upload to S3 only — do not restart on-box agents |
--agent-version <semver> |
Version recorded in the registry |
--host <user@host> |
SSH target for a named external node |
--ssh-key <path> |
SSH private key for a named external node |
--ssh-port <port> |
SSH port for a named external node |
--dry-run |
Show targets without changing anything |
--yes |
Skip confirmation |
launchpad node upgrade-agent byos-app \
--host ubuntu@203.0.113.10 --ssh-key ~/.ssh/id_ed25519 --yesRotate the long-lived IAM access key for an external (BYOS) node without re-enrolling it. The
command creates a replacement key on the node's per-node IAM user, rewrites
/etc/launch-pad/agent.env over SSH, restarts launch-pad-agent, then deletes superseded
access keys only after the restart succeeds. If SSH fails, the replacement key is deleted and
the old key remains active.
| Flag | Description |
|---|---|
--host <user@host> |
SSH target (defaults to the node's publicIp / advertiseIp) |
--ssh-key <path> |
SSH private key |
--ssh-port <port> |
SSH port |
--dry-run |
Show the rotation plan without creating an access key |
--yes |
Skip confirmation |
Recommended cadence: rotate BYOS node credentials at least every 90 days, after operator turnover, and after any suspected host or key exposure.
Revoke-on-compromise runbook:
-
If the host is still trusted and reachable, run:
launchpad node rotate-creds byos-app --host ubuntu@203.0.113.10 --ssh-key ~/.ssh/id_ed25519 --yes -
If the host may be compromised, remove it from service first:
launchpad node destroy byos-app --evacuate --yes
Then tear down or rebuild the server yourself.
node destroydeletes the per-node IAM user and all of its access keys.
Install the legacy CloudWatch Agent logging support on an existing EC2 node. New Rust agents
ship service logs directly, but this remains useful for older EC2 nodes that lack the system
log forwarders. With no name, targets every EC2 node in the cluster. --dry-run / --yes as
above.
Repair EC2 console drift: start stopped nodes, replace terminated ones (same node id; the
edge keeps its Elastic IP). deploy runs this automatically unless --no-repair.
External (BYOS) nodes have no EC2 instance to repair, but node reconcile still checks their
heartbeat. A live external node stuck in provisioning is marked ready; a stale or missing
heartbeat is reported with the same recovery choices as node list: restart the host/agent if
it should still be live, or run launchpad node destroy <name> --evacuate --yes if the host is
gone.
| Flag | Description |
|---|---|
--dry-run |
Show drift without changing anything |
--no-recreate |
Fail instead of replacing terminated instances |
--yes |
Skip confirmation |
Graph a node's CPU/memory usage over time. Historic mode reads the launchpad.stats
samples the agent emits (~60s) to CloudWatch; live mode (--watch) samples EC2 nodes
over SSM and redraws a sparkline. External (BYOS) nodes are not SSM-managed, so live mode
reads the latest status.json.host sample from the agent heartbeat and labels the graph
from heartbeat. Resource usage only — for app output use logs, for deploy convergence use
status.
Heartbeat-sourced BYOS live mode includes host CPU/memory only. Per-service live graphs still
need SSM or historic CloudWatch stats; use --since without --watch for BYOS service rows.
| Flag | Description |
|---|---|
--since <window> |
Historic window (15m, 1h, 24h, 7d; default 1h) |
--watch |
Live mode: poll over SSM until Ctrl+C |
--interval <sec> |
Watch poll interval (default 3) |
--window <duration> |
Watch ring-buffer span (default 5m) |
--service <name> |
Only graph this service (needs launch-pad.toml) |
--env <name> |
Resolve --service against the named environment |
Live mode needs a running, SSM-managed instance and ssm:SendCommand on your operator
profile; historic mode needs only logs:FilterLogEvents.
Inspect deployed project footprints in the active cluster (from each node's published
desired.json — declarative placement, not live container health). Use status
for agent-reported rollout state.
launchpad project list [options]
launchpad project show <name> [options]| Flag | Description |
|---|---|
--env <name> |
Named environment footprint (project show only; same as deploy --env) |
Examples:
launchpad project list --cluster prod
launchpad project show auth-example --cluster prod
launchpad project show auth-example --cluster prod --env pr-123project list shows every base footprint and every marker-backed environment in the
cluster, with service counts and node ids. project show drills into one footprint:
services, image tags, domains, cron schedules, and which nodes each service is scheduled on.
Named environments also show TTL/expiry and projected domains from the env marker.
Federated projects (TOMLs declaring a component — see
configuration): project list labels each footprint with its
component (shop · auth, shop · notes), and project show <project> aggregates the
whole logical project from the component registry — one panel per component, with
--env <name> projecting every component's env footprint. Components with no deployed
footprint for the requested env show as empty rather than erroring.
Manage named clusters — scoped groups of nodes that share an edge (and optionally an AWS account/region via local config).
Save the cluster's AWS target locally and write cluster.json to S3.
| Flag | Description |
|---|---|
--role-arn <arn> |
Cross-account role (reserved; not yet supported) |
--edge <nodeId> |
Set the cluster's default edge up front |
The implicit default cluster cannot be created — it uses un-prefixed S3 keys for backward
compatibility.
List the implicit default cluster plus named clusters from local config / S3. default
is always shown because it is the legacy un-prefixed cluster future commands target when no
named default has been selected.
Show cluster config, AWS account/region, member nodes, every service scheduled on
each node (from its published desired.json — the declarative source of truth, not live
container status), and every named environment created by deploy --env with its
services and node placement. Use status when you need agent-reported health
and image rollout state. When name is omitted, it targets --cluster, then your saved
default cluster, then the implicit default cluster, matching deploy.
Set the cluster's default edge (the Caddy router for its web services). The node must have
role edge. When name is omitted, it targets --cluster, then your saved default cluster,
then the implicit default cluster. The implicit default cluster cannot store a default
edge in cluster.json.
Set the default cluster for future commands. cluster use default clears the persisted
default and reverts to the implicit cluster.
Show the cluster future commands will target (account, region, profile).
Stop or start every node in the cluster concurrently to save money / bring it back up.
When name is omitted, both commands target --cluster, then your saved default cluster,
then the implicit default cluster, matching deploy. The edge keeps its Elastic IP + disk.
--yes skips confirmation.
Terminate every node, release IPs, and delete all the cluster's S3 state. Cannot target
default. When name is omitted, it targets --cluster, then your saved default cluster,
then the implicit default cluster. --yes skips confirmation.
Disaster recovery for the declarative state in S3 (the authoritative registry — there's no
control-plane database to back up). backup exports a cluster's state to a local directory;
restore re-uploads it.
launchpad backup # export the default cluster's state
launchpad backup --cluster prod --out ./prod-backup
launchpad restore ./prod-backup --yes # re-upload (overwrites existing state)| Command | Description |
|---|---|
backup [--out <dir>] |
Mirror the cluster's S3 state (registry + desired/status + config baselines + deploy events) into a local directory keyed by S3 key, plus a manifest.json. Read-only; never changes AWS. |
restore <dir> [--yes] |
Re-upload a backup directory to S3 for the cluster in its manifest (override with --cluster). Gated by a confirmation. Nodes reconcile to the restored desired state on their next poll. |
Backups contain no plaintext secrets — desired.json stores SSM parameter references, not
values. Restore fails closed against a modified backup directory: every file must be a clean
relative key, stay within the target cluster's keyspace, be listed in the manifest, and be under a
sane size cap; symlinks are skipped. ECR images aren't backed up (they're immutable and
rebuildable). The state bucket itself is per account+region and is never deleted by these commands.
Estimate the cluster's ongoing monthly cost from its registry — the running nodes' on-demand EC2 + agent S3 polling — with an optional budget gate.
launchpad cost # estimate the current cluster
launchpad cost --cluster prod --budget 100 # warn + non-zero exit if over $100/mo
launchpad cost --idle-days 3 # flag nodes idle longer than 3 days
launchpad cost --json --budget 100 # machine-readable, for CI / scheduled checks| Flag | Description |
|---|---|
--budget <usd> |
Monthly USD budget — exit non-zero (and flag) when the estimate exceeds it |
--idle-days <n> |
Age (days) before an idle node is flagged in the recommendations (default 7) |
Running EC2 nodes (ready/provisioning) are estimated for EC2 + agent S3. External (BYOS)
nodes are labeled as no-EC2-cost and contribute only their agent S3 polling estimate. Paused
nodes are noted separately (a stopped instance has no compute/agent charge, but its EBS volume
and Elastic IP still cost — not estimated). It's a baseline, not a bill: it excludes data
transfer, ECR / CloudWatch storage, and gp3 root volumes. With --budget, the non-zero exit
makes it gateable in CI or a scheduled job to catch a cluster that grew past its threshold.
It also surfaces idle-node recommendations — money spent without work being done:
- paused — a stopped node still paying for its EBS volume + Elastic IP.
resumeit ornode destroyit. - empty — a running node hosting no services (or an edge routing no domains), burning its full EC2 rate for nothing — the recommendation dollar-estimates the wasted compute.
Only nodes idle longer than --idle-days (default 7) are flagged. These are advisory — only
--budget changes the exit code.
Check the cluster's health and notify on problems — a probe you run on a schedule (cron / a GitHub Action) rather than a continuous control plane.
launchpad alerts check # print any alerts; exit non-zero if any
launchpad alerts check --cluster prod --webhook "$SLACK_WEBHOOK" # also POST to Slack/Discord
launchpad alerts check --json # machine-readable| Flag | Description |
|---|---|
--webhook <url> |
POST alerts to this http(s) URL (Slack/Discord/generic JSON). Env: LAUNCHPAD_ALERT_WEBHOOK. |
--stale <ms> |
Heartbeat staleness threshold (default 60000). |
alerts check reads each node's registry entry + status.json and flags real faults on nodes
that are supposed to be running:
- heartbeat-stale — a live node whose agent stopped reporting (or, past a ~10-minute boot grace, never reported). The node may be down, the agent crashed, or it lost network.
- service-unhealthy — a service in the
errorstate, or one wanting replicas but running zero (fully down).
It is deliberately quiet on non-faults: a paused node's agent is off on purpose, a
still-booting node is given its grace window, and a partially-degraded service (e.g. 2/3 replicas
mid-rollout) doesn't alert. It exits non-zero when there's any alert, so a scheduled run can
gate on it; with --webhook it also POSTs a Slack/Discord-compatible payload (text summary +
structured alerts). The webhook URL is operator-supplied (keep it out of source — use the env
var or a CI secret).
Run the built-in read-only web dashboard — clusters, nodes, services, environments,
deploy history, live CPU/memory, and live logs, rendered from this same CLI's --json
output. It never mutates anything; deploys and changes stay in the CLI/CI.
launchpad dashboard # → http://127.0.0.1:4000
launchpad dashboard --project ~/code/my-app # register a project dir for logs/history| Flag | Meaning |
|---|---|
--port <port> |
Listen port (default PORT env or 4000). |
--host <host> |
Bind interface (default 127.0.0.1). Non-loopback requires LAUNCH_PAD_DASHBOARD_TOKEN. |
--project <dir...> |
Register project directory(ies) — logs/history need a launch-pad.toml cwd. The launch cwd auto-registers when it holds one. |
--no-open |
Don't open the browser. |
Global --cluster / --profile / --region become the defaults for every read the
dashboard performs. With LAUNCH_PAD_DASHBOARD_TOKEN set, every page requires the token
(Bearer header, session cookie, or one-time ?token=…); comparison is constant-time and
failures are rate-limited. See dashboard.md for pages, SSE streaming,
and the VPS story.
Print a shell-completion script for launch-pad / lpd, generated from the live command tree
(so it never drifts from the real commands).
launchpad completions bash >> ~/.bash_completion
launchpad completions zsh > "${fpath[1]}/_launch-pad" # then run: compinit
launchpad completions fish > ~/.config/fish/completions/launch-pad.fishCompletes top-level commands, their subcommands (e.g. node create, cluster use), and the
global flags. Supported shells: bash, zsh, fish.