API

Services

Manage project services, containers, and environment variables.

A service is one runnable unit inside a project: a container in a docker-compose stack, or a sub-app in a monorepo. Both live in the same table (discriminated by kind), so this API covers listing and editing services, syncing them from a docker-compose file, starting/stopping/restarting their containers, resolving compose drift, and setting per-service environment variables. In the dashboard this is a project's Services tab; from the terminal it's openship service.

These operations are available with supported self-hosted and Cloud providers. A fixed organization scope requires a direct compatible Cloud connection; see Cloud compatibility.

Service and deployment responses mask non-empty buildArgs values as ••••••••, just like environment. This also covers retained deployment snapshots and Compose drift previews. Empty strings stay empty and null still means inherit from the build environment. On write, echoing a mask keeps that key's stored value; a mask without a stored source is dropped. buildArgs remains a whole-map replacement: omit a key to remove it, or send {} to clear the map.

For literal build args, responses also include buildArgsFingerprints, keyed by argument name. After rotating a literal value, compare the fingerprint in the service write response with the same service's fingerprint in deployment history (meta.composeServices[]) or build status (composeServices[]). Equal fingerprints mean the stored literal values match; a rotation changes the fingerprint without returning either value. They are HMAC-SHA256 values scoped to the project, service name and argument key, so they cannot be computed offline or compared across services. Rotating the instance's auth secret also changes them.

Fingerprints describe the stored build configuration, not a running container. Inherited (null) and interpolated args have no fingerprint because their effective values depend on the build environment. Snapshots remain intact internally for rollback; existing history is masked when read, without a migration.

Source scans also mask build arguments by default. An authorized editing scan with includeEnv: true includes source values so the deploy form can edit them; it requires write access and, for GitHub sources, permission to read the repository's full contents.

Share a service between projects

A service can stay in its owning project and be connected to several other projects. This reuses the existing instance and its data. In the dashboard, open the service and choose Use in a project, or choose Add service → Connect existing from the consuming project's Services tab. Shared services appear there with their source project; the owner can see which projects use each service.

Use the existing project connection API to create these links:

const { outputs } = await ship.projects.getAppConnection("source_project");
const output = outputs.find(item => item.sourceServiceId === "svc_database");
if (!output?.value) throw new Error("Configure and deploy the source database first.");

await ship.projects.connectBundle("consumer_project", {
  sourceProjectId: "source_project",
  items: [{ outputId: output.id, envKey: "DATABASE_URL" }],
  mode: "internal",
});

The caller needs write access to both projects in the same organization. Private service connections require Docker projects on the same server. Credentials are stored as secret environment variables and refreshed from the source on normal consumer deployments. Disconnecting removes that project's link; the source service and other consumers remain. Disconnect all consumers before deleting a shared service or its owning project.

Add a service

A service row is a union: kind: "compose" uses the container fields (image, build, ports…), while kind: "monorepo" uses the sub-app build fields (rootDirectory, framework…). Both field sets are accepted and optional; the kind decides which subset is the source of truth. Unknown keys are rejected.

const result = await ship.services.create("proj_123", {
  name: "db",
  kind: "compose",
  image: "postgres:16",
  ports: ["5432:5432"],
});
console.log(result);
curl -X POST "$OPENSHIP_URL/api/projects/proj_123/services" \
  -H "Authorization: Bearer $OPENSHIP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"db","kind":"compose","image":"postgres:16","ports":["5432:5432"]}'

Prop

Type

Compose fields (used when kind is compose):

Prop

Type

Monorepo sub-app fields (used when kind is monorepo):

Prop

Type

Update a service

Every field is optional — send only what changes. Accepts the same compose and monorepo fields as create, except kind: switching a row's kind is a destructive operation, so delete the service and re-create it under the new kind instead.

Two fields are merged onto their stored value instead of replacing it — environment and advanced, the endpoint's keyed maps:

You sendResult
the key is absentthe stored value is kept
{"KEY": "value"}that one key is set; every other key is untouched
{"KEY": null}that one key is removed
{"environment": null}every variable is removed
{"environment": {}}nothing changes

Env values read back masked as •••••••• (GET /:serviceId never returns plaintext). Echo the sentinel to keep a value unchanged, or send a real string to replace it. Omitting a key never deletes it — a delete has to be spelled null. To read a value explicitly, use reveal.

Every other field replaces its stored value wholesale, ports, volumes, dependsOn and publicEndpoints included, so send the complete list for those.

const result = await ship.services.update("proj_123", "svc_456", {
  restart: "unless-stopped",
  enabled: true,
});
console.log(result);
curl -X PATCH "$OPENSHIP_URL/api/projects/proj_123/services/svc_456" \
  -H "Authorization: Bearer $OPENSHIP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"restart":"unless-stopped","enabled":true}'

Sync from docker-compose

Explicitly replace Compose service configuration with the supplied parsed services, adding or updating rows and removing services absent from the list. The CLI's service sync command reads and validates the file before submitting this request. Ordinary redeploys automatically merge repo changes with saved overrides; they do not require this replacement operation.

POST /api/projects/:id/services/sync

Resolve compose drift

These endpoints resolve pending reviews saved by older versions. Routine redeploys now preserve explicit overrides and automatically apply other source changes, clearing old review records. Accepting a legacy review applies source configuration while preserving saved environment values.

POST /api/projects/:id/services/:serviceId/drift/accept   # accept source config, preserve saved env
POST /api/projects/:id/services/:serviceId/drift/keep     # keep your edits, stop flagging

Container actions

await ship.services.restart("proj_123", "svc_123");
curl -X POST "$OPENSHIP_URL/api/projects/proj_123/services/svc_123/restart" \
  -H "Authorization: Bearer $OPENSHIP_TOKEN"

Start, stop, or restart the running container for a single service. These act on the service's active-deployment container.

POST /api/projects/:id/services/:serviceId/start
POST /api/projects/:id/services/:serviceId/stop
POST /api/projects/:id/services/:serviceId/restart

Use GET /api/projects/:id/services/containers (openship service containers -p my-stack) to list the running containers across the whole stack.

Environment variables

GET .../environment returns the effective saved configuration: shared project values, Compose values and service overrides. Each row has a source, and secrets are masked. It defaults to the active deployment's environment, or production before the first deployment. Add inspectRuntime=true to compare with the owned Docker container; a failed inspection leaves the saved variables in the response.

const state = await ship.services.getEnvironment("proj_123", "svc_456", {
  inspectRuntime: true,
});
console.log(state.status, state.changedKeys);

Use PATCH .../env for partial edits. Send only changed overrides in upserts, and explicit removals in deletes. Existing overrides require their sourceId from the read; null means creating an override. Stale IDs return ENVIRONMENT_CHANGED without applying the request.

const currentEnvironment = await ship.services.getEnvironment("proj_123", "svc_456");
await ship.services.mergeEnvVars("proj_123", "svc_456", {
  environment: currentEnvironment.environment,
  upserts: [{
    key: "LOG_LEVEL",
    value: "info",
    sourceId: currentEnvironment.variables.find(row => row.key === "LOG_LEVEL")?.sourceId ?? null,
  }],
  deletes: [],
});

GET .../env lists only service override rows (secret values masked). PUT .../env replaces those overrides for one environment — send every override you want to keep. Neither endpoint represents the inherited Compose or shared project layers.

GET /api/projects/:id/services/:serviceId/env
PUT /api/projects/:id/services/:serviceId/env
GET /api/projects/:id/services/:serviceId/environment
PATCH /api/projects/:id/services/:serviceId/env
const result = await ship.services.setEnvVars("proj_123", "svc_456", {
  environment: "production",
  vars: [
    {
      key: "DATABASE_URL",
      value: "postgres://…",
      isSecret: true,
    },
  ],
});
console.log(result);
curl -X PUT "$OPENSHIP_URL/api/projects/proj_123/services/svc_456/env" \
  -H "Authorization: Bearer $OPENSHIP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"environment":"production","vars":[{"key":"DATABASE_URL","value":"postgres://…","isSecret":true}]}'

Prop

Type

PUT replaces, it doesn't merge

PUT .../env overwrites the service override set with exactly what you send. Use PATCH .../env when changing individual values so unrelated overrides remain intact.

Reveal environment values

Add source: "effective" to reveal named values from the complete saved view. For recovery, use source: "runtime" with the containerId returned by runtime inspection and names from recoverableKeys. Recovery excludes image defaults and keys already saved in Openship. It returns values without persisting them; save the selected recovered values with mergeEnvVars. A changed container returns ENVIRONMENT_RUNTIME_CHANGED.

The dashboard uses one environment editor. Imported Compose values are visible immediately; Demo mode blurs them like IP addresses. Saved values start masked, and clicking a row's eye reveals only that value. Show values is the explicit action for revealing all saved rows. Revealing does not save or overwrite configuration.

SDK and REST requests name 1–500 keys, each up to 512 characters. Only requested keys that exist are returned. Reading masked configuration does not grant permission to reveal it.

const environment = await ship.services.revealEnv("proj_123", "svc_456", {
  keys: ["POSTGRES_PASSWORD"],
});
curl -X POST "$OPENSHIP_URL/api/projects/proj_123/services/svc_456/env-reveal" \
  -H "Authorization: Bearer $OPENSHIP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"keys":["POSTGRES_PASSWORD"]}'

The returned values are under environment.

This reads saved Compose configuration and requires service write access. Add environment: "production", "preview", or "development" to read the service's variables for that environment instead.

For an import editor, request values with the initial scan. This avoids a second source read when the editor opens:

SourceSDKREST API
Git or local scandeployments.prepare({ ...prepareInput, includeEnv: true })POST /api/deployments/prepare with includeEnv: true
Uploaded snapshotsources.scan("session_123", { includeEnv: true })POST /api/projects/folder/scan/session_123 with { "includeEnv": true }
Local directoryprojects.scanLocal({ path: "/srv/app", includeEnv: true })POST /api/projects/scan with the same body

Ordinary scans remain masked. includeEnv: true requires the scan's write permission and, for Git, whole-repository content read access. Local paths are self-hosted only. Demo mode only changes the browser display; it does not change API permissions or returned values.

Errors

400 — invalid body

The payload failed validation (unknown key, wrong type, or a value over its length limit). Unknown keys are rejected outright — check field names against the tables above. On create, a kind: "monorepo" service must carry a rootDirectory.

404 on a per-service route

The :serviceId doesn't belong to this project (or was deleted), or the project :id isn't in your organization. List the project's services with GET /api/projects/:id/services to get valid ids.

Operations

OperationSDKREST API
Stream log events for a service.services.streamLogs(projectId, serviceId, input?, options?)GET /api/projects/:id/services/:serviceId/logs/stream
project:service:read
List services belonging to a project.services.list(id)GET /api/projects/:id/services
project:service:list
Add a service definition to a project.services.create(id, input)POST /api/projects/:id/services
project:service:write
Synchronize service definitions from Compose source.services.sync(id, input)POST /api/projects/:id/services/sync
project:service:write
List active project containers.services.activeContainers(id)GET /api/projects/:id/services/containers
project:read
Read one service definition and state.services.get(parentId, id)GET /api/projects/:id/services/:serviceId
project:service:read
Update service configuration.services.update(parentId, id, input)PATCH /api/projects/:id/services/:serviceId
project:service:write
Remove a service and its selected resources.services.remove(parentId, id)DELETE /api/projects/:id/services/:serviceId
project:service:admin
Accept a legacy source review while preserving saved environment values.services.acceptDrift(parentId, id)POST /api/projects/:id/services/:serviceId/drift/accept
project:service:write
Keep the current configuration when resolving drift.services.keepDrift(parentId, id)POST /api/projects/:id/services/:serviceId/drift/keep
project:service:write
Read the effective saved service environment and optionally compare it with Docker.services.getEnvironment(parentId, id, input?)GET /api/projects/:id/services/:serviceId/environment
project:service:read
Save named service overrides without replacing unrelated values.services.mergeEnvVars(parentId, id, input)PATCH /api/projects/:id/services/:serviceId/env
project:service:write
Read service environment overrides with masked secrets.services.listEnvVars(parentId, id, input?)GET /api/projects/:id/services/:serviceId/env
project:service:read
Apply service environment variable changes.services.setEnvVars(parentId, id, input)PUT /api/projects/:id/services/:serviceId/env
project:service:write
Reveal authorized service environment values.services.revealEnv(parentId, id, input)POST /api/projects/:id/services/:serviceId/env-reveal
project:service:write
Apply saved runtime environment by replacing the service container using its current image, without a build.services.applyEnvironment(parentId, id)POST /api/projects/:id/services/:serviceId/apply-env
project:service:write
Read persistent-volume sizes for the service.services.volumeSizes(parentId, id)GET /api/projects/:id/services/:serviceId/volume-sizes
project:service:read
Start the service runtime.services.start(parentId, id)POST /api/projects/:id/services/:serviceId/start
project:service:write
Stop the service runtime.services.stop(parentId, id)POST /api/projects/:id/services/:serviceId/stop
project:service:write
Restart the service runtime.services.restart(parentId, id, input?)POST /api/projects/:id/services/:serviceId/restart
project:service:write
Read recent service runtime logs.services.runtimeLogs(parentId, id, input?)GET /api/projects/:id/services/:serviceId/logs
project:service:read
Execute an authorized command in the service runtime.services.exec(parentId, id, input)POST /api/projects/:id/services/:serviceId/exec
project:service:write

On this page