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 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.
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/syncResolve 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 flaggingContainer 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/restartUse 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/envconst 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:
| 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
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
| Operation | SDK | REST API |
|---|---|---|
| Stream log events for a service. | services.streamLogs(projectId, serviceId, input?, options?) | GET /api/projects/:id/services/:serviceId/logs/streamproject:service:read |
| List services belonging to a project. | services.list(id) | GET /api/projects/:id/servicesproject:service:list |
| Add a service definition to a project. | services.create(id, input) | POST /api/projects/:id/servicesproject:service:write |
| Synchronize service definitions from Compose source. | services.sync(id, input) | POST /api/projects/:id/services/syncproject:service:write |
| List active project containers. | services.activeContainers(id) | GET /api/projects/:id/services/containersproject:read |
| Read one service definition and state. | services.get(parentId, id) | GET /api/projects/:id/services/:serviceIdproject:service:read |
| Update service configuration. | services.update(parentId, id, input) | PATCH /api/projects/:id/services/:serviceIdproject:service:write |
| Remove a service and its selected resources. | services.remove(parentId, id) | DELETE /api/projects/:id/services/:serviceIdproject:service:admin |
| Accept a legacy source review while preserving saved environment values. | services.acceptDrift(parentId, id) | POST /api/projects/:id/services/:serviceId/drift/acceptproject:service:write |
| Keep the current configuration when resolving drift. | services.keepDrift(parentId, id) | POST /api/projects/:id/services/:serviceId/drift/keepproject: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/environmentproject:service:read |
| Save named service overrides without replacing unrelated values. | services.mergeEnvVars(parentId, id, input) | PATCH /api/projects/:id/services/:serviceId/envproject:service:write |
| Read service environment overrides with masked secrets. | services.listEnvVars(parentId, id, input?) | GET /api/projects/:id/services/:serviceId/envproject:service:read |
| Apply service environment variable changes. | services.setEnvVars(parentId, id, input) | PUT /api/projects/:id/services/:serviceId/envproject:service:write |
| Reveal authorized service environment values. | services.revealEnv(parentId, id, input) | POST /api/projects/:id/services/:serviceId/env-revealproject: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-envproject:service:write |
| Read persistent-volume sizes for the service. | services.volumeSizes(parentId, id) | GET /api/projects/:id/services/:serviceId/volume-sizesproject:service:read |
| Start the service runtime. | services.start(parentId, id) | POST /api/projects/:id/services/:serviceId/startproject:service:write |
| Stop the service runtime. | services.stop(parentId, id) | POST /api/projects/:id/services/:serviceId/stopproject:service:write |
| Restart the service runtime. | services.restart(parentId, id, input?) | POST /api/projects/:id/services/:serviceId/restartproject:service:write |
| Read recent service runtime logs. | services.runtimeLogs(parentId, id, input?) | GET /api/projects/:id/services/:serviceId/logsproject:service:read |
| Execute an authorized command in the service runtime. | services.exec(parentId, id, input) | POST /api/projects/:id/services/:serviceId/execproject:service:write |