Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

HTTP API

The daemon’s transport-independent API is piqueld::api::ApplicationService. It is a cheap, clonable handle shared by HTTP handlers and future MCP or scheduled callers. Its private store and runtime back every domain operation: mutation acceptance, planning, application views, history, logs, manifest export, and host status. Its implementation lives in the private api::service module; the Axum adapter lives in api::http. Future MCP and cron adapters can be added alongside HTTP. Adapters decode transport inputs and map service results and errors, while runtime implementation and boundary types remain in application. accept enforces revision/identity preconditions, explicit force overrides, and idempotency for every caller; Mutation::save saves configuration and optionally deploys it. Inputs use validated manifests and typed application and environment IDs.

ApplicationService::start opens the store, connects Docker, and starts the reconciliation worker. The process binds listeners and holds directory locks before calling it, then cancels and joins the returned worker before releasing those locks. ApplicationService::new accepts supplied storage and runtime adapters for tests or embedded use; it does not start background work.

To script the API from Rust, use piqueld-client, the typed client piquelctl and the dashboard use. Its documentation is on docs.rs. Use the release matching your daemon’s version.

The API is rooted at /api/v1 over a Unix socket and optional localhost or Tailscale TCP. Responses use a data envelope; lists contain items and an opaque next_cursor. Errors expose a safe message, code, details, and request ID. Clients poll for progress.

Request bodies are limited to 2 MiB, and to 64 KiB on the public sign-in endpoints (16 KiB for device starts); larger ones fail with 413 request_body_too_large. A body that sends nothing for a minute fails with 408 request_body_timeout. Deployment analytics, logs, commands, event streams and installation checks each run a bounded number at once across all listeners (4 analytics reads, 8 log reads, 32 commands, 128 event streams, 2 installation check runs); more fail at once with 503 server_busy, which records no diagnostic. Retry them shortly.

An application owns the saved manifest and its configuration revision (generation). Its environments deploy that manifest; each owns its deployment history, operations, status, volumes, generated secrets, routes, and Docker network; manually set secrets live in the application’s secret store. Environment IDs are the IDs Docker names, ownership labels, and history derive from. New applications get the environments their manifest declares, or a production environment when it declares none, each with its own env-… ID. An application created before environments had their own IDs keeps a production environment that shares the application’s app-… ID. An environment’s source is { "type": "saved" } for applications without a manifest repository, or { "type": "branch", "branch": "main" } for repository-backed ones: each such environment follows its own branch of the application’s repository, optionally pinned to a commit ("commit"). The repository URL and manifest path stay on the application (spec.manifest). Connecting a repository points every environment at the branch spec.manifest names; disconnecting returns them to the saved manifest. A promoted environment has { "type": "promoted", "environment": "env-..." }, naming by ID the environment it receives releases from; repository changes leave it promoted.

A repository-backed environment deploys the manifest last fetched from its branch, which EnvironmentDetailView.manifest returns (null before its first fetch). Hostname reservations and the environment_configured rename check read that manifest, so a fetch for one environment never changes another. The application’s saved manifest is the one last fetched from the branch spec.manifest names, by any environment, with the application’s own connection; it is what application views show and what environments deploy after disconnecting. A sibling hostname conflict returns hostname_conflict with the hostname and reserving environment name in details.hostname and details.environment.

A preview is an environment whose kind is { "type": "preview", "branch": "feat/login", "slot": "agent-2", "slug": "notes-feat-login-agent-2-503aa8" }; environments have { "type": "environment" }. A preview’s name is its slug and its source is its branch. Application views list previews separately in previews, never in environments, and the environment mutation endpoints (deploy, rename, branch, source, sync, telemetry, promote, restore, delete, reconcile, stop, start) return 404 for a preview’s ID, as the preview endpoints do for an environment’s. The environment read endpoints (GET /environments/{id}, detail, status, deployments and their attempts, logs, secrets) accept a preview’s ID. Preview creation, deployment and deletion take no expected_generation and never advance the application revision: creation is idempotent on (branch, slot) instead.

Creating a preview that would exceed a [previews] limit fails with 409 preview_limit_reached. Its details are a PreviewLimitReached: the limit (per_application or total), its max, and the previews it counts that the caller may read, oldest deployment first, each with its id, application_id, preview (branch, slot, slug), deleting (previews count until their deletion finishes) and last_deployment (id, created_at_ms). Returning an existing preview never counts against a limit. PreviewView.bounds lists how the limits bounded the preview’s deployed target: preview_limits_defaulted and preview_replicas_capped warnings, also recorded on its deployments and plans. SystemStatus.previews, null when they cannot be counted, counts previews against the limits, installation-wide and for each readable application, sums the CPU and memory limits of their deployed replicas, and counts the replicas deployed before the limits applied, which run without them until redeployed.

Deploying on push is configured by spec.manifest.sync. Environment views carry sync, true once an environment opted in (previews follow their application), and synced, the branch head as of its last deployment (commit, at_ms), which sync follows from; commit is absent when a local snapshot was deployed before its branch was pushed. PreviewView.sync is following while the application syncs and once the preview was deployed, and ApplicationView.sync_check is the last listing of the repository’s branches (checked_at_ms, and error when it failed). Sync deployments are ordinary operations whose events carry a system actor, sync:poll or sync:webhook, with a branch_synced event naming the commit. GitHub deliveries never reach this API; see push webhooks.

Application list items contain id, name, generation metadata, deletion intent, timestamps, and their environments. Read /api/v1/applications/{id} when the complete normalized manifest is needed.

MethodPathPurpose
GET/api/v1/system/statusDaemon status, with tailscale: the tailnet node’s login and certificate state; images: the built images cleanup kept, the total size of those it removed since the daemon started, and when it last ran; DNS providers and certificates only with system:read; preview counts per readable application; snapshots: the largest local snapshot upload, working tree and file count the daemon accepts
GET/api/v1/system/configurationEffective read-only host settings (system:read); 503 configuration_unavailable without them
GET/api/v1/system/checksInstallation checks, each pass, warn, fail or skip with a next step (system:read); see piquelctl doctor
POST/api/v1/system/dns/refreshCheck DNS provider credentials and zones now (system:operate); returns DnsStatus, empty without system:read
GET/api/v1/openapi.jsonGenerated API schema
GET/api/v1/applicationsPaginated application summaries (up to 100 per page)
GET/api/v1/applications/{id}Full latest accepted application intent and its environments, with drift: how they differ from the environments the saved manifest declares
POST/api/v1/applications/planPlan a manifest without pulling images; environment=ID selects the environment to compare with
POST/api/v1/applications/applySave configuration by name, converging the environments it declares; ?deploy=true also deploys its only environment, or every declared one that builds (operation_ids)
DELETE/api/v1/applications/{id}Request deletion of every environment and preview; no body. environments=a,b must name every environment when there are several
POST/api/v1/applications/{id}/renameRename an idle application without redeployment; 409 repository_managed for a repository-backed one
POST/api/v1/applications/{id}/environmentsAdd an environment: { "name": "staging", "branch": "main", "commit": null, "expected_generation": 3 }; branch defaults to the one spec.manifest names and requires a repository-backed application. "promote_from": "env-..." instead creates a promoted environment; it excludes branch and commit (400 source_invalid). A commit without branch is 400 revision_invalid. 409 environment_declared for a name the saved manifest declares
PUT/api/v1/environments/{id}/branchFollow another branch, or pin or unpin a commit, without redeploying: { "branch": "release", "commit": null, "expected_generation": 3 }; 409 environment_promoted for a promoted environment
PUT/api/v1/environments/{id}/telemetryGrant or revoke an environment’s telemetry: { "granted": true }; needs telemetry:grant and no expected_generation. Revoking stops export at once. 404 for previews, which are never granted
PUT/api/v1/environments/{id}/syncOpt an environment into or out of its application’s sync: { "enabled": true }; needs no expected_generation, and opting in while it syncs needs apps:deploy too. 404 for previews, which follow their application
GET/api/v1/applications/{id}/webhookWebhookView: the payload URL to configure in GitHub (absent until the daemon sets ingress.webhook_hostname) and when the secret was generated
POST/api/v1/applications/{id}/webhook/secretGenerate a new webhook secret, replacing the previous one (apps:write); the WebhookSecret response is the only time it is shown
PUT/api/v1/environments/{id}/sourceMake the environment promoted, { "promote_from": "env-...", "expected_generation": 3 }, or, with promote_from null, track the saved manifest or the branch spec.manifest names again; deploys nothing
POST/api/v1/environments/{id}/promotePromote a release into a promoted environment: { "deployment": null, "release": null, "expected_generation": 3 }; 202 with AcceptedPromotion
POST/api/v1/environments/{id}/promote/planPlan that promotion, or an earlier release for any environment, without changing anything; PlanView with release
GET/api/v1/environments/{id}Environment metadata
GET/api/v1/environments/{id}/detailEnvironment, application intent, the manifest it deploys, resolved generation, status, current release, observed runtime, operation, diagnostics, the URL of each rendered route with its state (see URL readiness), and its links (see below)
GET/api/v1/environments/{id}/statusIntent progress and separate runtime health
POST/api/v1/environments/{id}/deployDeploy the environment from its source with fresh source resolution; supersede pending work. branch=NAME or commit=SHA fetches a repository-backed manifest from that revision instead of the environment’s branch, for this deployment only
GET/api/v1/environments/{id}/deploymentsDeployment snapshots, newest first; limit per page, from 1 to 100, three without it
GET/api/v1/environments/{id}/deployments/{deployment}/attemptsRetained outcomes, newest first, 100 per page
GET/api/v1/applications/{id}/releasesThe application’s releases, newest first, twenty per page
POST/api/v1/environments/{id}/renameRename an environment without redeployment: { "name": "...", "expected_generation": 3 }
DELETE/api/v1/environments/{id}Request deletion of one environment; no body
POST/api/v1/environments/{id}/reconcileRetry the latest operation with its saved inputs once it has ended or failed, starting a stopped environment; an operation still in progress is returned unchanged
POST/api/v1/environments/{id}/stopStop the environment: withdraw its routes and scale its services to zero; 202 with its latest operation
POST/api/v1/environments/{id}/startStart a stopped environment, as reconcile does; 202 with its latest operation
POST/api/v1/environments/{id}/restoreRestore an earlier, succeeded deployment exactly: { "deployment": "operation-..." }; 202 with AcceptedOperation
GET/api/v1/applications/{id}/previewsPreviews with status, latest operation, hostnames, the origin of what they run and branch state (exists, moved, gone, local or unknown), from one git ls-remote, shared by the reads of a repository that arrive while it runs; at most four repositories are listed at once
POST/api/v1/applications/{id}/previewsCreate and deploy a preview: { "branch": "feat/login", "slot": "agent-2" }; 202 with CreatedPreview (preview, operation, created: true), or 200 with the existing preview of that branch and slot and its latest operation (created: false), never redeploying it. 409 preview_requires_repository without a manifest repository, preview_limit_reached over a [previews] limit
POST/api/v1/applications/{id}/previews/local?branch=…&slot=…Deploy a local snapshot: the body is a Git bundle (application/octet-stream); 202 with CreatedPreview. 408 snapshot_timed_out, 413 snapshot_too_large, 422 snapshot_invalid or snapshot_base_unavailable
POST/api/v1/applications/{id}/previews/pruneDelete each listed preview whose branch is confirmed gone: { "previews": [preview IDs] }; returns the DeletedPreviews and keeps the rest. 502 repository_unavailable, deleting nothing, when the repository cannot be read
GET/api/v1/previews/{id}One PreviewView
POST/api/v1/previews/{id}/deployDeploy the head of the preview’s branch, replacing a local snapshot it runs; 202 with AcceptedOperation
POST/api/v1/previews/{id}/restoreRestore an earlier, succeeded deployment of the preview exactly, as for an environment; 202 with AcceptedOperation
DELETE/api/v1/previews/{id}Delete the preview and every volume it created; 202 with AcceptedOperation
GET/api/v1/operations/{id}Inspect progress, attempt count, and safe diagnostics
GET/api/v1/eventsPaginated informational history, oldest first
GET/api/v1/events/kindsThe distinct kinds of the events the caller may read, in name order, to filter the history by kind
GET/api/v1/events/streamReplay and follow history as server-sent events; see event history
GET/api/v1/environments/{id}/execWebSocket streaming a command in a running service task

Local snapshots

POST /api/v1/applications/{id}/previews/local?branch=feat/login&slot=agent-2 deploys a snapshot of a local worktree, which piquelctl preview deploy --local makes, to the preview of branch and slot. It needs previews:deploy (included in apps:deploy) and no expected_generation, and creates the preview first if needed, as POST /api/v1/applications/{id}/previews does: idempotently, within the [previews] limits. The response is always 202 with CreatedPreview, whose created says whether the preview was new, and a new deployment.

The body is a Git bundle, sent as application/octet-stream (a Content-Length lets the daemon refuse it early), of exactly one ref pointing at the snapshot commit. A thin bundle needs prerequisites (at most 64), which the daemon fetches from the application’s repository: its base and, for a history with merges, ancestors of that base. 422 snapshot_base_unavailable (with details.prerequisites) means it could not fetch them: push the base, or send a complete bundle. The body is streamed to the daemon’s data directory and refused with 413 snapshot_too_large (with details.limit) once it exceeds [snapshots] max_upload_bytes, which is separate from the 2 MiB limit of other bodies, and also once its files and directories (or the prerequisites fetched for it) take more than [checkouts] max_bytes, which details.limit then names, or number more than 500,000. SystemStatus.snapshots reports these limits. The daemon receives two uploads at a time, each holding its slot until its deployment is accepted; a body that sends nothing for 30 seconds, or an upload not received within 10 minutes, fails with 408 snapshot_timed_out. The bundle is received into a new repository of its own: a malformed header, a bundle git bundle verify rejects, an object that fails Git’s checks, prerequisites that are not all ancestors of one of them, the base, or a snapshot that does not descend from its base is 422 snapshot_invalid.

The deployment’s origin is { "type": "local", "commit": …, "base": … } (base absent for a complete bundle). It reads its manifest from the snapshot at the application’s manifest path, renders git.sha as the snapshot commit, and builds repository = "self" services from the snapshot, through the preview’s usual path, [previews] bounds and build priority included. It records the branch’s head, or its absence, for deploy on push, so a snapshot stays until its branch moves or is first pushed. PreviewView.origin is the origin of what the preview runs (of its latest deployment before one was prepared), and its branch is { "state": "local", "head": … } (head absent while the branch was never pushed) while it runs or deploys a local snapshot, even after a redeployment of its branch failed; pruning keeps such previews. Snapshots no preview’s latest deployment uses are removed on the next full reconciliation scan.

Field endpoints

All paths below are relative to /api/v1/applications/{id}. Edits accept expected_generation=N (required unless force=true), deploy=true|false (default false), and Idempotency-Key. They return Envelope<SavedApplication> with HTTP 200 for saving or HTTP 202 when a deployment is accepted. deploy=true deploys the application’s only environment, or every environment its manifest declares that builds, and otherwise fails with 409 environment_required (listing them in details.environments) when it has several or none. The server loads, edits, validates, and saves its internal manifest inside one transaction; clients do not need to read and replace it. The request receipt and optional immutable deployment snapshot commit in the same transaction.

MethodPathJSON body
PUT/name{ "value": "new-name" }
POST/servicesService (new name required)
DELETE/services/{service}None
POST/volumes{ "name": "data" }
DELETE/volumes/{volume}None; mounted volumes are rejected
PUT/routes{ "value": [{ "hostname": "notes.example.com", "service": "web", "port": 3000 }] }; replaces this application’s routes
PUT/jobs{ "value": [{ "name": "migrate", "service": "web", "command": ["notes", "migrate"], "run": "before-rollout", "timeout_seconds": 300, "volume_access": "read-only" }] }; replaces the jobs, in execution order
PUT/exports{ "value": [{ "service": "api", "applications": ["notes"], "previews": false }] }; replaces the services other applications may reach
PUT/generated-secrets{ "value": [{ "name": "database-password", "generate": { "type": "random", "bytes": 32, "encoding": "hex" } }] }; replaces the spec.secrets declarations, keeping values already generated
PUT/imports{ "value": [{ "application": "auth", "service": "api", "environment": "staging" }] }; replaces the services of other applications this one reaches; environment is optional
PUT/variables{ "value": { "defaults": { "domain": "piquel.fr" }, "environments": { "staging": { "domain": "staging.piquel.fr" } }, "previews": { "domain": "preview.piquel.fr" } } }; replaces every variable value, clearing those it omits
PUT/environments/{environment}/visibility{ "value": "private" }; sets the route visibility ceiling of the environment with that name
PUT/environments/{environment}/rollback{ "value": "manual" }; sets what happens when the rollout of the environment with that name fails: automatic, manual, or null for the default (rollback)
PUT/services/{service}/name{ "value": "worker" }
PUT/services/{service}/replicas{ "value": 3 }, or a reference such as "${{ vars.web_replicas }}"; typed fields accept references
PUT/services/{service}/source{ "value": Source }
PUT/services/{service}/source/image{ "value": "nginx:stable" }
PUT/services/{service}/source/git/{url,branch,commit,dockerfile,context}{ "value": "..." }; commit may be null
PUT / DELETE/services/{service}/environment/{key}PUT: { "value": "..." }; DELETE: none
PUT/services/{service}/{command,arguments}{ "value": ["element", "..."] }
PUT/services/{service}/mountMount; adds/replaces by container target
DELETE/services/{service}/mount{ "value": "/container/target" }
PUT/services/{service}/fileServiceFile, e.g. { "target": "/etc/app/motd", "content": "hello" }; adds/replaces by container target
DELETE/services/{service}/file{ "value": "/container/target" }
PUT/services/{service}/depends-on{ "value": ["db"] }; replaces the services that must be healthy before this one rolls out
PUT/services/{service}/receives-telemetry{ "value": true }; makes the service the application’s telemetry receiver
PUT/services/{service}/rollout{ "value": { "order": "stop-first", "monitor_seconds": 30 } }; replaces the rollout block, and omitted fields use their defaults
PUT/services/{service}/secrets{ "value": [{ "name": "token", "target": "/run/secrets/token" }] }; replaces file references without exposing values
PUT/services/{service}/healthcheck{ "value": HealthCheck }; null clears
PUT/services/{service}/healthcheck/{port,path,command,interval,timeout,start-period,retries}Typed { "value": ... }
PUT/services/{service}/resources/{cpu,memory}{ "value": 500 }; null clears the selected limit
PUT / DELETE/repositoryPUT: { "value": RepositoryManifest }; DELETE: disconnect
PUT/repository/{url,path}{ "value": "..." }; every environment fetches from them. Branches belong to environments: see /api/v1/environments/{id}/branch
PUT/repository/sync{ "value": { "mode": "poll", "interval_seconds": 300 } }, { "mode": "webhook" }, or { "mode": "off" }. Turning sync on needs apps:deploy too, as does saving or connecting with it on

The braces listing multiple names denote separate documented endpoints. Nested source/check settings require the appropriate variant; switch variants through source or healthcheck. Unknown fields, missing values, invalid manifests, and missing resources are rejected. Optional values must explicitly use null to clear. The same validation rules apply even with force=true. Repository-backed applications accept every edit; each applies to the manifests their deployments fetch (see editing repository-backed applications). Disconnecting a repository preserves saved services and volumes for local editing. Renaming a service updates its routes, its jobs, its export, and dependents’ depends_on; removing a service removes its routes, jobs, and export and drops it from other services’ depends_on. Jobs inherit the referenced service’s depends_on, including transitive dependencies. Those services converge before the job starts; other services wait for all jobs to succeed. If a dependency has its own jobs, they must appear earlier in the job list (job_dependency_order_invalid). volume_access is read-only (the default) or exclusive; exclusive access needs the service to mount a writable volume, and no startup dependency may mount any of those volumes (job_volume_access_invalid). A job’s optional environment and secrets, shaped like a service’s, are added to its service’s for the job’s container only; a variable or secret target the service already sets is rejected with job_setting_conflict. Routes remain saved while ingress is disabled and become active only after deployment with ingress enabled in the daemon’s read-only TOML configuration.

For dashboard forms, typed section endpoints also allow atomically replacing /volumes, service /environment, /mounts, /files, /resources, /general (source and replicas), and /process (command and arguments). These never replace the complete application manifest. See the generated OpenAPI document for exact request schemas and error responses.

POST /api/v1/applications with { "value": "name" } creates an empty application, requiring the name to be absent. It supports deploy and Idempotency-Key. Full-manifest apply remains available for import.

Saving and deployment acceptance work while Docker is unavailable. Execution errors are recorded asynchronously. Plan requires runtime observation and returns 503 docker_unavailable during an outage. Environment detail remains readable and reports unavailable runtime observation as a diagnostic.

Apply and plan accept JSON { "manifest": ..., "expected_generation": 3, "expected_application_id": "app-..." }, or complete TOML with Content-Type: application/toml or text/toml. TOML preconditions use X-Expected-Generation and X-Expected-Application-Id.

Every mutation of an application or one of its environments, except sync opt-ins, telemetry grants and previews, is conditioned on the inspected application revision. Apply, deploy, delete, rename, and environment creation require preconditions unless the endpoint is explicitly called with the query parameter force=true. Missing preconditions return 400 precondition_required. Apply requires generation zero to create an absent name, or both the inspected application ID and generation to update an existing name. Generation zero for a taken name returns 409 application_name_collision, as renames do. Deploy and delete require expected_generation in its query; renames, environment creation, source changes and promotions take it in JSON. Revision mismatches return 409 generation_conflict; identity mismatches return 409 identity_conflict. Checks and acceptance are atomic.

Force overrides both revision and name-based identity preconditions, even if stale values were supplied. Forced apply overwrites whichever application currently has the manifest name, or creates one if absent. ID-based mutations still target their URL’s ID. Force never bypasses manifest validation, resource ownership, or rename busy/name-collision checks, and needs the same permissions as the change without it.

Reconcile acts on the current intent without requiring preconditions; it accepts an optional expected_generation query for callers that want one. Reconcile can continue an already-requested deletion. Plan preconditions are also optional. The CLI supplies apply/delete/rename preconditions automatically; --yes skips confirmation and --force requests the override independently.

Configuration generation starts at 1 and advances on saves, changed names, application deletion intent, and environment creation, renames, deletions, and branch and source changes, since environment names select [spec.environments.<name>] configuration. Deployments leave it unchanged; each environment records the revision it last resolved as resolved_generation, which environment changes and renames keep current unless the environment’s configuration renders the changed name (${{ env.name }}, ${{ env.slug }}, ${{ app.name }}). Apply replaces the full configuration without merging. It returns 200 with SavedApplication (application_id, generation, null operation_id and empty operation_ids). With ?deploy=true, apply atomically saves and deploys, returning 202 with one ID per deployed environment in operation_ids (operation_id is the first). Saving during deletion is rejected.

Deploy captures exactly the inspected saved revision, returning 202 with AcceptedOperation. The saved manifest is rendered for the environment when captured (repository manifests once fetched): a reference without a value fails the request with 422 and variable_value_missing in details.errors. Deployment snapshots record the captured manifest as template, the rendered values as variables, and the rendered manifest as application (null until a repository manifest is fetched). A fetched file’s own spec.manifest is ignored: the snapshot’s template.spec.manifest is the repository, path, and revision it was actually fetched from. When the file names another repository URL or path, the snapshot’s warnings lists manifest_connection_ignored; branches are not compared, since they differ between environments. Every explicit Deploy creates a new snapshot and supersedes pending work, even for unchanged configuration. It resolves image tags again; matching healthy containers need no restart. Empty applications are valid: an empty deployment removes services and networks while retaining volume data.

Reconciliation and retries use deployment snapshots and their prepared digests, never newer saved edits. Configuration saves do not supersede operations. Deployments and attempt outcomes remain indefinitely until environment deletion. The deployments response distinguishes current_target, last_successful, and mutable operation progress. A failed rollout is rolled back only in environments with automatic rollback, by a new restore deployment. History endpoints accept cursor for subsequent pages.

Restoring a deployment

POST /api/v1/environments/{id}/restore with { "deployment": "operation-..." } deploys an earlier deployment of the environment again exactly as it ran: its rendered manifest and values, its release, its prepared target with the same images, and the secret versions it pinned. The new deployment’s origin is { "type": "restore", "deployment": "operation-..." }, and it becomes the maintained target like any other deployment. Nothing is rendered, fetched or built, and no newer input is substituted. Jobs never run, on the restore or on its retries: an earlier deployment’s before-rollout migrations must not run against newer data. Volume contents and migrations are not reverted.

It needs apps:deploy, no expected_generation, and supersedes pending work. Before anything is accepted it fails with 404 for a deployment of another environment, 409 deployment_not_restorable (details.deployment) for one that never succeeded, and 409 image_unavailable naming the service and image when an image is gone and can’t be pulled again by digest; a missing registry image is re-pulled first. Acceptance fails with 409 restore_secrets_unavailable, listing in details.secrets every secret whose pinned version was deleted or discarded by key recovery since, with secret_access_denied for a stored secret whose access list no longer allows the environment (access narrowed since applies; only versions are kept), and with secret_deleting while one is being deleted. Links stay bound to the applications the deployment bound while they exist, so an application that took a bound one’s name since gains nothing. A request repeating an accepted one’s Idempotency-Key returns its response before any check. Previews restore through POST /api/v1/previews/{id}/restore, which needs previews:deploy.

Each successful preparation in an environment that builds its own source (one deploying the saved manifest or following a branch) records an immutable release, and the deployment’s release names it; a promotion’s deployment names the release it received. A ReleaseView holds the captured manifest with references unresolved (release.template), the commit it was read from when repository-backed (release.commit), each service’s provenance and image (release.sources: the registry digest of an image pulled by reference, or the commit and local image ID of a Git build), and fingerprint, each service’s rendered build inputs by field: source.image, or source.repository, source.commit, source.build.dockerfile, source.build.context, source.build.args.<NAME>, and source.build.target. content_hash covers the whole captured manifest (including its name and the branch it was read from, which ${{ app.name }} and ${{ git.branch }} render), its commit, and every source; preparations of one application with the same hash share one release, so environments running the same content name the same release. Releases belong to the application: deleting an environment keeps them, and they are removed with the application. EnvironmentDetailView.release names the release the environment’s current target runs. A recorded digest does not guarantee the image still exists on the host: availability says whether it does, as {"state": "present"}, {"state": "pullable", "missing": [...]} when only registry images are missing, which deploying the release pulls again by digest, or {"state": "unavailable", "missing": [...]} when a built image is gone, each missing entry naming its service and image. It is absent when Docker can’t be asked. Deployments that use a retained image again fail with image_unavailable, naming the service and image, when it is gone and can’t be pulled again; see image retention.

A release can be rendered for another environment of its application without rebuilding: its own manifest renders with that environment’s variables, and every build input must render exactly as in its fingerprint. Otherwise the release is incompatible there (422 manifest_validation_failed with a release_incompatible error per field in details.errors, its path e.g. web.source.build.args.VITE_ORIGIN), typically because a build argument bakes in one environment’s domain. Promotion uses this.

A promoted environment never builds or fetches: deploying it returns 409 environment_promoted, making an environment promoted returns 409 application_busy while a deployment of it is requested or running, and retrying an earlier deployment that never prepared fails with that diagnostic. Its source must be another live environment of the application, never a preview (422 promotion_source_invalid, details.environment), and must not promote from it, directly or through others (422 promotion_cycle, details.environments lists the chain). Deleting an environment others promote from returns 409 promotion_source_in_use, naming them in details.environments.

POST /api/v1/environments/{id}/promote takes the source’s current deployment by default; deployment requires it to still be that one (409 promotion_source_changed otherwise), and release deploys an earlier release of the application instead, with the environment’s current secrets and no source check. Both together return 400 promotion_invalid. Like Deploy, it requires expected_generation unless forced, needs apps:deploy, and supersedes pending work; it never advances the application revision. Before anything is captured, and after checking those and the revision, it checks that the source’s current deployment succeeded, recorded a release, and its services run as deployed and healthy now (409 promotion_source_not_ready, details.environment), that the release instantiates for the environment (422, release_incompatible), that every stored secret it mounts exists and allows the environment (409 secrets_unavailable, with every problem in details.secrets as { "problem": "missing" | "access_denied" | "unavailable" | "deleting", "secret": name }; unavailable is a stored value key recovery discarded, and deleting covers generated secrets too), and that its images are present or can be pulled again by digest (409 image_unavailable). A request repeating an accepted one’s Idempotency-Key returns its response before any check. Acceptance checks the source again in its transaction: a source whose current deployment is no longer the requested or checked one returns 409 promotion_source_changed with details.environment and details.deployment. Promoting into an environment that builds its own source returns 409 environment_not_promoted.

The release’s own manifest is rendered with the environment’s [spec.environments.<name>] block and variables from that manifest and compiled with the release’s images, and the prepared target is saved with the accepted deployment, so the controller only rolls it out. AcceptedPromotion is an AcceptedOperation with the pinned release_id and its origin. DeploymentView.origin records where every deployment came from: { "type": "build" }, { "type": "promotion", "environment": "env-...", "deployment": "operation-..." }, { "type": "release" } for a release deployed by ID, { "type": "restore", "deployment": "operation-..." } for a restored deployment, with "rolled_back": "operation-..." naming the failed deployment when an automatic rollback restored it, or { "type": "local", "commit": "...", "base": "..." } for a local snapshot deployed to a preview. ReleaseView.promotions lists, newest first, the deployments that received the release (environment_id, deployment_id, origin, created_at_ms).

POST /api/v1/environments/{id}/promote/plan takes the same body, ignores expected_generation, and needs apps:read. It applies the source checks and release_incompatible, then returns a PlanView whose changes compare the rendered release with the environment’s current deployment and whose plan compares it with the observed runtime. Its release is a ReleasePlan: the ReleaseView with availability, the origin, new_volumes that would be created empty, and every unusable secret in secrets, which do not fail the plan. An unavailable release adds a blocking image_unavailable diagnostic for each missing image, since promoting it would fail. Plans of an earlier release work for any environment.

Mutation endpoints accept an optional Idempotency-Key (1–128 ASCII letters, digits, -, _, ., or :; 400 invalid_request otherwise). The CLI generates one UUID per command and reuses it for transport retries. The daemon atomically stores the accepted response and a fingerprint of the normalized request in SQLite for 24 hours. A matching retry returns the original response before checking the current generation, even after restart, failure, or supersession. It never restarts the operation. Different input, or another account, under the same key returns 409 request_id_conflict. Expired keys are treated as new requests subject to current preconditions. Receipts contain no manifests or raw request bodies, and do not guarantee exactly-once Docker effects. Force is part of request identity: retrying a forced request replays its receipt instead of overwriting intervening changes again. A new forced command needs a new key.

Rename accepts JSON { "name": "new-name", "expected_generation": 3 } and returns 200 with RenamedApplication. It rejects pending/running operations in any environment and deletion intent with 409 application_busy, occupied names with 409 application_name_collision, and repository-backed applications, whose name their fetched manifests must keep, with 409 repository_managed. It preserves the stable ID, runtime resources, and operation identity; a changed name advances generation and records an application_renamed event in the application’s history. Update the manifest’s name before subsequent name-based apply.

Environment creation and rename return 200 with EnvironmentView. Names follow the logical-name rules and are unique within the application (409 application_name_collision). A new environment starts not_deployed; renaming never touches the runtime. Every environment reserves the hostnames the manifest it deploys renders for it, so environments of one application may use different hostnames through variables; two that render the same hostname fail with 409 hostname_conflict. Renaming an environment returns 409 environment_configured while that manifest has a [spec.environments.<name>] block for the old or new name. Creating, deleting, or renaming to or from a name the saved manifest declares, or changing the branch, source or sync of one whose block sets its source, returns 409 environment_declared, naming it in details.environment. An application edit that would add a [spec.environments.<name>] block for a name no environment has, or that names one, like setting its visibility, even when it would add none, fails with 422 environment_unknown, naming it in details.environment. Creating an environment with a branch, or changing the branch of one, for an application without a manifest repository fails with 422 manifest_validation_failed and manifest_repository_required in details.errors. An invalid branch or commit fails with 400 git_branch_invalid or git_commit_invalid, as for previews. Changing a branch or source advances the application revision and leaves that environment unresolved until it deploys or is promoted into.

Deleting an application requests deletion of each environment and preview and returns 202 with DeletedApplication (application_id, generation, and one AcceptedOperation per environment and preview). Previews need no confirmation. With several environments, environments must list every environment name (409 environment_confirmation_required otherwise, naming them in details.environments); force never skips this. The application disappears with its last environment; an application without environments is removed immediately. Deleting one environment keeps the application and its other environments.

Operations have kind apply, refresh (the stored kind used by Deploy), or delete and state requested, running, succeeded, failed, cancelled, or superseded. New intent marks pending/running older operations superseded, separately from cancellation. The CLI treats supersession as success with an explicit outcome and stops waiting immediately, without following the replacement. Each execution increments attempt. Deployment attempt outcomes remain available even after event pruning. Deletion retains volumes and completes only after runtime absence is verified. It then removes the environment, its operations, deployments, attempts, builds, and receipts. Its events stay in the application’s history until the application is deleted. Clients waiting for deletion poll environment (or application) absence; its operation endpoint also returns 404 after cleanup.

Plan returns 200 with a PlanView, no durable changes, and no image pulls. The response includes the inspected generation (zero for an absent name), an identical flag, latest operation, redacted manifest field changes, a runtime plan, each service’s effective rollout order (derived or explicit) and monitor window, and variables: the value of every reference in scope for the selected environment. The manifest is rendered for that environment; a missing value fails with 422. environments lists how saving the manifest would change the application’s environments: create, source and sync changes, and undeclared environments it no longer declares (see declared environments). A start-first order set on a service with a writable volume adds a non-blocking rollout_start_first_writable_volume warning to the plan. Each job appears as a run_job action where the deployment runs it, and a job that stops its own service, or an exclusive job, adds a non-blocking job_stops_services warning naming the services it stops. When the selected environment rolls back automatically, each routed service without a health check adds a non-blocking rollback_without_health_check warning, to plans and to promotion plans. For an existing environment, each import that would not connect adds a non-blocking import_unconnected warning, as do promotion plans. A service receiving telemetry in an environment not granted it adds a non-blocking telemetry_not_granted warning, to plans and to the deployment. Manifest differences and the runtime plan compare with the environment selected by environment=ID (by default the application’s only environment): its last deployment snapshot, not saved configuration, and its observed runtime. Without a selection and with several environments, they compare the saved manifests as written and the runtime plan is empty. runtime_coverage says how much of the runtime change the plan lists: complete; resolution when sources must be resolved or built first, so the plan lists those actions and declared jobs, while network, volume and service changes are planned by the deployment; or omitted without an environment. A new application renders for the first environment it declares that builds, or production. An environment of another application returns 404, and a promoted one 409 environment_promoted: plan its promotion instead. Image tags report resolution requirements even when unchanged, matching Deploy’s refresh behavior. Environment, command, argument, and health-check values are redacted in plans, including runtime actions. Execution computes its own unredacted plan after preparation. Plans cannot freeze mutable tags or runtime state.

Events accept optional cursor, limit (1–100, default 50), and the filters listed in event history, such as application_id and environment_id. They survive ordinary operation pruning and environment deletion, and are removed with their application. Application-level facts (edits, renames) are recorded once, without an environment: filter by application_id, not environment_id, to include them. Event retention is independently configured by retention.event_days (default 90; zero disables pruning). Events contain safe diagnostics and identifiers, never manifests, environment values, or raw Docker errors. Failure events preserve error_code, phase, and resource; operation reads expose the current phase and resource. Significant resource mutations and active-target repairs are recorded, while unchanged observations and timer ticks are omitted.

The API requires a session cookie or bearer credential on every transport, except from the host operator on the Unix socket. Only setup/login endpoints and TCP /health are public; see the public endpoints. What each caller may do is decided by its grants; every operation names its requirement in the contract’s x-piqueld-access extension. TCP requests also validate Host and browser Fetch Metadata headers before authentication; DNS names need server.allowed_hosts (see configuration). Rejected requests return 403 browser_access_denied, even without credentials. Authentication checks API mutation origins against auth.public_url, returning 403 origin_mismatch for rejected origins. Undecodable URL path parameters return 400 path_invalid, and malformed IDs 400 application_id_invalid or environment_id_invalid, with the usual JSON error and request ID. The dashboard is served at /dashboard/; /health is an unversioned TCP liveness endpoint. The Unix socket serves the API alone. See the CLI guide and the generated contract.

POST /api/v1/environments/{id}/deploy prepares all sources again, including Git builds. It requires the inspected application generation unless forced and supersedes pending work in that environment. An identical idempotency-key replay returns the original acceptance. Use piquelctl env deploy NAME [ENV] --yes (or app deploy NAME --yes for an application with one environment) to request and wait for deployment.

When spec.manifest is configured, each environment that is not promoted has source: branch, and Deploy first fetches the manifest from that environment’s branch (or the one-off branch/commit). Once the deployment is prepared, it becomes that environment’s last fetched manifest and, when fetched from the branch spec.manifest names, the application’s saved configuration; other environments keep their own. Its refresh operation records fetching_manifest progress and a manifest_fetched event with the commit hash. A fetch advances the generation when it changes the saved manifest, or when the saved manifest’s declared environments change the source of an environment with a deployment in progress. Failures use manifest_not_found, manifest_fetch_failed, or manifest_invalid. Direct apply saves a repository-backed application’s configuration like any other’s. Once it read one, ApplicationView.repository is the manifest the application last read from the branch spec.manifest names, with its connection: where the saved manifest differs from it are its edits, which deployments of that branch deploy until a push changes the manifest, and applying it again (piquelctl app repository reset) discards them. Reconcile retries the latest operation with its saved inputs; deploy refreshes sources and fetches repository-backed configuration when configured.

GET /api/v1/applications/{id}/manifest downloads saved configuration as application/toml, with an attachment filename and Cache-Control: no-store. It does not observe Docker or resolve sources.

GET /api/v1/environments/{id}/logs reads Docker container output for services owned by this environment and daemon instance. Optional service filters by logical service name; optional stream=stdout|stderr filters before limiting results (omit it for both, including merged terminal output). tail defaults to 200 (1–1000) and since_seconds to 3600 (1–86400). Records include timestamp, service, task ID, stream and message. Snapshots are capped at 1 MiB of collected text and 256 tasks, with truncated indicating a partial result. Docker retains the source logs; removed containers have no available history. No output is stored by piqueld.

GET /api/v1/environments/{id}/exec opens a WebSocket that runs a one-off command in a running task of a service (preferring healthy tasks over those still starting) owned by this environment and daemon instance. Requests that are not WebSocket handshakes return 426 upgrade_required. The caller needs apps:exec on the environment’s application; cookie-authenticated handshakes must send the configured Origin, like mutations.

The client’s first message is a JSON text message such as { "service": "auth", "command": ["auth-service", "invite", "create"], "stdin": false, "tty": null }; tty takes an initial { "width", "height" } size and reports terminal output as stdout. Without it within 30 seconds, the session fails with 408 exec_request_timeout. Every later message is binary: a one-byte tag, then the payload, at most 1 MiB per message. Clients send stdin bytes (1), stdin end (2; ignored with a terminal, where disconnecting detaches) and terminal resizes (3, u16 width then height). Closing the connection, rather than sending stdin end, stops the session. The daemon only sees the close after reading the input sent before it, so while a command is not reading its input, the daemon’s pings, sent every 5 seconds, notice a client whose connection closed. A client that sends Close behind unread input but keeps its connection open is noticed when the command reads or exits, or the daemon shuts down.

The daemon sends stdout (1), stderr (2), and finally either the exit code (3, big-endian i64) or a failure (4): the u16 HTTP status an equivalent request would have returned, then the ErrorBody JSON. Failures to start the command, such as an invalid request, 404 not_found or 409 service_not_running, arrive this way too, so browsers can read them. The caller’s access is rechecked while the command runs (see when access changes take effect); once it is lost, the session ends with the refusal, such as 401 authentication_required or 403, and the command, like after a disconnect, may keep running. When the daemon shuts down, the connection closes without a final message. piqueld_core::exec implements the messages. Progenitor’s generated WebSocket methods do not build for WASM, so piqueld-client implements this method by hand, natively and in the browser. The dashboard’s session cookie and page Origin authenticate its handshake. Browsers hide the response to a refused handshake, so the dashboard can only report that the connection closed before it opened. History records command_started and command_finished events, never the command.

GET /api/v1/system/readiness reports top-level ready plus separate database, docker, and swarm verdicts. Each verdict has status: "ready" or status: "failed"; failed verdicts include a required safe message, while ready verdicts omit it. HTTP 200 means deployment dependencies are available; HTTP 503 carries the same structured envelope when they are not. Probes have bounded deadlines and do not initialize Swarm or repair resources. Docker being unavailable does not block configuration saves or history reads. /health remains a process-liveness endpoint. No registry checks are introduced.

A separate ingress object reports the gateway, without affecting ready:

  • enabled: whether [ingress] is enabled.
  • healthy: the gateway applied its configuration, or is stopped, and, through a Cloudflare Tunnel, the tunnel is connected. An application’s broken network clears it.
  • gateway: only whether the gateway applied its configuration, or is stopped, whatever one application’s network does.
  • message: what failed, with the details in the daemon logs.
  • public: {"mode": "direct"}, or {"mode": "tunnel"} with the tunnel’s id, connected and message.
  • private: the apps node’s enabled, healthy, Tailscale state, dns_name, addresses and message.
  • routes: each deployed route of the applications the caller can read, with its environment_id, name when it has one, hostname, effective visibility, destination, the dns records its hostname needs, their dns_state, its state (disabled, pending, ready or failed) and message.

The generated contract describes each field, as IngressStatus.

EnvironmentDetailView.links reports the links the environment’s active target takes part in. imports lists each import with the environment it reaches, its alias, and its state: connected, unavailable (no deployed environment of that name exports the service to this one; whether it exists is not told), or full (the exporter already links to 64 older environments). A connected import reports the exporter’s current name. importers lists the environments reaching its exported services, only from applications the caller can read. Daemons older than links omit the field.

Stopped environments

POST /api/v1/environments/{id}/stop stops an environment until it is started: its services keep their configuration, volumes and secrets but run no replicas, and the gateway serves none of its routes, withdrawing them before the request returns; if the gateway can’t be reconfigured then, its next pass retries within ten seconds. Their hostnames stay reserved, and their certificates and managed DNS records stay in place, so starting serves them again within ten seconds. EnvironmentView.run_state is stopped (running otherwise, and from daemons older than stopping), its runtime health stopped once every service has scaled down, and its sync state stopped: pushes don’t deploy it, and sync deploys the head it missed once it starts. A stopped environment that receives telemetry gets none.

Stopping responds 202 with the environment’s latest operation. When its deployment had rolled out and has ended, the operation runs again with its saved inputs and no replicas, so the services stop within seconds; a deployment still in progress continues, rolling out with no replicas. A deployment that ended before rolling out is not retried: active-target repair scales down what the environment runs. Stopping needs apps:deploy, takes an optional expected_generation, and returns 404 before the environment’s first deployment and 409 application_busy while it is deleted.

POST /api/v1/environments/{id}/start starts it again, and so does every deployment you request: deploy, reconcile (which start is), a promotion into it, and a save or edit that deploys it. Starting retries the latest operation once it has ended or failed, so its services scale back to their replicas, and the gateway serves the routes again within ten seconds.

Event history

Routes require the permissions described in permissions: application history, diagnostics, and analytics are limited to the applications a caller holds events:read on, and daemon history requires system:read. See the generated OpenAPI document for exact contracts.

RoutePurpose
GET /api/v1/eventsCursor pagination and filtering
GET /api/v1/events/streamReplay and follow committed history through SSE
GET /api/v1/diagnostics/{id}Original event and details for an occurrence
GET /api/v1/system/resourcesCached process, storage and queue measurements
GET /api/v1/analytics/deploymentsDeployment outcomes, retries, durations and failure codes
GET /api/v1/notifications/destinationsNotification destinations, without URLs; system:read or notifications:manage
PUT /api/v1/notifications/destinations/{name}Create or replace a destination
DELETE /api/v1/notifications/destinations/{name}Delete a destination
POST /api/v1/notifications/destinations/{name}/testSend a test notification now
GET /api/v1/notifications/deliveriesCredential-free delivery history
POST /api/v1/notifications/deliveries/{id}/retryRetry a failed delivery under current policy; system:operate. 409 delivery_not_retryable names why one cannot be

Event filters: application_id (application-wide events and those of all its environments), environment_id, operation_id, attempt, action_id, kind, error_code, errors_only, scope, since_ms, until_ms, and descending. Pages contain at most 100 records and use opaque v1:<id> cursors. Oldest-first ordering remains the default. Timestamps are Unix milliseconds.

SSE uses the same filters with oldest-first ordering. Set Last-Event-ID to the last received SSE ID to reconnect (it takes precedence over cursor). Without a cursor, replay starts at retained history. When the filter excludes newer events, the stream sends an ID-only message so Last-Event-ID still advances. A stale resume position receives HTTP 410, or a terminal history_expired event if pruning happens during the stream. A terminal stream_error indicates a storage failure. Clients should refresh their snapshot before reconnecting after an expired history response. Streams recheck the caller’s access while open (see when access changes take effect): history it may no longer read stops arriving, and a caller that lost access entirely receives a terminal access_ended message whose data is the refusal’s ErrorBody. Streams end without a message when the daemon shuts down; reconnect with Last-Event-ID.

URL readiness

EnvironmentDetailView.urls lists a RouteUrl for every route the current runtime target of an environment or preview renders: its name, url (https:// and the hostname), visibility, destination, state and pending. The daemon derives the state from what it already observes and never probes the URL for a client. A URL is ready exactly when nothing in pending holds:

  1. ingress: the gateway acknowledged this exact route (hostname, visibility and destination);
  2. https (with a message): the listener serving the route’s visibility is enabled (ingress, and private ingress for private routes) and healthy now (the gateway and, in tunnel mode, the tunnel for public routes; the apps node for private ones), and the daemon’s latest check of this route found it ready, i.e. DNS answers that listener, which serves the hostname with a trusted certificate (a DNS-01 certificate for private routes). A check made before the route changed does not count;
  3. dns (with its state): the hostname’s managed records are not pending or dns_conflict (manual and managed records do not keep a URL pending);
  4. service (with the service): the route’s service is observed converged with every desired replica healthy. Redirects have no service.

Otherwise the URL is pending, with every condition that holds, in that order. Daemons older than URL readiness omit urls; clients must not read that as “no routes”. piquelctl env url, preview url and wait --ready routes, and the dashboard, read this field.

GET /api/v1/builds lists attempts newest first, with optional application_id, environment_id, cursor, and limit (1–100, default 50). Each executed Git-service preparation creates an independent record before checkout. Image pulls do not create records. Outcomes are running, succeeded, failed, or interrupted. Resolved commits and image IDs are recorded when available; retries create new attempts. Job runs appear in the same history with job set, the service whose container they reuse, the image that ran, and exit_code once the container exited. Notes from piqueld, such as Docker’s explanation of a failed task or unavailable output, are appended to a job’s output as stderr lines prefixed piqueld:.

GET /api/v1/builds/{id}/logs returns the newest output in chronological order, with previous_offset as an exclusive before cursor to load older chunks. Optional stream=stdout|stderr filters in the daemon before paging. Each of the up to 16 chunks includes its byte offset, capture timestamp in milliseconds, stream, and text. Stream and capture time are required for every chunk. Pages contain at most 64 KiB of output, decoded as lossy UTF-8; offsets count original bytes. The old forward offset query and combined text response are removed. Migration expires previously captured unstructured output while retaining build metadata. Truncation and expiration are explicit. Output retains the configured prefix, defaults to 4 MiB per attempt and expires 30 days after completion. Metadata survives operation pruning and is deleted with its environment.

See history and diagnostics for history, daemon statistics and analytics, telemetry for telemetry export, and notifications for destinations and their deliveries.

Manually set secrets live in one store per application; generated secrets belong to their environment. Secret endpoints expose metadata only:

  • GET /api/v1/applications/{id}/secrets lists the store’s secrets: names, current generations, update times, deleting and unavailable status, and access. Access is { "environments": "all" | { "only": [environment IDs] }, "previews": bool }. all covers environments created later; IDs keep a renamed environment’s access, and deleting an environment removes it from every list. Previews may mount the secret only when previews is true; all never covers them, and lists accept only environment IDs.
  • PUT /api/v1/applications/{id}/secrets/{name} accepts an application/octet-stream value of 1–512000 bytes (other types return 415 secret_content_type). X-Expected-Generation: 0 creates; a current generation replaces. The environments query parameter (all, or comma-separated environment IDs; empty for none) replaces the access list, together with previews (default false). previews alone returns 400 query_invalid, so it never resets the environments. Without them a new secret allows every environment and no previews, and an existing one keeps its list. Names the saved manifest or an environment’s last fetched one declares in spec.secrets return 422 manifest_validation_failed with secret_name_conflict in details.errors. Each store (and each environment’s generated secrets) supports at most 100 logical secrets, 1,000 retained values and 100 MiB of ciphertext. Discarded unavailable versions do not consume this value quota. Creating a 101st secret or exceeding the retained-version or byte quota returns 409 secret_quota_exceeded; delete unused secrets to free space.
  • PUT /api/v1/applications/{id}/secrets/{name}/access replaces the access list with a JSON SecretAccess body. Environments must belong to the application (400 otherwise). Narrowing it affects later deployments only. A secret being deleted keeps its access (409 secret_deleting).
  • GET /api/v1/environments/{id}/secrets lists an environment’s generated secrets with the same metadata, without access. POST /api/v1/environments/{id}/secrets/{name}/regenerate with the current X-Expected-Generation generates a new version from the declaration in the manifest the environment deploys (404 when it declares none), to rotate a value or replace one discarded by key recovery; running deployments keep their pinned versions. DELETE at /api/v1/environments/{id}/secrets/{name} discards a generated value, so a later deployment generates a new one.
  • DELETE on either secret path requires X-Expected-Generation and refuses references in saved configuration, the current runnable deployment, or the active target of any environment that uses the secret (409 secret_referenced). The captured deployment manifest protects references even before version pinning. Deletion waits for deployments’ Docker changes in progress and holds off new ones, for every application, until it finishes: up to 30 seconds per environment whose Docker secrets it removes. It reserves the secret, releases the database writer lock, then removes Docker versions before encrypted records. Partial failure or restart leaves deleting: true; retry DELETE to finish. Replacement and new configuration references return 409 secret_deleting until cleanup completes. A missing or repeated X-Expected-Generation returns 400 secret_generation_required, and a version conflict 409 secret_generation_conflict (details.expected_generation, details.actual_generation); missing or invalid key material returns 503 secret_storage_unavailable with a details.diagnostic_id for value-dependent work.

Both lists are unpaginated arrays returned directly in data, without items or next_cursor.

A service secret mount names a generated secret when spec.secrets declares the name, and a stored secret otherwise; mount names may reference variables and are resolved after rendering. A deployment checks access when it captures its inputs, before rollout: an environment that mounts a stored secret its access list excludes fails with 409 secret_access_denied and details { "environment": name, "secret": name }. POST /api/v1/applications/plan?environment=ID returns the same error, so it surfaces before a deploy. A name both declared and stored fails with 422 manifest_validation_failed with secret_name_conflict in details.errors.

Values never appear in responses, manifests or deployment snapshots. Deployments pin immutable versions during effective-input preparation; retries preserve those pins. Earlier ciphertext versions remain until logical-secret, environment or application deletion. Each environment that mounts a stored version gets its own Docker secret for it.

Quota enforcement never evicts pinned versions. To retire a secret, save and deploy configuration without its references, then delete it. An existing database above the quota remains readable and deployable; new writes require freeing space.

POST /api/v1/system/secrets/recover-key recovers from a lost master key by discarding stored and generated values for all applications and environments. A key is lost, and recovered, only when something is encrypted (the database’s key verifier, a stored or generated value, a webhook secret, or a notification destination’s URL) and secrets.key is missing, does not hold exactly 32 bytes, or decrypts none of it. It returns 409 secret_key_usable while the key decrypts the verifier, when there is no verifier but the key decrypts a stored value or webhook secret, or when nothing is encrypted and the key file is missing or loads. When nothing is encrypted but the key file does not hold 32 bytes, it moves that file aside and returns 200 with nothing discarded, so the next value write generates a new key. It returns 503 secret_storage_unavailable, discarding nothing, when the key decrypts a stored value or webhook secret but not a corrupt verifier (restore the database from backup), or can’t be read for another reason, such as an I/O error or a key file that is not private to the daemon user (fix the host and retry the request that failed). Discarded versions are marked unavailable; metadata, running Docker services and their secrets are preserved. Every application’s webhook secret is deleted too; generate a new one. So is every notification destination; create them again. Supplying replacement stored values creates new versions, and an explicit new deployment is required to adopt them; that deployment also generates new values for discarded generated secrets it mounts. Deployments that need discarded stored values fail with secret_unavailable and the logical names. The response contains affected_environments, affected_applications, affected_secrets, discarded_versions and deleted_destinations; no key or secret value is returned. Repeating the request after a lost response returns secret_key_usable, because no stored value then needs the old key; it never creates a key.