A service is one container. Give it a public port and it gets its own URL; services in the same deployment reach each other by name over a private network.
Service names match ^[a-z][a-z0-9-]{0,30}$ and must be unique within the spec.
build or image
Each service either builds from your repo or pulls a prebuilt image — exactly one.
build
| Field | Type | Required | Notes |
|---|
context | string | yes | Path within the repo (e.g. ., ./api). |
dockerfile | string | no | Path to the Dockerfile, relative to context. Defaults to Dockerfile. |
args | map | no | Build args. |
services:
api:
build:
context: ./api
dockerfile: Dockerfile
args:
NODE_ENV: development
NPM_TOKEN: ${secret.NPM_TOKEN}
Builds are content-addressed: the image tag comes from the commit SHA plus a hash of the build inputs. If a previous deploy on the same commit produced the same inputs, the build is skipped and the cached image is reused.
Passing a secret into build.args is safe by itself, but if your Dockerfile does RUN echo $TOKEN the value gets baked into the image layer. Keep credentials out of build args unless your Dockerfile discards them inside the same RUN step.
image
services:
redis:
image: redis:7-alpine
For private registries, add the credentials once under Settings → Registries — see Private registries. Slipway wires them in automatically; no per-service field needed.
ports
Ports the container listens on. Omit ports and the service is a worker — internal-only, no URL, no readiness gate at cutover.
| Field | Type | Required | Notes |
|---|
port | int | yes | Container port (1–65535). |
public | bool | no | If true, gets a public URL at <env-name>-<prefix>-<id>.<apps-base>. |
prefix | string | no | The <prefix> in the public hostname. Defaults to the service name. |
domain | string | no | A verified custom domain to serve under instead of the default. |
ports:
- { port: 3000, public: true } # served at <env>-web-<id>.<apps>
- { port: 9000 } # internal-only
- { port: 3001, public: true, domain: app.acme.com }
One public port per service. For path-based routing across services, define each separately and put a reverse proxy in your repo.
env
Environment variables, as a plain map:
env:
LOG_LEVEL: info
DATABASE_URL: ${secret.DATABASE_URL}
See Secrets & variables for the ${...} substitution syntax.
files
Project committed repo files into the container, read-only — the cluster-native counterpart of a Docker Compose bind mount, for config and seed files: a Postgres init.sql, an nginx.conf, a fixtures file. Each entry is <repo-path>:<container-path> — the source is a path in your repo relative to the spec file’s directory, the target an absolute path in the container.
services:
db:
image: postgres:16-alpine
files:
- init.sql:/docker-entrypoint-initdb.d/init.sql
slipway reads each file from your repo at the deploy commit and mounts it with subPath, so only that one file lands at the target — the rest of the directory (the image’s own contents) is untouched. A Compose ./init.sql:/docker-entrypoint-initdb.d/init.sql bind mount is translated to this automatically.
files are projected through a plaintext ConfigMap, so they’re for non-secret config and seed data only. Keep credentials in secrets (${secret.X}) and reference them from env — never from a committed, mounted file.
Constraints: small text files (each under ~1 MB), and the source must stay inside the repo (no absolute or .. paths). For data that must persist, or large/binary content, use a named volume instead.
depends_on
Sibling services that must be ready before this one starts — Compose’s service_healthy semantics. All services are created at once so images pull and volumes attach in parallel, but a service’s own process doesn’t start until every dependency passes its readiness probe. It waits instead of crash-looping.
services:
api:
build: { context: ./api }
ports:
- { port: 8080, public: true }
depends_on:
- db
db:
image: postgres:16
ports:
- { port: 5432 }
volumes:
- pgdata:/var/lib/postgresql/data
Dependencies must be services in the same spec. A service can’t depend on itself or form a cycle — both are rejected at parse time. A long dependency chain still adds up (each link waits for the one before it), and depends_on guarantees start ordering, not zero-downtime ordering (services run a single replica).
healthcheck
Three probe phases — set any subset:
startup — runs first and blocks the others until it passes. For slow-booting apps.
readiness — gates traffic and gates cutover: a deploy isn’t healthy and its URL doesn’t go live until every public service is ready.
liveness — runs continuously; failures restart the container.
healthcheck:
startup: { http: { path: /startup, port: 3000 }, periodSeconds: 5, failureThreshold: 30 }
readiness: { http: { path: /ready, port: 3000 }, periodSeconds: 5 }
liveness: { http: { path: /healthz, port: 3000 }, periodSeconds: 30 }
If a ported service has no healthcheck, slipway adds a default TCP readiness probe on its public port, so a URL only goes live once the app accepts connections. Declaring any healthcheck replaces that default.
Handlers
Each probe takes exactly one handler:
| Handler | Healthy when | Example |
|---|
http | any 2xx response | http: { path: /ready, port: 3000 } |
tcp | the connection establishes | tcp: { port: 5432 } |
exec | the command exits 0 | exec: { command: [pg_isready] } |
grpc | the gRPC health protocol reports serving | grpc: { port: 9000 } |
Timing
All handlers accept the standard knobs — initialDelaySeconds, periodSeconds, timeoutSeconds, failureThreshold, and successThreshold (readiness/liveness only). Omit them to use sensible defaults.
For most web apps a single readiness probe is enough. Add startup only when boot takes more than ~30 seconds; add liveness only when you’ve seen the app deadlock in a way readiness doesn’t catch. A worker with no ports needs no probe — it’s ready as soon as it’s running.
Resource limits
You don’t size containers in the spec. CPU, memory, and disk caps come from your plan. If a deploy asks for more than your plan allows, it fails with a clear error.