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 synq 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 "$SYNQ_URL/api/projects/proj_123/services" \
-H "Authorization: Bearer $SYNQ_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 "$SYNQ_URL/api/projects/proj_123/services/svc_456" \
-H "Authorization: Bearer $SYNQ_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 "$SYNQ_URL/api/projects/proj_123/services/svc_123/restart" \
-H "Authorization: Bearer $SYNQ_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 (synq 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 "$SYNQ_URL/api/projects/proj_123/services/svc_456/env" \
-H "Authorization: Bearer $SYNQ_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 Synq. 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 "$SYNQ_URL/api/projects/proj_123/services/svc_456/env-reveal" \
-H "Authorization: Bearer $SYNQ_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 |
| Read the host ports a service publishes (public or loopback, tcp/udp), its routed ports and the ports Synq reserves. | services.getPublishedPorts(parentId, id) | GET /api/projects/:id/services/:serviceId/portsproject:service:read |
| Read a service's Markdown notes and the length limit. | services.getNotes(parentId, id) | GET /api/projects/:id/services/:serviceId/notesproject:service:read |
| Replace a service's Markdown notes (up to 20,000 characters; null clears them). Needs write access. | services.setNotes(parentId, id, input) | PUT /api/projects/:id/services/:serviceId/notesproject:service:write |
| Replace a service's published ports after range, reserved-port and same-server clash checks, then recreate its container with the new bindings. A row without a host port gets a free one; a typed port that is taken answers 409 PORT_UNAVAILABLE with a suggestedPort. A failed apply changes nothing. | services.setPublishedPorts(parentId, id, input) | PUT /api/projects/:id/services/:serviceId/portsproject:service:write |
| Recreate a service's running container with its saved ports, keeping image, environment, volumes and routing. | services.applyPublishedPorts(parentId, id) | POST /api/projects/:id/services/:serviceId/ports/applyproject:service:write |
| Read a service's load balancing: the container count (1 to 10), the strategy, whether it can run more than one container and why not, the volumes the containers would share, and each container with its loopback port, state and health. | services.getReplicas(parentId, id) | GET /api/projects/:id/services/:serviceId/replicasproject:service:read · Self-hosted |
| Set how many identical containers (1 to 10) a service runs and how the edge splits requests (round_robin, least_conn or ip_hash), applied to the running service without a rebuild: new copies are health-checked before they get traffic, removed ones are drained, and later deploys replace one container at a time. Answers status applied, saved or unchanged; 409 with a code when the service cannot be copied. | services.setReplicas(parentId, id, input) | POST /api/projects/:id/services/:serviceId/replicasproject:service:write · Self-hosted |
| Read whether a database service is exposed to the internet, with internal and external addresses (passwords masked) and how the external host was chosen (connection hostname, panel domain or IP). | services.getPublicAccess(parentId, id) | GET /api/projects/:id/services/:serviceId/public-accessproject:service:read |
| Expose a database service on a public host port (the standard port, or the next free one when it is taken; a typed port that is taken answers 409 with a free one), or remove that port, and apply it to the running container. A failed apply changes nothing. | services.setPublicAccess(parentId, id, input) | PUT /api/projects/:id/services/:serviceId/public-accessproject:service:write |
| Read a database service's connection details, and whether Synq can change its password. | services.getDatabaseCredentials(parentId, id) | GET /api/projects/:id/services/:serviceId/database-credentialsproject:service:read |
| Change a database's password inside the running container, verify it, then update the service and its linked apps (owners and admins). | services.rotateDatabasePassword(parentId, id, input) | POST /api/projects/:id/services/:serviceId/database-credentials/rotateproject:service:admin |
| Read the database's admin UI (built in, already attached, or none) and how to reach it. | services.getDatabaseAdminUi(parentId, id) | GET /api/projects/:id/services/:serviceId/database-admin-uiproject:service:read |
| Turn the database's admin UI on or off; it is published on 127.0.0.1 only and reached through an SSH tunnel. | services.setDatabaseAdminUi(parentId, id, input) | PUT /api/projects/:id/services/:serviceId/database-admin-uiproject:service:admin |
| Read a WordPress service's versions, site URL, plugins and themes with their updates, maintenance and debug state, PHP settings and the backup policies that cover it (WP-CLI in a one-shot container beside the site). | services.getWordPress(parentId, id) | GET /api/projects/:id/services/:serviceId/wordpressproject:service:read |
| Run one WordPress action through WP-CLI: update core, plugins or themes, activate, deactivate or delete a plugin, activate a theme, flush the cache and rewrite rules, or switch maintenance or debug mode (owners and admins). | services.runWordPressAction(parentId, id, input) | POST /api/projects/:id/services/:serviceId/wordpress/actionsproject:service:admin |
| Count (dry run) or replace an old site URL with a new one in every table, GUIDs kept; a replace needs the dry run's confirmed count (owners and admins). | services.searchReplaceWordPress(parentId, id, input) | POST /api/projects/:id/services/:serviceId/wordpress/search-replaceproject:service:admin |
| Give a WordPress administrator a new generated password, returned once and never stored (owners and admins). | services.resetWordPressPassword(parentId, id, input) | POST /api/projects/:id/services/:serviceId/wordpress/reset-passwordproject:service:admin |
| Save a WordPress service's PHP limits as a conf.d file mount; redeploy the service to apply them (owners and admins). | services.setWordPressPhpSettings(parentId, id, input) | PUT /api/projects/:id/services/:serviceId/wordpress/phpproject:service:admin |
| Read a Box's editor address, the dev-server ports exposed on their own domains, the runtimes it installs and the repository it clones (no password). | services.getBox(parentId, id) | GET /api/projects/:id/services/:serviceId/boxproject:service:read |
| Give a Box's editor a new generated password and restart it. The new password is returned once; when the restart fails the old one is kept. | services.regenerateBoxPassword(parentId, id) | POST /api/projects/:id/services/:serviceId/box/passwordproject:service:write |
| List the files mounted read-only into a service's container, without their contents. | services.listFileMounts(parentId, id) | GET /api/projects/:id/services/:serviceId/file-mountsproject:service:read |
| Read one mounted file's contents (write permission required). | services.revealFileMount(parentId, id, input) | POST /api/projects/:id/services/:serviceId/file-mounts/revealproject:service:write |
| Create, edit or move a file mounted read-only into a service's container; contents are encrypted at rest. | services.putFileMount(parentId, id, input) | PUT /api/projects/:id/services/:serviceId/file-mountsproject:service:write |
| Remove a mounted file from a service; it disappears on the next deploy. | services.removeFileMount(parentId, id, input) | DELETE /api/projects/:id/services/:serviceId/file-mountsproject:service:write |