# Services
URL: https://openship.io/docs/api/services.md

Manage project services, containers, and environment variables.

import { TypeTable } from 'fumadocs-ui/components/type-table';

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`](/docs/cli/projects).

These operations are available with supported self-hosted and Cloud providers. A fixed organization
scope requires a direct compatible Cloud connection; see [Cloud compatibility](/docs/api/sdk/compatibility#cloud).

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:

```ts
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.

<Tabs items={['SDK', 'REST API']} groupId="api-transport" persist>
<Tab value="SDK">

```ts
const result = await ship.services.create("proj_123", {
  name: "db",
  kind: "compose",
  image: "postgres:16",
  ports: ["5432:5432"],
});
console.log(result);
```

</Tab>
<Tab value="REST API">

```bash
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"]}'
```

</Tab>
</Tabs>

<TypeTable
  type={{
    name: { type: 'string', description: 'Service name (1–120 chars). Unique within the stack.', required: true },
    kind: { type: '"compose" | "monorepo"', description: 'Which field set is load-bearing. Omit for a plain compose service.' },
  }}
/>

**Compose fields** (used when `kind` is `compose`):

<TypeTable
  type={{
    image: { type: 'string', description: 'Container image to run, e.g. postgres:16 (max 500).' },
    build: { type: 'string', description: 'Docker build context — the directory docker builds from. Relative to the compose file\u2019s folder, exactly as compose resolves it (max 500).' },
    dockerfile: { type: 'string', description: 'Dockerfile path relative to the build context (max 500).' },
    ports: { type: 'string[]', description: 'Port mappings like "8080:80" (max 50 entries, each ≤100 chars).' },
    dependsOn: { type: 'string[]', description: 'Names of services this one depends on (max 50).' },
    environment: { type: 'Record<string, string>', description: 'Compose environment defaults baked into the service. On update this map is merged, not replaced, and accepts nulls — see Update a service.' },
    volumes: { type: 'string[]', description: 'Volume mappings (max 50 entries, each ≤500 chars).' },
    command: { type: 'string', description: 'Override the container command (max 1000).' },
    advanced: { type: 'object', description: 'Extended Compose settings, including healthcheck, readiness and extraHosts (hostname to IPv4/IPv6 address or Docker host-gateway). Unknown keys are rejected; null removes an individual setting.' },
    exposed: { type: 'boolean', description: 'Whether this service is publicly routed.' },
    exposedPort: { type: 'string', description: 'Container port to expose publicly (max 50).' },
    domain: { type: 'string', description: 'Free subdomain label for this service (max 255).' },
    customDomain: { type: 'string', description: 'A custom domain to route to this service (max 255).' },
    domainType: { type: '"free" | "custom"', description: 'Which kind of domain the service uses.' },
    restart: { type: '"no" | "always" | "on-failure" | "unless-stopped"', description: 'Container restart policy.' },
    enabled: { type: 'boolean', description: 'Whether the service is included in deploys.' },
    sortOrder: { type: 'number', description: 'Display/ordering position (≥0).' },
  }}
/>

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

<TypeTable
  type={{
    rootDirectory: { type: 'string', description: "The sub-app's directory within the repo (1–200). Required by the service layer when kind=monorepo." },
    installCommand: { type: 'string', description: 'Dependency install command (max 1000).' },
    buildCommand: { type: 'string', description: 'Build command (max 1000).' },
    startCommand: { type: 'string', description: 'Start command (max 1000).' },
    outputDirectory: { type: 'string', description: 'Build output directory (max 200).' },
    framework: { type: 'string', description: 'A supported stack id (e.g. nextjs, vite, django).' },
    packageManager: { type: 'string', description: 'A supported package manager (e.g. npm, pnpm, yarn, bun).' },
    buildImage: { type: 'string', description: 'Override the build image (max 200).' },
  }}
/>

## 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 send | Result |
| --- | --- |
| the key is absent | the 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](#reveal-environment-values).

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

<Tabs items={['SDK', 'REST API']} groupId="api-transport" persist>
<Tab value="SDK">

```ts
const result = await ship.services.update("proj_123", "svc_456", {
  restart: "unless-stopped",
  enabled: true,
});
console.log(result);
```

</Tab>
<Tab value="REST API">

```bash
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}'
```

</Tab>
</Tabs>

## 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

<Tabs items={['SDK', 'REST API']} groupId="api-transport" persist>
<Tab value="SDK">

```ts
await ship.services.restart("proj_123", "svc_123");
```

</Tab>
<Tab value="REST API">

```bash
curl -X POST "$OPENSHIP_URL/api/projects/proj_123/services/svc_123/restart" \
  -H "Authorization: Bearer $OPENSHIP_TOKEN"
```

</Tab>
</Tabs>

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.

```ts
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.

```ts
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
```

<Tabs items={['SDK', 'REST API']} groupId="api-transport" persist>
<Tab value="SDK">

```ts
const result = await ship.services.setEnvVars("proj_123", "svc_456", {
  environment: "production",
  vars: [
    {
      key: "DATABASE_URL",
      value: "postgres://…",
      isSecret: true,
    },
  ],
});
console.log(result);
```

</Tab>
<Tab value="REST API">

```bash
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}]}'
```

</Tab>
</Tabs>

<TypeTable
  type={{
    environment: { type: '"production" | "preview" | "development"', description: 'Which environment these vars apply to.', required: true },
    vars: { type: 'Array<{ key, value, isSecret? }>', description: 'The full set of vars (0–100). key 1–256 chars, value ≤10000, isSecret defaults to false.', required: true },
  }}
/>

<Callout title="PUT replaces, it doesn't merge" type="warn">
`PUT .../env` overwrites the service override set with exactly what you send.
Use `PATCH .../env` when changing individual values so unrelated overrides remain intact.
</Callout>

### 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.

<Tabs items={['SDK', 'REST API']} groupId="api-transport" persist>
<Tab value="SDK">

```ts
const environment = await ship.services.revealEnv("proj_123", "svc_456", {
  keys: ["POSTGRES_PASSWORD"],
});
```

</Tab>
<Tab value="REST API">

```bash
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`.

</Tab>
</Tabs>

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:

| Source | SDK | REST API |
| --- | --- | --- |
| Git or local scan | `deployments.prepare({ ...prepareInput, includeEnv: true })` | `POST /api/deployments/prepare` with `includeEnv: true` |
| Uploaded snapshot | `sources.scan("session_123", { includeEnv: true })` | `POST /api/projects/folder/scan/session_123` with `{ "includeEnv": true }` |
| Local directory | `projects.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

<Callout title="400 — invalid body" type="warn">
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`.
</Callout>

<Callout title="404 on a per-service route" type="error">
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.
</Callout>

## Operations

{/* api-operations:start */}

| Operation | SDK | REST API |
| --- | --- | --- |
| Stream log events for a service. | `services.streamLogs(projectId, serviceId, input?, options?)` | `GET /api/projects/:id/services/:serviceId/logs/stream`<br />`project:service:read` |
| List services belonging to a project. | `services.list(id)` | `GET /api/projects/:id/services`<br />`project:service:list` |
| Add a service definition to a project. | `services.create(id, input)` | `POST /api/projects/:id/services`<br />`project:service:write` |
| Synchronize service definitions from Compose source. | `services.sync(id, input)` | `POST /api/projects/:id/services/sync`<br />`project:service:write` |
| List active project containers. | `services.activeContainers(id)` | `GET /api/projects/:id/services/containers`<br />`project:read` |
| Read one service definition and state. | `services.get(parentId, id)` | `GET /api/projects/:id/services/:serviceId`<br />`project:service:read` |
| Update service configuration. | `services.update(parentId, id, input)` | `PATCH /api/projects/:id/services/:serviceId`<br />`project:service:write` |
| Remove a service and its selected resources. | `services.remove(parentId, id)` | `DELETE /api/projects/:id/services/:serviceId`<br />`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`<br />`project:service:write` |
| Keep the current configuration when resolving drift. | `services.keepDrift(parentId, id)` | `POST /api/projects/:id/services/:serviceId/drift/keep`<br />`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`<br />`project:service:read` |
| Save named service overrides without replacing unrelated values. | `services.mergeEnvVars(parentId, id, input)` | `PATCH /api/projects/:id/services/:serviceId/env`<br />`project:service:write` |
| Read service environment overrides with masked secrets. | `services.listEnvVars(parentId, id, input?)` | `GET /api/projects/:id/services/:serviceId/env`<br />`project:service:read` |
| Apply service environment variable changes. | `services.setEnvVars(parentId, id, input)` | `PUT /api/projects/:id/services/:serviceId/env`<br />`project:service:write` |
| Reveal authorized service environment values. | `services.revealEnv(parentId, id, input)` | `POST /api/projects/:id/services/:serviceId/env-reveal`<br />`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`<br />`project:service:write` |
| Read persistent-volume sizes for the service. | `services.volumeSizes(parentId, id)` | `GET /api/projects/:id/services/:serviceId/volume-sizes`<br />`project:service:read` |
| Start the service runtime. | `services.start(parentId, id)` | `POST /api/projects/:id/services/:serviceId/start`<br />`project:service:write` |
| Stop the service runtime. | `services.stop(parentId, id)` | `POST /api/projects/:id/services/:serviceId/stop`<br />`project:service:write` |
| Restart the service runtime. | `services.restart(parentId, id, input?)` | `POST /api/projects/:id/services/:serviceId/restart`<br />`project:service:write` |
| Read recent service runtime logs. | `services.runtimeLogs(parentId, id, input?)` | `GET /api/projects/:id/services/:serviceId/logs`<br />`project:service:read` |
| Execute an authorized command in the service runtime. | `services.exec(parentId, id, input)` | `POST /api/projects/:id/services/:serviceId/exec`<br />`project:service:write` |

{/* api-operations:end */}
