@thelacanians/chalupa-cli
v0.9.4
Published
Ephemeral Docker Compose environments on DigitalOcean, SSH-only
Readme
Chalupa 🛶
Chalupa turns a selected subset of a Docker Compose project into a disposable
DigitalOcean environment. It derives services, TCP ports, named volumes and
dependencies from Compose, forces every published service port onto
127.0.0.1, and exposes only SSH through an explicit cloud firewall.
The local Pulumi backend keeps compute and persistent data in separate stacks:
<name>owns disposable compute and its SSH-only firewall.<name>-dataowns an optional protected block volume.
Compute consumes the exact volumeId exported by the protected data stack
through a Pulumi stack reference; it never discovers storage by a potentially
ambiguous name. Destroying compute does not destroy data.
Every pulumi invocation in the Taskfile runs with PULUMI_CONFIG_PASSPHRASE=""
(env: at the top of Taskfile.yml), which is what makes pulumi login
--local usable without a passphrase prompt. The accepted tradeoff: an empty
passphrase means Pulumi's own secret outputs are encrypted with a
well-known key, so they are effectively plaintext-at-rest in the local state
directory (.pulumi/). This is a deliberate, accepted local-backend choice,
not an oversight — the DigitalOcean token itself never passes through Pulumi
config or Pulumi secrets at all (it is read from tvault by the Taskfile and
handed to the provider as a process environment variable), so the one
credential this project cannot afford to leak was never exposed to that
tradeoff in the first place. Treat .pulumi/ as sensitive local state anyway;
anything Pulumi does mark secret (for example cloud-init user data) is only
as protected as the passphrase, and here that is none.
Install
curl -fsSL https://chalupa.run/install.sh | bash
chalupa setupThe installer puts checksum-verified Bun 1.3.14+ and go-task 3.51.1+ on
your PATH, then installs or upgrades the CLI
(@thelacanians/chalupa-cli from the npm registry) with Bun. Every downloaded
runtime artifact is pinned by release and SHA-256; nothing runs with sudo.
If Bun and go-task are already installed, bun add -g
@thelacanians/chalupa-cli installs or upgrades only the CLI.
chalupa setup then walks the onboarding: the DigitalOcean token (validated against the API, stored in tvault, never echoed), an SSH key (reuses ~/.ssh/id_ed25519 or creates a dedicated one and registers it in your DigitalOcean account), a control-plane account (signup + magic-link verification), and a starter chalupa.yml in the current directory. The console signup is open — email verification is the only gate.
chalupa doctor reports which of Task, Pulumi, tvault, ssh, and nc are on
PATH without running or querying any of them.
Requirements
- Bun
- Task — required for every lifecycle action, not
optional tooling.
chalupa/the operator UI delegate to it for anything that touches a stack; withouttaskonPATH, only offline inspection/preview keeps working.chalupa doctorchecks for it. - Pulumi CLI
- TinyVault
v0.19.0or newer sshandnc, the system OpenSSH client and netcat — used for tunnels,chalupa ssh, agent bootstrap, and the seed task's tunnel check- A DigitalOcean API token stored in tvault project
chalupa
chalupa doctor reports which of Task, Pulumi, tvault, ssh, and nc are on
PATH without running or querying any of them.
Only Bun is supported for JavaScript dependencies and scripts.
Try the offline demo
The repository ships a self-contained synthetic fixture:
examples/demo-harbor/.
bun install --frozen-lockfile
task validateValidation is free and offline. It runs TypeScript checks, Bun tests and the
same Compose inference used by Pulumi, then prints the complete redacted
remote Compose document. Only offline preview and validation default to the
synthetic demo. Every task that can create or destroy provider resources
refuses to run without an explicit CONFIG.
Preview redaction covers every value resolved from the operator shell, common secret-bearing keys, and URL userinfo. It is not a general DLP scanner: never place literal secrets under unusual field names in Compose.
Use the local operator interface
chalupa is the operator entrypoint. From a checkout, link it once so it is
on your PATH (the public installer above already did this for you):
bun install --frozen-lockfile
bun link
chalupa --versionWithout the link, every example also works as
bun run chalupa -- <arguments> from the checkout:
bun run chalupa -- doctor
bun run chalupa -- inspect
bun run chalupaWith no explicit config, inspection and the interactive UI use the synthetic offline demo and disable provider actions.
Each allowlisted action is a direct command; namespaced Taskfile actions are typed as two words instead of with a colon:
chalupa up --config ./chalupa.yml
chalupa tunnel --config ./chalupa.yml
chalupa ssh --config ./chalupa.yml
chalupa ci arm --config ./chalupa.yml
chalupa teardown register --config ./chalupa.ymlA relative --config resolves against the current directory, so these run
as written from the project that owns chalupa.yml. chalupa ssh resolves the
droplet address from Pulumi output: operating an environment never requires
reading or pasting its IP.
chalupa login --config ./chalupa.yml # or --url https://chalupa.example
chalupa open
chalupa logoutchalupa login signs this terminal into the control-plane console. It prints a
short code and the address where you approve it, waits until you confirm the
same code in the browser, and stores the resulting credential in
~/.config/chalupa/session.json with owner-only permissions. chalupa open
then opens the console already signed in, and chalupa logout revokes the
terminal and removes the local copy. The stored credential is never the
administrator token, never printed, expires after 30 days, and can be revoked
from Terminals in the console. login, open, and logout are the only
commands where the CLI itself makes a network call directly. Every other
chalupa action stays offline until it delegates to Task — but delegated
lifecycle actions (up, down, data-up, tunnel, agent-bootstrap, the
ci:* actions, report, and more) absolutely do reach the network once
Task runs them: they contact DigitalOcean through Pulumi, SSH into the
droplet, and, when cloud.url is configured, call the control plane. "Only
login/open/logout touch the network" describes the CLI process itself before
it hands off, not the lifecycle it triggers. The DigitalOcean token still
lives only in tvault and is never written to disk.
chalupa run <action> remains accepted as the compatibility spelling of the
same allowlist. The CLI delegates to public Taskfile commands and passes only
the canonical config path; other Taskfile inputs such as SSH_IDENTITY are
read from the environment. data-nuke is intentionally unavailable. The CLI
does not read credentials, providers, stacks, Docker, or SSH at startup. See
the Local CLI and operator UI guide for
exact confirmation phrases, distinct compute and protected-data status, TTY
rules, and interrupted-operation recovery. The responsive TUI uses the
website's accessible green/teal visual language, supports arrow, direct,
paging, and Vim navigation, and preserves every status and risk cue when
NO_COLOR is set. It can bind down to a fresh local provider ID; a direct
chalupa down explicitly remains unbound.
Every action honours one verbosity control: -q/--quiet, -v/--verbose,
or CHALUPA_VERBOSITY=quiet|verbose in the environment (a flag always wins
over the environment). VERBOSITY=quiet|verbose works identically when the
Taskfile is invoked directly with task. Verbose is exactly the historical
firehose — Pulumi's own preview table and per-resource progress, chalupa ci
follow's raw NDJSON. The default level captures Pulumi's output and prints a
short summary (resource counts, duration, warnings, and ip/priceHourly/
providerId for compute) instead, and renders ci follow's journal as one
line per step with a running tally instead of raw JSON. Quiet shortens a
clean success further. None of this touches a failure: at every level, a
failed pulumi up/down/data-up prints the complete transcript it always
did, so a diagnostic is never harder to reach than before.
Use it with a project
Place chalupa.yml next to that project's Compose file:
name: my-environment
# instance: staging # optional: run a second copy of this config concurrently
compose: ./docker-compose.yml
services: [api, postgres]
persist:
sizeGb: 10
ssh:
keys: [my-laptop]
allowedCidrs: [203.0.113.10/32]An optional instance suffix lets one config provision more than one
environment at the same time: everything that names a stack, a DigitalOcean
resource, or a control-plane environment composes name-instance (the
confirmation phrases, stack names, droplet, firewall, and data volume all
follow). Without it, the environment identity is exactly name, so existing
configs are unchanged. Two instances of the same config in one DigitalOcean
account still need the same SSH keys and CIDRs, but they no longer collide on
resource names.
Then use the lifecycle:
chalupa data-up --config /absolute/path/to/chalupa.yml # optional, one time
chalupa up --config /absolute/path/to/chalupa.yml
chalupa tunnel --config /absolute/path/to/chalupa.yml
chalupa down --config /absolute/path/to/chalupa.ymlThose commands delegate to the Taskfile, which stays the owner of the
lifecycle logic. Repository work keeps using task from this checkout:
task setup, task typecheck, task test, task validate, task preview,
task ci-preview, task report, task cloud:*, the teardown runner and
schedule tasks, and task data-nuke.
up and data-up always depend on the offline validate gate.
They never install dependencies. Every billable or destructive Taskfile
lifecycle action requires the stack name restated as an explicit
confirmation variable, checked inside the task itself, not only by whatever
called it: task up requires CONFIRM_UP=<config.name>, task down
requires CONFIRM_DOWN=<config.name>, task data-up requires
CONFIRM_DATA_UP=<config.name>, and task data-nuke requires
CONFIRM_DATA_NUKE=<config.name> — the same long-standing gate the other
three now mirror. The chalupa CLI and its operator UI supply the matching
CONFIRM_* variable automatically, but only after their own interactive (or
--confirm) typed-phrase flow succeeds; calling task up/task
down/task data-up directly means restating the variable yourself. task
data-nuke is additionally deliberately separate from the CLI allowlist —
it has no CLI command at all — because it unprotects and permanently
destroys the data stack. When the control plane is configured, down
captures the deployment identity before destroy and sends a signed,
idempotent sunk event only after Pulumi succeeds; delivery failure is a
warning. Provider credentials and the ingest key are never loaded into the
same subprocess: a private, short-lived receipt connects the completed
destroy to the reporting phase. Provider reconciliation may recover a
deployment Chalupa already observed, but the provider invoice remains
authoritative.
task down and task data-nuke delegate their signal-sensitive bodies to
real Bash scripts. Their INT/TERM/HUP cleanup restores private receipts;
data-nuke also re-protects the <name>-data stack if a signal arrives after
unprotect and before a completed destroy. A hard kill or machine loss cannot
run any cleanup, so if an interrupted nuke reports that protection could not
be restored, repair it before doing anything else:
pulumi stack select <name>-data pulumi state protect --all --yes
See Troubleshooting for the recovery procedure.
Supported Compose boundary
Chalupa supports pre-built images, explicit environments, commands,
healthchecks, TCP port mappings, depends_on, resource overrides and named
volumes. It rejects configurations that would make a disposable remote copy
unsafe or non-reproducible, including:
- bind mounts and container-engine sockets;
build,env_file, custom/external volume drivers and anonymous volumes;- UDP, port ranges and ports without a stable published host port;
- host/custom networking, privileged containers, devices and host namespaces;
- secrets/config files, elevated capabilities and other host-dependent runtime features.
A service may declare omitBindMountTargets in its Chalupa override when a
source Compose bind is strictly local and its remote behavior is supplied by
an env or command override. Targets are literal absolute container paths,
must identify exactly one bind, and produce a preview warning. Named volumes
cannot be omitted, and every unlisted bind remains an error, so this exception
never places a host path in the remote Compose document.
Every accepted published port is rewritten to 127.0.0.1 and included in the
SSH tunnel output.
Compose labels provide optional, project-owned classification:
services:
postgres:
image: postgres:17-alpine
labels:
run.chalupa.role: database
run.chalupa.database.kind: postgresql
run.chalupa.database.primary: true
migrate:
image: ghcr.io/example/demo-migrations:2026-07-24
labels:
run.chalupa.role: initializer
run.chalupa.initializes: postgres
depends_on:
postgres:
condition: service_healthy
api:
image: ghcr.io/example/demo-api:2026-07-24
labels:
run.chalupa.role: application
run.chalupa.logs: include
depends_on:
migrate:
condition: service_completed_successfullypreview-compose validates the initializer graph, shows inferred service
roles and explicit log sources, and rejects unknown run.chalupa.* labels.
Database image detection is advisory only. Chalupa never creates SQL,
credentials, images, or initializer commands. Logs are excluded by default;
included services use a deterministic local journald sink without granting
the collector Docker-socket access.
Log selection may instead live with the environment in chalupa.yml. Values
under session.logs.services override the Compose label for the same service;
services mentioned by only one source keep that source's value:
session:
purpose: test
expiresAfterMinutes: 120
logs:
services:
api: include
noisy-support-service: exclude
retentionDays: 7Control plane
An optional cloud.url enables a small systemd heartbeat agent. The agent
reports container state, memory/CPU, uptime and disk use with replay-resistant
HMAC v1 requests. It receives no DigitalOcean credential and cannot create or
delete infrastructure. Pulumi marks all cloud-init user data as secret, but
the ingest key is not part of that user data or any Pulumi resource input.
cloud:
url: https://your-control-plane.example
heartbeatSeconds: 30
session:
purpose: demo
expiresAfterMinutes: 120
logs:
services:
api: include
retentionDays: 7
maxTotalBytes: 67108864
maxBytesPerSecond: 262144
maxChunkBytes: 1048576
maxLineBytes: 8192The shared CHALUPA_INGEST_KEY lives in tvault and in the control plane's
deployment environment. task up preflights its availability before creating
compute. After provisioning, a separate ingest-only subprocess streams it over
the same SSH trust path as task tunnel; it never places the value in a
command argument. Initial
accept-new host-key enrollment is trust on first use, not independent host
identity verification. If delivery cannot authenticate, compute remains
provisioned and may be billable. Fix SSH access and run
task agent-bootstrap CONFIG=/absolute/path/to/chalupa.yml.
For account separation, pass a dedicated local key as an absolute Task input:
task up CONFIG=/absolute/path/to/chalupa.yml \
SSH_IDENTITY="$HOME/.ssh/id_ed25519_chalupa"Chalupa requires that file to be operator-owned, regular rather than a
symlink, recognized by the system OpenSSH implementation, unencrypted for
batch use, and mode 0400 or 0600. Every local SSH operation then adds
IdentitiesOnly=yes, so OpenSSH offers only that key.
Use the same input with agent-bootstrap, tunnel, and down; the latter
uses SSH for final log and Monitor flushes. When SSH_IDENTITY is absent,
normal OpenSSH agent/default identity selection remains unchanged. Only the
public half belongs in DigitalOcean; never put the private key in
chalupa.yml, tvault, Vercel, or the repository.
session gives a demo or test run a visible teardown deadline and an optional,
bounded private-log budget. It does not place a DigitalOcean credential in
Vercel or the droplet, so expiry cannot destroy local Pulumi state by itself.
A trusted operator can explicitly register the exact local stack for the
opt-in local expiry runner: task teardown:register CONFIG=/absolute/path/to/chalupa.yml.
Its default run is dry (task teardown:run); the armed path requires a local
TEARDOWN_ENABLED=1 and delegates only to task down after rechecking the
config digest and provider ID. On macOS, review the dry run and then use
tvault agent start --idle 0, followed in another terminal by
task teardown:schedule:install plus task teardown:schedule:status, to enable
the user-level launchd guard. Installation verifies the persistent same-user
agent can read the exact direct chalupa provider credential without
inheriting unlock secrets; the agent still has its documented broader
same-user vault authority, and the plist contains no credential. A due run
acquires the provider credential before any remote flush, then keeps it out of
the log and Monitor subprocesses. Remove and reinstall the schedule when its
interval or pinned paths change.
task teardown:schedule:remove disables it first, refuses to unload a running
job, and retains registrations and logs; stop the TinyVault agent after removal
and a clean dry run. If the deployment uses a dedicated SSH identity, pass the
same SSH_IDENTITY input during schedule installation; the private launchd
file retains only its local path for final flushes. It never targets a -data
stack, and Chalupa never forwards log bytes through a Vercel Function: the
control plane authorizes
direct private-object uploads owned by file.cheap and retains only indexed
metadata and bounded excerpts in Neon.
The droplet collector reads journald directly and accepts only exact
CONTAINER_TAG=chalupa.<service> records for services whose generated
/etc/chalupa/services.json entry says logs: include. It has no Docker
socket access. The policy in /etc/chalupa/log-policy.json bounds each UTF-8
line, the per-second capture rate, each zstd-compressed NDJSON chunk and the
whole session. Cursor, session, sequence and pending chunks survive service
restarts in /var/lib/chalupa-logs; invalid policy, state, journal metadata or
upload grants fail closed. Excerpts are independently bounded and redact
credential assignments, bearer values, URL credentials and private-key
markers before leaving the host.
The collector source is not embedded in cloud-init. task agent-bootstrap
streams it through the existing trusted SSH stdin channel beside the HMAC key,
verifies a locally precomputed SHA-256 digest on the droplet, and installs it
atomically. Plan, commit and finalize requests use HMAC v1. Chunk bytes use the
returned one-object private PUT grant to go straight to file.cheap; Chalupa,
Vercel and Neon never receive storage credentials or proxy the bytes.
task down makes a bounded best-effort stop that flushes, uploads and finalizes
the current session before destroying compute. Failure warns but never touches
the protected data stack.
Monitor performance telemetry is a separate opt-in capability. Chalupa
installs an exact checksum-pinned Linux release, runs monitor telemetry
without the ingest key, and gives signing and bounded retries to a hardened
local forwarder. The control plane retains seven days of source windows and
indefinite hourly summaries, so deployment charts survive compute destruction.
observability:
monitor:
version: 1.15.0
sha256:
amd64: f8c729041cdd10681f3adc7ec40f9f4e9c1f0e25215b078a028e401a0e9048ef
arm64: 1712d96f9803e297440b3baffbe9e6d09fb4bbe4afaf88be19aef3aa2bf77f4e
sampleSeconds: 5
windowSeconds: 30Monitor v1.15.0 and the checksums above are published and independently
verified. Telemetry remains opt-in. Its contract contains only fixed aggregate
metrics and availability; it excludes host, process, path and application
identity. CPU or heap profiles remain an explicit future artifact workflow,
not part of automatic telemetry. See the integrated Performance telemetry and
private-operations documentation before enabling it.
Monitor v1.15.0 also understands Chalupa's CHALUPA_CI_* run, step, suite,
attempt, and environment identifiers for explicit local investigations. That
diagnostic lane is separate from automatic telemetry: it creates grouped local
issues and credential-free evidence references only when an operator or CI step
invokes it.
task report accepts Cairn stats v1 generated with
--include-runs, removes raw payloads, label maps and filesystem paths, and
sends only the normalized allowlist (including the selected group value as a
bounded variant). To attach evidence from a local vault, use file.cheap
v0.31.0 or newer to generate an ArtifactRefV1 after the artifact pack is
complete, place the complete envelope in a sidecar keyed by the raw Cairn run
ID, and pass it explicitly:
fcheap artifact-ref <stash-id> \
--kind cairntrace.run \
--producer-tool cairntrace \
--native-schema urn:cairntrace.dev:run:v1 \
--native-id <run-id> \
--entrypoint run.json \
--json
task report \
REPORT=/path/to/cairn-stats.json \
ARTIFACTS=/path/to/artifact-sidecar.jsonThe local reader bounds the raw stats file at 4 MiB and the sidecar at 1 MiB; the signed normalized payload remains capped at 256 KiB.
task ci:report publishes the parent pipeline record for a CI run: it reads a
bounded summary.json from the environment over the existing SSH boundary,
resolves providerId from the stack so the run's spend is attributable, derives
the stop reason from the summary itself, and sends it under the same
ingest-only credential profile as task report. A finished run reaches the
control plane without anyone remembering to do it — task ci:cancel publishes
what it collected, and task down publishes before compute is destroyed, which
is the last moment summary.json exists at all. Both are best effort and never
block a destroy; publishing the same run twice is inert, because the control
plane keys on sourceRunId. To show a suite's individual spec rows underneath
their pipeline, hand task ci:report the same document you reported with
task report:
task report REPORT=/path/to/cairn-stats.json
task ci:report CONFIG=/path/to/chalupa.yml SPEC_RUNS=/path/to/cairn-stats.jsonSPEC_RUNS accepts exactly what reporting accepts — Cairn stats v1, a glyphrun
run.v1 array, or a plain array of run IDs — so the IDs are guaranteed to match
the suite_runs rows that were just created rather than being retyped.
Cairntrace v2.1.0 and Glyphrun v0.16.0 can separately publish bounded
complete-run evidence to the private hosted file.cheap service and return a
fcheap-cloud reference.
Chalupa stores reference metadata only. It never uploads artifact bytes,
fetches external links server-side, or tries to resolve a local file.cheap
vault.
Lifecycle and control-plane commands use fixed credential profiles, so each
subprocess receives only the tvault keys it needs plus a fixed operational
environment allowlist. Arbitrary parent-shell variables, NODE_OPTIONS, and
unrelated cloud credentials are not forwarded. Useful offline and
private-staging checks are:
task cloud:verify
task cloud:staging-check CLOUD_ORIGIN=https://your-private-chalupa.example
task cloud:db:migrateThe integrated English documentation lives in cloud/content/docs and renders
at /docs. Start with the quickstart, then use the tvault and private
operations runbooks before connecting a real fleet.
Repository map
src/config.ts— runtime config schema and path resolution.src/compose.ts— safe Compose inference and preview redaction.src/cloud-init.ts— persistent, hardened host bootstrap and heartbeat.src/cli/— thechalupaCLI entrypoint, its operator TUI (src/cli/tui/), and the scoped helper scripts (with-tvault.ts,config-value.ts,pulumi-summary.ts,report-ci.ts,teardown-runner.ts, and the rest) thatTaskfile.ymlshells out to.runtime/ci-engine/— the droplet-residentchalupa-ci.pyengine that runs an armedci:plan, collects evidence, and publishes artifacts.runtime/log-collector/— digest-verified droplet log collector source.src/report.ts— Cairn v1 normalization and request signing.index.ts— thin Pulumi resource layer.preview-compose.ts— offline entrypoint using the same Compose-inference engine asindex.ts.preview-ci.ts— offline entrypoint that renders a declaredci:plan without touching the cloud.cloud/— web control plane.tests/glyph/— TUI behavior fixtures exercised through theglyphrunner.AGENTS.md— operational and safety rules for contributors and agents.
