Do Railway
The concepts map one to one, most API documents run unchanged and the CLI commands have the same names. What to change, in order.
Concept mapping#
| Railway | Skiffly | Notes |
|---|---|---|
| Workspace / team | Workspace | roles owner, admin, member; no access groups |
| Project → Environment → Service | same | PR environments are pr-<n>, enabled per project |
| Service source: repo, image | same | private registries not supported yet |
| Railpack / Nixpacks / Dockerfile | Railpack / Dockerfile | Nixpacks projects build with Railpack or a Dockerfile |
${{Postgres.DATABASE_URL}}, ${{shared.VAR}} | same syntax | |
RAILWAY_PUBLIC_DOMAIN, RAILWAY_PRIVATE_DOMAIN, … | SKIFFLY_PUBLIC_DOMAIN, SKIFFLY_PRIVATE_DOMAIN, … | see built-ins |
Private networking service.railway.internal | <service-slug> (http://api:8080) | IPv4, same environment only |
Generated domain *.up.railway.app | *.skiffly.cloud | |
| Custom domain: CNAME + verification | CNAME to edge.skiffly.cloud + TXT _skiffly.<host> | |
| TCP proxy | TCP proxy (edge.skiffly.cloud:2xxxx) | skiffly proxy, skiffly connect |
| Volumes | Volumes | one replica per volume service; daily backups |
| Cron schedule | Cron schedule | |
| App Sleeping | App Sleeping | ~10 min idle |
| Health check path | Health check path | up to 5 min startup |
| Replicas, regions | Replicas | EU only today |
| Staged changes → Deploy | none | changes apply immediately; redeploy when needed |
| Usage billing, invoice | Usage billing, prepaid balance | PayPal or crypto; 3-day grace then pause |
railway CLI | skiffly CLI | same command names where the feature exists |
| Railway MCP server | Skiffly MCP server | same tool names where the feature exists |
railway/iac (defineRailway) | @skiffly/config (defineSkiffly) | search-and-replace |
| Templates | Templates | 136 in the catalog, templateDeployV2 |
| Observability: logs, metrics | Logs (5 000 lines buffer), metrics (7 days) | no log drains yet |
| Webhooks, functions, buckets, sandboxes | not available |
Import with the CLI: skiffly import --from-railway#
The CLI (or the import_from_railway MCP tool) reads your Railway project through the Railway Public API and recreates
it on Skiffly in one go, so steps 2, 4, 5 and most of 6 below happen automatically. You need a Railway
account token (a workspace token works too); it is only used to read and is not stored.
skiffly login
skiffly import --from-railway --dry-run # pick the Railway project, preview the plan
skiffly import --from-railway # apply: creates a new Skiffly project (or --project <id|name>)Flags: --railway-token <t> (or RAILWAY_TOKEN), --railway-project <id> (the id from the Railway dashboard URL or
railway status), --project <id|name|new> and --workspace <w> for the Skiffly target, --environment <name> to
import one environment only, --dry-run, --yes, --json.
What is imported, per environment (PR environments are skipped):
| Railway | Skiffly |
|---|---|
| Environments | Environments with the same name (production maps onto the project's production) |
| Services from a GitHub repository — branch, root directory, Dockerfile path, build command | serviceCreate with the same source and builder (RAILPACK, or DOCKERFILE when a Dockerfile path is set); Nixpacks/Heroku/Paketo builds fall back to Railpack |
| Services from a Docker image | Image service; the port is guessed from PORT or the image (Postgres 5432, Redis 6379, …) |
| Start command, health check, cron schedule, replicas, app sleeping | serviceInstanceUpdate per environment |
| Variables (per service and shared), unrendered | Copied with ${{Service.VAR}} and ${{shared.VAR}} references kept; RAILWAY_PUBLIC_DOMAIN, RAILWAY_PRIVATE_DOMAIN, RAILWAY_ENVIRONMENT, … inside references become SKIFFLY_*; the RAILWAY_* variables themselves are not copied |
| Volumes | Created empty at the same mount path (size rounded up to whole GB) |
| Custom domains | Attached to the service; the CNAME and _skiffly TXT records are printed |
| TCP proxies | Recreated on edge.skiffly.cloud with a new port |
Not imported: the contents of volumes and databases, Railway-generated *.up.railway.app domains, pre-deploy
commands, deployment history, private registry credentials, railway.json/railway.toml (the values on the service
instance are used instead). Services that already exist by name in the target project are left untouched.
Nothing is deployed — services are created with deployments off so you can review variables first.
The command ends with a table of what was created and a Do this by hand list, typically:
- copy database/volume data (
pg_dumpon Railway →skiffly connect <service>/skiffly sshto restore, see step 3); - create the DNS records for each custom domain (
skiffly domain status <domain>shows them), then remove the domain from Railway; - update clients of TCP proxies to the new host:port (
skiffly proxy list) and variables that referencedRAILWAY_TCP_PROXY_*; - set any variable that referenced a Railway-only built-in; move pre-deploy commands into the start command;
skiffly link <projectId>, thenskiffly up/skiffly redeploy -s <service>per service, keeping Railway running until Skiffly is healthy.
From an AI agent: the import_from_railway tool of the Skiffly MCP server takes the same
parameters (railwayToken, railwayProjectId, projectId or createProjectName, environment, dryRun) and
returns the same report.
Step by step#
-
Sign in at app.skiffly.dev with GitHub, install the GitHub App for private repositories, top up the balance.
-
Export variables from Railway:
railway variables --kv > vars.envper service. RemoveRAILWAY_*entries; replace references to Railway-provided variables with theSKIFFLY_*equivalents. -
Databases first. Deploy the template (
skiffly deploy --template postgres), then move the data through a TCP proxy:# Railway side pg_dump "$RAILWAY_DATABASE_URL" -Fc -f db.dump # Skiffly side skiffly connect postgres --print # prints the URL; creates the proxy on first use pg_restore -d "<url from --print>" --no-owner --clean --if-exists db.dump skiffly proxy delete <port> # close the public port when doneRedis and Mongo:
redis-cli --rdb/mongodumpand the matching restore overskiffly connect. -
Services.
skiffly initin each repository, thenskiffly up. Set the variables:skiffly variables set --skip-deploys $(grep -v '^RAILWAY_' vars.env | xargs) skiffly variables set 'DATABASE_URL=${{Postgres.DATABASE_URL}}' skiffly redeployOr describe everything once in
.skiffly/skiffly.tsandskiffly config apply; a Railwayrailway.tsports withdefineRailway→defineSkiffly. -
Settings that Railway kept in
railway.json/railway.toml(build command, start command, health check, restart policy, cron, watch patterns) go on the service:update-servicein MCP,serviceInstanceUpdatein GraphQL, or the config file. Skiffly does not readrailway.json. -
Domains. Generate a
skiffly.clouddomain to test, then add the custom domain and switch the CNAME when the certificate is issued. Keep the Railway service running until DNS has propagated. -
API clients. Change the endpoint to
https://api.skiffly.dev/graphqland the token. Documents usingprojects,deployments(input:),variables,serviceInstanceUpdate,serviceInstanceDeployV2,customDomainCreate,templateDeployV2and the other compatible fields work as they are;buildLogsbecomesdeploymentLogsfiltered bystream. -
Automation. GitHub Actions that ran
railway uprunnpx skiffly up(orskiffly config apply --yes) withSKIFFLY_TOKEN.
Things to check before cutting over#
- The app listens on
$PORT(Skiffly sets it; default 8080 if you set none). - Nothing relies on
*.railway.internalhostnames or IPv6 private networking. - Outbound SMTP on port 25 is blocked; use 587 with authentication or an email API.
- Plan limits: Hobby allows 8 GiB / 8 vCPU per service, Pro 32 GiB / 32 vCPU. Free is one service.
- Preview environments: enable PR previews per project; forks are ignored.