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.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/system/status | Daemon 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/configuration | Effective read-only host settings (system:read); 503 configuration_unavailable without them |
| GET | /api/v1/system/checks | Installation checks, each pass, warn, fail or skip with a next step (system:read); see piquelctl doctor |
| POST | /api/v1/system/dns/refresh | Check DNS provider credentials and zones now (system:operate); returns DnsStatus, empty without system:read |
| GET | /api/v1/openapi.json | Generated API schema |
| GET | /api/v1/applications | Paginated 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/plan | Plan a manifest without pulling images; environment=ID selects the environment to compare with |
| POST | /api/v1/applications/apply | Save 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}/rename | Rename an idle application without redeployment; 409 repository_managed for a repository-backed one |
| POST | /api/v1/applications/{id}/environments | Add 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}/branch | Follow 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}/telemetry | Grant 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}/sync | Opt 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}/webhook | WebhookView: 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/secret | Generate 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}/source | Make 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}/promote | Promote a release into a promoted environment: { "deployment": null, "release": null, "expected_generation": 3 }; 202 with AcceptedPromotion |
| POST | /api/v1/environments/{id}/promote/plan | Plan 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}/detail | Environment, 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}/status | Intent progress and separate runtime health |
| POST | /api/v1/environments/{id}/deploy | Deploy 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}/deployments | Deployment snapshots, newest first; limit per page, from 1 to 100, three without it |
| GET | /api/v1/environments/{id}/deployments/{deployment}/attempts | Retained outcomes, newest first, 100 per page |
| GET | /api/v1/applications/{id}/releases | The application’s releases, newest first, twenty per page |
| POST | /api/v1/environments/{id}/rename | Rename 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}/reconcile | Retry 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}/stop | Stop the environment: withdraw its routes and scale its services to zero; 202 with its latest operation |
| POST | /api/v1/environments/{id}/start | Start a stopped environment, as reconcile does; 202 with its latest operation |
| POST | /api/v1/environments/{id}/restore | Restore an earlier, succeeded deployment exactly: { "deployment": "operation-..." }; 202 with AcceptedOperation |
| GET | /api/v1/applications/{id}/previews | Previews 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}/previews | Create 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/prune | Delete 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}/deploy | Deploy the head of the preview’s branch, replacing a local snapshot it runs; 202 with AcceptedOperation |
| POST | /api/v1/previews/{id}/restore | Restore 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/events | Paginated informational history, oldest first |
| GET | /api/v1/events/kinds | The distinct kinds of the events the caller may read, in name order, to filter the history by kind |
| GET | /api/v1/events/stream | Replay and follow history as server-sent events; see event history |
| GET | /api/v1/environments/{id}/exec | WebSocket 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.
| Method | Path | JSON body |
|---|---|---|
| PUT | /name | { "value": "new-name" } |
| POST | /services | Service (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}/mount | Mount; adds/replaces by container target |
| DELETE | /services/{service}/mount | { "value": "/container/target" } |
| PUT | /services/{service}/file | ServiceFile, 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 | /repository | PUT: { "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’sid,connectedandmessage.private: the apps node’senabled,healthy, Tailscalestate,dns_name,addressesandmessage.routes: each deployed route of the applications the caller can read, with itsenvironment_id,namewhen it has one,hostname, effectivevisibility, destination, thednsrecords its hostname needs, theirdns_state, itsstate(disabled,pending,readyorfailed) andmessage.
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.
| Route | Purpose |
|---|---|
GET /api/v1/events | Cursor pagination and filtering |
GET /api/v1/events/stream | Replay and follow committed history through SSE |
GET /api/v1/diagnostics/{id} | Original event and details for an occurrence |
GET /api/v1/system/resources | Cached process, storage and queue measurements |
GET /api/v1/analytics/deployments | Deployment outcomes, retries, durations and failure codes |
GET /api/v1/notifications/destinations | Notification 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}/test | Send a test notification now |
GET /api/v1/notifications/deliveries | Credential-free delivery history |
POST /api/v1/notifications/deliveries/{id}/retry | Retry 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:
ingress: the gateway acknowledged this exact route (hostname, visibility and destination);https(with amessage): 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 itready, 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;dns(with itsstate): the hostname’s managed records are notpendingordns_conflict(manualandmanagedrecords do not keep a URL pending);service(with theservice): the route’s service is observedconvergedwith 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}/secretslists the store’s secrets: names, current generations, update times,deletingandunavailablestatus, andaccess. Access is{ "environments": "all" | { "only": [environment IDs] }, "previews": bool }.allcovers 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 whenpreviewsis true;allnever covers them, and lists accept only environment IDs.PUT /api/v1/applications/{id}/secrets/{name}accepts anapplication/octet-streamvalue of 1–512000 bytes (other types return 415secret_content_type).X-Expected-Generation: 0creates; a current generation replaces. Theenvironmentsquery parameter (all, or comma-separated environment IDs; empty for none) replaces the access list, together withpreviews(defaultfalse).previewsalone returns 400query_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 inspec.secretsreturn 422manifest_validation_failedwithsecret_name_conflictindetails.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 409secret_quota_exceeded; delete unused secrets to free space.PUT /api/v1/applications/{id}/secrets/{name}/accessreplaces the access list with a JSONSecretAccessbody. Environments must belong to the application (400 otherwise). Narrowing it affects later deployments only. A secret being deleted keeps its access (409secret_deleting).GET /api/v1/environments/{id}/secretslists an environment’s generated secrets with the same metadata, withoutaccess.POST /api/v1/environments/{id}/secrets/{name}/regeneratewith the currentX-Expected-Generationgenerates 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.DELETEat/api/v1/environments/{id}/secrets/{name}discards a generated value, so a later deployment generates a new one.DELETEon either secret path requiresX-Expected-Generationand refuses references in saved configuration, the current runnable deployment, or the active target of any environment that uses the secret (409secret_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 leavesdeleting: true; retry DELETE to finish. Replacement and new configuration references return 409secret_deletinguntil cleanup completes. A missing or repeatedX-Expected-Generationreturns 400secret_generation_required, and a version conflict 409secret_generation_conflict(details.expected_generation,details.actual_generation); missing or invalid key material returns 503secret_storage_unavailablewith adetails.diagnostic_idfor 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.