REST API
← Docs · Reference

REST API

The team-scoped JSON API behind the CLI: apps, env, deploys, logs and servers.

A small, team-scoped JSON API for driving Railyard from a CLI, CI job, or script — list and create apps, set env, trigger deploys, read deploy logs, check the fleet.

  • Base URL — your control plane, under /api. Local Compose: http://localhost:3000/api
  • Format — JSON only. Send Content-Type: application/json on writes. No CSRF, no session, no cookies.
  • Everything is scoped to one team — the team the token belongs to.

Authentication

Every request needs a bearer token:

Authorization: Bearer rly_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Get a token: dashboard → Team → API tokens → name it, pick a scope, Create. The rly_… string is shown once — copy it then. Admin only.

Scopes

ScopeCan
readevery GET
writeeverything read can, plus POST / PATCH / DELETE

A read-only token on a mutating endpoint → 403 {"error":"this API token is read-only"}. Missing / bad / revoked / expired token → 401 {"error":"missing or invalid API token"}.

Revoke from the same Team page; a revoked or past-expires_at token stops working immediately.


Conventions

  • Errors — non-2xx responses are { "error": "<message>" }. Status codes: 400 bad request · 401 unauthenticated · 403 wrong scope · 404 not found (or not in your team) · 409 conflict (e.g. a deploy is already running) · 422 validation failed · 429 rate limit.
  • Timestamps — ISO-8601 UTC (2026-09-02T14:03:11Z).
  • IDs — integers, stable per team.
  • Pagination — list endpoints that take limit accept 1–100 (default 30). No cursor yet; newest-first where ordered by time.
  • Rate limit — deploy creation is capped per team per hour (GET /api/me → limits.deploys_per_hour); over it → 429.

Endpoints

MethodPathScopePurpose
GET/api/mereadToken + team info, limits, counts
GET/api/applicationsreadList apps
POST/api/applicationswriteCreate an app
GET/api/applications/:idreadApp detail
PATCH/api/applications/:idwriteUpdate an app
DELETE/api/applications/:idwriteDelete an app (and its deploys/logs)
POST/api/applications/:id/archivewriteStop the container, keep the record
POST/api/applications/:id/unarchivewriteUn-archive
GET/api/applications/:id/envreadRead env vars
PATCH/api/applications/:id/envwriteMerge env vars (null value deletes)
DELETE/api/applications/:id/env?key=KEYwriteDelete one env var
GET/api/applications/:id/edgereadEdge settings (same shape as the manifest’s edge: section, see Edge rules)
PATCH/api/applications/:id/edgewriteMerge edge settings: {"edge":{"access_logs":false,"headers":[…],"cors":{…}}}
GET/api/applications/:id/certificatesreadUploaded certificates (names, covered hosts, validity, fingerprint; never the key)
POST/api/applications/:id/certificateswriteUpload {"certificate":{"certificate_pem":"…","private_key_pem":"…"}}
DELETE/api/applications/:id/certificates/:cert_idwriteRemove one (automatic certificates after the next deploy)
GET/api/applications/:id/domainsreadList custom domains
POST/api/applications/:id/domainswriteAdd a custom domain
DELETE/api/applications/:id/domains/:domain_idwriteRemove a custom domain
GET/api/applications/:id/deploysreadDeploys for one app
POST/api/applications/:id/deployswriteTrigger a deploy ({"image": "registry/repo:tag"} deploys that prebuilt image once)
GET/api/applications/:id/registrywriteThe team’s build registry for deploy --local: repository, username, password, platform
POST/api/applications/:id/tunnelwriteOpen a tunnel ({"target": "web|db|redis|<process>", "local_port": 5432}): returns a 12-hour ticket for the TunnelChannel WebSocket, the remote port, and a localhost url for databases; logged as app.tunnel_opened
GET/api/applications/:id/logsreadRuntime container logs (?tail=, ?process=)
GET/api/applications/:id/processesreadPer-container state (for ps)
POST/api/applications/:id/restartwriteBounce every container, or one ?process=
GET/api/applications/:id/process_typesreadProcess types with env, shutdown and size settings
PATCH/api/applications/:id/process_types/:pt_idwriteSet env_vars_text, kill_timeout_seconds, stop_signal, size, cpu_limit, memory_limit
GET/api/applications/:id/commandsreadRecent one-off command runs
POST/api/applications/:id/commandswriteRun a one-off command ({"command": "..."})
GET/api/applications/:id/commands/:cmd_idreadOne run: status, output, exit code
POST/api/applications/:id/reindexwriteRebuild the search indices: runs the app’s search.reindex_command in a one-off container (202; 422 without a command or a successful deploy, 409 while one runs). Set reindex_command / auto_reindex with PATCH /api/applications/:id; GET shows them under search with the detected library
POST/api/applications/:id/clonewriteClone the app into a staging copy
POST/api/applications/:id/promotewritePromote a staging app’s release to production
GET/api/applications/:id/manifestreadThe app as railyard.yml (?secrets=true includes variable values)
POST/api/applications/:id/apply_manifestwriteApply a manifest ({"manifest": "<yaml text>"})
POST/api/applications/:id/db_queryread / writeRun SQL ({"sql": "...", "write": false}); write: true needs a write-scoped token
GET/POST/api/applications/:id/database_backupsread / writeList backups / take one now
POST/api/database_backups/:id/restorewriteRestore a backup
GET/api/database_backups/:id/downloadreadSigned download link
GET/POST/api/applications/:id/volume_backupsread / writeList backups / back up one volume ({"volume": "name"})
POST/api/volume_backups/:id/restorewriteRestore a volume backup
GET/api/applications/:id/trafficreadRequests, p95, error rate and slowest endpoints (?range=1h|24h|7d|30d)
PATCH/api/applications/:id/autoscalewriteScale web replicas on p95/CPU ({"max":N,"min":N,"p95":ms,"cpu":pct}); {"max":null} turns it off
POST/api/applications/:id/data_movewriteMove the app’s data to a database server ({"database_server_id": 12}); 202 with what gets copied
POST/api/applications/:id/external_datastoreswritePoint the app’s database, redis or elasticsearch at an outside URL ({"kind","url","copy"}); the URL is checked first (502 if unreachable), 202 when queued
DELETE/api/applications/:id/external_datastores/:kindwriteBring that data back under Railyard (?copy=true copies it back)
GET/api/applications/:id/addons/:kind/transfersreadRecent exports/imports of an add-on (redis, mongodb, elasticsearch, storage, influxdb, rabbitmq), with download links
POST/api/applications/:id/addons/:kind/transferswriteExport the add-on’s data; with multipart file + confirm=<app name>, load a file into it
GET/api/applications/:id/storage/objectsreadBrowse the storage add-on’s bucket (?prefix=, ?token=): folders, files with size, public flag and a signed link
POST/api/applications/:id/storage/objectswriteUpload a file (multipart file, prefix or key)
DELETE/api/applications/:id/storage/object?key=writeDelete a file, or a folder when the key ends in /
PATCH/api/applications/:id/storage/visibilitywriteMake a folder public or private ({"prefix":"avatars/","public":true})
GET/api/applications/:id/redisreadWhere the app’s Redis lives (shared db N or its own), its limit and policy, memory used
PATCH/api/applications/:id/rediswrite{"dedicated":true,"maxmemory_mb":512,"policy":"allkeys-lru"} gives the app its own Redis (keys copied) or changes its limit; {"dedicated":false} moves it back
GET/api/deploysreadDeploys across the team (?application_id=, ?limit=)
GET/api/deploys/:idreadDeploy detail
GET/api/deploys/:id/logsreadDeploy log lines (oldest first, ≤5000)
GET/api/applications/:id/log_searchreadStored log lines: time range, search, cursor pages (see Logs, metrics and alerts)
GET/api/applications/:id/log_downloadreadStored log lines for a range as text
GET/PATCH/DELETE/api/applications/:id/otel_exportread / writeOpenTelemetry export settings (see Logs, metrics and alerts)
GET/POST/api/applications/:id/metric_alertsread / writeMetric threshold alerts and their history
PATCH/DELETE/api/applications/:id/metric_alerts/:alert_idwriteChange or remove an alert
GET/api/applications/:id/job_queuereadJob queue stats, failed jobs, history (see Logs, metrics and alerts)
POST/api/applications/:id/job_queue/failed/:job_id/retrywriteRetry a failed job
DELETE/api/applications/:id/job_queue/failed/:job_idwriteDiscard a failed job
GET/api/serversreadThe fleet
GET/api/servers/:idreadServer detail + live process states
POST/api/servers/:id/reboot | /power_cycle | /cordon | /uncordon | /prune_dockerwriteServer lifecycle actions
PATCH/api/servers/:id/resizewriteResize at the provider ({"size": "..."})
GET/api/notification_rulesreadTeam-wide notification rules
GET/api/webhook_endpointsreadTeam-wide webhook endpoints
GET/api/configreadTeam-wide config entries

GET /api/me

curl -s https://cp.example.com/api/me -H "Authorization: Bearer $RLY"
{
  "team":  { "id": 1, "name": "Acme", "slug": "acme", "suspended": false },
  "token": { "name": "ci", "scope": "write", "last_used_at": "2026-09-02T14:00:00Z" },
  "limits": { "servers": 10, "apps": 50, "deploys_per_hour": 30 },
  "counts": { "applications": 11, "servers": 3 }
}

Applications

app object (list form):

{
  "id": 11,
  "name": "chatwoot-copy",
  "environment": "production",
  "parent_id": null,
  "git_url": "image://chatwoot/chatwoot:v4.17.1",
  "git_ref": "main",
  "hostname": "chatwoot-copy.apps.example.com",
  "url": "https://chatwoot-copy.apps.example.com",
  "server": "railyard-8",
  "container_port": 3000,
  "addons": ["postgres", "redis"],
  "archived": false,
  "auto_deploy": false,
  "latest_deploy": { "id": 31, "status": "succeeded", "sha": null, "created_at": "2026-09-02T13:00:00Z" },
  "created_at": "2026-09-01T09:00:00Z"
}

GET /api/applications/:id adds: release_command, health_path, deploy_approval_required, deploy_window {days, start, end, time_zone, open_now, opens_at}, strict_pushes, db_extensions[], processes[] {name, command, role}, env_keys[] (names only — values are never returned in app detail; use the env endpoint), domains[] {hostname, redirect, cert_state}.

Create — POST /api/applications

curl -s -X POST https://cp.example.com/api/applications \
  -H "Authorization: Bearer $RLY" -H "Content-Type: application/json" \
  -d '{"application":{
        "name":"blog",
        "git_url":"https://github.com/acme/blog",
        "git_ref":"main",
        "container_port":3000,
        "server":"railyard-8",
        "addons":["postgres"],
        "env_vars":{"RAILS_ENV":"production"}
      }}'

Body is {"application": { … }}. Accepted keys: name, git_url, git_ref, container_port, hostname, health_path, release_command, build_method (auto | dockerfile | nixpacks), dockerfile_path, docker_build_target, cpu_limit, memory_limit, process_types_text (one name: command per line), scheduled_tasks_text (name | cron | command per line), db_extensions, deploy_approval_required, strict_pushes (a git push whose build fails is rejected; default true), addons (array), env_vars (object). On update also: server_id.

  • server (or server_id) may be a server id or hostname. Omit it and the app lands on the first uncordoned server in the team.
  • Blank hostname is derived — <name>.<server app_domain>, else APP_DOMAIN_SUFFIX, else <name>.<server-ip>.sslip.io.
  • Creating does not deploy. Follow with POST …/deploys.
  • Over the team app limit → 422.

201 with the detailed app object. PATCH → 200. DELETE → 204.

Archive / unarchive — POST /api/applications/:id/archive stops the running container and sets archived: true; the record, config and history stay. unarchive clears the flag (redeploy to bring it back).


Env vars — /api/applications/:id/env

# read
curl -s https://cp.example.com/api/applications/11/env -H "Authorization: Bearer $RLY"
# {"env":{"RAILS_ENV":"production","LOG_LEVEL":"info"}}

# merge (set two, delete one)
curl -s -X PATCH https://cp.example.com/api/applications/11/env \
  -H "Authorization: Bearer $RLY" -H "Content-Type: application/json" \
  -d '{"env":{"LOG_LEVEL":"debug","FEATURE_X":"1","OLD_KEY":null}}'

# delete one
curl -s -X DELETE "https://cp.example.com/api/applications/11/env?key=FEATURE_X" \
  -H "Authorization: Bearer $RLY"

PATCH merges into the existing set; a null value removes that key. All three return the full {"env": { … }}. Changes take effect on the next deploy.


Custom domains — /api/applications/:id/domains

curl -s -X POST https://cp.example.com/api/applications/11/domains \
  -H "Authorization: Bearer $RLY" -H "Content-Type: application/json" \
  -d '{"domain":{"hostname":"support.acme.com","redirect":false}}'
{ "id": 4, "hostname": "support.acme.com", "redirect": false,
  "cert_state": "pending", "dns_target": "203.0.113.9", "cert_issuer": null }

redirect: true makes the hostname 301 to the app’s primary hostname instead of serving it. Point the shown dns_target at your DNS and Caddy issues the cert. DELETE …/domains/:domain_id → 204.


Deploys

deploy object:

{
  "id": 31, "application_id": 11, "application": "chatwoot-copy",
  "status": "succeeded", "ref": "main", "trigger": "manual", "message": null, "sha": null, "rollback": false,
  "created_at": "2026-09-02T13:00:00Z",
  "started_at": "2026-09-02T13:00:04Z",
  "finished_at": "2026-09-02T13:02:31Z"
}

status ∈ queued · pending_approval · running · succeeded · failed. GET /api/deploys/:id adds error, triggered_by (email), duration_seconds.

A canary deploy also has a canary object (null otherwise):

"canary": {
  "percent": 10, "state": "running", "started_at": "2026-09-29T12:00:00Z",
  "promote_at": "2026-09-29T12:15:00Z", "requests": 412, "error_rate": 0.49, "p95_ms": 88.4,
  "rest_of_app": { "requests": 3710, "error_rate": 0.4, "p95_ms": 91.0 }
}

state ∈ running · promoted · rolled_back · replaced.

Canary — POST /api/deploys/:id/canary/promote starts the promotion deploy (202, promotion_deploy_id); DELETE /api/deploys/:id/canary rolls it back. Both need a write token and answer 409 once the canary is no longer live.

Deploy strategy — PATCH /api/applications/:id/deploy_strategy with any of deploy_strategy (standard · canary), canary_percent (1–99), canary_steps_text (comma-separated ascending percentages, e.g. "5, 25, 50"; blank = one step), canary_promote_after_minutes (1–1440, empty = by hand), canary_max_error_rate (0–100). GET /api/applications/:id shows the same fields. With steps set, promoting a step starts the next step as its own canary instead of going straight to 100%.

Redeploy when the image changes (image apps only, else 422) — PATCH /api/applications/:id/image_watch with enabled (true/false); POST /api/applications/:id/image_watch/check checks the registry now (202). Both return, and GET /api/applications/:id shows as image_watch:

{ "enabled": true, "image": "ghcr.io/acme/api:prod",
  "webhook_url": "https://cp.example.com/webhooks/image/2cV…",
  "digest": "sha256:fb84…", "checked_at": "2026-09-29T12:05:00Z", "error": null }

Registries call POST /webhooks/image/<token> (no API token; any body) to trigger a check.

Trigger — POST /api/applications/:id/deploys

curl -s -X POST https://cp.example.com/api/applications/11/deploys \
  -H "Authorization: Bearer $RLY" -H "Content-Type: application/json" \
  -d '{"ref":"v2.1.0"}'
  • ref (optional) — branch, tag, or SHA to deploy once; defaults to the app’s git_ref, which a ref never changes. The deploy’s trigger is ref and its ref field shows what it used. Names with characters git refs can’t have get 422. Ignored for an image:// app.
  • message (optional) — a deploy note (up to 500 characters), returned as message and shown in the dashboard, activity, notifications and the deploy.* webhooks.
  • at (optional) — ISO 8601 time to deploy at instead of now. Answers 201 with status: "scheduled" and scheduled_for; the time must be in the future and inside the deploy window (422 otherwise). Cancel it with POST /api/deploys/:id/cancel (also cancels a queued or running deploy).
  • ignore_window (optional) — deploy even though the app is outside its deploy window. Without it such a deploy answers 409 with outside_window: true and window_opens_at.
  • rollback_to (optional) — id of an earlier succeeded deploy. Redeploys its commit and puts back the variables it ran with; the response adds restored_config. Send "restore_config": false to roll back the code only. Releases deployed before Railyard kept a copy of their variables always roll back code only.
  • 202 Accepted + the deploy object when it’s queued and running.
  • 200 OK + the object with status: "pending_approval" if the app requires deploy approval (approve it in the dashboard).
  • 409 if a deploy is already active or awaiting approval for that app.
  • 429 if the team’s deploys-per-hour limit is hit.
  • 422 if the app is archived.
  • 422 with missing_env (array of names) if the app has never deployed successfully and credentials its repo documents are unset or still placeholders. Set them via the env endpoint, or send "force": true to deploy anyway — those names aren’t asked for again.

Deploy window — PATCH /api/applications/:id/deploy_window

curl -s -X PATCH https://cp.example.com/api/applications/11/deploy_window \
  -H "Authorization: Bearer $RLY" -H "Content-Type: application/json" \
  -d '{"days":["mon","tue","wed","thu","fri"],"start":"09:00","end":"17:00","time_zone":"America/Toronto"}'

Returns {days, start, end, time_zone, open_now, opens_at} (also under deploy_window in GET /api/applications/:id). "days": [] removes the window. Pushes, GitHub/GitLab webhooks and coalesced pushes outside the window become one scheduled deploy that starts when it opens.

Logs — GET /api/deploys/:id/logs

{
  "deploy_id": 31,
  "status": "succeeded",
  "lines": [
    { "stream": "system", "line": "Deploying web (web)", "at": "2026-09-02T13:00:05Z" },
    { "stream": "stdout", "line": "Pulled chatwoot/chatwoot:v4.17.1", "at": "2026-09-02T13:00:40Z" },
    { "stream": "system", "line": "Deployed at https://chatwoot-copy.apps.example.com", "at": "2026-09-02T13:02:30Z" }
  ]
}

stream ∈ stdout · stderr · system. Capped at 5000 lines, oldest first. Poll while status is queued/running.

List — GET /api/deploys?application_id=11&limit=50 or GET /api/applications/11/deploys.


Runtime — logs, processes, commands, restart

The programmatic half of the app-page console. Everything here goes to the agent on the box over an mTLS channel.

Runtime logs — GET /api/applications/:id/logs?tail=200&process=web

What the running app is printing now — not the build output (that’s /api/deploys/:id/logs). tail is 1–5000 (default 200); process filters to one process. 409 with {"error": "No running deployment"} when nothing is up yet, or the agent is unreachable.

{
  "application_id": 11,
  "lines": [
    { "stream": "stdout", "process": "web", "line": "GET / 200 12ms", "at": "2026-09-06T12:00:01Z" }
  ]
}

There is no server-push follow yet — railyard logs --follow re-requests on an interval.

Processes — GET /api/applications/:id/processes

Last-reported state of every container, refreshed each agent heartbeat. Empty list = nothing running.

{ "processes": [
  { "name": "web", "container": "railyard-app-11-web", "state": "running",
    "status": "running", "health": "healthy", "crash_looping": false,
    "restarts": 0, "cpu_percent": 1.2, "mem_bytes": 92210000,
    "mem_percent": 44.1, "last_seen_at": "2026-09-06T12:00:00Z" } ] }

status ∈ running · unhealthy · crash-looping · exited · dead · …

Restart — POST /api/applications/:id/restart[?process=web]

Bounces every container, or just one process’s. 409 if nothing is running.

{ "restarted": 2, "processes": ["railyard-app-11-web", "railyard-app-11-worker-1"] }

Process types — GET /api/applications/:id/process_types, PATCH /api/applications/:id/process_types/:pt_id

{ "id": 7, "name": "worker", "role": "worker", "command": "bundle exec sidekiq",
  "replicas": 2, "env_vars": {}, "kill_timeout_seconds": 30, "stop_signal": "",
  "size": "small", "cpu_limit": "0.5", "memory_limit": "512m", "cpus": "0.5", "memory": "512m" }

size is small, medium, large (mapped from PROCESS_SIZE_*), custom (then send cpu_limit and memory_limit) or blank for the app-wide limit. cpus / memory are the effective limits after that fallback.

kill_timeout_seconds (1–300, blank for the default) is how long the process gets after stop_signal (SIGTERM, SIGINT, SIGQUIT, blank for the image’s own) before it is killed. Changes apply on the next deploy. An invalid value → 422.

One-off commands — POST /api/applications/:id/commands

curl -s -X POST https://cp.example.com/api/applications/11/commands \
  -H "Authorization: Bearer $RLY" -H "Content-Type: application/json" \
  -d '{"command":"bin/rails db:migrate"}'

Runs inside the web container via the agent’s RunTask RPC. 202 Accepted with the row; poll GET /api/applications/:id/commands/:cmd_id for status (queued → running → succeeded/failed), streamed output, and exit_code. GET /api/applications/:id/commands lists recent runs.

Interactive console (a TTY, Exec RPC) is not in the API yet.


Servers (read-only)

curl -s https://cp.example.com/api/servers -H "Authorization: Bearer $RLY"

server object:

{
  "id": 5, "hostname": "railyard-8", "name": "railyard-8",
  "status": "online",
  "agent_version": "20260902-a9821d1",
  "cpu_percent": 1.5, "memory_percent": 64.5, "disk_percent": 40.3,
  "last_heartbeat_at": "2026-09-02T14:03:00Z",
  "cordoned": false
}

status ∈ online (heartbeat < 30 s) · stale (< 5 min) · offline. GET /api/servers/:id adds apps[] (names) and processes[] {container, state, restarts, crash_looping}.

Cordoning, rebooting, power-cycling, pruning and resizing an existing server are POST/PATCH on /api/servers/:id (see the table above). Attaching a new server is dashboard-only for now.

Data maintenance window — GET/PATCH /api/maintenance_window (team) and GET/PATCH /api/servers/:id/maintenance_window (one server; blank day = use the team’s). Fields: maintenance_day 0–6 (Sunday = 0), maintenance_hour 0–23 UTC, maintenance_hours 1–12. See Database servers.

Postgres major upgrades — GET/POST /api/servers/:id/postgres_upgrades (to_image; creating one runs the compatibility check), GET …/:id (with the log), PATCH …/:id (when=window to wait for the maintenance window, otherwise it starts now), POST …/:id/rollback, DELETE …/:id (removes the kept old version, or drops an unstarted plan).

Connection pooler — GET/POST/DELETE /api/servers/:id/connection_pooler (PgBouncer, transaction mode, port 6432). Per app: PATCH /api/applications/:id/database with app_database[pooled]=true points DATABASE_URL at the pooler from the next deploy and keeps DATABASE_DIRECT_URL for migrations.

Database forks — GET/POST /api/applications/:id/database_forks (target_server_id for a new database or target_application_id for a staging/preview env, source=live|backup, optional mask_sql), GET …/:id (with the fork’s URL once ready), DELETE …/:id (drops it).

Standby replicas — GET/POST /api/servers/:id/database_replicas (replica_server_id), POST …/:id/promote, DELETE …/:id.


Quick recipes

Deploy on every push (CI):

curl -fsS -X POST "$RAILYARD/api/applications/$APP_ID/deploys" \
  -H "Authorization: Bearer $RAILYARD_TOKEN" -H "Content-Type: application/json" \
  -d "{\"ref\":\"$GIT_SHA\"}"

Wait for a deploy to finish:

id=$(curl -fsS -X POST "$RAILYARD/api/applications/$APP_ID/deploys" \
      -H "Authorization: Bearer $TOKEN" -d '{}' | jq .id)
while :; do
  s=$(curl -fsS "$RAILYARD/api/deploys/$id" -H "Authorization: Bearer $TOKEN" | jq -r .status)
  case $s in succeeded) exit 0;; failed) exit 1;; esac
  sleep 5
done

Sync env from a file (KEY=value lines):

jq -Rn '[inputs | select(test("=")) | split("=") | {(.[0]): (.[1:] | join("="))}] | add | {env: .}' .env.prod \
| curl -fsS -X PATCH "$RAILYARD/api/applications/$APP_ID/env" \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d @-

Not in the API yet

Interactive console (a TTY over the Exec RPC), attaching a new server, addon management beyond the addons array, creating or editing notification rules / webhook endpoints / config entries (listing is read-only today), activity feed. These are dashboard-only today; open an issue if you need one over the API.

MCP (AI agents)

POST /api/mcp is a Model Context Protocol server (JSON-RPC over streamable HTTP), so AI agents can use Railyard with the same API tokens.

  • A read token can list servers and apps, show an app and read its logs.
  • A write token can also deploy, restart and set env vars.
  • Deleting an app or removing an env var never runs directly: it creates an approval request. A team admin approves or rejects it under Approvals (it also shows on the dashboard).

Claude Code:

claude mcp add --transport http railyard https://app.railyard.run/api/mcp --header "Authorization: Bearer rly_..."

Cursor (.cursor/mcp.json):

{ "mcpServers": { "railyard": { "url": "https://app.railyard.run/api/mcp", "headers": { "Authorization": "Bearer rly_..." } } } }

Tools: list_servers, list_apps, get_app, app_logs, deploy_app, restart_app, set_env, unset_env (approval), delete_app (approval).