mcpField
M
migrate-to-railcode

migrate-to-railcode

github.com/railcode-hqCloud & DevOps179 installsUpdated 2d ago
Repository

About migrate-to-railcode

migrate-to-railcode is a skill that guides porting working applications to Railcode while preserving user-facing behavior and accounting for platform differences. It helps developers inventory dependencies, map services, migrate in stages, and verify the deployed app before switching users.

Key features

  1. 01Inventories authentication, data, and hosting dependencies
  2. 02Maps project components to Railcode services
  3. 03Guides staged application porting
  4. 04Plans alternatives for unsupported components
  5. 05Deploys alongside the original application
  6. 06Verifies migrated behavior and reports remaining tasks

Use cases

  1. 01Assessing whether an application fits Railcode
  2. 02Porting a Next.js app or container-based service
  3. 03Planning replacements for queues or webhooks
  4. 04Checking data ownership mapping before import

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/railcode-hq/railcode-skills --skill migrate-to-railcode

After installing, type/migrate-to-railcodein Claude Code to confirm it loaded.

Bundled files

References and scripts installed along with SKILL.md.

agents/
openai.yaml
references/
containers.md
databases.md
nextjs-cache-adapter.ts
nextjs-under-api.md
nextjs.md

Details

From the project README

Migrate to Railcode

A migration is a port, not a rewrite: the project already works somewhere, and the job is to keep what it does for its users while replacing what the old host provided (auth, a database, env vars, cron, file storage) with what Railcode provides.

Most internal tools fit. Usually one or two pieces have no direct equivalent, and almost all of those can be rebuilt a different way that the user will not notice — see Find another way. A short list of things cannot be hosted at all today — see What Railcode cannot host today. Know both lists before you promise anything.

Guides by source project:

Source projectGuideStatus
Next.js (App Router)references/nextjs.mdSupported as "type": "next", built by @railcode/next. Proven on a real app, with gaps listed in the guide
A container: a Dockerfile or docker-compose stack running a server in any languagereferences/containers.mdMethod and mappings built from the platform's documented behaviour; not yet backed by a recorded migration

For any other source, follow the method below and build the app with create-railcode-app.

Railcode's own db is meant for fast, simple use. It has limits for a lot of data or complex queries (no joins, aggregates or transactions; paged reads), so a project that needs those keeps its database. references/databases.md covers those limits, connecting a database as a data source, using it directly with a secret, databases Railcode has no connector for, where schema migrations run, and how to register interest in a managed Postgres through railcode agent-feedback.

Update First

Before running a railcode command, update the skills and the CLI and confirm what is published:

npx skills add Railcode-HQ/railcode-skills
npm install -g railcode@latest
railcode --version
npm view railcode version

If npm is unreachable, say so and do not claim this guidance is current. This version was written against CLI 0.3.8, @railcode/sdk 0.4.0 and @railcode/next 0.1.0.

This skill does not restate the SDK, the manifest or the deploy flow. Those live in create-railcode-app (references/worker-sdk.md, references/cli-workflow.md, references/deployment.md, references/app-patterns.md); read them when a step below names an SDK surface.

What Railcode is, for someone arriving from another host

Four facts shape every migration. Check the project against them first.

  • Every viewer is a signed-in member of the organization. There are no anonymous visitors and no self-signup. Railcode hosts internal tools.
  • The backend is one worker module on a Workers runtime (nodejs_compat), not a Node server and not a container. It runs once per request and keeps nothing in memory between requests. No filesystem, no child processes, no native addons, no raw TCP.
  • The worker runs only when a signed-in member's browser calls it, or on the app's own cron. Nothing on the public internet can call it.
  • Authentication is ambient. The platform signs the user in before any app code runs and hands the worker a verified ctx.user. The project's own login goes away, and that is usually the largest deletion in the migration.

In exchange the app gets, with no setup: sign-in, a store (db), file storage (files), an LLM gateway (llm), email sending, per-app secrets, cron, connectors to the org's databases and SaaS accounts, managed agents, deploy history and logs.

Method

Work in this order. Do the inventory before touching code: most failed migrations port the UI first and discover on day two that the data layer has no equivalent.

1. Inventory

Read the project and write down, with file paths:

WhatWhere to look
AuthNextAuth/Auth.js, Clerk, Supabase auth, Passport, a session cookie, a /login route
DataAn ORM (Prisma, Drizzle, SQLAlchemy, ActiveRecord), a database URL, a hosted KV or blob store
Secrets and config.env*, process.env.*, ENV lines in a Dockerfile, the old host's dashboard
Scheduled and background workvercel.json crons, a queue and its workers, a setInterval in a server, a cron container
Outbound callsEvery host the server code fetches
Inbound callsWebhooks, public API routes, OAuth callbacks, anything not called by the app's own frontend
FilesUploads, generated documents, an S3 client, a mounted volume
Processes and binariesEvery service in docker-compose, every apt-get install, every binary the code shells out to
Host-specific packages@vercel/*, platform SDKs, edge-only APIs
2. Map each item
The project hasOn Railcode
Its own login and sessionsDelete the authentication machinery. Read ctx.user (id, email, name, is_admin, roles). appUsers lists the org's members
A user tableKeep what is application data (profiles, preferences, app-level roles): move it to db. Railcode user ids are not the old ids — see step 4 before importing anything keyed by user
Per-user rows in its own databasedb collections — one flat store per app, so put the user id in the key (<userId>:<id>) and enforce access in server code. Suited to small, simple data; for large tables or complex queries keep the database
A database it reads (Postgres, BigQuery, Turso)Connect it as a data source: a data connector an org admin sets up. Read-only. Prefer saved queries (query(name, params)); direct SQL is data(name).runSQL(...) with adhoc_sql: in the manifest
A database it reads and writesKeep it and use it directly: the connection credential in railcode secrets, the host under egress:, and a client that speaks HTTPS (no raw TCP). Or move the app's own tables into db. See references/databases.md
A database Railcode has no connector for (MySQL, MongoDB, DynamoDB, Redis and so on)The same: set its credentials as secrets, allow its host, and use it as before over its HTTPS API
Schema migrations (Prisma, Drizzle, Alembic, Rails)Run them in the build or deploy script, on the machine that runs it, never in the worker. See references/databases.md
process.env.SECRETrailcode secrets set NAME, read as secrets.NAME
Cron jobscrons: in manifest.yaml, each calling one of the app's own routes with POST
Outbound fetchesList each host under egress:
File uploads, a mounted volumefiles
An LLM provider key and clientllm, with llm: true in the manifest
Transactional emailemail, with email: true in the manifest
A SaaS API it calls with its own OAuth flow or API keyA connector an admin or the user links, called with connector(name); or a secret plus an egress: host
A queue, websockets, a webhook receiver, a native binary, a search indexNo direct equivalent. Go to Find another way

For every item with no direct equivalent, tell the user before porting: what the project does today, how you propose to rebuild it, and what changes for them (for example "new payments show up within a minute instead of instantly"). Get a yes on each one. A different implementation the user agreed to is a good migration; an approximation they discover later is a bug.

3. Port
  1. Add the Railcode files (railcode.json, manifest.yaml) and get an unchanged page rendering under railcode dev.
  2. Replace auth.
  3. Replace the data layer, one entity at a time.
  4. Move secrets, crons and egress into the manifest and railcode secrets.
  5. Rebuild the pieces from Find another way.
  6. Remove the old host's packages and config.

Keep each step runnable. A migration that only works when all six are done is one you cannot debug.

railcode dev keeps db and files in local scratch storage, but SQL, connectors, email, llm and agents are forwarded to the real instance. Testing a route that sends email sends email.

4. Deploy beside the original

Deploy to a new app slug and leave the original running. Nothing about a first deploy is destructive, and the user can compare the two. Only move data and switch people over once the new app has been checked.

Existing data does not move by itself. If it matters, write a one-off import (a script that reads the old store and writes through a temporary admin-only route, or railcode app kv from the CLI), run it once, and delete it.

Records owned by a user need their owner translated. The old app's user ids mean nothing on Railcode, so importing rows under their old ids leaves them unreachable. Build the mapping first — old id → email → the Railcode member with that email (appUsers) — show the user who did not match, and only then import, writing each record under the Railcode id.

5. Verify, then report

Load the deployed app as a real user and exercise each migrated feature; a green deploy says only that the bundle uploaded. railcode logs app --app <slug> lists every invocation, and railcode logs app <invocation-id> shows its console output and errors.

Report in this order: what works and how it was checked, what was rebuilt differently and what changed for the user, what was left out and why, what the user must still do (secrets to set, connectors to link, data to import).

Find another way

When a piece of the project has no direct equivalent, do not stop and do not drop it. Ask what the piece is for, not what it is built from. A queue exists so that slow work finishes without the user waiting. A webhook exists so the app learns about a payment. A websocket exists so a list looks current. Each of those outcomes can usually be reached with a cron, the store, and a frontend that polls.

The building blocks you have:

  • A cron that fires as often as once a minute and can use db, files, llm, email, secrets, egress, SQL and org connectors.
  • A store you can use as a queue, a cache, a lock or a cursor.
  • A frontend that can call the worker as often as it likes. A browser tab has no subrequest budget and stays open, so it can drive long work one step at a time.
  • Managed agents for work that is long, needs code execution, or reads files with AI.
  • Connectors to the org's existing databases and SaaS accounts.
  • Outbound HTTP to any host you declare, so a hosted API can do what a local binary did.

Shapes that work:

The project does thisBuild it like thisWhat changes for the user
Receives a webhook to learn that something happened (a payment, a new issue, a form entry)A cron polls the provider's list or events API. Keep the last-seen id or timestamp in db as a cursor, and make processing idempotentUp to a minute of delay
Runs a job queue (BullMQ, Celery, Sidekiq)A jobs collection. Routes insert rows with status: "queued"; a cron route takes a small batch, processes it, marks each row done. Cron runs are at-least-once and may overlap, so stamp claimed rows with ctx.invocationId and keep side effects safe to repeatJobs start within a minute, not instantly
Does one long task in a request (a big import, a report over thousands of rows)Split it into steps with a cursor in db. The frontend calls a step route in a loop and shows progress, or a cron advances it. Keep each step well under the ~100 subrequest budgetA progress bar instead of a spinner
Pushes live updates over websockets or SSEThe frontend polls a cheap route every few seconds, only while the tab is visible. Return an updated_at so an unchanged poll is a tiny responseUpdates arrive within the poll interval
Streams text to the browserReturn a streamed Response; toNdjson() in the SDK does the framingNothing
Joins and filters in SQL on its own databaseDesign keys for the reads the screens make (<userId>:<id>, prefix()), store a little duplicated data, filter with query().where(), and paginate until a short page. If the data is shared with other systems, leave it in that database and read it through a data connectorNothing, if the keys match the screens
Writes to a relational database that must stay relational (transactions, constraints, other systems read it)Keep the database and use it directly from the worker with a secret and an HTTPS driver (references/databases.md). Data connectors are read-only, so they cover the reads only. If the database has no HTTPS interface, put one in front of it or keep a small write service on the old hostNothing, when an HTTPS driver exists
Caches in Redis or in process memoryA db collection with an expires_at field checked on read. In-memory caches do not survive between requestsNothing
Rate-limits or locks with RedisA db row per key holding a counter and a window start. It is not atomic, so use it for fairness, not for moneyNothing
Full-text search over its own recordsFor a few thousand records, load and filter in the worker or the browser. Beyond that, query the source database through a connector, or call a hosted search API through egress:Nothing
Shells out to a binary (ffmpeg, ImageMagick, Chromium, pandoc)First look for a pure-JavaScript library that fits in the worker bundle; PDF and spreadsheet generation usually do. Otherwise call a hosted API through egress:, or hand the job to a managed agent, which can run codePossibly a slower or asynchronous result
Runs its own OAuth flow against Google, Slack, GitHub and so onA connector. An admin links an org account, or each user links their own; the worker calls connector(name) and never sees a tokenUsers link the account once in Railcode instead of in the app
Receives email, or sends from a real person's or team's addressA Gmail connector for that account: polled on a cron with a stored cursor when org-owned, or read by a personal managed agent on a schedule for one person's own inboxUp to a minute of delay on inbound mail
Needs embeddings, vision or another model feature the llm gateway lacksCall that provider directly: the key in secrets, the host in egress:Nothing
Sends push or chat notificationsemail, or a Slack connector posting to a channelThe notification arrives in email or Slack
Has one public page among many internal ones (a public form, a status page)Split it. Leave the public page on the old host writing to its database; the Railcode app reads that database through a data connectorTwo deployments instead of one
Renders HTML on the server with templates (Jinja, ERB, EJS)A static frontend that fetches JSON from /api/* routesNothing
Has several services in docker-composeOne worker with several route groups. If two parts have different audiences, make them two appsNothing

Rules for doing this well:

  • Say what changes. Every row trades something, usually latency. State it in one sentence and get agreement before building.
  • Prefer the simplest block. A frontend poll beats a cron; a cron beats an agent.
  • Check the cron limits before designing around one: one-minute minimum, five schedules per app, no caller (ctx.user is null), and it cannot start or read managed agent runs. One cron route can dispatch several kinds of work when five schedules is not enough.
  • Do not fake a guarantee. If the original relied on a transaction, exactly-once delivery or an instant response to a third party, a polling version does not provide it. That belongs in the next section.

If the user needs something that is not in this table, design it from the building blocks, tell them it is a new pattern, and report it (see Feedback) so it can be added.

What Railcode cannot host today

Some things have no workaround, because they need the internet to reach the app, a process that stays alive, or a runtime the platform does not offer. Recognise these during the inventory, not after the port.

The project needsWhy it does not fit
Inbound webhooks that must be answered — Slack slash commands and interactive buttons, Twilio voice or SMS replies, a provider that retries until it gets an acknowledgement and offers no API to pollNothing outside the organization can call the worker. A webhook that only notifies can be replaced by polling (above); one that expects the app's response cannot
A public API or public endpoints for customers, partners, mobile apps or other serversSame reason. Requests come from a signed-in member's browser or from Railcode's own agents, never from an outside system
Public or customer-facing pages: a marketing site, a storefront, self-signup, anything meant for search enginesEvery viewer is a signed-in org member
Its own identity system: logging in people who are not org members, issuing its own API keys or tokens to third partiesIdentity is the organization's membership
True real time: collaborative editing, presence, multiplayer, a websocket serverThere is no push surface. Polling covers "looks current", not "keystroke by keystroke"
A long-lived process: a daemon, a bot holding a socket open, a stream consumer (Kafka, Postgres LISTEN), work that must fire more often than once a minuteThe worker runs per request and then stops
Non-HTTP protocols: a database driver over TCP (pg, mysql2, ioredis), an SMTP server, gRPCNo raw TCP in or out. Databases are reached through a data connector (reads) or an HTTPS driver with a secret (reads and writes); see references/databases.md. A database with only a socket interface and nothing in front of it cannot be reached
The container itself: a Python, Go, Ruby, Java or PHP server run as it is, system packages, native addons, a GPU, a local modelThe backend is one JavaScript module. Other languages are ported, not hosted — see the container guide
A large or multi-file backend bundle: .wasm modules, a worker over the size cap (10 MB; 5 MB on a server without worker_routes)The worker is a single ESM module
A custom domain, a native mobile app, browser push notificationsApps are web apps at <app>.<parent>
Its own mail domain: an inbound mail server, an address the app owns, or email.send() from a custom senderemail is send-only from a platform sender. Mail through a Gmail account someone owns is supported; see Find another way

When the project needs one of these:

  1. Tell the user at once, plainly. Name the feature, and say in a sentence that it cannot run on Railcode today and why. Do not present a polling imitation of something that needs a response as if it were equivalent.

  2. Offer what is still possible. Usually the rest of the project migrates and the one piece stays where it is, sharing a database that Railcode reads through a connector. If the missing piece is the core of the project (a public storefront, a Slack bot), say the project should stay on its current host for now.

  3. Submit the request with the CLI. The Railcode team decides what to build next from these reports. Send one for each distinct missing capability, even when part of the project migrated:

    railcode agent-feedback --category suggestion \
      --message "Migration blocked on inbound webhooks. The project receives POST /webhooks/slack for slash commands and must reply within 3 seconds; there is no API to poll instead. Request: a way for an app to expose a public, signature-verified endpoint." \
      --context "Source: Express + Postgres in a container, about 40 routes. Everything else mapped to db, secrets and crons. The Slack routes stayed on the old host; the rest was migrated." \
      --agent "<your assistant name>" --model "<your model>"
    

    Write it so someone who has never seen the project understands the need: what the project is (stack and rough size), exactly what it needed, what you did instead, and whether the migration went ahead. Leave out credentials, customer data, source code and anything that identifies the user's customers. The command needs an existing login and prints Feedback accepted.; all options are in create-railcode-app under "Report Railcode feedback". If it fails, tell the user the report was not sent and carry on.

  4. Tell the user you sent it, and what it said.

Do not file a request for something the platform already does a different way; check Find another way and create-railcode-app first.

Machine safety

Framework builds are heavy, and a migration runs many of them.

  • Run one build or dev server at a time, and stop every process you start before finishing a turn. Check with ps rather than assuming.
  • Never point a package.json build script at something that calls the framework's adapter if that adapter runs npm run build itself. That is an unbounded chain of builds. The Next.js guide has the specific rule.
  • Do not start the project's docker-compose stack to see how it works unless the user asks. Read the code.

Feedback

After every migration, report concrete friction with railcode agent-feedback (usage above and in create-railcode-app), one report per finding: what the source project used, what had no equivalent, which rebuilt pattern you used, and what broke that the guide did not predict. A missing capability is --category suggestion; a wrong or unclear step in this skill is --category friction. That is how the next guide gets written.

Comments

Markdown supported