# Databases API

17 operations under /api/v1. Errors are RFC 9457 problem+json with a stable `code` field. The complete schemas are in https://nebulactrl.com/api/v1/openapi.json.

## GET /database-engines

List the database engines and versions that can be created

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Response 200: OK (CatalogOutputBody)
  - `engines` (EngineView[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 500 Internal Server Error

## POST /environments/{environmentId}/databases

Create a managed database

Creates a staged database service with its data volume and generated credentials; nothing runs until it is deployed. Other services connect with `${{<slug>.DATABASE_URL}}` (or `REDIS_URL`, `MONGO_URL`).

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `environmentId` (path, string, required)

Request body (application/json, required): CreateDatabaseInputBody
  - `acceptLicense` (boolean): Required for engines with a source-available licence (MongoDB, Redis)
  - `databaseName` (string)
  - `engine` (enum: postgres | mysql | mariadb | valkey | redis | mongodb | clickhouse, required): Valkey is the default key-value engine
  - `flavor` (enum: postgis): Image variant, e.g. postgis for PostgreSQL
  - `name` (string): Display name; defaults to the engine name
  - `serverId` (string)
  - `version` (string): Major version; omitted means the engine's default

Response 201: Created (ServiceView)
  - `appliedGeneration` (integer (int64), required)
  - `config` (Config, required)
  - `createdAt` (string (date-time), required)
  - `currentDeploymentId` (string, required)
  - `currentRelease` (ReleaseRef, required)
  - `desiredState` (enum: running | stopped, required)
  - `domains` (DomainView[] | null, required)
  - `environmentId` (string, required)
  - `generation` (integer (int64), required)
  - `git` (GitRef, required)
  - `gitTokenSet` (boolean, required)
  - `hasStagedChanges` (boolean, required)
  - `id` (string, required)
  - `kind` (enum: app | worker | database | route | stack, required)
  - `latestDeployment` (DeploymentRef, required)
  - `maintenance` (MaintenanceView, required)
  - `name` (string, required)
  - `networks` (NetworkRef[] | null, required)
  - `observed` (ObservedView, required)
  - `observedAt` (string (date-time) | null, required)
  - `pendingChangeCounts` (PendingChangeCounts, required)
  - `privateHost` (string, required)
  - `projectId` (string, required)
  - `registryAuthSet` (boolean, required)
  - `scaling` (ScalingView, required)
  - `server` (ServerRef, required)
  - `serverId` (string, required)
  - `slug` (string, required)
  - `sourceType` (enum: image | git | external | compose, required)
  - `stagedKind` (string | null, required): new | edited | null
  - `stale` (boolean, required)
  - `tailnet` (TailnetView, required)
  - `updatedAt` (string (date-time), required)
  - `volumes` (VolumeRef[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error

## GET /services/{serviceId}/database

Get a managed database's engine, exports and state

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Response 200: OK (DatabaseView)
  - `credentialKeys` (string[] | null, required): Variables that hold credentials; rotate them with the rotate action
  - `deployed` (boolean, required)
  - `engine` (EngineView, required)
  - `exports` (ExportView[] | null, required)
  - `image` (string, required)
  - `info` (InfoView)
  - `replication` (ReplicationView)
  - `rotation` (RotationView)
  - `serviceId` (string, required)
  - `spec` (Spec, required)
  - `upgrade` (UpgradeView)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error

## POST /services/{serviceId}/database/promote

Promote a read replica to primary (manual)

Called on the replica. Manual: NebulaCtrl does not detect failures or switch automatically. With `dryRun` the plan is returned (status 200): `switchover` while the old primary is reachable (it is frozen, stopped and fenced, the replica replays everything, no data is lost) or `forced` otherwise (it needs `acceptDataLoss`: what the replica had not received is lost). The old primary is fenced and never runs as a primary again; rebuild it as a replica or delete it. Variables that reference the old primary are rewritten as staged changes. Production environments need approval. Asynchronous: poll `replication.promotion`.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `serviceId` (path, string, required)

Request body (application/json, required): PromoteInputBody
  - `acceptDataLoss` (boolean): Required for a forced promote: what the replica had not received is lost
  - `confirm` (string): Must equal the replica's slug
  - `dryRun` (boolean): Only return the plan

Response 202: Accepted (PromoteResult)
  - `dryRun` (boolean, required)
  - `plan` (PromotePlan, required)
  - `promotionId` (string)
  - `status` (string)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error

## GET /services/{serviceId}/database/public-port

Get a database's public port

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Response 200: OK (PublicPortView)
  - `allowCidrs` (string[] | null, required)
  - `denyCidrs` (string[] | null, required)
  - `deployed` (boolean, required): The proxy publishes the port
  - `enabled` (boolean, required)
  - `host` (string): The address clients connect to (the server's public IP); empty when the server has none
  - `listenPort` (integer (int64))
  - `newEntrypoint` (boolean, required): The proxy does not listen on this port yet: applying it recreates the proxy container (about a second of dropped connections)
  - `openToAll` (boolean, required): The allow-list admits every address (confirmed with the database slug)
  - `routeId` (string): The route service that holds the port
  - `serverId` (string)
  - `serverName` (string)
  - `staged` (boolean, required): The route has changes that are not deployed yet
  - `suggestedPort` (integer (int64), required): A free port to start from when enabling
  - `tls` (enum: passthrough, required): The proxy never terminates TLS on this port
  - `warnings` (string[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error

## PUT /services/{serviceId}/database/public-port

Publish (or change) a database's public port

Stages a TCP route on `listenPort` of the database's server with a mandatory IP allow-list and an optional deny list (deny first). `0.0.0.0/0` or `::/0` is accepted only with `confirmOpen` set to the database slug. The proxy forwards raw TCP and never terminates TLS. Nothing changes until the route is deployed; a new port recreates the proxy container (about a second of dropped connections). Needs `projects:write` and `proxy:write`. 422 codes: `allowlist_required`, `open_confirmation_required`, `invalid_config`, `invalid_listen_port`, `no_server`, `agent_too_old`. 409 `port_in_use`.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Request body (application/json, required): SetPublicPortInputBody
  - `allowCidrs` (string[] | null, required): Client networks (CIDRs or addresses) allowed to connect. Mandatory.
  - `confirmOpen` (string): Required when allowCidrs admits every address (0.0.0.0/0 or ::/0): the database slug
  - `denyCidrs` (string[] | null): Client networks refused first, even when allowCidrs admits them
  - `listenPort` (integer (int64), required): Port on the server (not 80, 443 or a proxy port)

Response 200: OK (PublicPortView)
  - `allowCidrs` (string[] | null, required)
  - `denyCidrs` (string[] | null, required)
  - `deployed` (boolean, required): The proxy publishes the port
  - `enabled` (boolean, required)
  - `host` (string): The address clients connect to (the server's public IP); empty when the server has none
  - `listenPort` (integer (int64))
  - `newEntrypoint` (boolean, required): The proxy does not listen on this port yet: applying it recreates the proxy container (about a second of dropped connections)
  - `openToAll` (boolean, required): The allow-list admits every address (confirmed with the database slug)
  - `routeId` (string): The route service that holds the port
  - `serverId` (string)
  - `serverName` (string)
  - `staged` (boolean, required): The route has changes that are not deployed yet
  - `suggestedPort` (integer (int64), required): A free port to start from when enabling
  - `tls` (enum: passthrough, required): The proxy never terminates TLS on this port
  - `warnings` (string[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict (`port_in_use`); 422 Unprocessable Entity; 500 Internal Server Error

## DELETE /services/{serviceId}/database/public-port

Close a database's public port

Deletes the route service. A deployed route disappears from the server's table at once.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Response 200: OK (PublicPortView)
  - `allowCidrs` (string[] | null, required)
  - `denyCidrs` (string[] | null, required)
  - `deployed` (boolean, required): The proxy publishes the port
  - `enabled` (boolean, required)
  - `host` (string): The address clients connect to (the server's public IP); empty when the server has none
  - `listenPort` (integer (int64))
  - `newEntrypoint` (boolean, required): The proxy does not listen on this port yet: applying it recreates the proxy container (about a second of dropped connections)
  - `openToAll` (boolean, required): The allow-list admits every address (confirmed with the database slug)
  - `routeId` (string): The route service that holds the port
  - `serverId` (string)
  - `serverName` (string)
  - `staged` (boolean, required): The route has changes that are not deployed yet
  - `suggestedPort` (integer (int64), required): A free port to start from when enabling
  - `tls` (enum: passthrough, required): The proxy never terminates TLS on this port
  - `warnings` (string[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error

## POST /services/{serviceId}/database/public-port/connection

Reveal the external connection string (audited)

Like `revealDatabaseExport`, but with the server's public IP and the public port instead of the private host. Needs `deployments:write`, is uncached and recorded as `secret.reveal`. 409 `public_port_disabled`; 422 `no_public_address` when the server has no public IP.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Request body (application/json, required): PublicConnectionInputBody
  - `key` (string): One of the database's exports; defaults to the engine's connection URL (DATABASE_URL, REDIS_URL, MONGO_URL)

Response 200: OK (PublicConnection)
  - `expiresAt` (string (date-time), required): The value is meant to be shown for this long only; clients must not persist it
  - `host` (string, required)
  - `key` (string, required)
  - `port` (integer (int64), required)
  - `revealedAt` (string (date-time), required)
  - `value` (string, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict (`public_port_disabled`); 422 Unprocessable Entity (`no_public_address`); 500 Internal Server Error

## POST /services/{serviceId}/database/rebuild

Rebuild a replica, or a fenced old primary as a replica

Clones the primary again into a new data volume (the old volume is kept until it is deleted). On a fenced old primary the body may name the new primary to follow. Asynchronous.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `serviceId` (path, string, required)

Request body (application/json, required): RebuildInputBody
  - `followServiceId` (string): For a fenced old primary: the new primary to follow (default: the replica that was promoted)

Response 202: Accepted (RebuildResult)
  - `serviceId` (string, required)
  - `status` (string, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error

## POST /services/{serviceId}/database/refresh

Ask the agent for the database's version, size and connections

Asynchronous: poll `GET /services/{id}/database` (or watch the event feed) for the new `info`.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Response 202: Accepted (RefreshResult)
  - `commandId` (string, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error

## DELETE /services/{serviceId}/database/replica

Remove a read replica

Called on the replica. Deletes its container and data volume, drops its slot, role and pg_hba.conf line on the primary and releases its mesh link. Asynchronous.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `serviceId` (path, string, required)

Response 202: Accepted (RemoveResult)
  - `serviceId` (string, required)
  - `status` (string, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error

## POST /services/{serviceId}/database/replicas

Add a read replica of a PostgreSQL primary

Creates a read replica service in the primary's environment and clones the primary into it. The replica runs on the primary's server or, with the server mesh enabled on both, on another server. Asynchronous: the replica is `pending`, then `preparing`, `cloning`, `deploying` and `streaming`; follow it with `GET /services/{id}/database/replication`. With `dryRun` only the plan is returned (status 200). Replication is asynchronous and manual: nothing is detected or switched automatically.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `serviceId` (path, string, required)

Request body (application/json, required): AddReplicaInputBody
  - `acceptMeshChanges` (boolean): Confirms that the replica adds tailnet devices (a replication link)
  - `dryRun` (boolean): Only return the plan
  - `name` (string): Display name; defaults to <primary>-replica-<n>
  - `serverId` (string, required): The server the replica runs on (the primary's server, or another server with the mesh enabled)

Response 202: Accepted (AddReplicaResult)
  - `dryRun` (boolean, required)
  - `plan` (ReplicaPlan, required)
  - `serviceId` (string)
  - `status` (string)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error

## GET /services/{serviceId}/database/replication

Get the read replica group of a PostgreSQL database

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Response 200: OK (ReplicationView)
  - `canPromote` (boolean, required)
  - `primary` (ServiceRef)
  - `promotion` (PromotionView)
  - `rebuildable` (boolean, required): A replica that can be rebuilt, or a fenced old primary that can be rebuilt as a replica
  - `replicas` (ReplicaMemberView[] | null, required)
  - `role` (enum: standalone | primary | replica | fenced, required)
  - `self` (ReplicaStateView)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error

## POST /services/{serviceId}/database/reveal

Reveal a connection string (audited)

Returns one derived export such as `DATABASE_URL` in plain text, uncached. Requires the `deployments:write` scope (or admin) and is recorded in the audit log as `secret.reveal`. Every other read stays masked.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Request body (application/json, required): RevealInputBody
  - `key` (string, required): One of the database's exports, e.g. DATABASE_URL

Response 200: OK (Revealed)
  - `expiresAt` (string (date-time), required): The value is meant to be shown for this long only; clients must not persist it
  - `key` (string, required)
  - `revealedAt` (string (date-time), required)
  - `value` (string, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error

## POST /services/{serviceId}/database/rotate-credentials

Rotate the database password

Changes the application password inside the running database through the agent (or, for Valkey and Redis, stages a new password), updates the variable and stages every service that references the database. Nothing is deployed. With `dryRun` only the plan is returned.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Request body (application/json, required): RotateInputBody
  - `dryRun` (boolean): Only return the plan

Response 200: OK (RotationResult)
  - `commandId` (string)
  - `dryRun` (boolean, required)
  - `plan` (RotationPlan, required)
  - `rotationId` (string)
  - `status` (enum: planned | pending | applied, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error

## POST /services/{serviceId}/database/upgrade

Upgrade the database version

In-place engines stage the new image. PostgreSQL majors are converted with pg_upgrade into a new volume (the old volume is kept) and the new version is staged when the conversion finishes. Take a backup first.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Request body (application/json, required): UpgradeInputBody
  - `confirm` (string, required): Must equal the service slug
  - `targetVersion` (string, required)

Response 200: OK (UpgradeResult)
  - `commandId` (string)
  - `plan` (UpgradePlan, required)
  - `status` (enum: staged | pending, required)
  - `upgradeId` (string)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error

## POST /services/{serviceId}/database/upgrade/plan

Plan a version upgrade

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Request body (application/json, required): UpgradePlanInputBody
  - `targetVersion` (string, required)

Response 200: OK (UpgradePlan)
  - `blockers` (string[] | null, required)
  - `downtime` (string, required)
  - `engine` (string, required)
  - `executable` (boolean, required)
  - `from` (string, required)
  - `helperImage` (string)
  - `image` (string)
  - `kind` (string, required)
  - `method` (string, required)
  - `steps` (string[] | null, required)
  - `to` (string, required)
  - `warnings` (string[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error
