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. On a deploy from source this wins over the service’s stored health-check setting; the path used is recorded on the deployment, so a rollback to it probes the same path.
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.
csp no report-only static + spa only: extend the browser Content-Security-Policy, set enforce: true to make it blocking, or false to emit no security headers. See “Content-Security-Policy”.
hsts no full policy Custom domains only: weaken the default Strict-Transport-Security (includeSubDomains: false and/or preload: false). Any service type. See “HTTPS and HSTS”.
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”.
jobs no — image and dockerfile only: one-off pre/post jobs run once per deploy against the new image (e.g. database migrations before cutover). See “Deploy jobs”.

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

HTTPS and HSTS

Every hostname the platform serves is HTTPS-only: plain HTTP gets a 301 to HTTPS, and every HTTPS response carries

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload

set at the platform ingress (it replaces any HSTS header your app sends itself). Platform hostnames (*.launchtandem.com) always get exactly this.

On a custom domain, includeSubDomains on the apex (example.com) tells browsers that every subdomain of it — including ones hosted elsewhere — is HTTPS-only for two years. If any subdomain you run outside Tandem still serves plain HTTP, opt out for that service before attaching the apex:

services:
  web:
    path: .
    type: static
    build: npm run build
    output: dist
    hsts:
      includeSubDomains: false   # also drops preload (preload requires it)

preload: false keeps includeSubDomains but drops the preload token. There is no switch to turn HSTS off or shorten max-age: once a browser has seen the header, only an explicit max-age=0 unwinds it — ask support. The setting applies to every hostname attached to the service (redirect hostnames included) and is ignored for *.launchtandem.com hostnames.

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.

Content-Security-Policy

A static service with spa: true is served with a browser Content-Security-Policy and a set of hardening headers.

By default the policy is report-only — it never blocks anything. The browser reports what would have been blocked and loads it anyway. You opt into enforcement when you’re ready:

    csp:
      enforce: true

This is deliberate. We can’t know every origin your app legitimately talks to, and a wrong guess on our side would take your site down — so we report by default and let you decide when the policy is complete. The generated policy points report-uri / report-to at the platform. After you deploy, open the service in the portal Logs tab (CSP violations) or call list_csp_violations — a blocked resource shows up there within seconds. Add anything legitimate under csp:, redeploy, and flip enforce: true only when that list is clean.

Everything the platform provisioned for you is allowed automatically, in report-only and enforcing mode alike:

Resource Directives
Your project’s hostnames connect-src — call your sibling API on any domain
Your storage buckets img-src, media-src, connect-src — render assets, upload from the browser
Your analytics script host script-src, connect-src

None of these need configuration, and all follow your project as you add domains, buckets or analytics — the Caddy ingress rebuilds the policy on the next route refresh, with no tenant image rebuild.

Use csp: to allow third-party origins, or to make the app embeddable:

services:
  web:
    path: apps/web
    type: static
    build: npm run build
    output: dist
    csp:
      scriptSrc: ["https://js.stripe.com"]
      connectSrc: ["https://api.stripe.com"]
      frameSrc: ["https://hooks.stripe.com"]

Available keys: connectSrc, scriptSrc, styleSrc, imgSrc, fontSrc, frameSrc, mediaSrc, frameAncestors, plus enforce. Each source key adds to the platform defaults.

frameAncestors is the exception — it replaces the default frame-ancestors 'none', because the spec forbids combining 'none' with any other source. Set it for an app that must run inside someone else’s iframe, such as a Shopify embedded admin app:

    csp:
      frameAncestors: ["https://admin.shopify.com"]

Widening frameAncestors also drops the legacy X-Frame-Options: DENY header, which has no origin-list form and would otherwise contradict the policy in some browsers.

Set csp: false to disable the security headers entirely for a service.

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.

Deploy jobs (jobs:)

image and dockerfile services can declare one-off jobs that run once per deploy, in the image that deploy is about to ship. This is the place for a release step like prisma migrate deploy: out of the container’s start command, so several replicas never race a migration at boot, and never forgotten, because every deploy runs it.

services:
  api:
    path: apps/api
    type: dockerfile
    healthcheck: /healthz/ready
    jobs:
      - name: migrate
        command: ["pnpm", "--filter", "@acme/db", "migrate:prod"]
        when: pre
        timeoutSeconds: 180

jobs is a list. Each entry:

Key Required Default Notes
name yes — Shown in the deployment log and in the failure message.
command yes — argv array: appended after the image, so it passes through your image’s ENTRYPOINT if it has one (docker command: semantics). A string runs via sh -lc "<string>", so the image needs a shell.
when no pre pre runs before the new container starts; post runs after it passes its health check.
timeoutSeconds no none 1–3600. A job that runs longer is killed and fails the deploy.

How jobs run:

  • Each job is a fresh docker run --rm of the new image with the service’s runtime env (including attached databases, Redis and buckets, and the env: overlay), on the project network, with the same resource limits as the service.
  • pre: a non-zero exit (or timeout) fails the deploy before the new container is started. The previous deployment keeps serving, and the job’s output is attached to the failed deployment.
  • post: a failure still fails the deploy so you see it, but the new container has already passed its health check.
  • Jobs run in order, one at a time, and only on a deploy. Restarts, scaling and rollbacks never run them. A rollback does not reverse a migration, so keep migrations backward-compatible with the previous release.

jobs is not valid on static, node or php services, because their images are generated by the platform. Use an on-demand command there instead.

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. The exact parse shapes (name regex, param object, rejected keys) are in platform.yml commands.

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: database=..., platform.yml=... — the type in platform.yml and the type stored on the service disagree, so nothing is built. This is the error you get after converting an app from one type to another (an SPA into a server, say) and pushing. Fix whichever side is wrong — both are self-serve:
    • the repo is right (the app really is now the new type): change the stored type. MCP: update_service_config { serviceId, type: "node", confirmTypeChange: "static->node" }. Portal: project → Services → Edit service → Type. Either way the stored buildCommand/startCommand/outputDirectory overrides are cleared, so platform.yml becomes the source of truth for this service — leaving a stale output: or build command behind is the classic way to clear this error and then fail the build a step later for a much less obvious reason.
    • the stored type is right: set type: back in platform.yml and push.
    • Do not delete and re-import the service to change its type — that destroys its env vars, deployment history and hostname binding.
    • After flipping to a server type, set the runtime env vars the server needs (set_env_vars with scope: "runtime", or the portal’s Env tab) before deploying: only runtime/both-scoped vars reach the running container, so a missing one shows up as a passing build and a failing health check. Build-scoped bundler vars (VITE_*, NEXT_PUBLIC_*, …) are often vestigial after such a flip. Both surfaces list these cautions back to you when you change the type.
  • 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.