Documentation

API v1

Automate nouva projects, services, deployments, and operations with the stable, versioned REST API.

Start with v1

Base URL

https://api.nouva.sh/v1

GET /openapi.json is public and describes only the customer-facing /v1 routes. It uses bearer authentication. Nouva does not publish a Swagger UI. Request and response bodies are JSON, except the bucket upload route, which takes multipart form data.

List projects

export NOUVA_API_TOKEN="nouva_v1_REPLACE_WITH_YOUR_TOKEN"
export NOUVA_API_BASE="https://api.nouva.sh/v1"

curl --include "$NOUVA_API_BASE/projects?limit=20" \
  --header "Authorization: Bearer $NOUVA_API_TOKEN"

Authentication and project reach

Current workspace admins create, rotate, and revoke API tokens in Dashboard Settings. A token secret is shown only once when it is created or rotated, so store it in your CI secret manager rather than source control.

Bearer token

Send Authorization: Bearer <token> on every authenticated request. Secrets start with nouva_v1_.

Project reach

Tokens can cover all workspace projects or an explicit project allowlist. Selected project tokens cannot create projects or use workspace-wide server routes.

Expiry

Every token expires between 1 and 365 days after creation, and defaults to 90 days. Non-expiring tokens are not supported.

Rotation and revocation

Rotating revokes the old secret immediately and issues a replacement that keeps the same scopes and project reach. A token also stops working if its owner loses workspace admin.

Scopes

Choose only the scopes your integration needs. Every write scope automatically includes its matching read scope.

AreaRead scopeWrite scope
Projectsprojects:readprojects:write
Environmentsenvironments:readenvironments:write
App and worker servicesservices:readservices:write
Databasesdatabases:readdatabases:write
Deploymentsdeployments:readdeployments:write
Logslogs:readRead only
Metricsmetrics:readRead only
Volumesvolumes:readvolumes:write
Backupsbackups:readbackups:write
Domainsdomains:readdomains:write
Bucketsbuckets:readbuckets:write
Serversservers:readRead only
GitHub sourcessources:readRead only

Endpoint reference

Every path below is relative to https://api.nouva.sh/v1. The OpenAPI document is the authoritative source for request and response bodies.

Projects

Creating a project requires an all-projects token and a connected, online server.

MethodPathScopeDescription
GET/projectsprojects:readList projects.
POST/projectsprojects:writeCreate a project.
GET/projects/{projectId}projects:readRead one project.
PATCH/projects/{projectId}projects:writeRename a project.
DELETE/projects/{projectId}projects:writeDelete an empty project. It stays readable until the agent removes its network.
GET/projects/{projectId}/environmentsenvironments:readList environments.
POST/projects/{projectId}/environmentsenvironments:writeCreate an environment.

Services

These routes cover app and worker services only. Databases have their own routes.

MethodPathScopeDescription
GET/projects/{projectId}/servicesservices:readList app and worker services.
POST/projects/{projectId}/servicesservices:writeCreate a service and its first deployment.
GET/services/{serviceId}services:readRead one service.
PATCH/services/{serviceId}services:writeUpdate service configuration.
DELETE/services/{serviceId}services:writeRequest agent-confirmed cleanup of a service.
GET/services/{serviceId}/release-configservices:readRead the pre-activation job and verification command of an app or worker.
PUT/services/{serviceId}/release-configservices:writeSet or clear release phases; applies from the next deployment.
GET/services/{serviceId}/changesservices:readPreview staged changes; a database needs databases:read.
POST/services/{serviceId}/actions/apply-changesservices:writeApply staged changes; a database needs databases:write.

Databases

MethodPathScopeDescription
GET/projects/{projectId}/databasesdatabases:readList databases.
POST/projects/{projectId}/databasesdatabases:writeProvision a database.
GET/databases/{serviceId}databases:readRead one database.
PATCH/databases/{serviceId}databases:writeUpdate database configuration.
DELETE/databases/{serviceId}databases:writeRequest agent-confirmed cleanup of a database.
POST/databases/{serviceId}/actions/restartdatabases:writeRestart a database.
POST/databases/{serviceId}/actions/retry-provisiondatabases:writeRetry a failed provision.
POST/databases/{serviceId}/actions/postgres-pitrdatabases:writeStart a Postgres point-in-time restore.

Deployments and logs

MethodPathScopeDescription
GET/services/{serviceId}/deploymentsdeployments:readList deployments.
POST/services/{serviceId}/deploymentsdeployments:writeReplay a deployment's source snapshot.
POST/services/{serviceId}/actions/deploy-current-sourcedeployments:writeBuild the branch tip with the applied configuration.
POST/services/{serviceId}/actions/deploy-commitdeployments:writeBuild one explicitly named commit with the applied configuration.
POST/services/{serviceId}/actions/push-previewdeployments:read + services:readReport which services a push of a commit, or of a path list, would deploy.
GET/services/{serviceId}/push-decisionsdeployments:read + services:readList recent pushes and why each did or did not deploy the service.
GET/deployments/{deploymentId}deployments:readRead one deployment.
POST/deployments/{deploymentId}/actions/restartdeployments:writeRestart the running deployment.
POST/deployments/{deploymentId}/actions/rollbackdeployments:writeRoll back to this deployment.
GET/deployments/{deploymentId}/release-jobsdeployments:readRead how the deployment's release phases ran.
POST/deployments/{deploymentId}/release-jobs/pre_activation/actions/resolvedeployments:writeRetry a stopped pre-activation job, or mark an unknown outcome succeeded.
GET/deployments/{deploymentId}/logs/buildlogs:readRead build logs.
GET/deployments/{deploymentId}/logs/runtimelogs:readPage through runtime logs.
GET/schedule-runs/{runId}/logslogs:readPage through worker job logs.

Worker schedules

Worker schedules are governed by the services scopes, not a scope of their own.

MethodPathScopeDescription
GET/services/{serviceId}/schedulesservices:readList schedules for a worker.
POST/services/{serviceId}/schedulesservices:writeCreate a cron schedule.
PATCH/schedules/{scheduleId}services:writeUpdate or disable a schedule.
DELETE/schedules/{scheduleId}services:writeDelete a schedule.
GET/schedules/{scheduleId}/runsservices:readList schedule runs.
POST/schedules/{scheduleId}/runsservices:writeStart a run now.

Volumes and backups

MethodPathScopeDescription
GET/projects/{projectId}/volumesvolumes:readList volumes.
GET/volumes/{volumeId}volumes:readRead one volume.
PATCH/volumes/{volumeId}volumes:writeRename a volume.
DELETE/volumes/{volumeId}volumes:writeQueue volume deletion.
POST/volumes/{volumeId}/actions/attachvolumes:writeStage a volume attachment.
POST/volumes/{volumeId}/actions/increase-reservationvolumes:writeIncrease the reserved size.
GET/volumes/{volumeId}/backupsbackups:readList backups for a volume.
GET/backups/{backupId}backups:readRead one backup.
PATCH/backups/{backupId}backups:writeLock or unlock a backup.
DELETE/backups/{backupId}backups:writeQueue backup deletion.
POST/services/{serviceId}/backupsbackups:writeTake a backup now.
GET/services/{serviceId}/backups/schedulebackups:readRead the backup schedule.
PATCH/services/{serviceId}/backups/schedulebackups:writeToggle daily, weekly, and monthly backups.
POST/backups/{backupId}/actions/restorebackups:writeRestore a backup into a pending volume.
POST/services/{serviceId}/actions/apply-restorebackups:writeCut the service over to the restored volume.
GET/services/{serviceId}/database-importsbackups:readList external backup imports for a database service.
POST/services/{serviceId}/database-importsbackups:writeRegister an external backup import and its SHA-256 digest.
GET/database-imports/{importId}backups:readRead import progress, failure category, and receipt.
POST/database-imports/{importId}/artifactbackups:writeUpload the backup artifact for a registered import.
POST/database-imports/{importId}/actions/startbackups:writeVerify the artifact and stage it onto a new volume.
POST/database-imports/{importId}/actions/cancelbackups:writeAbandon an import and release everything it holds.

Compose migrations

MethodPathScopeDescription
GET/compose-migrations/supported-subsetprojects:readList every Compose directive and what Nouva does with it.
POST/compose-migrations/previewprojects:readPlan a Compose file without storing it. Values are never echoed.
GET/projects/{projectId}/compose-migrationsprojects:readList a project's Compose migrations.
POST/projects/{projectId}/compose-migrationsprojects:writeRecord a reviewed plan and keep the file encrypted until retirement.
GET/compose-migrations/{migrationId}projects:readRead a migration's status, plan, staging progress, and checklist.
GET/compose-migrations/{migrationId}/host-readinessprojects:readCheck ports 80 and 443, capacity, and kernel on the project's server. Add ?sourceService= (and optionally &storageSizeGb=) to ask whether staging that one service would be refused.
GET/compose-migrations/{migrationId}/sourceprojects:writeDownload the stored Compose file while it is retained.
POST/compose-migrations/{migrationId}/actions/stageprojects:writeCreate or skip the next service in stage order. Source containers are untouched.
POST/compose-migrations/{migrationId}/actions/start-cutoverprojects:writeOpen the cutover checklist once every service is staged or skipped.
POST/compose-migrations/{migrationId}/actions/mark-checklist-itemprojects:writeMark or reopen one cutover step. Nouva records it; the operator does it.
POST/compose-migrations/{migrationId}/actions/record-data-decisionprojects:writeRecord that a database's data moved, or why it is not migrated.
POST/compose-migrations/{migrationId}/actions/completeprojects:writeFinish once the checklist and data steps are settled; deletes the stored file.
POST/compose-migrations/{migrationId}/actions/abandonprojects:writeEnd a migration and delete its stored file. Nothing on the server changes.

Domains

MethodPathScopeDescription
GET/services/{serviceId}/domainsdomains:readList custom domains.
POST/services/{serviceId}/domainsdomains:writeAdd a custom domain.
DELETE/services/{serviceId}/domains/{domainId}domains:writeRemove a custom domain.

Buckets

MethodPathScopeDescription
GET/projects/{projectId}/bucketsbuckets:readList buckets.
POST/projects/{projectId}/bucketsbuckets:writeCreate a bucket.
GET/buckets/{bucketId}buckets:readRead a bucket and its per-environment instances.
DELETE/buckets/{bucketId}buckets:writeQueue bucket deletion.
GET/buckets/{bucketId}/objectsbuckets:readList objects and prefixes.
GET/buckets/{bucketId}/objects/download-urlbuckets:readGet a public or signed download URL.
POST/buckets/{bucketId}/objects/uploadbuckets:writeUpload one object as multipart form data.
DELETE/buckets/{bucketId}/objectsbuckets:writeDelete objects by key or prefix.

Servers and metrics

Every server route requires an all-projects token. Selected-project tokens receive 403.

MethodPathScopeDescription
GET/serversservers:readList servers.
GET/servers/{serverId}servers:readRead one server.
GET/servers/{serverId}/healthservers:readRead derived status and readiness.
GET/servers/{serverId}/capabilitiesservers:readRead agent capability flags.
GET/services/{serviceId}/metricsmetrics:readRead service CPU, memory, network, and disk series.
GET/volumes/{volumeId}/metricsmetrics:readRead volume usage series.
GET/servers/{serverId}/metricsmetrics:read + servers:readRead host-level metrics.

GitHub sources

Source discovery is workspace-level. It reads only installations already connected to this workspace, and never returns installation or OAuth tokens.

MethodPathScopeDescription
GET/github/installationssources:readList connected GitHub installations and the setup state.
GET/github/installations/{installationId}/repositoriessources:readList repositories the installation can build.
GET/github/installations/{installationId}/repositories/{repositoryId}/branchessources:readList branches of one accessible repository.

Addresses and release provenance

A service response carries every address it answers on, so a client never concatenates a platform suffix of its own. A deployment response carries the source and image identity the build actually used. Anything Nouva has not verified is null rather than a guess: a database has no providedHostname, and a build that has not pushed yet has no image.

Service addresses, source, and release

{
  "service": {
    "id": "service_...",
    "subdomain": "web-abc",
    "endpoints": {
      "providedHostname": "web-abc.up.nouva.cloud",
      "publicUrl": "https://web-abc.up.nouva.cloud",
      "customUrls": ["https://shop.example.com"],
      "internalHost": "web.project.internal",
      "internalPort": 3000
    },
    "source": {
      "type": "github",
      "repository": "acme/storefront",
      "repositoryId": 123456,
      "branch": "main",
      "installationId": 654321
    },
    "release": {
      "currentDeploymentId": "deployment_live",
      "inFlightDeploymentId": "deployment_building",
      "latestDeploymentId": "deployment_building"
    }
  }
}
  • release.currentDeploymentId is the deployment answering requests right now.release.latestDeploymentId is the newest attempt, whatever became of it. The two differ for the whole of a build and permanently after a failed one, so compare the deployment ID you requested against currentDeploymentId before treating a release as live.
  • source is the service's current binding, and moves when you PATCH the branch. A deployment records the commit it was built from, so use commitSha to verify a specific release.
  • GET /services/{serviceId}/domains returns the same providedHostname and publicUrl alongside the custom-domain records.

Deployment provenance

{
  "deployment": {
    "id": "deployment_...",
    "serviceId": "service_...",
    "status": "LIVE",
    "commitSha": "9f1c2b8e...",
    "appBuildType": "dockerfile",
    "build": {
      "buildRoot": "apps/api",
      "dockerfilePath": "apps/api/Dockerfile",
      "dockerContextPath": ".",
      "dockerBuildStage": "runner",
      "publishDirectory": null
    },
    "image": {
      "repository": "registry.nouva.sh/project_.../service_...",
      "tag": "deployment_...",
      "digest": "sha256:..."
    }
  }
}

build and imagecome from the deployment's own frozen configuration, not the service's current settings, so they stay accurate after the service is reconfigured. Registry credentials, environment values, and internal error text never appear in either object.

Deleting services and what survives

DELETE /services/{serviceId} and DELETE /databases/{serviceId} record the request and queue cleanup for the agent on your server. Nouva never removes the container from the control plane, so the service stays readable, with status DELETING, until the agent reports the cleanup finished. Both routes are safe to repeat: a second call answers with the same in-flight cleanup rather than starting another one.

Delete a service and wait for cleanup

curl -sS --request DELETE "$NOUVA_API/services/$SERVICE_ID" \\
  --header "Authorization: Bearer $NOUVA_API_TOKEN"

# {
#   "cleanup": {
#     "serviceId": "service_...",
#     "state": "cleaning",
#     "requestedAt": "2026-09-05T00:00:00.000Z",
#     "retainedVolumeIds": ["volume_..."]
#   }
# }

for _ in $(seq 1 60); do
  CODE=$(curl -sS -o /dev/null -w '%{http_code}' "$NOUVA_API/services/$SERVICE_ID" \\
    --header "Authorization: Bearer $NOUVA_API_TOKEN")
  if [ "$CODE" = "404" ]; then echo "Cleanup confirmed."; break; fi
  sleep 5
done
  • While the request is outstanding, the service DTO carries a deletion object with the same state, requestedAt, and retainedVolumeIds. staged means the request is recorded, cleaning means agent work is outstanding. A 404 on the service is the confirmation that cleanup completed.
  • Volumes are never cascaded. A volume attached to the service is detached and kept, and the IDs are listed in retainedVolumeIds. Deleting the stored data is a separate authorized operation: DELETE /volumes/{volumeId}.
  • Cleanup that fails on the server is retried by the control plane, and re-issuing the same DELETE re-queues it immediately. The request is refused with a 412 while the service is in a state that forbids removal, and the staged request is preserved.
  • DELETE /projects/{projectId} still refuses a project that contains services, so delete each service and wait for its 404 before deleting the project.
  • A project delete answers 204once it is accepted, and the agent on your server then removes the project's network. GET /projects/{projectId} keeps returning the project until that finishes, then answers 404. Meanwhile every change to the project answers 412 precondition_failed with the details[].code project_being_deleted. If the removal fails, the project stays and accepts changes again, and re-issuing the same DELETE queues it again.

Staged changes and building current source

A PATCH that only changes environment variables does not restart anything: it stages the new map and leaves the running release alone. GET /services/{serviceId}/changes reports exactly what is staged, and POST /services/{serviceId}/actions/apply-changes promotes it. Both routes work for databases too, and take databases:read and databases:write for a database service.

Review staged changes, then apply them

curl -sS "$NOUVA_API/services/$SERVICE_ID/changes" \
  --header "Authorization: Bearer $NOUVA_API_TOKEN"

# {
#   "changes": {
#     "serviceId": "service_...",
#     "state": "staged",
#     "environment": {
#       "added": ["FEATURE_FLAG"],
#       "changed": ["API_TOKEN"],
#       "removed": [],
#       "stagedAt": "2026-09-06T09:00:00.000Z"
#     },
#     "resources": {
#       "active":  { "cpuMillicores": 500,  "memoryBytes": "536870912",  ... },
#       "desired": { "cpuMillicores": 1000, "memoryBytes": "1073741824", ... },
#       "stagedAt": null
#     },
#     "volume": null,
#     "deletion": null
#   }
# }

curl -sS --request POST "$NOUVA_API/services/$SERVICE_ID/actions/apply-changes" \
  --header "Authorization: Bearer $NOUVA_API_TOKEN" \
  --header "Idempotency-Key: $(uuidgen)"

# 202 { "apply": { "changeSetId": "...", "serviceId": "service_...",
#                  "state": "applying", "deploymentId": "deployment_..." } }
  • Names, never values. The environment diff lists variable names under added, changed, and removed. No route in the public API returns an environment value, staged or applied.
  • resources.desired is what the service would run once the staged configuration is applied; resources.active is what it runs now. They are equal on a service with nothing staged, and state is then clean.
  • apply-changes applies only the service named in the path. A staged change on a sibling service in the same project is never carried along. It answers 409 when there is nothing staged, and needs an Idempotency-Key.
  • Poll the returned deploymentId through GET /deployments/{deploymentId}. It is null when the apply reconfigured in place without a build, in which case re-read /changes until state is clean.

POST /services/{serviceId}/actions/deploy-current-sourcebuilds the tip of the service's configured branch with its applied build configuration and environment. Use it after a build-only change — a new build type, Dockerfile path, or publish directory — which is stored immediately but does not deploy on its own. It never requires touching an unrelated setting to force a build.

Build the current branch tip

curl -sS --request POST "$NOUVA_API/services/$SERVICE_ID/actions/deploy-current-source" \
  --header "Authorization: Bearer $NOUVA_API_TOKEN" \
  --header "Idempotency-Key: $(uuidgen)"

# 202, Location: /v1/deployments/deployment_...
# { "deployment": { "id": "deployment_...", "status": "QUEUED", "commitSha": "..." } }
  • It resolves the branch tip at request time, so a service tracking main builds whatever main points at now. Takes deployments:write, and refuses a database service with a 400.
  • It answers 409 with detail code staged_changes_pending when the service has any staged change — variables, a volume, resources or a requested deletion — because a fresh build resolves the applied configuration and would quietly ship without them. Call apply-changes first.
  • POST /services/{serviceId}/deploymentsis unchanged and still replays an existing deployment's source snapshot. Use it to rebuild a specific past commit; use deploy-current-source to move forward.

POST /services/{serviceId}/actions/deploy-commit builds one explicitly named commit instead of the branch tip. A release pipeline that approved commit A must never ship commit B because the branch advanced before Nouva resolved it, so this action freezes the commit on the deployment the moment it is accepted and never substitutes the tip.

Deploy an approved commit and hand its identity to your release tooling

DEPLOYMENT=$(curl -sS --request POST "$NOUVA_API/services/$SERVICE_ID/actions/deploy-commit" \
  --header "Authorization: Bearer $NOUVA_API_TOKEN" \
  --header "Idempotency-Key: $(uuidgen)" \
  --header "Content-Type: application/json" \
  --data "{\"commitSha\":\"$GITHUB_SHA\"}")

# 202, Location: /v1/deployments/deployment_...
# { "deployment": { "id": "deployment_...", "status": "QUEUED", "commitSha": "<$GITHUB_SHA, lowercased>" } }
echo "$DEPLOYMENT" | jq -r '.deployment.id, .deployment.commitSha'
  • commitShamust be the full 40-character SHA of a commit in the service's authorized repository. It does not have to be on the configured branch. An abbreviated SHA is refused with 400, and a commit the repository does not have answers 400 with detail code commit_not_found on the commitSha field.
  • The response carries the deployment id and the exact commit it will build, so external tooling can record the release against the image that actually ships. Retrying with the same Idempotency-Key returns that same deployment rather than queuing a second one.
  • It takes the same guards as deploy-current-source: 409 with staged_changes_pending while anything is staged, and 400 for a database service. Rollback and replaying a past deployment remain separate actions.

Deploy triggers and push previews

Every app and worker built from GitHub carries a deployTrigger in its source. push deploys the service whenever a push to its branch touches one of its watch paths; manual never deploys from a push, even when GitHub cannot say which files changed, and leaves releases to deploy-current-source, deploy-commit, or the dashboard. Set it and watchPaths when creating a service or with PATCH; neither change restarts anything. watchPaths is a list of repo-relative directories, or null to follow the build root, and the response reports the resolved list as effectiveWatchPaths (null means the whole repository).

POST /services/{serviceId}/actions/push-previewanswers "what would a push do?" before it happens. It runs the same decision the webhook handler runs against every service in the workspace on the same repository and branch, and reports each verdict with its reason. Name a commit and Nouva reads its changed files from GitHub, or hand it a list of paths to try a change you have not committed yet.

Preview which services a commit would deploy

curl -sS --request POST "$NOUVA_API/services/$SERVICE_ID/actions/push-preview" \
  --header "Authorization: Bearer $NOUVA_API_TOKEN" \
  --header "Content-Type: application/json" \
  --data "{\"commitSha\":\"$GITHUB_SHA\"}"

# {
#   "change": { "source": "commit", "commitSha": "...", "changedPaths": ["apps/api/src/server.ts"], "changedPathsTruncated": false },
#   "repository": { "repository": "acme/app", "branch": "main" },
#   "services": [
#     { "id": "service_...", "name": "api", "type": "app", "projectId": "project_...",
#       "deployTrigger": "push", "effectiveWatchPaths": ["apps/api"],
#       "deploy": true, "reason": "path-matched", "matchedPaths": ["apps/api/src/server.ts"] },
#     { "id": "service_...", "name": "jobs", "type": "worker", "projectId": "project_...",
#       "deployTrigger": "manual", "effectiveWatchPaths": ["apps/api"],
#       "deploy": false, "reason": "manual-trigger", "matchedPaths": [] }
#   ]
# }

# Or try a change by hand: --data '{"changedPaths":["packages/shared/index.ts"]}'
  • Send exactly one of commitSha (full 40-character SHA) or changedPaths (up to 1000 paths). A commit the repository does not have answers 400 with detail code commit_not_found, exactly as deploy-commit does.
  • reason is one of manual-trigger, no-watch-paths, changed-paths-unknown, path-matched, and no-path-matched. Only the last one and a manual trigger skip a service; every uncertain case deploys, so a preview shows you what a push would do rather than what it might do.
  • The preview needs both deployments:read and services:read, because it names sibling services and their watch paths. A token limited to selected projects sees only the services in those projects, even though a real push also reaches the rest of the workspace. The preview is a read and needs no Idempotency-Key; it takes 400 for a database service.
  • A commitSha preview reports repository.repository under the name GitHub uses now, so a renamed repository shows its new name. A changedPaths preview never asks GitHub and reports the name stored when the service was connected.
  • Previewing the same commit again is answered from memory and costs nothing. Previews of new commits read from GitHub, so they share an hourly budget per GitHub installation that keeps them from using up the requests your deploys need. Past it the preview answers 429 rate_limited with a github_preview_budget_exhausted detail and a Retry-After header, which can be up to an hour. The X-RateLimit-* headers describe your token, not this budget. changedPaths previews never touch GitHub and keep working.

GET /services/{serviceId}/push-decisionsanswers "why did my push not deploy?" after the fact. A skipped push creates no deployment, so this is where it shows up: each item is one push to the service's repository, newest first, with its commitSha, a verdict, the reason, the matchedPaths, and the deploymentId it created. It pages with cursor and limit like every other list. matchedPaths holds at most 20 paths; matchedPathCountis how many changed paths matched in all. Because each push shows the service's watch paths, the endpoint needs services:read as well as deployments:read.

  • reason says why the push was or was not chosen for the service; verdict says what happened. deployed means the push created a deployment: it carries the deploymentId and that deployment's current deploymentStatus, which is FAILED when the deployment could not start, and null once it is deleted. pending is still being processed, skipped was not chosen, and not-deployed was chosen but created no deployment, with a cause of source-changed, source-incomplete, or processing-failed.
  • reason is one of the preview reasons above, or one of installation-missing, installation-suspended, installation-deleted, and installation-unbound when the GitHub App installation could not deliver the push to your workspace at all.
  • Pushes are kept for 30 days. The list follows the service's current repository, so pushes to a repository it was connected to before are not listed.

Worker shutdown and rollouts

When a worker is redeployed, rolled back, or scaled down, the agent retires the old process gracefully. It sends the worker's shutdownSignal, waits up to shutdownGracePeriodSeconds for the process to exit, and then force-kills it with SIGKILL. Set these fields and rolloutPolicy when creating a worker or with PATCH. Each one is optional, and a field you leave out keeps its stored value. Changing them does not redeploy anything; they take effect on the next deploy. Sending any of them in a PATCH to an app returns 400 validation_failed naming the field.

Stop a scheduler before its replacement starts

curl -sS --request PATCH "$NOUVA_API/services/$SERVICE_ID" \
  --header "Authorization: Bearer $NOUVA_API_TOKEN" \
  --header "Content-Type: application/json" \
  --data '{"shutdownSignal":"SIGTERM","shutdownGracePeriodSeconds":120,"rolloutPolicy":"no_overlap"}'

# "workerShutdown": { "signal": "SIGTERM", "gracePeriodSeconds": 120, "rolloutPolicy": "no_overlap" }
  • shutdownSignal is one of SIGTERM, SIGINT, SIGQUIT, SIGHUP, SIGUSR1, and SIGUSR2, or null. It defaults to null, which keeps the image's own stop signal: its STOPSIGNAL, or SIGTERM when it declares none. Send null in a PATCH to go back to the image's signal. shutdownGracePeriodSeconds is a whole number from 1 to 3600 and defaults to 30.
  • rolloutPolicy defaults to overlap, which starts the new worker before stopping the old one and suits queue consumers. no_overlap stops the old worker before starting the new one, so two copies never run at once. Use it for schedulers and singletons. It also applies to rollbacks and retries.
  • A worker with a volume always stops the old process first, whatever its rolloutPolicy says, so two processes never write to the volume at once.
  • Every service response carries workerShutdown with the stored signal, gracePeriodSeconds, and rolloutPolicy. It is null for apps and databases, which have no worker process to retire.
  • A force-kill does not fail the rollout, because the new worker is already running. The deployment still reaches LIVE and its statusMessage reports which process was force-killed, since an in-flight job may have been interrupted.
  • An older server agent cannot apply these settings. On a server that has not been updated, changing any of them answers 412 with precondition_failed, while sending the stored values unchanged still succeeds. GET /servers/{serverId}/capabilities reports workerShutdownPolicyV1 once the agent supports them.

Resource limits and swap

resourceLimitssets a service's CPU, memory, and swap when creating it or with PATCH. memoryAndSwapBytesis the combined RAM and swap ceiling, the same unit as Docker's MemorySwap, so 512 MiB of memory with 512 MiB of swap is 1073741824. An app or worker redeploys with the new limits; a database stages them until you apply its changes.

Give a service 512 MiB of memory and 512 MiB of swap

curl -sS --request PATCH "$NOUVA_API/services/$SERVICE_ID" \
  --header "Authorization: Bearer $NOUVA_API_TOKEN" \
  --header "Content-Type: application/json" \
  --data '{"resourceLimits":{"cpuMillicores":500,"memoryBytes":536870912,"memoryAndSwapBytes":1073741824}}'

# "cpuLimitMillicores": 500, "memoryLimitBytes": "536870912", "memoryAndSwapLimitBytes": "1073741824"
  • resourceLimits replaces the whole set. A limit you leave out goes back to the protected default, and leaving out memoryAndSwapBytes removes the swap allowance. Send all three to change one.
  • memoryAndSwapBytes must be at least the memory limit and at most twice it, so a service gets at most as much swap as memory. Without memoryBytes it is measured against the default memory limit. Outside that range the request answers 400 validation_failed with a memory_and_swap_below_memory_limit or memory_and_swap_above_maximum detail naming the numbers.
  • Only memory counts against server capacity, not swap. An allowance only helps when the host itself has swap configured.
  • Service responses carry memoryAndSwapLimitBytes, and /changes reports memoryAndSwapBytes under resources.active and resources.desired. Without swap it equals the memory limit.
  • Build containers keep a fixed limit derived from the server's size and never get swap; resourceLimits applies to the running service only.

GitHub sources and setup handoff

Creating an app or worker needs the numeric installationId and repoId of a GitHub installation already connected to your workspace. GitHub access on its own is not enough, so discover both from the API rather than guessing them. These routes need sources:read; they read workspace-level connections and are not narrowed by a token's project reach.

Discover a source, then create a service

SOURCES=$(curl -sS "$NOUVA_API/github/installations" \
  --header "Authorization: Bearer $NOUVA_API_TOKEN")

if [ "$(echo "$SOURCES" | jq -r '.setup.state')" != "connected" ]; then
  echo "Connect GitHub at $(echo "$SOURCES" | jq -r '.setup.url'), then re-run."
  exit 1
fi

INSTALLATION_ID=$(echo "$SOURCES" | jq -r '.installations[] | select(.state == "active") | .installationId' | head -n 1)

REPO=$(curl -sS "$NOUVA_API/github/installations/$INSTALLATION_ID/repositories?limit=100" \
  --header "Authorization: Bearer $NOUVA_API_TOKEN" \
  | jq '.items[] | select(.fullName == "acme/storefront")')

curl -sS --request POST "$NOUVA_API/projects/$PROJECT_ID/services" \
  --header "Authorization: Bearer $NOUVA_API_TOKEN" \
  --header "Idempotency-Key: $CI_KEY-service" \
  --header "Content-Type: application/json" \
  --data "{
    \"name\": \"storefront\",
    \"type\": \"app\",
    \"installationId\": $INSTALLATION_ID,
    \"repoId\": $(echo "$REPO" | jq '.id'),
    \"repoFullName\": $(echo "$REPO" | jq '.fullName'),
    \"branch\": $(echo "$REPO" | jq '.defaultBranch')
  }"
  • setup.state is connected when at least one installation can authorize a build, reauthorization_required when every connected installation was suspended or removed on GitHub, and setup_required when the workspace has never connected one. setup.url is the dashboard screen that completes it.
  • Installing or reauthorizing a GitHub App needs a human in a browser. Send someone to setup.url, then poll GET /github/installations and resume; nothing about the API sequence changes once the state turns connected.
  • Repository listing follows the usual limit, cursor, nextCursor, and hasMorepagination, and returns only the repositories that installation was granted. A repository outside the installation's selection answers 404, never a listing of unrelated private repositories.
  • A connected installation that GitHub has since suspended or removed answers 412 precondition_failed. Installation tokens and OAuth tokens are never part of any response.

Responses, headers, and errors

Nouva never runs your workloads directly, so most mutations return 202 and queue work for the agent on your server. Poll the resource until it reaches a terminal state instead of assuming the response body is final.

StatusWhen you see it
200A read, or a mutation that completed inside the control plane.
201A record was created and needs no agent work.
202Work was queued for your server. Poll the resource for the outcome.
204The delete was accepted and there is no body. A project delete still finishes on your server.

Every response includes a server-generated X-Request-Id. Keep that ID when contacting support or correlating API activity. Asynchronous responses usually also carry a Location header pointing at the resource to poll. Errors use one stable envelope.

Error response

{
  "error": {
    "code": "stable_snake_case_code",
    "message": "Safe actionable message",
    "requestId": "00000000-0000-4000-8000-000000000000",
    "details": []
  }
}
Error codeStatusMeaning
authentication_required401No bearer token was supplied.
invalid_token401The token is malformed or not recognized.
token_expired401The token passed its expiry date.
token_revoked401The token was revoked or rotated away.
token_owner_disabled401The owning user is no longer a workspace admin.
insufficient_scope403The token lacks the required scope.
permission_denied403The token owner may not perform this action on this resource. Scopes are fine.
project_not_allowed403The project is outside the token's project reach.
github_installation_not_bound403The GitHub App installation is not connected to this workspace.
not_found404The resource does not exist, or belongs to another workspace.
validation_failed400The request did not match the contract.
idempotency_key_required400A POST route was called without an Idempotency-Key.
conflict409The resource conflicts with existing state.
idempotency_conflict409The key was reused with a different payload, or is still in flight.
precondition_failed412 or 413The resource is not in a state that allows the operation.
unresolved_reference412A referenced service or variable could not be resolved.
insufficient_capacity412Your server cannot accommodate the requested CPU or memory.
rate_limited429A rate-limit window was exhausted.
request_timeout408An upload stopped sending data before it finished. Send it again.
dependency_unavailable503A required Nouva dependency was unavailable.
internal_error500An unexpected error. Report the request ID.

The code names the recovery action, so a caller never has to guess between rotating a token, connecting GitHub, repairing a reference, and asking for less capacity. When a failure has a specific cause, details carries it: the missing scope, the reference category that failed, what still blocks a project delete, the conflicting resource name, or the requested and accepted CPU and memory amounts. New code and details[].code values may be added within v1, so treat an unrecognized one as its HTTP status.

Resources outside your token workspace return 404 rather than 403, so probing IDs cannot confirm that they exist. Error messages are deliberately generic and never echo credentials or provider diagnostics.

Pagination and idempotency

Collection endpoints use versioned keyset cursors based on creation time and ID. The default page size is 20 and the maximum is 100. Pass the previous nextCursor back as ?cursor= to walk a collection; a cursor from a different collection is rejected with 400.

Paginated response

{
  "items": [],
  "nextCursor": "opaque-versioned-cursor",
  "hasMore": false
}

Every POST route requires an Idempotency-Key header of at most 255 characters. Reuse the same key only for the same request. Matching completed requests replay safely; a different payload or a concurrent in-flight reuse returns 409. Keys are retained for 24 hours. PATCH and DELETE routes do not take the header.

Multipart bucket uploads also require a lowercase SHA-256 contentSha256 query parameter. Compute it from the file bytes before upload. It is part of the idempotency fingerprint, so a changed artifact cannot replay an earlier upload. Uploads carry one file per request and are capped at 1 GiB by default.

Upload one bucket object

FILE="./artifact.tar.gz"
CONTENT_SHA256="$(shasum -a 256 "$FILE" | awk '{print $1}')"

curl --fail-with-body --request POST \
  "$NOUVA_API_BASE/buckets/$NOUVA_BUCKET_ID/objects/upload?environmentId=$NOUVA_ENVIRONMENT_ID&key=artifacts/artifact.tar.gz&contentSha256=$CONTENT_SHA256" \
  --header "Authorization: Bearer $NOUVA_API_TOKEN" \
  --header "Idempotency-Key: $CI_KEY-upload" \
  --form "file=@$FILE"

Run a deployment from CI

set -euo pipefail

export NOUVA_API_TOKEN="${NOUVA_API_TOKEN:?Set a CI secret}"
export NOUVA_API_BASE="https://api.nouva.sh/v1"
export NOUVA_PROJECT_NAME="${NOUVA_PROJECT_NAME:-ci-project}"
export NOUVA_SERVICE_NAME="${NOUVA_SERVICE_NAME:-ci-app}"

request() {
  curl --fail-with-body --silent --show-error \
    --header "Authorization: Bearer $NOUVA_API_TOKEN" "$@"
}

CI_KEY="ci-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
PROJECT_ID="${NOUVA_PROJECT_ID:-}"

# Select the requested project, or create it once with a CI-derived key.
if [ -z "$PROJECT_ID" ]; then
  PROJECT_ID="$(request "$NOUVA_API_BASE/projects?limit=100" | \
    jq -r --arg name "$NOUVA_PROJECT_NAME" '.items[] | select(.name == $name) | .id' | head -n 1)"
fi
if [ -z "$PROJECT_ID" ]; then
  PROJECT_BODY="$(jq -nc --arg name "$NOUVA_PROJECT_NAME" '{name: $name}')"
  PROJECT_ID="$(request --request POST "$NOUVA_API_BASE/projects" \
    --header "Idempotency-Key: $CI_KEY-project" \
    --header "Content-Type: application/json" \
    --data "$PROJECT_BODY" | jq -er '.project.id')"
fi

# Select the service, or create an app from the connected GitHub installation.
SERVICE_ID="$(request "$NOUVA_API_BASE/projects/$PROJECT_ID/services?limit=100" | \
  jq -r --arg name "$NOUVA_SERVICE_NAME" '.items[] | select(.name == $name) | .id' | head -n 1)"
if [ -z "$SERVICE_ID" ]; then
  : "${NOUVA_GITHUB_INSTALLATION_ID:?Set the connected GitHub installation ID}"
  : "${NOUVA_GITHUB_REPOSITORY_ID:?Set the GitHub repository ID}"
  SERVICE_BODY="$(jq -nc \
    --arg name "$NOUVA_SERVICE_NAME" \
    --argjson installationId "$NOUVA_GITHUB_INSTALLATION_ID" \
    --argjson repoId "$NOUVA_GITHUB_REPOSITORY_ID" \
    '{type: "app", name: $name, installationId: $installationId, repoId: $repoId,
      branch: "main", appBuildType: "railpack", appBuildConfig: {buildRoot: "."},
      resourceLimits: null, storageSizeGb: 0}')"
  CREATE_RESPONSE="$(request --request POST "$NOUVA_API_BASE/projects/$PROJECT_ID/services" \
    --header "Idempotency-Key: $CI_KEY-service" \
    --header "Content-Type: application/json" \
    --data "$SERVICE_BODY")"
  SERVICE_ID="$(printf %s "$CREATE_RESPONSE" | jq -er '.service.id')"
  DEPLOYMENT_ID="$(printf %s "$CREATE_RESPONSE" | jq -er '.deployment.id')"
else
  DEPLOYMENT_ID="$(request --request POST "$NOUVA_API_BASE/services/$SERVICE_ID/deployments" \
    --header "Idempotency-Key: $CI_KEY-deployment" \
    --header "Content-Type: application/json" \
    --data '{}' | jq -er '.deployment.id')"
fi

# Poll a terminal deployment state, then fetch a bounded runtime-log page.
for attempt in $(seq 1 60); do
  DEPLOYMENT="$(request "$NOUVA_API_BASE/deployments/$DEPLOYMENT_ID")"
  STATUS="$(printf %s "$DEPLOYMENT" | jq -er '.deployment.status')"
  case "$STATUS" in
    LIVE) break ;;
    FAILED|REPLACED|REMOVED) printf '%s\n' "$DEPLOYMENT" >&2; exit 1 ;;
  esac
  sleep 5
done

if [ "$STATUS" != "LIVE" ]; then
  printf 'Deployment did not reach LIVE before timeout (last status: %s)\n' "$STATUS" >&2
  exit 1
fi

request "$NOUVA_API_BASE/deployments/$DEPLOYMENT_ID/logs/runtime?limit=200&mode=initial&type=all" | jq .

Logs and metrics

Logs

  • Runtime and worker-job log routes accept limit (1 to 500, default 200), mode (initial or older), type (stdout, stderr, or all), and cursor.
  • Page backwards by passing the returned nextCursor with mode=older.
  • Build logs return the last 500 entries and are not paginated.
  • Runtime logs are retained for seven days.
  • Values that match your service or deployment environment variables are redacted.

Metrics

  • Metric routes accept range of 1h, 6h, 1d, 7d, or 30d, defaulting to 1h.
  • Worker services also accept replicaIndex to scope the series to one continuous replica.
  • Responses carry a status of ready, pending, stale, or unavailable alongside a human-readable message, so treat a missing series as a normal outcome.
  • Metrics are retained for 30 days.

Rate limits and compatibility

Rate limits

  • 120 requests per minute per token.
  • 600 requests per minute per workspace.
  • 30 invalid authentication attempts per minute per trusted client IP.
  • Rate-limit headers are returned with responses.

Compatibility

  • Changes within /v1 are backward-compatible and additive.
  • Breaking changes require a new version such as /v2.
  • Nouva documents deprecations at least 90 days before removal.
Header prefixWindow
X-RateLimit-Per token.
X-Workspace-RateLimit-Per workspace.
X-Invalid-Auth-RateLimit-Per client IP, returned on invalid-authentication responses.

Each prefix carries Limit, Remaining, and Reset, where Reset is a Unix timestamp in seconds. If the API rate-limit store is unavailable, requests fail closed with 503.