diff --git a/.icons/railway.svg b/.icons/railway.svg
new file mode 100644
index 000000000..b372817de
--- /dev/null
+++ b/.icons/railway.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/registry/bpmct/templates/railway/README.md b/registry/bpmct/templates/railway/README.md
new file mode 100644
index 000000000..d4bd29caa
--- /dev/null
+++ b/registry/bpmct/templates/railway/README.md
@@ -0,0 +1,128 @@
+---
+display_name: Railway (via GraphQL)
+description: Coder workspaces backed by Railway services, provisioned via direct GraphQL calls (no community Terraform provider).
+icon: ../../../../.icons/railway.svg
+verified: false
+tags: [railway, cloud, container, docker]
+---
+
+# Railway (via GraphQL)
+
+Each Coder workspace is a fully isolated [Railway](https://railway.com) project running a container built from a public image on GHCR. Every workspace becomes a first-class Railway project (its own environment, its own volumes, its own service token), which sets up a natural extension point: the workspace user can manage further Railway resources inside their own project via the [Railway CLI](https://docs.railway.com/reference/cli-api).
+
+
+
+
+
+
+ Demo (Loom, ~5 min)
+
+
+> [!NOTE]
+> The Loom above was recorded before startup was optimized. The demo shows a per-start Docker build (~62s median). This template now uses `serviceInstanceUpdate(source: { image })` against a pre-built image (`ghcr.io/bpmct/railway-coder-workspace:latest`), so median start time is ~6s. The rest of the flow in the demo (workspace creation, project isolation, Railway CLI) still applies.
+
+## Why direct GraphQL instead of the Terraform provider
+
+The community [`terraform-community-providers/railway`](https://github.com/terraform-community-providers/terraform-provider-railway) provider is a great starting point, but hits two blocking issues for a Coder template that has to run reliably under stress:
+
+1. **Volume creation is racy.** `railway_volume` can write to a service before the service is fully attached, and Railway rejects `volumeCreate` on services that have had deployments. Terraform's implicit ordering does not respect Railway's API contract here.
+2. **`variableUpsert` triggers extra deploys.** Every env var write during workspace start races a redeploy that intermittently fails with "Cannot redeploy without a snapshot" (just after `serviceDisconnect`) or "Cannot redeploy yet, please wait for the original deployment to finish building" (during the first second after a `deploymentCancel`).
+
+This template bypasses the provider entirely for the Railway operations and calls the [Railway public API](https://docs.railway.com/reference/public-api) directly with `curl`. Key fixes that keep the reliability suite green:
+
+- **`skipDeploys: true`** on `variableUpsert`. An undocumented flag that tells Railway to persist the value without enqueuing a redeploy. The subsequent `serviceInstanceDeployV2` is what actually deploys, and it picks up the freshly-set variables.
+- **Explicit `volumeCreate` before any deployment activity**, with backoff on the "creating volumes too quickly" rate limit.
+- **`serviceInstanceUpdate(source: { image })` + `serviceInstanceDeployV2`** for the image deploy, rather than `serviceConnect` (which builds from a repo on every start).
+
+See [bpmct/coder-railway](https://github.com/bpmct/coder-railway) for the full write-up, the reliability suite, and three other variants (Terraform-provider-based, hybrid, and Railway-CLI-based) that were tried along the way.
+
+## Prerequisites
+
+### 1. Railway API token
+
+Create a Railway account/team token at [railway.com/account/tokens](https://railway.com/account/tokens). It must be an account or team token (not a project token) so that the template can call `projectCreate`.
+
+### 2. Push the template with the token
+
+```sh
+coder templates push railway --directory . \
+ --variable railway_token=YOUR_RAILWAY_TOKEN
+```
+
+The Railway master token is stored as a Terraform variable at the template level and is never exposed to workspace users. If `enable_project_management` is set to `true`, each workspace also gets its own project-scoped Railway token (a much narrower blast radius) injected as `$RAILWAY_TOKEN` inside the workspace, and the Railway CLI is auto-installed and pre-authenticated:
+
+```sh
+coder templates push railway --directory . \
+ --variable railway_token=YOUR_RAILWAY_TOKEN \
+ --variable enable_project_management=true
+```
+
+## Usage
+
+Create a workspace from the `railway` template. The only end-user parameter is:
+
+- **Region**: US West, US East, EU West, Asia Southeast.
+
+Everything else (project, service, volume, env vars, image source, first deploy) happens under the hood via GraphQL.
+
+## Architecture
+
+For each workspace, this template provisions on Railway:
+
+- **Project** (`coder--`, one per workspace).
+- **Service** (`workspace`) pinned to the image in `workspace_image`.
+- **Volume** persisting `/home/coder`, survives stop/start.
+- **Env vars** on the service: `CODER_AGENT_TOKEN`, `CODER_INIT_SCRIPT_B64`, `RAILWAY_RUN_UID=0`.
+- Optionally, a **project-scoped Railway token** exposed to the workspace as `$RAILWAY_TOKEN`.
+
+Persistent (survive stop/start): project, service, volume, project token. Ephemeral (per start): env vars, image deploy.
+
+### Custom workspace image
+
+The default image is `ghcr.io/bpmct/railway-coder-workspace:latest`, which is `codercom/enterprise-base:ubuntu` plus a small entrypoint that fixes Railway volume ownership, decodes `CODER_INIT_SCRIPT_B64`, and runs the Coder agent as the `coder` user. The Dockerfile and entrypoint are vendored in this template under [`build/`](./build) so you can read, fork, or extend them without leaving the registry:
+
+- [`build/Dockerfile`](./build/Dockerfile) - 10 lines, thin layer on `codercom/enterprise-base:ubuntu`.
+- [`build/entrypoint.sh`](./build/entrypoint.sh) - Railway volume `chown`, skeleton seed, `CODER_INIT_SCRIPT_B64` decode + drop to `coder`.
+
+**Adding your own tools:** the default image is deliberately minimal, so most teams will want to extend it. Two patterns:
+
+1. **Extend the default image** (recommended for most cases). Write a Dockerfile that starts `FROM ghcr.io/bpmct/railway-coder-workspace:latest` and layer whatever you need on top. Entrypoint and Coder agent wiring are inherited, so you only touch what you actually need.
+
+ ```dockerfile
+ FROM ghcr.io/bpmct/railway-coder-workspace:latest
+ USER root
+ RUN apt-get update && apt-get install -y --no-install-recommends \
+ postgresql-client redis-tools \
+ && rm -rf /var/lib/apt/lists/*
+ ```
+
+2. **Duplicate `build/` and build from a different base**, if you need to swap the base image entirely (e.g. `codercom/example-universal:ubuntu`, an internal golden image, or a non-Ubuntu distro). Copy [`build/entrypoint.sh`](./build/entrypoint.sh) verbatim - the image contract that the template relies on (volume chown, `CODER_INIT_SCRIPT_B64` decode, drop to `coder` user) lives in that entrypoint, not in the base image.
+
+Build, push to any registry, and point the template at it:
+
+```sh
+coder templates push railway --directory . \
+ --variable railway_token=YOUR_RAILWAY_TOKEN \
+ --variable workspace_image=ghcr.io/yourorg/your-image:tag
+```
+
+For private registries, also set `image_registry_username` and `image_registry_password`.
+
+The only contract the template requires from the image is that its `ENTRYPOINT` consumes:
+
+- `CODER_INIT_SCRIPT_B64`: base64-encoded Coder agent init script.
+- `CODER_AGENT_TOKEN`: Coder agent token.
+- `RAILWAY_RUN_UID=0`: Railway UID override so the entrypoint can chown the root-owned Railway volume mount before dropping to the workspace user.
+
+## Other Railway approaches
+
+This template ships the GraphQL variant, which is the most reliable of four approaches I tried against the Railway API. The others live in [bpmct/coder-railway/variants/wip/](https://github.com/bpmct/coder-railway/tree/main/variants/wip):
+
+- **`tf-patched`**: Uses the Railway Terraform provider with a small patch and a `trailing_zombie` workaround for the redeploy race.
+- **`hybrid`**: Uses `railway_service` from the provider, GraphQL for everything else.
+- **`cli`**: Uses the Railway CLI (`railway up`, `railway link`) instead of the provider or GraphQL.
+
+See the [benchmark table](https://github.com/bpmct/coder-railway#benchmarks) for the reliability comparison across variants.
+
+> [!NOTE]
+> This template is designed to be a starting point. Edit the Terraform to extend it for your use case.
diff --git a/registry/bpmct/templates/railway/build/Dockerfile b/registry/bpmct/templates/railway/build/Dockerfile
new file mode 100644
index 000000000..d4af2500e
--- /dev/null
+++ b/registry/bpmct/templates/railway/build/Dockerfile
@@ -0,0 +1,8 @@
+FROM codercom/enterprise-base:ubuntu
+
+USER root
+
+COPY entrypoint.sh /coder-entrypoint.sh
+RUN chmod +x /coder-entrypoint.sh
+
+ENTRYPOINT ["/coder-entrypoint.sh"]
diff --git a/registry/bpmct/templates/railway/build/entrypoint.sh b/registry/bpmct/templates/railway/build/entrypoint.sh
new file mode 100755
index 000000000..6fe51ea93
--- /dev/null
+++ b/registry/bpmct/templates/railway/build/entrypoint.sh
@@ -0,0 +1,36 @@
+#!/bin/bash
+set -e
+
+# Railway volumes are root-owned by default. When the container
+# runs as root (via RAILWAY_RUN_UID=0), we fix ownership before
+# dropping privileges to the coder user.
+if [ "$(id -u)" = "0" ]; then
+ chown coder:coder /home/coder 2> /dev/null || true
+fi
+
+# Seed home directory with skeleton files on first start.
+if [ ! -f /home/coder/.init_done ]; then
+ cp -rT /etc/skel /home/coder 2> /dev/null || true
+ touch /home/coder/.init_done
+ if [ "$(id -u)" = "0" ]; then
+ chown -R coder:coder /home/coder 2> /dev/null || true
+ fi
+fi
+
+# The Coder init script is passed base64-encoded to avoid shell
+# escaping issues with multi-line environment variable values.
+if [ -n "$CODER_INIT_SCRIPT_B64" ]; then
+ INIT_SCRIPT_FILE=$(mktemp /tmp/coder-init.XXXXXX)
+ echo "$CODER_INIT_SCRIPT_B64" | base64 -d > "$INIT_SCRIPT_FILE"
+ chmod +x "$INIT_SCRIPT_FILE"
+ if [ "$(id -u)" = "0" ]; then
+ chown coder:coder "$INIT_SCRIPT_FILE"
+ exec su -s /bin/bash coder "$INIT_SCRIPT_FILE"
+ else
+ exec "$INIT_SCRIPT_FILE"
+ fi
+else
+ echo "ERROR: CODER_INIT_SCRIPT_B64 is not set."
+ echo "This image is designed to run as a Coder workspace on Railway."
+ sleep infinity
+fi
diff --git a/registry/bpmct/templates/railway/main.tf b/registry/bpmct/templates/railway/main.tf
new file mode 100644
index 000000000..17d0cfe49
--- /dev/null
+++ b/registry/bpmct/templates/railway/main.tf
@@ -0,0 +1,531 @@
+# ============================================================================
+# Railway (via GraphQL) — Coder Template
+# ============================================================================
+#
+# Each Coder workspace becomes a fully isolated Railway project running
+# a container built from a pre-built image on GHCR. Every Railway
+# operation is a direct GraphQL mutation via `curl` (no community
+# Railway Terraform provider).
+#
+# See registry/bpmct/templates/railway/README.md for the full write-up
+# on why this template calls the Railway API directly instead of using
+# the community provider, and https://github.com/bpmct/coder-railway
+# for three alternative approaches (tf-patched, hybrid, cli) and the
+# reliability suite behind the design choices.
+#
+# Image contract:
+# The workspace image's ENTRYPOINT must consume:
+# CODER_INIT_SCRIPT_B64 base64-encoded Coder agent init script
+# CODER_AGENT_TOKEN Coder agent token
+# RAILWAY_RUN_UID "0" so the entrypoint can chown the
+# root-owned Railway volume mount and then
+# drop to the coder user.
+# The default image (ghcr.io/bpmct/railway-coder-workspace:latest) is
+# codercom/enterprise-base:ubuntu plus a small entrypoint. Source at
+# https://github.com/bpmct/coder-railway/tree/main/build.
+#
+# Lifecycle:
+# Persistent (survive stop): project, service, volume, project token
+# Ephemeral (start_count): env vars, image deploy
+#
+# Provisioner layout:
+# Bash bodies live in scripts/*.sh, one file per (resource, action).
+# main.tf only wires `command = "bash ${path.module}/scripts/X.sh"`
+# and passes runtime values via environment {} blocks. Destroy
+# provisioners reference self.input.* because vars/locals/data are
+# not allowed in destroy scopes. ${path.module} is a constant and
+# safe to use everywhere.
+# ============================================================================
+
+terraform {
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ }
+ }
+}
+
+variable "railway_token" {
+ type = string
+ description = "Railway API token."
+ sensitive = true
+}
+
+variable "enable_project_management" {
+ type = bool
+ default = false
+ description = <<-EOT
+ If true, provision a project-scoped Railway token in each workspace's
+ Railway project and install the Railway CLI in the workspace. The
+ workspace user can then run `railway logs`, `railway up`,
+ `railway run`, etc. against their own project. The master token
+ (var.railway_token) is never exposed to the workspace; only the
+ project-scoped token is.
+ EOT
+}
+
+variable "workspace_image" {
+ type = string
+ default = "ghcr.io/bpmct/railway-coder-workspace:latest"
+ description = <<-EOT
+ Pre-built workspace image. The image's ENTRYPOINT must consume the
+ CODER_INIT_SCRIPT_B64, CODER_AGENT_TOKEN, and RAILWAY_RUN_UID env
+ vars. The default image is public; see
+ https://github.com/bpmct/coder-railway/tree/main/build for the
+ Dockerfile if you want to build your own.
+ EOT
+}
+
+variable "image_registry_username" {
+ type = string
+ default = ""
+ description = <<-EOT
+ Optional registry username for private image pulls. Leave empty
+ when pointing at the public default image
+ (ghcr.io/bpmct/railway-coder-workspace) or any other public
+ registry.
+ EOT
+}
+
+variable "image_registry_password" {
+ type = string
+ default = ""
+ sensitive = true
+ description = <<-EOT
+ Optional registry password / PAT for private image pulls. Leave
+ empty when pointing at the public default image
+ (ghcr.io/bpmct/railway-coder-workspace) or any other public
+ registry.
+ EOT
+}
+
+provider "coder" {}
+
+locals {
+ username = data.coder_workspace_owner.me.name
+ workspace = lower(data.coder_workspace.me.name)
+ started = data.coder_workspace.me.start_count > 0
+ railway_api = "https://backboard.railway.app/graphql/v2"
+ state_dir = "${path.module}/.railway-state"
+ scripts_dir = "${path.module}/scripts"
+ project_name = lower(substr("coder-${local.username}-${local.workspace}", 0, 32))
+}
+
+data "coder_provisioner" "me" {}
+data "coder_workspace" "me" {}
+data "coder_workspace_owner" "me" {}
+
+data "coder_parameter" "region" {
+ name = "region"
+ display_name = "Region"
+ description = "Railway region for the workspace."
+ icon = "/emojis/1f30e.png"
+ type = "string"
+ default = "us-west1"
+ mutable = false
+ option {
+ name = "US West"
+ value = "us-west1"
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ option {
+ name = "US East"
+ value = "us-east4"
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ option {
+ name = "Europe West"
+ value = "europe-west4"
+ icon = "/emojis/1f1ea-1f1fa.png"
+ }
+ option {
+ name = "Asia Southeast"
+ value = "asia-southeast1"
+ icon = "/emojis/1f1f8-1f1ec.png"
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Coder agent
+# ---------------------------------------------------------------------------
+
+resource "coder_agent" "main" {
+ os = "linux"
+ arch = "amd64"
+
+ startup_script = <<-EOT
+ set -e
+ if [ ! -f ~/.init_done ]; then
+ cp -rT /etc/skel ~ 2>/dev/null || true
+ touch ~/.init_done
+ fi
+ EOT
+
+ env = {
+ GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
+ GIT_AUTHOR_EMAIL = data.coder_workspace_owner.me.email
+ GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
+ GIT_COMMITTER_EMAIL = data.coder_workspace_owner.me.email
+ }
+
+ metadata {
+ display_name = "CPU Usage"
+ key = "0_cpu_usage"
+ script = "coder stat cpu"
+ interval = 10
+ timeout = 1
+ }
+ metadata {
+ display_name = "RAM Usage"
+ key = "1_ram_usage"
+ script = "coder stat mem"
+ interval = 10
+ timeout = 1
+ }
+ metadata {
+ display_name = "Home Disk"
+ key = "3_home_disk"
+ script = "coder stat disk --path $${HOME}"
+ interval = 60
+ timeout = 1
+ }
+}
+
+# ===========================================================================
+# Railway resources, all via GraphQL
+# ===========================================================================
+
+# Helper: every curl call follows this pattern:
+# 1. POST the mutation.
+# 2. Print the response (for terraform logs).
+# 3. grep for "errors", exit 1 if found.
+# 4. Extract the ID with sed and write to state file.
+
+# ---------------------------------------------------------------------------
+# 1. Project (persistent)
+# ---------------------------------------------------------------------------
+resource "terraform_data" "project" {
+ # Triggers replacement if project name changes. Stores token and
+ # project name so the destroy provisioner can authenticate and find
+ # the project by name without depending on state files.
+ input = {
+ project_name = local.project_name
+ token = var.railway_token
+ scripts_dir = local.scripts_dir
+ }
+
+ triggers_replace = local.project_name
+
+ provisioner "local-exec" {
+ interpreter = ["bash", "-c"]
+ command = "bash ${local.scripts_dir}/project_create.sh"
+ environment = {
+ API = local.railway_api
+ TOKEN = var.railway_token
+ PROJECT_NAME = local.project_name
+ STATE_DIR = local.state_dir
+ }
+ }
+
+ provisioner "local-exec" {
+ when = destroy
+ interpreter = ["bash", "-c"]
+ command = "bash ${self.input.scripts_dir}/project_destroy.sh"
+ environment = {
+ API = "https://backboard.railway.app/graphql/v2"
+ TOKEN = self.input.token
+ PROJECT_NAME = self.input.project_name
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# 2. Service (persistent)
+# ---------------------------------------------------------------------------
+resource "terraform_data" "service" {
+ input = {
+ token = var.railway_token
+ scripts_dir = local.scripts_dir
+ }
+ depends_on = [terraform_data.project]
+
+ provisioner "local-exec" {
+ interpreter = ["bash", "-c"]
+ command = "bash ${local.scripts_dir}/service_create.sh"
+ environment = {
+ API = local.railway_api
+ TOKEN = var.railway_token
+ PROJECT_NAME = local.project_name
+ STATE_DIR = local.state_dir
+ }
+ }
+
+ provisioner "local-exec" {
+ when = destroy
+ interpreter = ["bash", "-c"]
+ command = "bash ${self.input.scripts_dir}/service_destroy.sh"
+ environment = {
+ API = "https://backboard.railway.app/graphql/v2"
+ TOKEN = self.input.token
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# 3. Volume (persistent)
+#
+# CRITICAL: This must run before ANY deployment activity on the service.
+# Railway rejects volumeCreate on services that have had deployments.
+# By using pure GraphQL we control ordering exactly.
+# ---------------------------------------------------------------------------
+resource "terraform_data" "volume" {
+ input = var.railway_token
+ depends_on = [terraform_data.service]
+
+ provisioner "local-exec" {
+ interpreter = ["bash", "-c"]
+ command = "bash ${local.scripts_dir}/volume_create.sh"
+ environment = {
+ API = local.railway_api
+ TOKEN = var.railway_token
+ PROJECT_NAME = local.project_name
+ STATE_DIR = local.state_dir
+ }
+ }
+
+ # Volume is deleted when service/project is deleted (cascade).
+}
+
+# ---------------------------------------------------------------------------
+# 4. Environment variables (ephemeral, only when started)
+#
+# Set BEFORE the image is attached, so that when the first deployment
+# runs it already has the correct env vars. This avoids triggering
+# extra redeploys that would happen if we upserted vars after the
+# source was set (each variableUpsert on a connected service triggers
+# a redeploy).
+# ---------------------------------------------------------------------------
+resource "terraform_data" "env_vars" {
+ count = data.coder_workspace.me.start_count
+ depends_on = [terraform_data.volume]
+
+ input = {
+ token = var.railway_token
+ project_name = local.project_name
+ scripts_dir = local.scripts_dir
+ }
+
+ provisioner "local-exec" {
+ interpreter = ["bash", "-c"]
+ command = "bash ${local.scripts_dir}/env_vars_create.sh"
+ environment = {
+ API = local.railway_api
+ TOKEN = var.railway_token
+ PROJECT_NAME = local.project_name
+ STATE_DIR = local.state_dir
+ CODER_INIT_SCRIPT_B64 = base64encode(coder_agent.main.init_script)
+ CODER_AGENT_TOKEN = coder_agent.main.token
+ }
+ }
+
+ provisioner "local-exec" {
+ when = destroy
+ interpreter = ["bash", "-c"]
+ command = "bash ${self.input.scripts_dir}/env_vars_destroy.sh"
+ environment = {
+ API = "https://backboard.railway.app/graphql/v2"
+ TOKEN = self.input.token
+ PROJECT_NAME = self.input.project_name
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# 4b. Project-scoped Railway token + RAILWAY_TOKEN env var (optional).
+#
+# When var.enable_project_management is true, provision a Railway
+# project-scoped token via projectTokenCreate and upsert it as
+# RAILWAY_TOKEN on the workspace service. The Coder script
+# `railway_cli` (below) installs the Railway CLI in the workspace.
+# The CLI auto-picks up RAILWAY_TOKEN from the container env.
+#
+# Persistent across stop/start (count is tied to the variable, not
+# start_count). The token survives until workspace delete, when the
+# destroy provisioner deletes it and the env var. The variableUpsert
+# happens before image_deploy, so the first deploy already has
+# RAILWAY_TOKEN in scope.
+# ---------------------------------------------------------------------------
+resource "terraform_data" "project_token" {
+ count = var.enable_project_management ? 1 : 0
+ depends_on = [terraform_data.project, terraform_data.service]
+
+ triggers_replace = local.project_name
+
+ input = {
+ token = var.railway_token
+ project_name = local.project_name
+ scripts_dir = local.scripts_dir
+ }
+
+ provisioner "local-exec" {
+ interpreter = ["bash", "-c"]
+ command = "bash ${local.scripts_dir}/project_token_create.sh"
+ environment = {
+ API = local.railway_api
+ TOKEN = var.railway_token
+ PROJECT_NAME = local.project_name
+ STATE_DIR = local.state_dir
+ }
+ }
+
+ provisioner "local-exec" {
+ when = destroy
+ interpreter = ["bash", "-c"]
+ command = "bash ${self.input.scripts_dir}/project_token_destroy.sh"
+ environment = {
+ API = "https://backboard.railway.app/graphql/v2"
+ TOKEN = self.input.token
+ PROJECT_NAME = self.input.project_name
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# 5. Image deploy (ephemeral, only when started)
+#
+# Points the workspace service at a pre-built container image and
+# triggers the first deployment. Two GraphQL calls:
+# 1. serviceInstanceUpdate(source: { image, registryCredentials? })
+# Updates the source. Does NOT trigger a deploy on its own in
+# current Railway behavior.
+# 2. serviceInstanceDeployV2(serviceId, environmentId)
+# Explicitly triggers a deploy of the current config.
+#
+# On stop, the destroy provisioner cancels active deployments to
+# scale the container to zero, then polls briefly for stray deploys
+# that env_vars destroy might race.
+# ---------------------------------------------------------------------------
+resource "terraform_data" "image_deploy" {
+ count = data.coder_workspace.me.start_count
+ depends_on = [terraform_data.env_vars, terraform_data.project_token]
+
+ # Store IDs needed by the destroy provisioner. Destroy provisioners
+ # can only reference self, not vars or other resources.
+ input = {
+ token = var.railway_token
+ project_name = local.project_name
+ scripts_dir = local.scripts_dir
+ }
+
+ provisioner "local-exec" {
+ interpreter = ["bash", "-c"]
+ command = "bash ${local.scripts_dir}/image_deploy_create.sh"
+ environment = {
+ API = local.railway_api
+ TOKEN = var.railway_token
+ PROJECT_NAME = local.project_name
+ STATE_DIR = local.state_dir
+ WORKSPACE_IMAGE = var.workspace_image
+ IMAGE_REGISTRY_USERNAME = var.image_registry_username
+ IMAGE_REGISTRY_PASSWORD = var.image_registry_password
+ }
+ }
+
+ provisioner "local-exec" {
+ when = destroy
+ interpreter = ["bash", "-c"]
+ command = "bash ${self.input.scripts_dir}/image_deploy_destroy.sh"
+ environment = {
+ API = "https://backboard.railway.app/graphql/v2"
+ TOKEN = self.input.token
+ PROJECT_NAME = self.input.project_name
+ }
+ }
+}
+
+
+# ===========================================================================
+# Metadata and apps
+# ===========================================================================
+
+resource "coder_metadata" "workspace" {
+ resource_id = coder_agent.main.id
+ item {
+ key = "region"
+ value = data.coder_parameter.region.value
+ }
+ item {
+ key = "image"
+ value = var.workspace_image
+ }
+ item {
+ key = "project_id"
+ value = try(file("${local.state_dir}/project_id"), "pending")
+ }
+ item {
+ key = "service_id"
+ value = try(file("${local.state_dir}/service_id"), "pending")
+ }
+}
+
+module "code-server" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/coder/code-server/coder"
+ version = "~> 1.0"
+ agent_id = coder_agent.main.id
+ order = 1
+}
+
+# Optional: install the Railway CLI on workspace start when project
+# management is enabled. The CLI auto-authenticates against the
+# project-scoped RAILWAY_TOKEN that terraform_data.project_token sets
+# on the workspace service, so the user can immediately run e.g.
+# `railway status`, `railway logs`, `railway up`, `railway run -- npm test`.
+#
+# The installer writes to ~/.railway which is on the persistent home
+# volume, so subsequent starts are instant (no re-download).
+resource "coder_script" "railway_cli" {
+ count = var.enable_project_management ? 1 : 0
+ agent_id = coder_agent.main.id
+ display_name = "Install Railway CLI"
+ icon = "/icon/railway.svg"
+ run_on_start = true
+ start_blocks_login = false
+
+ script = <<-EOT
+ set -eu
+
+ # Install Railway CLI to ~/.railway if not already present.
+ # The upstream installer is bash-only; running it via /bin/sh
+ # surfaces a harmless "Bad substitution" error and a non-zero
+ # exit, which Coder reports as a startup-script failure. Run it
+ # explicitly under bash and ignore its exit code; we verify the
+ # binary after.
+ if ! [ -x "$HOME/.railway/bin/railway" ]; then
+ echo "Installing Railway CLI..."
+ curl -fsSL https://railway.app/install.sh -o /tmp/railway-install.sh
+ bash /tmp/railway-install.sh || true
+ rm -f /tmp/railway-install.sh
+ fi
+
+ if ! [ -x "$HOME/.railway/bin/railway" ]; then
+ echo "ERROR: Railway CLI install failed."
+ exit 1
+ fi
+
+ # Symlink into /usr/local/bin so the CLI is on PATH for every
+ # shell, including non-interactive `coder ssh ... -- cmd` runs.
+ # ~/.bashrc would only work for interactive shells; setting the
+ # PATH in coder_agent.env would not survive new bash sessions.
+ if ! [ -e /usr/local/bin/railway ]; then
+ sudo ln -sf "$HOME/.railway/bin/railway" /usr/local/bin/railway
+ fi
+
+ echo ""
+ echo "Railway CLI ready. RAILWAY_TOKEN is set to a project-scoped"
+ echo "token for this workspace's Railway project. Try:"
+ echo " railway status"
+ echo " railway logs --service workspace"
+ echo " railway variables"
+ EOT
+}
diff --git a/registry/bpmct/templates/railway/scripts/env_vars_create.sh b/registry/bpmct/templates/railway/scripts/env_vars_create.sh
new file mode 100755
index 000000000..e73e2248f
--- /dev/null
+++ b/registry/bpmct/templates/railway/scripts/env_vars_create.sh
@@ -0,0 +1,70 @@
+#!/usr/bin/env bash
+# Upsert the three required env vars on the workspace service:
+# CODER_INIT_SCRIPT_B64, CODER_AGENT_TOKEN, RAILWAY_RUN_UID.
+#
+# Runs BEFORE image_deploy so the first deployment already has the
+# correct values. variableUpsert on a connected service would trigger
+# an extra redeploy, which we explicitly avoid.
+#
+# Env vars required:
+# API, TOKEN, PROJECT_NAME, STATE_DIR
+# CODER_INIT_SCRIPT_B64 - base64 of agent init script
+# CODER_AGENT_TOKEN - agent token
+set -euo pipefail
+. "$(dirname "$0")/lib.sh"
+
+PROJECT_ID=""
+SERVICE_ID=""
+ENV_ID=""
+load_state
+
+if [ -z "$PROJECT_ID" ] || [ -z "$SERVICE_ID" ] || [ -z "$ENV_ID" ]; then
+ PROJECT_ID=$(lookup_project_id)
+ [ -z "$PROJECT_ID" ] && {
+ echo "FATAL: project not found"
+ exit 1
+ }
+ SE=$(lookup_service_and_env "$PROJECT_ID")
+ SERVICE_ID=$(echo "$SE" | awk '{print $1}')
+ ENV_ID=$(echo "$SE" | awk '{print $2}')
+fi
+[ -z "$SERVICE_ID" ] || [ -z "$ENV_ID" ] && {
+ echo "FATAL: service/env not found"
+ exit 1
+}
+
+# Helper: upsert a single variable with retry. Railway's
+# variableUpsert has been observed taking 60s+ under load and
+# returning 504 at the edge with no body, which would otherwise
+# surface as curl exit 28 and abort the script under set -e.
+#
+# skipDeploys: true tells Railway to set the value without triggering
+# a redeploy. We always rely on the subsequent serviceConnect to
+# trigger the actual deployment, so the deploy-on-variable-change
+# behavior is unwanted and racy:
+# - On a freshly-disconnected service it fails with
+# "Cannot redeploy without a snapshot".
+# - During the first second after a cancel it fails with
+# "Cannot redeploy yet, please wait for the original
+# deployment to finish building".
+upsert_var() {
+ local name="$1" value="$2"
+ local attempt resp
+ for attempt in 1 2 3 4 5; do
+ resp=$(curl -s --max-time 60 -X POST "$API" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H 'Content-Type: application/json' \
+ -d "{\"query\": \"mutation { variableUpsert(input: { projectId: \\\"$PROJECT_ID\\\", serviceId: \\\"$SERVICE_ID\\\", environmentId: \\\"$ENV_ID\\\", name: \\\"$name\\\", value: \\\"$value\\\", skipDeploys: true }) }\"}" || true)
+ if echo "$resp" | grep -q '"variableUpsert":true'; then
+ return 0
+ fi
+ echo "variableUpsert $name attempt $attempt failed (resp: ${resp:0:200}), retrying..." >&2
+ [ "$attempt" -lt 5 ] && sleep 5
+ done
+ echo "FATAL: variableUpsert $name failed after 5 attempts" >&2
+ exit 1
+}
+
+upsert_var "CODER_INIT_SCRIPT_B64" "$CODER_INIT_SCRIPT_B64"
+upsert_var "CODER_AGENT_TOKEN" "$CODER_AGENT_TOKEN"
+upsert_var "RAILWAY_RUN_UID" "0"
diff --git a/registry/bpmct/templates/railway/scripts/env_vars_destroy.sh b/registry/bpmct/templates/railway/scripts/env_vars_destroy.sh
new file mode 100755
index 000000000..648151bec
--- /dev/null
+++ b/registry/bpmct/templates/railway/scripts/env_vars_destroy.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+# Delete the three managed env vars on stop. By this point
+# image_deploy's destroy has already cancelled any active deploys, so
+# these variableDelete calls cannot trigger a new redeploy.
+#
+# Env vars required: API, TOKEN, PROJECT_NAME
+. "$(dirname "$0")/lib.sh"
+
+PROJECT_ID=$(lookup_project_id)
+[ -z "$PROJECT_ID" ] && exit 0
+
+SE=$(lookup_service_and_env "$PROJECT_ID")
+SERVICE_ID=$(echo "$SE" | awk '{print $1}')
+ENV_ID=$(echo "$SE" | awk '{print $2}')
+[ -z "$SERVICE_ID" ] || [ -z "$ENV_ID" ] && exit 0
+
+for VAR_NAME in CODER_INIT_SCRIPT_B64 CODER_AGENT_TOKEN RAILWAY_RUN_UID; do
+ gql "mutation { variableDelete(input: { projectId: \\\"$PROJECT_ID\\\", serviceId: \\\"$SERVICE_ID\\\", environmentId: \\\"$ENV_ID\\\", name: \\\"$VAR_NAME\\\" }) }" || true
+done
diff --git a/registry/bpmct/templates/railway/scripts/image_deploy_create.sh b/registry/bpmct/templates/railway/scripts/image_deploy_create.sh
new file mode 100755
index 000000000..bc168b793
--- /dev/null
+++ b/registry/bpmct/templates/railway/scripts/image_deploy_create.sh
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+# Set the workspace service's image source and trigger the first
+# deployment. Replaces source_connect_create.sh in the GraphQL variant.
+#
+# Unlike `serviceConnect` (which builds from a GitHub repo on every
+# start), `serviceInstanceUpdate(source: { image: "..." })` points the
+# service at a pre-built container image. Railway just pulls the image
+# at start, which removes the per-start Docker build (~30-60s).
+#
+# Sequence:
+# 1. serviceInstanceUpdate(serviceId, environmentId, input: { source: { image }, ... })
+# Updates the source. By itself does NOT trigger a deploy.
+# 2. serviceInstanceDeployV2(serviceId, environmentId)
+# Explicitly triggers a deploy of the current config.
+#
+# Env vars required:
+# API, TOKEN, PROJECT_NAME, STATE_DIR
+# WORKSPACE_IMAGE - e.g. ghcr.io/bpmct/railway-coder-workspace:latest
+# IMAGE_REGISTRY_USERNAME - optional, for private registries
+# IMAGE_REGISTRY_PASSWORD - optional, for private registries
+set -euo pipefail
+. "$(dirname "$0")/lib.sh"
+
+PROJECT_ID=""
+SERVICE_ID=""
+ENV_ID=""
+load_state
+
+if [ -z "$PROJECT_ID" ] || [ -z "$SERVICE_ID" ] || [ -z "$ENV_ID" ]; then
+ PROJECT_ID=$(lookup_project_id)
+ [ -z "$PROJECT_ID" ] && {
+ echo "FATAL: project '$PROJECT_NAME' not found"
+ exit 1
+ }
+ SE=$(lookup_service_and_env "$PROJECT_ID")
+ SERVICE_ID=$(echo "$SE" | awk '{print $1}')
+ ENV_ID=$(echo "$SE" | awk '{print $2}')
+fi
+[ -z "$SERVICE_ID" ] && {
+ echo "FATAL: service not found"
+ exit 1
+}
+[ -z "$ENV_ID" ] && {
+ echo "FATAL: environment not found"
+ exit 1
+}
+
+# Persist IDs for any later resources in this apply cycle (mirrors
+# source_connect_create.sh behavior so coder_metadata etc. work).
+mkdir -p "$STATE_DIR"
+echo "$SERVICE_ID" > "$STATE_DIR/service_id"
+echo "$ENV_ID" > "$STATE_DIR/environment_id"
+
+[ -z "${WORKSPACE_IMAGE:-}" ] && {
+ echo "FATAL: WORKSPACE_IMAGE is required"
+ exit 1
+}
+
+# Build the input object inline in GraphQL. Same nested-escaping pattern
+# the other scripts use: the call goes through gql() which wraps the
+# string in a JSON envelope, so every quote here is double-escaped.
+#
+# We omit registryCredentials when not supplied so we don't send an
+# empty {username: "", password: ""} block that Railway might reject.
+if [ -n "${IMAGE_REGISTRY_USERNAME:-}" ] && [ -n "${IMAGE_REGISTRY_PASSWORD:-}" ]; then
+ INPUT="{ source: { image: \\\"$WORKSPACE_IMAGE\\\" }, registryCredentials: { username: \\\"$IMAGE_REGISTRY_USERNAME\\\", password: \\\"$IMAGE_REGISTRY_PASSWORD\\\" } }"
+else
+ INPUT="{ source: { image: \\\"$WORKSPACE_IMAGE\\\" } }"
+fi
+
+RESP=$(gql "mutation { serviceInstanceUpdate(serviceId: \\\"$SERVICE_ID\\\", environmentId: \\\"$ENV_ID\\\", input: $INPUT) }")
+# Redact creds if present in any error echo. The serviceInstanceUpdate
+# return is just `true` on success so this is safe to log normally.
+echo "serviceInstanceUpdate response: $RESP"
+if ! echo "$RESP" | grep -q '"serviceInstanceUpdate":true'; then
+ echo "FATAL: serviceInstanceUpdate did not return true"
+ exit 1
+fi
+
+# Trigger the actual deploy. serviceInstanceUpdate by itself does not
+# enqueue a build in current Railway behavior. serviceInstanceDeployV2
+# returns the new deployment id on success.
+DEPLOY_RESP=$(gql "mutation { serviceInstanceDeployV2(serviceId: \\\"$SERVICE_ID\\\", environmentId: \\\"$ENV_ID\\\") }")
+echo "serviceInstanceDeployV2 response: $DEPLOY_RESP"
+if echo "$DEPLOY_RESP" | grep -q '"errors"'; then
+ echo "FATAL: serviceInstanceDeployV2 failed"
+ exit 1
+fi
diff --git a/registry/bpmct/templates/railway/scripts/image_deploy_destroy.sh b/registry/bpmct/templates/railway/scripts/image_deploy_destroy.sh
new file mode 100755
index 000000000..0e902d502
--- /dev/null
+++ b/registry/bpmct/templates/railway/scripts/image_deploy_destroy.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+# Stop the workspace by cancelling all active deployments. Runs on
+# workspace stop (start_count = 0).
+#
+# Unlike the GraphQL variant's source_connect_destroy.sh, there is no
+# `serviceDisconnect` to call: image sources are not "connected" to an
+# upstream that could trigger auto-redeploys on their own. We do still
+# poll briefly afterwards to catch any deploys that race with the
+# env_vars destroy (which runs right after this).
+#
+# Env vars required: API, TOKEN, PROJECT_NAME
+. "$(dirname "$0")/lib.sh"
+
+PROJECT_ID=$(lookup_project_id)
+if [ -z "$PROJECT_ID" ]; then
+ echo "WARN: project not found, skipping stop"
+ exit 0
+fi
+
+SE=$(lookup_service_and_env "$PROJECT_ID")
+SERVICE_ID=$(echo "$SE" | awk '{print $1}')
+ENV_ID=$(echo "$SE" | awk '{print $2}')
+echo "service_id=$SERVICE_ID env_id=$ENV_ID"
+[ -z "$SERVICE_ID" ] && {
+ echo "WARN: service not found"
+ exit 0
+}
+
+# Scale to zero by cancelling every recent deployment. deploymentCancel
+# reliably stops deployments in any state (BUILDING/DEPLOYING/SUCCESS),
+# whereas deploymentStop is unreliable for SUCCESS deployments.
+DEPS=$(gql "{ deployments(first: 5, input: { serviceId: \\\"$SERVICE_ID\\\", environmentId: \\\"$ENV_ID\\\" }) { edges { node { id status } } } }")
+echo "$DEPS"
+for DEP_ID in $(echo "$DEPS" | sed 's/"id":"/\n/g' | grep -o '^[^"]*' | grep -E '^[0-9a-f-]+$' || true); do
+ echo "Cancelling deployment $DEP_ID"
+ gql "mutation { deploymentCancel(id: \\\"$DEP_ID\\\") }" || true
+done
+
+# Poll briefly for new deployments triggered by env_vars destroy that
+# races with this. Cancel anything that appears. Same pattern as the
+# GraphQL variant; cheap insurance.
+for _ in 1 2 3 4 5 6; do
+ sleep 3
+ DEPS=$(gql "{ deployments(first: 5, input: { serviceId: \\\"$SERVICE_ID\\\", environmentId: \\\"$ENV_ID\\\" }) { edges { node { id status } } } }")
+ for DEP_ID in $(echo "$DEPS" | sed 's/"id":"/\n/g' | grep -o '^[^"]*' | grep -E '^[0-9a-f-]+$' || true); do
+ gql "mutation { deploymentCancel(id: \\\"$DEP_ID\\\") }" > /dev/null || true
+ done
+done
+echo "Done"
diff --git a/registry/bpmct/templates/railway/scripts/lib.sh b/registry/bpmct/templates/railway/scripts/lib.sh
new file mode 100755
index 000000000..443ad0278
--- /dev/null
+++ b/registry/bpmct/templates/railway/scripts/lib.sh
@@ -0,0 +1,77 @@
+# Common helpers for the Railway GraphQL provisioner scripts.
+# Source this file with: . "$(dirname "$0")/lib.sh"
+#
+# Required env vars set by the caller (TF environment {} block):
+# API - Railway GraphQL endpoint
+# TOKEN - Railway API token (Bearer)
+# Optional:
+# PROJECT_NAME - Railway project name (used by lookup helpers)
+# STATE_DIR - Local state directory holding *_id files
+
+# Send a GraphQL query/mutation. The query string is embedded inside a
+# JSON envelope via a temp file so we never need to shell-escape it.
+# Stdout is the response body. Caller decides how to parse.
+gql() {
+ local query="$1"
+ local tmpjson
+ tmpjson=$(mktemp)
+ printf '{"query": "%s"}' "$query" > "$tmpjson"
+ curl -s --max-time 120 -X POST "$API" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H 'Content-Type: application/json' \
+ -d @"$tmpjson"
+ local rc=$?
+ rm -f "$tmpjson"
+ return $rc
+}
+
+# Lookup project id + first environment id by $PROJECT_NAME. Prints
+# " " on success, empty on miss. Always returns 0.
+lookup_project_and_env() {
+ local resp pid env_id
+ resp=$(gql '{ projects { edges { node { id name environments { edges { node { id name } } } } } } }' || echo '')
+ pid=$(echo "$resp" | grep -o '"id":"[^"]*","name":"'"$PROJECT_NAME"'"' \
+ | sed 's/.*"id":"\([^"]*\)".*/\1/' | head -1 || true)
+ if [ -z "$pid" ]; then return 0; fi
+ env_id=$(echo "$resp" | sed 's/.*"id":"'"$pid"'","name":"'"$PROJECT_NAME"'","environments":{"edges":\[{"node":{"id":"\([^"]*\)".*/\1/' | head -1 || true)
+ # Detect "no replacement" case: sed prints the input unchanged. Falls
+ # back to a coarser grep that picks the first environment id seen.
+ if [ "${#env_id}" -gt 100 ]; then
+ env_id=$(echo "$resp" | grep -o '"environments":{"edges":\[{"node":{"id":"[^"]*"' \
+ | sed 's/.*"id":"\([^"]*\)".*/\1/' | head -1 || true)
+ fi
+ printf '%s %s\n' "$pid" "$env_id"
+}
+
+# Lookup project id only. Prints the id, empty on miss. Always returns 0.
+lookup_project_id() {
+ local resp
+ resp=$(gql '{ projects { edges { node { id name } } } }' || echo '')
+ echo "$resp" | grep -o '"id":"[^"]*","name":"'"$PROJECT_NAME"'"' \
+ | sed 's/.*"id":"\([^"]*\)".*/\1/' | head -1 || true
+}
+
+# Lookup service id and env id inside a project. Args: project_id.
+# Prints " ". Service name fixed to "workspace",
+# env name fixed to "production" to match the rest of the template.
+lookup_service_and_env() {
+ local pid="$1"
+ local resp svc_id env_id
+ resp=$(gql "{ project(id: \\\"$pid\\\") { services { edges { node { id name } } } environments { edges { node { id name } } } } }" || echo '')
+ svc_id=$(echo "$resp" | grep -o '"id":"[^"]*","name":"workspace"' \
+ | sed 's/.*"id":"\([^"]*\)".*/\1/' | head -1 || true)
+ env_id=$(echo "$resp" | grep -o '"id":"[^"]*","name":"production"' \
+ | sed 's/.*"id":"\([^"]*\)".*/\1/' | head -1 || true)
+ printf '%s %s\n' "$svc_id" "$env_id"
+}
+
+# Load PROJECT_ID, SERVICE_ID, ENV_ID from $STATE_DIR if files exist.
+# Sets the globals; never errors. Intended to be called before any
+# fallback Railway API lookup.
+load_state() {
+ [ -n "${STATE_DIR:-}" ] || return 0
+ [ -f "$STATE_DIR/project_id" ] && PROJECT_ID=$(cat "$STATE_DIR/project_id")
+ [ -f "$STATE_DIR/service_id" ] && SERVICE_ID=$(cat "$STATE_DIR/service_id")
+ [ -f "$STATE_DIR/environment_id" ] && ENV_ID=$(cat "$STATE_DIR/environment_id")
+ return 0
+}
diff --git a/registry/bpmct/templates/railway/scripts/project_create.sh b/registry/bpmct/templates/railway/scripts/project_create.sh
new file mode 100755
index 000000000..386eddf00
--- /dev/null
+++ b/registry/bpmct/templates/railway/scripts/project_create.sh
@@ -0,0 +1,71 @@
+#!/usr/bin/env bash
+# Create (or look up) the Railway project for this workspace.
+# Idempotent: reuses an existing project of the same name if found.
+# Writes project_id and environment_id to $STATE_DIR.
+#
+# Env vars required:
+# API, TOKEN, PROJECT_NAME, STATE_DIR
+set -euo pipefail
+. "$(dirname "$0")/lib.sh"
+
+mkdir -p "$STATE_DIR"
+
+# If a project with this name already exists (prior attempt succeeded
+# but TF lost the response), reuse it instead of creating a duplicate.
+EXISTING=$(lookup_project_and_env)
+if [ -n "$EXISTING" ]; then
+ EXISTING_PID=$(echo "$EXISTING" | awk '{print $1}')
+ EXISTING_EID=$(echo "$EXISTING" | awk '{print $2}')
+ if [ -n "$EXISTING_PID" ]; then
+ echo "Project $PROJECT_NAME already exists: $EXISTING_PID"
+ echo "$EXISTING_PID" > "$STATE_DIR/project_id"
+ echo "$EXISTING_EID" > "$STATE_DIR/environment_id"
+ exit 0
+ fi
+fi
+
+# Retry projectCreate. Railway projectCreate can take 30s+ under load,
+# exceeding edge timeouts (504). A 504 may still have created the
+# project, so we look up by name before each retry.
+BACKOFF=5
+for ATTEMPT in 1 2 3 4 5; do
+ echo "projectCreate attempt $ATTEMPT/5"
+ HTTP_CODE=$(curl -s --max-time 90 -o /tmp/proj-resp.$$ -w '%{http_code}' -X POST "$API" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H 'Content-Type: application/json' \
+ -d "{\"query\": \"mutation(\$input: ProjectCreateInput!) { projectCreate(input: \$input) { id environments { edges { node { id name } } } } }\", \"variables\": { \"input\": { \"name\": \"$PROJECT_NAME\" } } }" || echo '000')
+ RESP=$(cat /tmp/proj-resp.$$ 2> /dev/null || echo '')
+ rm -f /tmp/proj-resp.$$
+ echo "HTTP $HTTP_CODE"
+ echo "$RESP" | head -c 500
+ echo
+
+ # Success path: HTTP 200 with projectCreate.id in response.
+ if [ "$HTTP_CODE" = "200" ] && echo "$RESP" | grep -q '"projectCreate":{"id":"'; then
+ echo "$RESP" | sed 's/.*"projectCreate":{"id":"\([^"]*\)".*/\1/' > "$STATE_DIR/project_id"
+ echo "$RESP" | sed 's/.*"node":{"id":"\([^"]*\)".*/\1/' > "$STATE_DIR/environment_id"
+ echo "projectCreate succeeded"
+ exit 0
+ fi
+
+ # On any non-success, check whether the project got created anyway
+ # (504 or client timeout but the mutation still landed).
+ AFTER=$(lookup_project_and_env)
+ if [ -n "$AFTER" ]; then
+ AFTER_PID=$(echo "$AFTER" | awk '{print $1}')
+ AFTER_EID=$(echo "$AFTER" | awk '{print $2}')
+ if [ -n "$AFTER_PID" ]; then
+ echo "Found project created by attempt $ATTEMPT despite error: $AFTER_PID"
+ echo "$AFTER_PID" > "$STATE_DIR/project_id"
+ echo "$AFTER_EID" > "$STATE_DIR/environment_id"
+ exit 0
+ fi
+ fi
+
+ echo "projectCreate attempt $ATTEMPT failed (HTTP $HTTP_CODE), retrying in ${BACKOFF}s..."
+ [ "$ATTEMPT" -lt 5 ] && sleep "$BACKOFF"
+ BACKOFF=$((BACKOFF * 2))
+done
+
+echo "FATAL: projectCreate failed after 5 attempts"
+exit 1
diff --git a/registry/bpmct/templates/railway/scripts/project_destroy.sh b/registry/bpmct/templates/railway/scripts/project_destroy.sh
new file mode 100755
index 000000000..d73a7cbd3
--- /dev/null
+++ b/registry/bpmct/templates/railway/scripts/project_destroy.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+# Delete the Railway project for this workspace. Looks up by name so
+# we do not need state files to be present at destroy time.
+#
+# Env vars required: API, TOKEN, PROJECT_NAME
+. "$(dirname "$0")/lib.sh"
+
+PROJECT_ID=$(lookup_project_id)
+if [ -z "$PROJECT_ID" ]; then
+ echo "Project $PROJECT_NAME not found, nothing to delete"
+ exit 0
+fi
+
+echo "Deleting project $PROJECT_NAME ($PROJECT_ID)"
+gql "mutation { projectDelete(id: \\\"$PROJECT_ID\\\") }" || true
diff --git a/registry/bpmct/templates/railway/scripts/project_token_create.sh b/registry/bpmct/templates/railway/scripts/project_token_create.sh
new file mode 100755
index 000000000..9d1508181
--- /dev/null
+++ b/registry/bpmct/templates/railway/scripts/project_token_create.sh
@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+# Provision a project-scoped Railway token (named "coder-managed") and
+# upsert it as RAILWAY_TOKEN on the workspace service. The token is
+# rotated on every apply because Railway does not expose existing
+# token values after creation.
+#
+# Env vars required:
+# API, TOKEN, PROJECT_NAME, STATE_DIR
+set -euo pipefail
+. "$(dirname "$0")/lib.sh"
+
+TOKEN_NAME='coder-managed'
+
+PROJECT_ID=""
+SERVICE_ID=""
+ENV_ID=""
+load_state
+
+if [ -z "$PROJECT_ID" ] || [ -z "$SERVICE_ID" ] || [ -z "$ENV_ID" ]; then
+ PE=$(lookup_project_and_env)
+ PROJECT_ID=$(echo "$PE" | awk '{print $1}')
+ ENV_ID=$(echo "$PE" | awk '{print $2}')
+ [ -z "$PROJECT_ID" ] && {
+ echo "FATAL: project $PROJECT_NAME not found" >&2
+ exit 1
+ }
+ DETAIL=$(gql "{ project(id: \\\"$PROJECT_ID\\\") { services { edges { node { id name } } } } }")
+ SERVICE_ID=$(echo "$DETAIL" | grep -o '"id":"[^"]*","name":"workspace"' \
+ | sed 's/.*"id":"\([^"]*\)".*/\1/' | head -1 || true)
+fi
+[ -z "$SERVICE_ID" ] || [ -z "$ENV_ID" ] && {
+ echo "FATAL: service or env not found" >&2
+ exit 1
+}
+
+# Project tokens cannot be retrieved by value after creation. Delete
+# any existing token with our managed name so we can mint a fresh one.
+EXISTING=$(gql "{ projectTokens(projectId: \\\"$PROJECT_ID\\\") { edges { node { id name } } } }")
+OLD_ID=$(echo "$EXISTING" | grep -o '"id":"[^"]*","name":"'"$TOKEN_NAME"'"' \
+ | sed 's/.*"id":"\([^"]*\)".*/\1/' | head -1 || true)
+if [ -n "$OLD_ID" ]; then
+ echo "Removing existing project token $OLD_ID"
+ gql "mutation { projectTokenDelete(id: \\\"$OLD_ID\\\") }" > /dev/null || true
+fi
+
+# Create the project-scoped token.
+RESP=$(gql "mutation { projectTokenCreate(input: { projectId: \\\"$PROJECT_ID\\\", environmentId: \\\"$ENV_ID\\\", name: \\\"$TOKEN_NAME\\\" }) }")
+if echo "$RESP" | grep -q '"errors"'; then
+ echo "FATAL: projectTokenCreate failed: $RESP" >&2
+ exit 1
+fi
+NEW_TOKEN=$(echo "$RESP" | sed 's/.*"projectTokenCreate":"\([^"]*\)".*/\1/')
+if [ -z "$NEW_TOKEN" ] || [ "$NEW_TOKEN" = "$RESP" ]; then
+ echo "FATAL: could not parse projectTokenCreate response: $RESP" >&2
+ exit 1
+fi
+
+# Upsert RAILWAY_TOKEN on the workspace service with retry.
+# skipDeploys: true avoids triggering a Railway redeploy on the
+# variable change. The subsequent serviceConnect (or already-in-flight
+# deploy) picks up the new value.
+UPSERT_OK=""
+for ATTEMPT in 1 2 3 4 5; do
+ UPSERT=$(curl -s --max-time 60 -X POST "$API" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H 'Content-Type: application/json' \
+ -d "{\"query\": \"mutation { variableUpsert(input: { projectId: \\\"$PROJECT_ID\\\", serviceId: \\\"$SERVICE_ID\\\", environmentId: \\\"$ENV_ID\\\", name: \\\"RAILWAY_TOKEN\\\", value: \\\"$NEW_TOKEN\\\", skipDeploys: true }) }\"}" || true)
+ if echo "$UPSERT" | grep -q '"variableUpsert":true'; then
+ UPSERT_OK=1
+ break
+ fi
+ echo "variableUpsert RAILWAY_TOKEN attempt $ATTEMPT failed, retrying..." >&2
+ [ "$ATTEMPT" -lt 5 ] && sleep 5
+done
+if [ -z "$UPSERT_OK" ]; then
+ echo "FATAL: variableUpsert RAILWAY_TOKEN failed after 5 attempts" >&2
+ exit 1
+fi
+echo "Provisioned project-scoped Railway token and set RAILWAY_TOKEN env var."
diff --git a/registry/bpmct/templates/railway/scripts/project_token_destroy.sh b/registry/bpmct/templates/railway/scripts/project_token_destroy.sh
new file mode 100755
index 000000000..92da3686d
--- /dev/null
+++ b/registry/bpmct/templates/railway/scripts/project_token_destroy.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+# Delete the coder-managed project token and the RAILWAY_TOKEN env var.
+# Safe to run when the project is already gone (no-op in that case).
+#
+# Env vars required: API, TOKEN, PROJECT_NAME
+. "$(dirname "$0")/lib.sh"
+
+TOKEN_NAME='coder-managed'
+
+PE=$(lookup_project_and_env)
+PROJ=$(echo "$PE" | awk '{print $1}')
+ENV=$(echo "$PE" | awk '{print $2}')
+if [ -z "$PROJ" ]; then
+ echo "Project $PROJECT_NAME already gone, nothing to clean up."
+ exit 0
+fi
+
+DETAIL=$(gql "{ project(id: \\\"$PROJ\\\") { services { edges { node { id name } } } } }" || echo '')
+SVC=$(echo "$DETAIL" | grep -o '"id":"[^"]*","name":"workspace"' \
+ | sed 's/.*"id":"\([^"]*\)".*/\1/' | head -1 || true)
+
+# Delete the project token if it exists.
+EXISTING=$(gql "{ projectTokens(projectId: \\\"$PROJ\\\") { edges { node { id name } } } }" || echo '')
+TOKEN_ID=$(echo "$EXISTING" | grep -o '"id":"[^"]*","name":"'"$TOKEN_NAME"'"' \
+ | sed 's/.*"id":"\([^"]*\)".*/\1/' | head -1 || true)
+if [ -n "$TOKEN_ID" ]; then
+ gql "mutation { projectTokenDelete(id: \\\"$TOKEN_ID\\\") }" > /dev/null || true
+fi
+
+# Delete the RAILWAY_TOKEN env var.
+if [ -n "$SVC" ] && [ -n "$ENV" ]; then
+ gql "mutation { variableDelete(input: { projectId: \\\"$PROJ\\\", serviceId: \\\"$SVC\\\", environmentId: \\\"$ENV\\\", name: \\\"RAILWAY_TOKEN\\\" }) }" > /dev/null || true
+fi
diff --git a/registry/bpmct/templates/railway/scripts/service_create.sh b/registry/bpmct/templates/railway/scripts/service_create.sh
new file mode 100755
index 000000000..59339c4df
--- /dev/null
+++ b/registry/bpmct/templates/railway/scripts/service_create.sh
@@ -0,0 +1,47 @@
+#!/usr/bin/env bash
+# Create (or look up) the "workspace" service inside the Railway
+# project. Idempotent. Writes service_id to $STATE_DIR.
+#
+# Env vars required: API, TOKEN, PROJECT_NAME, STATE_DIR
+set -euo pipefail
+. "$(dirname "$0")/lib.sh"
+
+# Read project_id from state (same apply) or fall back to API lookup.
+PROJECT_ID=""
+[ -f "$STATE_DIR/project_id" ] && PROJECT_ID=$(cat "$STATE_DIR/project_id")
+if [ -z "$PROJECT_ID" ]; then
+ PROJECT_ID=$(lookup_project_id)
+fi
+[ -z "$PROJECT_ID" ] && {
+ echo "FATAL: project not found"
+ exit 1
+}
+
+# If a "workspace" service already exists, reuse it.
+EXISTING=$(gql "{ project(id: \\\"$PROJECT_ID\\\") { services { edges { node { id name } } } } }")
+EXISTING_SVC=$(echo "$EXISTING" | grep -o '"id":"[^"]*","name":"workspace"' \
+ | sed 's/.*"id":"\([^"]*\)".*/\1/' | head -1 || true)
+if [ -n "$EXISTING_SVC" ]; then
+ echo "Service already exists: $EXISTING_SVC"
+ mkdir -p "$STATE_DIR"
+ echo "$EXISTING_SVC" > "$STATE_DIR/service_id"
+ exit 0
+fi
+
+# Retry serviceCreate. Railway can return "Not Authorized" briefly
+# after projectCreate due to auth propagation delay.
+RESP=""
+for ATTEMPT in 1 2 3 4 5; do
+ RESP=$(gql "mutation { serviceCreate(input: { name: \\\"workspace\\\", projectId: \\\"$PROJECT_ID\\\" }) { id } }")
+ echo "$RESP"
+ if echo "$RESP" | grep -q '"serviceCreate"'; then break; fi
+ echo "serviceCreate attempt $ATTEMPT failed, retrying in 3s..."
+ [ "$ATTEMPT" -lt 5 ] && sleep 3
+done
+if ! echo "$RESP" | grep -q '"serviceCreate"'; then
+ echo "FATAL: serviceCreate failed"
+ exit 1
+fi
+
+mkdir -p "$STATE_DIR"
+echo "$RESP" | sed 's/.*"serviceCreate":{"id":"\([^"]*\)".*/\1/' > "$STATE_DIR/service_id"
diff --git a/registry/bpmct/templates/railway/scripts/service_destroy.sh b/registry/bpmct/templates/railway/scripts/service_destroy.sh
new file mode 100755
index 000000000..678414280
--- /dev/null
+++ b/registry/bpmct/templates/railway/scripts/service_destroy.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+# Delete the workspace service (nice-to-have; cascade from
+# projectDelete already covers this).
+#
+# Env vars required: API, TOKEN
+# Reads service id from local .railway-state/service_id if present.
+. "$(dirname "$0")/lib.sh"
+
+STATE_DIR=".railway-state"
+[ ! -f "$STATE_DIR/service_id" ] && exit 0
+SERVICE_ID=$(cat "$STATE_DIR/service_id")
+
+gql "mutation { serviceDelete(id: \\\"$SERVICE_ID\\\") }" || true
diff --git a/registry/bpmct/templates/railway/scripts/volume_create.sh b/registry/bpmct/templates/railway/scripts/volume_create.sh
new file mode 100755
index 000000000..901018107
--- /dev/null
+++ b/registry/bpmct/templates/railway/scripts/volume_create.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+# Create the persistent volume for the workspace.
+#
+# CRITICAL: This must run before any deployment activity on the
+# service. Railway rejects volumeCreate on services that have had
+# deployments. The pure-GraphQL ordering guarantees this.
+#
+# Env vars required: API, TOKEN, PROJECT_NAME, STATE_DIR
+set -euo pipefail
+. "$(dirname "$0")/lib.sh"
+
+PROJECT_ID=""
+SERVICE_ID=""
+ENV_ID=""
+load_state
+
+if [ -z "$PROJECT_ID" ] || [ -z "$SERVICE_ID" ] || [ -z "$ENV_ID" ]; then
+ PROJECT_ID=$(lookup_project_id)
+ [ -z "$PROJECT_ID" ] && {
+ echo "FATAL: project not found"
+ exit 1
+ }
+ SE=$(lookup_service_and_env "$PROJECT_ID")
+ SERVICE_ID=$(echo "$SE" | awk '{print $1}')
+ ENV_ID=$(echo "$SE" | awk '{print $2}')
+fi
+[ -z "$SERVICE_ID" ] || [ -z "$ENV_ID" ] && {
+ echo "FATAL: service/env not found"
+ exit 1
+}
+
+# Idempotent: if a workspace-volume already exists in this project,
+# reuse it instead of creating a duplicate.
+VOLUMES=$(gql "{ project(id: \\\"$PROJECT_ID\\\") { volumes { edges { node { id name } } } } }")
+EXISTING_VOL=$(echo "$VOLUMES" | grep -o '"id":"[^"]*","name":"workspace-volume"' \
+ | sed 's/.*"id":"\([^"]*\)".*/\1/' | head -1 || true)
+if [ -n "$EXISTING_VOL" ]; then
+ echo "Volume already exists: $EXISTING_VOL"
+ exit 0
+fi
+
+RESP=$(gql "mutation { volumeCreate(input: { projectId: \\\"$PROJECT_ID\\\", serviceId: \\\"$SERVICE_ID\\\", environmentId: \\\"$ENV_ID\\\", mountPath: \\\"/home/coder\\\" }) { id } }")
+echo "$RESP"
+if echo "$RESP" | grep -q '"errors"'; then
+ echo "FATAL: volumeCreate failed"
+ exit 1
+fi
+
+echo "$RESP" | sed 's/.*"volumeCreate":{"id":"\([^"]*\)".*/\1/' > "$STATE_DIR/volume_id"