Writing platform.yml
platform.yml is a file at the root of your repo that tells Tandem what services live inside it and how to build each one. When you connect a repo (or push a new commit), Tandem reads this file to plan deployments.
A minimal example
project: my-app
services:
web:
path: web
type: static
build: npm run build
output: dist
api:
path: api
type: node
build: npm ci
start: npm start
healthcheck: /health
That’s a complete file — a project name and one or more services.
Top-level fields
| Field | Required | Description |
|---|---|---|
project |
yes | A name for the project. Lowercase identifier, used as part of the auto-assigned hostname. |
services |
yes | A map of service name → service config. At least one service is required. |
ignored |
no | A list of directory names to record as “intentionally not deployed”. Purely informational — they don’t gate the build. |
The file is strict: unknown top-level keys cause a parse error.
Service fields
Each entry under services is a map with these fields:
| Field | Required | Default | Notes |
|---|---|---|---|
path |
yes | — | Path to the service relative to the repo root. Use . for repo-root services. |
type |
yes | — | One of static, node, or dockerfile. See below. |
install |
no | auto | Override the default install step. See “Monorepos” below. |
build |
no | — | Build command, run after install. Required for static; optional for node. |
start |
no | — | Command to launch the running container. Required for node. |
output |
no | — | Directory (relative to path) containing the built static files. Required for static. |
healthcheck |
no | / |
HTTP path the deploy worker polls until it returns 2xx before flipping the route. |
healthcheckTimeout |
no | platform default | Seconds to wait for the healthcheck to pass. Between 10 and 1800. |
spa |
no | true for static |
When true, the nginx config falls back to index.html for unknown paths so client-side routing survives a refresh. Ignored for non-static services. |
Service-config keys are also strict — typos will fail parsing rather than be silently ignored.
Service types
static
Used for sites with a build step that produces a folder of HTML/CSS/JS assets. The build runs in node:24-alpine and the output folder is served by nginx on port 80.
You must set build and output. The SPA fallback is on by default (spa: true); set spa: false if you’re shipping a multi-page static site that should let nginx 404 unknown paths.
services:
web:
path: web
type: static
build: npm run build
output: dist
node
Used for long-running Node processes (Express, Fastify, Next.js in server mode, anything that listens on a port). The container runs node:24-alpine and Tandem injects PORT=3000 — your start command must listen on process.env.PORT.
You must set start. build is optional but useful for compile steps (TypeScript, Next build, Prisma generate). NODE_ENV is intentionally not set during build, so npm install keeps devDependencies — set NODE_ENV=production via a runtime env var if you need it at runtime.
services:
api:
path: api
type: node
build: npm run build
start: node dist/server.js
healthcheck: /health
dockerfile
For when you want full control. Tandem just runs docker build in your service path, so you provide the Dockerfile. build, start, output, and install are ignored — your Dockerfile owns all of that.
services:
worker:
path: worker
type: dockerfile
healthcheck: /health
Your container should listen on the port you EXPOSE and respond to the configured healthcheck path.
Monorepos and install:
By default, Tandem runs npm ci (or npm install if there’s no lockfile) inside the service’s path directory. That’s wrong for npm/pnpm/yarn workspaces, where dependencies must be resolved from the repo root.
Override with install:. The command runs with the full repo mounted at /workspace and the working directory set to /workspace/<path>, so you can cd back to the root:
services:
api:
path: packages/api
type: node
install: cd /workspace && npm install
build: cd /workspace && npm run build --workspace=api
start: node dist/server.js
Environment variables
platform.yml doesn’t carry env vars — set them in the portal (Service → Env). Each variable has a scope:
- build — available as a
--build-argduringdocker build. Use this forNEXT_PUBLIC_*and other client-bundled values. - runtime — passed via
--env-filewhen the container starts. - both — available in both phases.
The platform always sets PORT=3000 at runtime for node services.
Recipes
Next.js (server mode)
services:
web:
path: web
type: node
build: npm ci && npm run build
start: npm start
healthcheck: /
Set NEXT_PUBLIC_* env vars with scope build so they’re inlined at build time.
Vite SPA
services:
web:
path: web
type: static
build: npm ci && npm run build
output: dist
Vite’s default output dir is dist. Set VITE_* env vars with scope build.
Plain Node / Express
services:
api:
path: api
type: node
build: npm ci
start: node server.js
healthcheck: /health
Make sure server.js calls app.listen(process.env.PORT).
Bring-your-own Dockerfile
services:
go-worker:
path: services/worker
type: dockerfile
healthcheck: /health
The Dockerfile lives at services/worker/Dockerfile. Inside it, EXPOSE your port and make sure it serves the healthcheck path.
Common errors
platform_yml_not_found— the file is missing from the branch Tandem fetched. Check it’s at the repo root and committed to the default branch.platform.yml does not define service <name>— the service exists in Tandem’s database but isn’t in yourservices:block. Either add it or remove the service from the portal.service <name> type mismatch— the type inplatform.ymldoesn’t match what was configured in the portal. Update one to match the other.static service ... requires a build command/requires an output directory— addbuild:andoutput:to your static service.node service ... requires a start command— addstart:.- Strict-mode parse errors — you used a key Tandem doesn’t recognize, or got the casing wrong (e.g.
healthCheckinstead ofhealthcheck). Check the field reference above.