Documentation/Luma CLI

Luma CLI#

For the full current command and option inventory, see the generated CLI reference. This guide explains workflows and operational semantics.

Luma is the command line interface for installing nodes, wiring providers, rendering Nomad jobs, and deploying services. The orchestrator underneath is HashiCorp Nomad, so the unit of deployment is a Nomad job.

The default path is control-plane first and Nomad-backed:

luma deploy service.yaml -> Luma Control API -> render jobspec on manager -> sync DNS -> Nomad API (/v1/jobs) -> docker driver

Luma Control is the authentication and orchestration layer. It renders the manifest into a Nomad jobspec and submits it directly to the Nomad HTTP API. Inspect deployments with luma status, the dashboard, or nomad job status on the manager.

Install#

CI runners should install the published package instead of running the shell installer:

python -m pip install "luma-infra==0.1.365"

The package distribution name is luma-infra, but the installed command is still luma.

For interactive machines, use the installer:

curl -fsSL https://raw.githubusercontent.com/LiuTianjie/luma/main/scripts/install-luma.sh | sh
~/.local/bin/luma preflight

The installer uses a GitHub archive, not git clone. It installs into ~/.local/share/luma/venv, writes ~/.local/bin/luma, and adds ~/.local/bin to your shell profile when needed. Use ~/.local/bin/luma immediately, or open a new shell / run exec $SHELL -l before using the shorter luma command.

Install a pinned release:

curl -fsSL https://raw.githubusercontent.com/LiuTianjie/luma/main/scripts/install-luma.sh | LUMA_INSTALL_REF=v0.1.365 sh

Development checkout:

./scripts/install-luma.sh
. .venv/bin/activate

Uninstall the local CLI:

curl -fsSL https://raw.githubusercontent.com/LiuTianjie/luma/main/scripts/uninstall-luma.sh | sh

By default uninstall keeps ~/.luma.config.json and ~/.config/luma so a reinstall can keep local prompts and login contexts. To remove those local files too:

curl -fsSL https://raw.githubusercontent.com/LiuTianjie/luma/main/scripts/uninstall-luma.sh | sh -s -- --purge

This only removes the local CLI install. It does not remove Docker, Nomad, Traefik, Luma Control, deployed services, or server-side /opt/luma state.

CI Usage#

CI can run Luma as a stateless control-plane client. It does not need SSH, Docker, Cloudflare, Nomad, or files under ~/.config/luma.

PR validation:

python -m pip install "luma-infra==0.1.365"

export LUMA_CONTROL_URL="https://luma.example.com"
export LUMA_DEPLOY_TOKEN="$CI_LUMA_MANAGEMENT_TOKEN"

luma validate deploy/app.yaml --format json
luma deploy deploy/app.yaml --dry-run --format json

Main or release deployment:

python -m pip install "luma-infra==0.1.365"

export LUMA_CONTROL_URL="https://luma.example.com"
export LUMA_DEPLOY_TOKEN="$CI_LUMA_MANAGEMENT_TOKEN"

luma status --format json
luma deploy deploy/app.yaml --format ndjson --timeout 3000

The control context priority is CLI flags, then environment variables, then the local login context. CI commonly uses:

LUMA_RESOLVE_IP keeps the control hostname in the Host header and requires insecure TLS mode.

For commands with shared Control options, --control-context CLUSTER selects a saved context for that invocation. Explicit flags override environment values, which override the selected saved context. If an explicit Control URL differs from the saved endpoint, Luma does not inherit its token, TLS override or IP override; supply credentials for the target endpoint. This also applies to doctor, service restart and service remove; they can run in CI without a prior login.

Interactive login can prompt for a hidden token, or read it from standard input:

luma login https://luma.example.com
printf '%s' "$LUMA_DEPLOY_TOKEN" | luma login https://luma.example.com --token-stdin
luma context list --format json
luma doctor --control-context staging --format json

login accepts --token or --token-stdin, then falls back to LUMA_DEPLOY_TOKEN and finally an interactive hidden prompt. Non-interactive login without a token fails with a setup hint. Prefer the environment or stdin to keep tokens out of process arguments.

Commands advertising --format support text, json and ndjson; check their exit status as well as the response. Doctor returns a healthy boolean and individual checks; an unhealthy result has a nonzero exit status. Streaming service logs support text and NDJSON, while JSON is available for snapshots.

Git Provider Credentials#

Repository import can use saved GitHub/Gitea credentials instead of a one-off repo URL token. Tokens are write-only and are injected only into the builder's leased clone task.

printf '%s' "$GITHUB_TOKEN" | luma git-provider set github personal --username octo --token-stdin

printf '%s' "$GITEA_TOKEN" | luma git-provider set gitea lin \
  --base-url https://gcode.example.com \
  --username lin \
  --token-stdin

List accounts and discover repositories:

luma git-provider list
luma git-provider repos gitea:lin
luma git-provider refs gitea:lin acme/app

Use the selected provider account with import:

luma import --provider-id gitea:lin --repository acme/app --build-node builder --env .env

For public GitHub repositories, the positional source can also be owner/repo; Luma expands it to https://github.com/owner/repo.git. Use a full URL or --provider-id ... --repository ... for Gitea/self-hosted Git.

Configuration#

luma.yaml is the single project config source:

project: example

providers:
  dns:
    type: cloudflare
    zone: example.com
    zoneId: ""
    apiTokenEnv: CLOUDFLARE_API_TOKEN
    edgeTarget: 203.0.113.10

nodes:
  manager-1:
    host: manager-1
    publicIp: 203.0.113.10
    region: cn
    roles:
      - nomad-server
      - edge
      - egress

defaults:
  exposure: cn-edge
  registry: ghcr.io/liutianjie
  stackRoot: stacks
  routesRoot: routes
  egressNetwork: egress
  entrypoint: websecure
  certResolver: letsencrypt
  engine: nomad

Secrets stay outside Git. For normal use, run the command you actually need:

luma bootstrap manager --domain luma.example.com

If required local values are missing, Luma prompts for them before continuing and saves them to ~/.luma.config.json with mode 0600. On worker servers, the same happens during:

luma node join https://luma.example.com --token <node-join-token> --region global --name global-sg-1

luma configure --role manager|worker remains available if you want to edit local secrets ahead of time, and luma configure --show lists configured keys without printing values. Luma loads .env and ~/.luma.config.json automatically. Use --env-file <path> to load another project-local env file or --no-env to disable local secret loading. Values already exported in your shell take priority. On the manager node, bootstrap and update save the required Cloudflare values in Manager Control state (control.sqlite3 after legacy import) so client machines do not need those secrets. If CLOUDFLARE_API_TOKEN is configured but providers.dns is missing, bootstrap and luma update manager infer the Cloudflare zone from the control domain and write the provider config before installing /opt/luma/luma.yaml. If no edge DNS target is configured, interactive bootstrap asks for LUMA_DNS_EDGE_TARGET; non-interactive update uses the configured edge node public IP or an existing LUMA_DNS_EDGE_TARGET.

Commands#

Initialize a config:

luma init

Check local requirements and .env:

luma preflight

Show control-plane and cluster state from any logged-in client:

luma status

luma status prints DNS readiness, the orchestrator (Nomad) with its server leader, and registered Luma nodes from the control state with role=client.

Store private registry credentials for image pulls:

printf '%s' "$GHCR_TOKEN" | luma registry login ghcr.io --username <user> --password-stdin
luma registry list
luma registry remove ghcr.io

Deploy an in-cluster registry for source-to-image builds:

luma registry serve --node build-1

luma registry serve deploys a registry:2 service on a docker-build-capable node and wires insecure-registries into every non-manager ready Linux node so they can pull built images over the Tailscale network. BuildKit and target nodes both use the reachable Builder Tailscale endpoint <build-node-tailscale-host>:5000; do not configure the removed localhost:5000 push endpoint. Optional flags: --port (default 5000), --storage-class (optional legacy class; omit to use a node-local Docker volume), --image (default registry:2), --name (default luma-registry), --timeout (default 1800). See the repository-import walkthrough in how-to-use-luma.md for the full source-to-image flow.

The same control plane also serves a read-only Web status panel:

https://<control-domain>/dashboard/

Paste the management token to view readiness, nodes, services, and inferred traffic paths from a trusted browser.

Luma has two user-facing tokens:

The per-node agent credential is internal and is installed automatically on the node. Users should check agent status with luma node status, not copy or manage agent credentials.

List nodes from local luma.yaml only:

luma node list

Bootstrap the manager by running this directly on the manager server:

luma bootstrap manager --domain luma.example.com

For single-node, it installs Docker, connects Tailscale when configured, installs and starts the Nomad server, applies node meta, deploys Traefik and Luma Control as Nomad jobs, configures firewall rules, and sets up egress. Set EGRESS_SUBSCRIPTION_URL first when the manager needs a proxy to pull the configured control image. Mainland managers using the default GHCR control image should not use --skip-egress.

It streams progress:

[start] Install Nomad server
[ok] Nomad server ready
[start] Deploy Luma Control
[fail] Deploy Luma Control
  Fix: Re-run luma bootstrap manager after fixing the error

Skip egress only when the control image registry is directly reachable, or when LUMA_CONTROL_IMAGE / defaults.images.lumaControl points at a registry the manager can pull:

luma bootstrap manager --domain luma.example.com --skip-egress

Login from any client machine:

luma login https://luma.example.com --token <management-token>
luma context list
luma context use <cluster-id>

Join additional servers by running this on each server:

luma region create batch-a --egress proxy
luma region list
luma node join https://luma.example.com --token <node-join-token> --region cn --name cn-worker-1
luma node join https://luma.example.com --token <node-join-token> --region global --name global-sg-1
luma node join https://luma.example.com --token <node-join-token> --region home --name home-mac-mini
luma node join https://luma.example.com --token <node-join-token> --region batch-a --name batch-a-01

--region is the scheduling pool. Built-in values are cn, global, and home. Create more with luma region create <name> --egress proxy|direct (also available on the Nodes page), then join machines into that name. Custom regions default to internal workloads (exposure: none). --egress proxy uses the manager gateway for join/image pulls, same as cn/home.

--name is the Luma node name used by luma status and by service manifests. Luma writes it to the Nomad client meta.luma_node_name and uses it for pinned scheduling; the Nomad node identity is a stable UUID, so a rejoin under the same name keeps pinned services valid. Add --engine nomad to force the Nomad client agent path explicitly; it is the default.

Refresh a joined node agent after upgrading an older node:

luma update --control-url https://luma.example.com --token <node-join-token>

Update every registered node that has a ready node agent:

luma update fleet
luma update fleet --install-ref v0.1.365 --timeout 900
luma update fleet --include-manager

--install-ref accepts a release tag, branch, or full 40-character Git commit. Use a full commit for a coordinated candidate rollout so manager and node agents cannot resolve different revisions while a branch moves.

Fleet update runs through the node agents. It updates the CLI on each ready non-manager node and then refreshes the local node-agent service and Tailscale watchdog. The Nomad server (manager) node is skipped by default; update the manager separately with luma update manager from the manager host. --include-manager is available for explicit repair workflows, but normal fleet updates should leave the active control plane alone. Nodes whose agent is too old to advertise luma-update are reported as skipped; run luma update once on those nodes, then they can participate in later fleet updates.

Manager control state under /opt/luma/control is root-only. On a manager where the operator account does not have passwordless sudo, run the break-glass CLI update with elevation (for example sudo ~/.local/bin/luma update manager) or configure LUMA_SUDO_PASSWORD through luma configure --role manager. Without that access, luma update must not fall back to the user's client login context. Older releases can take that wrong path and return a 401 if the client token is stale; restore access to manager state before changing credentials.

The Dashboard Update center is the preferred manager path. It first mirrors the selected Control image into the configured internal Registry, then starts the persisted manager operation. A direct CLI manager update pulls the image itself; on a network where GHCR is unavailable, pass a pullable internal image with LUMA_CONTROL_IMAGE or use the Dashboard flow.

Recover a manager whose public IPv4 address changed. Run the preview on the manager host first, then repeat without --dry-run:

luma manager ip-change \
  --old 8.147.65.253 \
  --new 8.145.62.128 \
  --domain luma.itool.tech \
  --dry-run

luma manager ip-change \
  --old 8.147.65.253 \
  --new 8.145.62.128 \
  --domain luma.itool.tech

The command validates the new address through HTTPS while preserving the control hostname for SNI and certificate checks. It then changes only the manager node publicIp, providers.dns.edgeTarget, and Cloudflare A records whose content exactly equals the old address. It backs up luma.yaml, reuses the image from the running luma-control Nomad job, and reconciles the manager control plane without reinstalling the CLI or redeploying user applications. It never performs a global replacement across Control state; historical incident messages remain untouched. The operation is idempotent, so rerunning it safely finishes any records left behind by a partial Cloudflare failure.

Drain the local Nomad client and optionally unregister the node from the control plane:

luma node exit --endpoint https://luma.example.com --token <management-or-node-join-token> --name home-mac-mini

Remove a node from any logged-in client:

luma node remove home-mac-mini

The control plane removes the Luma registration record and then drains the matching Nomad client on the manager. Matching uses the saved Nomad node ID, meta.luma_node_name, or the node name. Luma refuses to remove a Nomad server (manager) node through this command.

Connect Cloudflare and write providers.dns.zoneId:

luma cloudflare connect --zone example.com

Repair or refresh the outbound gateway:

luma egress setup
luma egress refresh

Install/login Tailscale:

luma tailscale connect

Managers and joined nodes install a lightweight Tailscale watchdog during bootstrap/update. The manager watchdog verifies Tailscale peers plus Nomad gossip/RPC TCP reachability; node watchdogs verify manager Tailscale plus the Nomad server ports. Consecutive failures restart local Tailscale. This is intended to recover tailnet TCP stalls without restarting Docker, Traefik, the Nomad agent, or application jobs.

Generate a service manifest interactively:

luma service new

Validate and render:

luma validate examples/public-cn-service.yaml
luma render examples/public-cn-service.yaml
luma render examples/public-cn-service.yaml --engine nomad

luma render renders locally. --engine nomad forces the Nomad jobspec renderer; this is also the default on current clusters.

Deploy through the control plane:

luma deploy examples/public-cn-service.yaml

Roll back or inspect version history of a deployed service:

luma history public-cn-service
luma rollback public-cn-service
luma rollback public-cn-service --to-version 3

luma history lists prior versions of the Nomad job (GET /v1/job/<id>/versions). luma rollback reverts to the previous version, or to the version given by --to-version N (POST /v1/job/<id>/revert). The web dashboard exposes the same operation from Applications -> Versions. Jobspecs also render update { auto_revert = true }, so a new version that fails its health checks rolls back automatically.

Rollback changes the running Nomad job only. It does not rewrite Git, update the stored manifest in Luma Control, roll back databases, or restore volumes. For predictable production rollback, deploy immutable image tags or digests rather than latest.

Search retained Control build and deployment attempts:

luma service history public-cn-service --kind deployment --status failed --limit 50 --format json
luma service history --source dashboard --since 2026-09-01T00:00:00+08:00 --format json
luma service history --id RECORD_ID --kind deployment --limit 50 --format json
luma build list --app public-cn-service --status failed --limit 50 --format json
luma build logs BUILD_ID --limit 50 --format json

Once a build ID is accepted, transient connection failures resume waiting for that same task until the original --timeout deadline; stopping the wait does not cancel the server build. Queries and read-only workflow checks make at most three attempts; authentication, certificate and response-format errors fail immediately. Build and deployment submissions are not automatically replayed. When Control provides event positions, the wait fetches only new events; older Control versions retain full pagination.

service history [NAME] searches Control records across builds and deployment attempts. Filters include kind (build or deployment), source (build, cli or dashboard), status, application, --since and --until. Time filters accept Unix seconds or RFC3339 timestamps with a timezone. The default page size is 50, with a maximum of 100; pass the returned nextCursor as --cursor while keeping the other filters unchanged. JSON responses include page metadata (limit, nextCursor, hasMore); text mode prints continuation information to stderr. Pagination reads more retained records rather than increasing retention.

service history --id ID --kind KIND reads one record's paged steps and cannot be combined with list filters. build logs ID also pages steps oldest first. These are build/deployment execution events; application stdout/stderr belongs to service logs. Existing luma history NAME remains the Nomad job-version list used with rollback, and is separate from Control attempt history. Importing legacy state cannot recover records that were already trimmed before migration. See Control storage for the retention and backup boundary.

Inspect a service before taking action:

luma service list --region cn --format json
luma service inspect public-cn-service --format json
luma service events public-cn-service --format json
luma service logs public-cn-service --tail 100
luma service logs public-cn-service --allocation ALLOCATION_ID --previous
luma service logs public-cn-service --follow --format ndjson

list --stack NAME narrows the inventory to one stack. Logs are scoped to the selected deployment; use --allocation when comparing individual replicas. --tail accepts a 1–500 line budget shared across selected sources. Text output labels each entry with [allocation/task/stream]; [partial] marks an unfinished fragment and [continued] marks its continuation, including when other sources are interleaved. This is a diagnostic view, not a byte-exact log export. Use JSON/NDJSON to retain the source, cursor and fragment metadata. --previous reads a previous instance where the runtime has retained it. Follow mode resumes from the latest line or heartbeat byte cursor after a clean EOF, transport failure or temporary HTTP failure. Reconnects use exponential backoff from 0.5 to 15 seconds, with at most eight consecutive reconnects without progress. Press Ctrl-C to stop. Authentication, invalid cursor and protocol errors fail immediately. An older Control that provides no resume cursor fails rather than replaying log snapshots. There is no --since flag: application log timestamps are not guaranteed to be parseable, so this CLI does not claim a server-side time filter.

Restart a running service without redeploying:

luma service restart public-cn-service
luma service restart my-stack --service web --mode task

--mode recreate reschedules the allocation; --mode task restarts the task in place. Omitting --mode uses recreate for a whole stack and task when --service targets one task. After the runtime action, Control reconciles the saved deployment's routes and DNS and probes its public HTTP services; Compose reconciles every exposed service. Restart refuses the system stacks traefik, egress, and luma-control. See operations.md for details.

Remove a deployed service:

luma service remove public-cn-service
luma service remove public-cn-service --dry-run

Check authentication, remote Control status, DNS configuration readiness, node agents and Nomad health (this does not audit the local Docker/OS environment):

luma doctor
luma doctor --deep

--deep also evaluates the remote node diagnostics reported in Control status (Docker mirrors/proxy and Nomad configuration). DNS readiness means configuration is present; it does not prove public DNS resolution or application availability.

Service Manifest#

name: app
image: ghcr.io/me/app:latest
region: cn
exposure: cn-edge
domain: app.example.com
port: 3000
replicas: 2

Required fields:

Public services also require:

Optional fields:

Example worker that needs the Luma egress proxy:

name: ai-worker
image: ghcr.io/acme/ai-worker:1.0.0
region: cn
exposure: none
proxy: true

Example bounded service for a small manager:

name: api
image: ghcr.io/acme/api:1.0.0
region: cn
exposure: none
resources:
  limits:
    memory: 512M
  reservations:
    cpus: "0.10"
    memory: 128M

Deploy Order#

For luma deploy service.yaml, Luma does:

  1. parse and validate the service manifest;
  2. if --env <file> is provided, parse that .env locally and keep only variables referenced as ${NAME} by the manifest;
  3. read the current login context from ~/.config/luma;
  4. submit the manifest and filtered scoped env secrets to the manager's Luma Control API;
  5. store incoming env secrets under the service name scope and resolve ${NAME} before rendering;
  6. render stacks/<region>/<service>/<service>.nomad.json (the jobspec) on the manager;
  7. render routes/<service>.yml on the manager for tailscale-relay or tcp-relay;
  8. upsert Cloudflare DNS unless skipped;
  9. submit the job to Nomad through PUT /v1/jobs (create or update);
  10. probe the public route for cn-edge and external-edge services.

The client prints local progress before submitting the request, while waiting for the control plane, and for each control-plane step. Luma validates generated Traefik file-provider routes, stages them outside the watched routes directory, then atomically publishes the final route file. A public route probe reports the HTTP status from /; an application-level 404 means the route is reachable but the application may not serve a root page, while Traefik's default 404 page not found is treated as a missing router and a failed public route. When the probe reports the route unhealthy (Traefik router not found, or a transient 502/503/504), Control recreates the service's allocation once and re-probes before failing the deploy. Single-service and Compose jobs allow 30 minutes for cold image acquisition and 40 minutes for rollout progress; the default deploy response timeout is 3000 seconds so Control remains attached beyond that bounded Nomad window. Use --timeout <seconds> to override it.

Deploy is an upsert. Re-running luma deploy service.yaml with the same service name updates the existing Nomad job (the job id is the service slug) instead of creating a duplicate. The update uses the current rendered jobspec as the source of truth, and Nomad keeps the previous version so luma rollback or the dashboard's Applications -> Versions action can return to it.

Use luma deploy service.yaml --env .env when the project already has a deployment env file. Scoped env secrets are isolated by service name, so api/DATABASE_URL and worker/DATABASE_URL are distinct values. Legacy global luma secret set NAME values are still used only for applications that have no scoped secrets.

For source-to-image deploys, luma import uses the same scoped secret model:

luma import https://github.com/acme/app --build-node builder --env .env
luma import acme/app --build-node builder --env .env   # GitHub owner/repo shortcut

When the shared Builder is slow, build the current checkout on your own computer and keep the artifact in the same Luma project:

cd app
luma build local . --env .env

luma build local infers the project identity from the checkout's origin (use --repo-url when there is no origin), reserves that project in Control, builds with local Docker Buildx, pushes to the configured Luma registry under the same owner/repository namespace used by luma import, and then runs the normal deploy path. The local computer must be able to reach the configured build.registryHost; for an authenticated registry, log in with Docker first. Single-service manifests and Compose sidecars are supported, including --compose-sidecar, --platform, --context, and --dockerfile overrides. Use --builder <name> to reuse an existing local Buildx builder when it has the required target platform or local registry/mirror configuration. Use --proxy <url> when local base-image and Dockerfile network access should use an HTTP proxy; the internal registry is kept in NO_PROXY. Both this command and Builder import derive the container architecture from the deployment node or currently ready nodes in the target region. A Darwin/ARM node therefore builds linux/arm64; a region with both amd64 and arm64 targets builds a multi-platform image. An explicit --platform is accepted only when it covers every resolved target architecture.

With a Control advertising build-queue-v1, the CLI automatically submits Repository Import and build retry to a persistent per-project FIFO. Local builds may build and upload concurrently using unique Control-assigned tags; after upload, their deployment joins the same FIFO. Queue order is submission order (upload completion for local builds), not local build start order. Only one queued operation per repository project runs at a time; unrelated projects can use other worker slots, subject to existing Builder capacity and runtime deployment locking. A failed or canceled attempt does not discard later work.

The CLI displays the build ID, queue position and blocking task while waiting. --timeout bounds the client wait; closing the CLI after acceptance does not cancel the server task. Inspect it with luma build logs <id> or cancel a waiting task with luma build cancel <id>. Queued requests survive Control restart. Interrupted active tasks are explicitly failed rather than automatically replaying deployment side effects; inspect runtime before retrying. Local builds that have not finished uploading still require the caller's machine. Per-attempt environment values are stored only in private Control state while queued/executing, isolated from other attempts and public build history.

Older Control versions retain the previous fail-fast active-build restriction; upgrade both Control and CLI to use the queue. Prebuilt-image deploy and compose deploy keep their existing synchronous runtime lock. Local uploads are still rejected if images escape the reserved project repository/tag.

or, with a saved Git provider account:

luma import --provider-id gitea:lin --repository acme/app --ref main --build-node builder --env .env

Import auto-discovers single-service Luma manifests (.luma.yml, luma.yml, nested *.luma.yml) and Compose sidecars (luma.compose.yml, .luma.compose.yml, *.luma.compose.yml, *.compose.luma.yml, docker-compose.luma.yml). Compose sidecar filenames are excluded from single-service manifest matching so docker-compose.luma.yml is treated as Compose, not as a service manifest.

If the repository does not contain a deployment file yet, provide the deployment file from the CLI:

luma import --provider-id github:personal --repository acme/app \
  --build-node builder \
  --manifest deploy/app.luma.yml \
  --env .env

Unlike plain luma deploy, import may discover the final deployment manifest on the builder after cloning the repository. Therefore the CLI sends the .env values to Luma Control, and the control plane keeps only values referenced by the resolved manifest or Compose content under the final service/stack scope.

For Compose repositories, luma import builds services that still have build: and injects the resulting image: before deployment. Plain luma compose validate and luma compose deploy do not build, so use import-mode validation when checking that path locally:

luma compose validate --import-mode luma.compose.yml

When a repository contains more than one deployment sidecar, select the exact Compose sidecar inside the cloned repository instead of relying on discovery:

luma import https://github.com/acme/platform.git \
  --ref v1.4.0 \
  --build-node builder \
  --compose-sidecar deploy/staging.luma.compose.yml \
  --env .env

--compose-sidecar accepts only a canonical POSIX repository-relative path and cannot be combined with --manifest. The CLI requires a Control capability before starting the build; Control requires the Builder to echo the same path; the Builder rejects absolute paths, .., missing/invalid YAML, and symlink escapes. An explicit selection never falls back to an auto-discovered sidecar. Update the manager and Builder agent first when either side lacks this capability.

--dry-run renders locally and does not submit a deployment. When local rendering cannot read optional cluster context such as node or storage metadata, JSON output includes validationMode: "degraded" plus warnings; text output prints [warn] lines. --skip-dns and --skip-orchestrator are sent to the control API. --commit and --push are deprecated in control-plane deploy mode.

Luma records deployment state before running external operations. A successful deploy is marked active; if DNS, the Nomad submission, route rendering, or probing fails after earlier steps have changed the manager, the recorded deployment is kept with status: failed_partial so the dashboard and luma service remove <name> can still find the partially applied job.

For luma service remove <name>, Luma looks up the manifest recorded by the control plane during the last successful deploy and removes the matching single-service or Compose deployment slug. This recorded manifest is the source of truth, so remove and storage cleanup also work for deployments created from the web UI when the client running the command has no YAML file. By default Luma deletes Luma-managed Cloudflare DNS, deregisters and purges the Nomad job, and deletes generated jobspec files such as stacks/<region>/<service>/<service>.nomad.json or stacks/compose/<name>/<name>.nomad.json. tailscale-relay and tcp-relay route files are removed too. Use --dry-run to preview, --skip-dns to keep the DNS record, and --skip-orchestrator only when you intentionally want to remove generated Luma files without stopping the Nomad job. Storage data is preserved by default; add --delete-storage to delete removable storage declared by the recorded deployment. For single-service deployments this removes managed storage paths referenced by storage.<volume>.path and removes named Docker volume objects such as data:/data; bind mounts are skipped. For Compose deployments this removes managed storage paths referenced by the sidecar. --delete-storage cannot be combined with --skip-orchestrator. cloudflare-tunnel public hostnames are still managed in Cloudflare Zero Trust, so Luma reports that cleanup as skipped.

Automatic build/deploy workflow checks#

Luma records each application's successful CLI build/deploy workflow on Control. The record is shared across machines and agents, independently of the checkout and of the bounded build/deployment history. No backfill of existing applications is required.

Before luma import, luma build local, luma build retry, luma deploy, or luma compose deploy starts a build, reserves a local upload, or submits a deployment:

An interactive text terminal asks Confirm this workflow change and deploy? [y/N]. JSON/NDJSON, quiet and non-interactive callers stop with a nonzero exit status. An agent must show the differences to the user and obtain approval before retrying with --accept-workflow-change; it must not add that flag on its own to fix an error. CI can use the flag only where a workflow change has been approved.

# Normal deployment: automatic check and recording; no extra flag needed.
luma import --provider-id github:me --repository acme/app --ref main --build-node builder

# Read the shared record, including the command and last recorded success.
luma workflow show app
luma workflow list --format json

# Add context to the record on the next successful deployment.
luma import acme/app --ref main --workflow-note 'Build on Builder; this project requires remote network access'

# Only after the user has approved the displayed differences:
luma build local . --platform linux/amd64 --accept-workflow-change

# Explicitly record/edit a workflow without executing it.
luma workflow record app --note 'Release from the main branch on Builder' -- \
  import acme/app --ref main --build-node builder

# Execute the recorded command from a checkout, using current login credentials.
luma workflow run app --path /path/to/app

The record retains the argument array, method, optional note and last successful recipe/evidence. Manual edits are labeled manual; an earlier success is kept with its original recipe and is not proof that the edited command has run. Failures, interrupted streams, dry runs and --skip-orchestrator do not overwrite success records. If deployment succeeds but saving the workflow fails, the CLI reports deployment success with workflow.saved: false and a warning; do not blindly redeploy to repair a recording error. Concurrent edits are preserved.

Application manifests identify service/Compose targets. Imports discover the record from the repository, or the selected Compose sidecar in a monorepo. When several applications match, select one with --workflow-app APP. A different application name has its own record. Repository-relative file paths can move with a checkout; external absolute config/env paths must exist on the next machine. Env-file contents, management tokens and URL credentials are never stored in the recipe. Free-text notes are user-authored: do not put secrets in them. A retry records the original build parameters and build retry ID; replaying that command requires the original build record to remain available. The guard compares CLI workflow parameters, not source-code changes, image tags or manifest contents.

CLI and Control must both support deployment-workflow-v1. Upgrade Control before using the new CLI for deployments. An unreachable or older Control is an unavailable check, not proof that no record exists, so deployment stops with an explanation. The workflow is a CLI coordination mechanism; older clients and direct API/dashboard deployments do not participate in these CLI checks.