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. |
slim |
no | false |
node only: ship a smaller runtime image with devDependencies pruned. See “Slim node images”. Ignored for static (already slim) and dockerfile (you own the image). |
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
Slim node images
By default a node image keeps everything that was present at build time, including devDependencies (TypeScript, bundlers, test tooling). Set slim: true to ship a smaller runtime image instead:
services:
api:
path: api
type: node
build: npm run build
start: node dist/server.js
slim: true
With slim: true, Tandem builds your service in one stage (with all dependencies, so the build still has its dev tools) and then ships a second stage that carries only your production dependencies — devDependencies are pruned with npm prune --omit=dev. Your built output and dependencies are kept, so the app runs exactly as before, just from a smaller image. Smaller images mean less disk usage (which may factor into billing in the future) and slightly faster container starts and rollbacks.
Use it when your start command only needs production dependencies (the normal case). Don’t set it if your service needs a package at runtime that’s declared under devDependencies — move that package into dependencies instead.
Two limitations to know:
- npm only. Slim uses
npm prune, so it only takes effect on the default npm install path. If you set a custominstall:command (for pnpm, yarn, or a monorepo root install — see Monorepos),slimis ignored and you get the normal full image. nodeonly.staticservices already ship a minimal nginx image, anddockerfileservices control their own image, soslimdoes nothing there.
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) or over MCP with set_env_var (serviceId + key + value + scope). Each variable has a scope:
- build — available as a
--build-argduringdocker build. Use this forNEXT_PUBLIC_*and other client-bundled values. - runtime (the
set_env_vardefault) — passed via--env-filewhen the container starts. - both — available in both phases.
list_env_vars shows keys + masked previews (never full values); delete_env_var removes one. Provisioning a database, bucket, redis, or mailbox with attachServiceId injects its connection vars for you — see Provisioning data resources.
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: /
slim: true
Set NEXT_PUBLIC_* env vars with scope build so they’re inlined at build time. slim: true is worthwhile for Next.js — its build tooling (webpack, TypeScript, etc.) lives in devDependencies and is dropped from the runtime image (see “Slim node images”).
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.