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 project | Guide | Status |
|---|---|---|
| Next.js (App Router) | references/nextjs.md | Supported 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 language |
references/containers.md | Method 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:
| What | Where to look |
|---|---|
| Auth | NextAuth/Auth.js, Clerk, Supabase auth, Passport, a session cookie, a /login route |
| Data | An 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 work | vercel.json crons, a queue and its workers, a setInterval in a server, a cron container |
| Outbound calls | Every host the server code fetches |
| Inbound calls | Webhooks, public API routes, OAuth callbacks, anything not called by the app's own frontend |
| Files | Uploads, generated documents, an S3 client, a mounted volume |
| Processes and binaries | Every 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 has | On Railcode |
|---|---|
| Its own login and sessions | Delete the authentication machinery. Read ctx.user (id, email, name, is_admin, roles). appUsers lists the org's members |
| A user table | Keep 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 database | db 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 writes | Keep 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.SECRET |
railcode secrets set NAME, read as secrets.NAME |
| Cron jobs | crons: in manifest.yaml, each calling one of the app's own routes with POST |
| Outbound fetches | List each host under egress: |
| File uploads, a mounted volume | files |
| An LLM provider key and client | llm, with llm: true in the manifest |
| Transactional email | email, with email: true in the manifest |
| A SaaS API it calls with its own OAuth flow or API key | A 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 index | No 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
- Add the Railcode files (
railcode.json,manifest.yaml) and get an unchanged page rendering underrailcode dev. - Replace auth.
- Replace the data layer, one entity at a time.
- Move secrets, crons and egress into the manifest and
railcode secrets. - Rebuild the pieces from Find another way.
- 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 this | Build it like this | What 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 idempotent |
Up 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 repeat |
Jobs 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 budget |
A progress bar instead of a spinner |
| Pushes live updates over websockets or SSE | The 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 response |
Updates arrive within the poll interval |
| Streams text to the browser | Return a streamed Response; toNdjson() in the SDK does the framing |
Nothing |
| Joins and filters in SQL on its own database | Design 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 connector |
Nothing, 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 host | Nothing, when an HTTPS driver exists |
| Caches in Redis or in process memory | A db collection with an expires_at field checked on read. In-memory caches do not survive between requests |
Nothing |
| Rate-limits or locks with Redis | A db row per key holding a counter and a window start. It is not atomic, so use it for fairness, not for money |
Nothing |
| Full-text search over its own records | For 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 code |
Possibly a slower or asynchronous result |
| Runs its own OAuth flow against Google, Slack, GitHub and so on | A connector. An admin links an org account, or each user links their own; the worker calls connector(name) and never sees a token |
Users link the account once in Railcode instead of in the app |
| Receives email, or sends from a real person's or team's address | A 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 inbox | Up to a minute of delay on inbound mail |
Needs embeddings, vision or another model feature the llm gateway lacks |
Call that provider directly: the key in secrets, the host in egress: |
Nothing |
| Sends push or chat notifications | email, or a Slack connector posting to a channel |
The 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 connector | Two deployments instead of one |
| Renders HTML on the server with templates (Jinja, ERB, EJS) | A static frontend that fetches JSON from /api/* routes |
Nothing |
| Has several services in docker-compose | One worker with several route groups. If two parts have different audiences, make them two apps | Nothing |
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.userisnull), 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 needs | Why 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 poll | Nothing 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 servers | Same 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 engines | Every 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 parties | Identity is the organization's membership |
| True real time: collaborative editing, presence, multiplayer, a websocket server | There 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 minute |
The worker runs per request and then stops |
Non-HTTP protocols: a database driver over TCP (pg, mysql2, ioredis), an SMTP server, gRPC |
No 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 model | The 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 notifications | Apps 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 sender |
email 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:
-
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.
-
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.
-
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 increate-railcode-appunder "Report Railcode feedback". If it fails, tell the user the report was not sent and carry on. -
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
psrather than assuming. - Never point a
package.jsonbuildscript at something that calls the framework's adapter if that adapter runsnpm run builditself. 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.