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
    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, dockerfile, or image. See below.
image for image image only: the published container image ref to pull and run (pin a tag or digest, e.g. twentycrm/twenty:v2.24.1).
command no image CMD image only: override the image’s baked command (docker command: semantics). Lets one image back several services (e.g. a server and a worker).
port no 3000 (80 for static) Container listen port the platform publishes + health-checks.
web no true Set false for a background worker with no HTTP listener: the deploy skips port publishing, routing, and HTTP health, and considers the container healthy once it is running.
env no Per-service env overlay applied at deploy (see “Environment variables”). Rename/alias injected vars to what your app expects.
install no auto-detected Override the detected install step. See “Installs and monorepos”.
build no Build command, run after install. Required for static; optional for node. A leading install that repeats the platform’s own is skipped — see “Build caching”.
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).
dedupeInstall no true Let the platform drop a leading npm ci / pnpm install / yarn install from build when it repeats the install the platform already ran. Set false to always run build verbatim. See “Build caching”.
dockerignore no Extra build-context exclusions (dockerignore patterns, one per entry), on top of the platform defaults (.git, **/node_modules). See “Build caching”.
keepGit no false Keep the .git directory in the build context, for builds that read git metadata (a version stamp from git rev-parse, a changelog from git log).
commands no Named commands runnable on demand inside the running container. See “Commands”.

Service-config keys are also strict — typos will fail parsing rather than be silently ignored.

Repo-less deploys (image apps)

Most services deploy from a platform.yml in a connected repo, which Tandem clones and reads. image services can also deploy with no repo at all — the platform.yml is stored directly on the project (this is how install_app sets up catalog apps like Twenty). There’s nothing to build for a published image, so no source is needed. Config precedence: a connected repo wins; otherwise the project’s stored platform.yml is used. Update a repo-less project’s config over MCP with set_project_platform_yml (e.g. to bump a pinned image tag) — no git commit involved. Repo-less deploys are always explicit (deploy_project / create_deployment); there’s no push to auto-deploy from.

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 when the install is npm (npm ci / npm install, detected or default). If the platform detects pnpm or yarn, or you set a custom install: command (see Installs and monorepos), slim is ignored and you get the normal full image.
  • node only. static services already ship a minimal nginx image, and dockerfile services control their own image, so slim does 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.

Installs and monorepos (install:)

Tandem installs your dependencies before running build, in a layer that is cached across deploys and only re-run when a manifest or lockfile changes. Which install runs is detected from the repo, so most projects — including workspaces — need no install: at all:

What the repo has Install the platform runs From
pnpm-workspace.yaml at the repo root corepack enable && pnpm install --frozen-lockfile repo root (/workspace)
pnpm-lock.yaml in the service path corepack enable && pnpm install --frozen-lockfile service path
yarn.lock + .yarnrc.yml (or packageManager: yarn@2+) corepack enable && yarn install --immutable service path, or the root for a workspaces repo
yarn.lock alone (classic) yarn install --frozen-lockfile service path, or the root for a workspaces repo
package-lock.json npm ci service path, or the root when the root package.json declares workspaces and the service has no lockfile of its own
bare package.json npm install service path

A packageManager field in package.json ("packageManager": "pnpm@9.12.0") wins over the lockfile and pins the version: the install is prefixed with corepack prepare pnpm@9.12.0 --activate. Pin it — that is the one line that makes the build use exactly the version you use locally.

Override with install: only when the detection is wrong for your repo (a custom registry step, a tool not listed above, an install that must run from somewhere else). 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

A custom install: replaces the platform’s step entirely: it is not cached across deploys the way the detected install is (it runs after the full repo copy), and dedupeInstall does not apply.

Build caching

Every deploy builds a fresh image, but the platform arranges the build so that a deploy only redoes the work your change actually touched:

  1. Install layer. The detected install runs against just your package.json files and lockfiles (plus .npmrc, .yarnrc.yml, .pnpmfile.cjs, .yarn/releases), before the rest of the repo is copied in. A commit that doesn’t touch those files reuses the cached layer and skips the install entirely. Package downloads are also cached across builds (npm, pnpm store, yarn/corepack caches), so even a changed lockfile installs warm. This layer is skipped — the install runs after the full copy instead — when a package.json the install covers has an install-lifecycle script (preinstall, install, postinstall, prepare), because those may need your source.
  2. Redundant install in build. If your build command starts with an install that repeats what the platform already ran — same tool, same directory, only flags like --frozen-lockfile, --immutable, --include=dev, --prod=false, --legacy-peer-deps — that leading segment is dropped and the build log says so: [tandem] skipped redundant install: "npm ci --include=dev" (platform installed with npm-ci). npm ci && npm run build becomes npm run build; corepack enable && corepack prepare pnpm@9.12.0 --activate && cd /workspace && pnpm install --frozen-lockfile && pnpm --filter web build keeps the corepack prefix and the cd, drops the install. A build that is only an install (build: npm ci) ends up with no build step. Anything the platform can’t prove equivalent (a different directory, a different tool, a --ignore-scripts, an install that isn’t the first segment) is left exactly as written — it just runs with warm package caches. Set dedupeInstall: false on the service to always run build verbatim.
  3. Framework caches. The build step mounts per-service caches at <path>/.next/cache, <path>/node_modules/.cache and (for services below the repo root) node_modules/.cache at the root, so Next.js, Vite, Babel, webpack and Turbo incremental caches survive between deploys. They never ship in the image and are never shared between services.
  4. Build context. The platform writes its own .dockerignore for the build: your repo’s .dockerignore lines first (if you have one), then .git, **/node_modules and the platform’s generated files, then anything in the service’s dockerignore: list. A smaller, deterministic context means an identical-content redeploy hits the cache all the way down instead of re-copying and rebuilding. Need .git at build time? keepGit: true.
services:
  web:
    path: apps/web
    type: node
    build: pnpm --filter web build
    start: pnpm --filter web start
    dockerignore:
      - docs
      - "**/*.psd"
    keepGit: true          # build stamps the version with `git rev-parse`
    dedupeInstall: false   # (rare) always run `build` exactly as written

None of this applies to dockerfile services (you own the context and the Dockerfile) or image services (nothing is built). The per-build .dockerignore and the cache mounts need BuildKit; on a host without it the build still works, just with the whole checkout as context.

Commands

The commands: block declares named commands the platform can run on demand inside the service’s running container — from the portal’s Commands tab or over MCP with list_service_commands / run_service_command:

services:
  api:
    path: api
    type: node
    start: npm start
    commands:
      migrate:
        run: ["npm", "run", "migrate"]
        description: Apply database migrations
        role: admin
        timeoutSeconds: 120
      seed-demo:
        run: ["node", "scripts/seed.js", "--demo"]
        description: Load the demo dataset

Per-command fields:

Field Required Default Notes
run yes The command as an argv array — never a shell string. No shell runs, so no pipes, globs, or &&; put anything complex in a script and call that.
description no Shown next to the command in the portal and the MCP catalog.
role no developer Minimum org role required to run it: developer, admin, or owner.
timeoutSeconds no 60 The command is killed at this deadline. Max 300.
params no Up to 8 named string params (name + optional label). At run time every declared param is required, and values are appended to run in declared order as discrete argv items — never interpolated.
enabled no true Set false to hide a platform preset with this name (see below).

Only catalog entries can run — there is no arbitrary-command surface. One command runs at a time per service, output is capped at 256 KiB, and every run is audit-logged.

Run history. Every run is also recorded — successes and failures (non-zero exit, timeout, or a command that never started). The portal’s Commands tab lists recent runs under Run history, and list_service_command_runs returns the same over MCP: what ran, who ran it (human or agent), when, exit code, and duration, with the stored output of any single run. History keeps the last 20 runs per service and stores the last 32 KiB of each transcript (the live response always carries the full 256 KiB) — so pull a failing command’s output soon after it fails rather than weeks later.

Scheduling a command. Any catalog command can also run on a schedule — a 5-field UTC cron expression, the same grammar scheduled jobs use — from the portal’s Commands tab (Schedules) or with create_command_schedule. Params are fixed when the schedule is written. Each firing takes the identical path as a manual run: the same catalog lookup, the same one-command-at-a-time-per-service lock, the same timeout and 256 KiB cap, the same audit line, and the same run history (scheduled runs appear there tagged schedule with the schedule’s name).

Two things worth knowing before you rely on one:

  • Scheduling a command needs the same org role as running it. A command declared role: admin can only be scheduled by an admin, and the schedule keeps running under that person’s (or agent’s) authority — re-checked against the live role: in platform.yml before every firing. If they lose the role, the schedule is disabled with a reason rather than fired; someone who still holds it can re-enable it.
  • Skew is recorded, not swallowed. If you delete the command from platform.yml, the service has no healthy deployment, the service or org is suspended, the stored params no longer match the declared ones, or another command is already running, the firing is skipped and a run appears in history saying exactly which of those happened. Nothing retries in a loop — it simply tries again at its next scheduled time.

Quotas: 5 schedules per service, 20 per org.

WordPress presets. Services where WordPress is detected at deploy automatically gain the wp:* preset commands (wp:login, wp:cache-flush, wp:plugin-list, wp:user-list, wp:search-replace) — see WordPress on Tandem. A repo command declared with the same name overrides the preset, and declaring one with enabled: false hides it.

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-arg during docker build. Use this for NEXT_PUBLIC_* and other client-bundled values.
  • runtime (the set_env_var default) — passed via --env-file when 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.

Renaming/aliasing injected vars — the env: field

Attachments inject fixed names: a database → DATABASE_URL, redis → REDIS_URL, a bucket → S3_ENDPOINT / S3_REGION / S3_BUCKET / S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY / S3_FORCE_PATH_STYLE. Many apps expect different names. The per-service env: block remaps them at deploy time — each value is a literal or a ${OTHER_KEY} reference to another env var:

services:
  server:
    type: image
    image: twentycrm/twenty:v2.24.1
    env:
      STORAGE_TYPE: S_3                              # literal
      STORAGE_S3_REGION: ${S3_REGION}                # rename the injected S3_REGION
      STORAGE_S3_NAME: ${S3_BUCKET}
      STORAGE_S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID}
      PG_DATABASE_URL: ${DATABASE_URL}               # app wants a different DB var name

Rules:

  • Resolved at deploy time over the already-injected env, applied to the container and its lifecycle jobs. It re-applies on every deploy (unlike a one-shot set_env_var).
  • The overlay wins for any key it declares.
  • A dangling reference (${MISSING}) fails the deploy loudly rather than injecting an empty value.
  • Put references here, not pasted secrets — platform.yml lives in your repo, but ${SECRET_VAR} keeps the secret’s value in the injected var. Use set_env_var for literal secret values.

The platform always sets PORT=3000 at runtime for node services.

Recipes

Next.js (server mode)

services:
  web:
    path: web
    type: node
    build: 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 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
    start: node server.js
    healthcheck: /health

Make sure server.js calls app.listen(process.env.PORT). No build is needed: dependencies are installed by the platform before the container is built (see “Installs and monorepos”). A build: npm ci or build: npm ci && npm run build still works — the redundant install is skipped.

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 your services: block. Either add it or remove the service from the portal.
  • service <name> type mismatch — the type in platform.yml doesn’t match what was configured in the portal. Update one to match the other.
  • static service ... requires a build command / requires an output directory — add build: and output: to your static service.
  • node service ... requires a start command — add start:.
  • Strict-mode parse errors — you used a key Tandem doesn’t recognize, or got the casing wrong (e.g. healthCheck instead of healthcheck). The error names the valid keys at that level and suggests the closest match, so read it before reaching for the field reference.
  • commands.<name>.run — expected array, received string — command run is exec form: an argv array like ["node", "dist/cli/task.js"]. Service-level build: and start: are shell strings; commands deliberately are not, so nothing is shell-parsed or interpolated.
  • commands.<name>.params[0] — expected object, received string — a param is { name, label? }, not a bare name. There is no required, description, or default: every declared param is required at run time.

A rejected platform.yml blocks every service in the project, not just the one with the bad block, and the previously-deployed containers keep serving — so the site looks fine while your push silently doesn’t ship. Check the deployment state after a push, and note the error message names the expected shape at each bad path.