Moving an Express service, its Railway Postgres, reference variables and cron schedule onto Encore in your own AWS account, with unported routes forwarded to Railway until the move is done.
Railway makes a Node.js backend feel like a diagram: an Express service built from the repository, a Postgres service next to it, a reference variable ${{Postgres.DATABASE_URL}} that wires one to the other, and a second copy of the service with a cron schedule that runs node scripts/expire.js every hour. It starts to pinch when usage-based billing grows with traffic in ways that are hard to predict, when a customer wants the data in an AWS account, or when the rest of the company already runs on AWS and the Railway project sits outside its access controls.
This guide moves an Express backend from Railway onto Encore in your own AWS account in small steps. The new backend takes over the domain first and forwards every route it does not know yet to the Railway service, and routes move over one at a time. The generic Railway to AWS migration guide covers the platform side for any language.
Encore combines an open source infrastructure SDK for TypeScript and Go with a platform that provisions what the code declares in your own AWS or GCP account. The boxes on the Railway canvas turn into objects in the code, encore run starts the app with Postgres locally on one port, and on deploy the database, secrets and schedule are created in your account, where Encore provisions an IAM role for the service.

Install the CLI and create an Encore app next to the existing code:
Before porting any route, add a fallback route. An api.raw endpoint with the path /!rest receives every request that no other endpoint matches, and this one forwards it to the Railway service, whose public URL is a secret so local, preview and production can each point at their own copy:
// shop/legacy.ts
import { api } from "encore.dev/api";
import { secret } from "encore.dev/config";
// Where the old app still runs while routes move over, for example
// https://shop-production.up.railway.app. Set per environment with `encore secret set`.
const legacyBaseUrl = secret("LegacyBaseURL");
// Any request that no Encore endpoint matches is forwarded to the old app.
export const fallback = api.raw(
{ expose: true, method: "*", path: "/!rest" },
async (req, resp) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const hasBody = req.method !== "GET" && req.method !== "HEAD";
const upstream = await fetch(new URL(req.url ?? "/", legacyBaseUrl()), {
method: req.method,
headers: {
"content-type": String(req.headers["content-type"] ?? ""),
authorization: String(req.headers["authorization"] ?? ""),
},
body: hasBody ? Buffer.concat(chunks) : undefined,
});
resp.writeHead(upstream.status, {
"content-type": upstream.headers.get("content-type") ?? "application/octet-stream",
});
resp.end(Buffer.from(await upstream.arrayBuffer()));
},
);
For Railway the value is the service's *.up.railway.app domain, set with encore secret set --type prod LegacyBaseURL. The proxy passes on the content type and authorization headers only, so add cookies or any custom headers your clients rely on. Node's built-in fetch does the forwarding, with no extra dependency.
The Postgres service and the reference variable that connected it become one declaration, with your existing schema as numbered SQL files in shop/migrations:
// shop/db.ts
import { SQLDatabase } from "encore.dev/storage/sqldb";
// Replaces the managed Postgres add-on: a local database under `encore run`,
// RDS when deployed to AWS.
export const db = new SQLDatabase("shop", { migrations: "./migrations" });
Each Express route you port becomes an API endpoint with typed parameters, and from that deploy on, Encore answers it and the fallback no longer sees it:
// shop/orders.ts
import { api, APIError } from "encore.dev/api";
import { db } from "./db";
export interface Order {
id: number;
email: string;
totalCents: number;
status: string;
}
export const getOrder = api(
{ expose: true, method: "GET", path: "/orders/:id" },
async ({ id }: { id: number }): Promise<Order> => {
const order = await db.queryRow<Order>`
SELECT id, email, total_cents AS "totalCents", status FROM orders WHERE id = ${id}`;
if (!order) throw APIError.notFound("order not found");
return order;
},
);
Running it locally with a copy of the Express app on port 5055 behind the fallback shows both halves on one port. The secret for local runs goes in .secrets.local.cue as LegacyBaseURL: "http://127.0.0.1:5055":
/orders/99 came from the Encore endpoint, while /health and the POST /echo with its body and 201 status came from Express through the fallback.
On Railway the hourly job is a separate service with a cron schedule that starts, runs the script and exits. In Encore it becomes a cron job attached to a private endpoint:
// shop/jobs.ts
import { api } from "encore.dev/api";
import { CronJob } from "encore.dev/cron";
import { db } from "./db";
// Only touches rows that are still pending, so a repeated run is harmless.
export const expirePending = api(
{ expose: false, method: "POST", path: "/internal/expire-pending" },
async (): Promise<{ cancelled: number }> => {
const rows = await db.queryAll`
UPDATE orders SET status = 'cancelled'
WHERE status = 'pending' AND created_at < now() - interval '24 hours'
RETURNING id`;
return { cancelled: rows.length };
},
);
// Replaces the scheduler entry on the old platform.
const _ = new CronJob("expire-pending", {
title: "Cancel orders left pending for a day",
every: "1h",
endpoint: expirePending,
});
Turn off the Railway cron schedule in the same deploy that ships this, so the job does not run twice. Cron jobs do not run under encore run; they start once deployed.
Connect your AWS account, create a production environment and push. Encore provisions the RDS database, the secrets in Secrets Manager, the schedule and the Fargate service in your own account, and provisions an IAM role for the service. The AWS infrastructure docs describe the VPC, subnets and load balancer it sets up.

Porting one route per pull request suits coding agents well: the agent writes the endpoint, adds a migration if the route needs a column, and confirms with encore run that the request is now answered by Encore instead of the fallback.
Without Encore, the Railway project turns into AWS resources defined in Terraform or CDK:
secretsmanager:GetSecretValue on the task execution role.Railway Postgres exposes a public connection URL on the database service. Stop writes by pausing the Express service and the cron service, dump in custom format, and restore only the data, since Encore already applied the migrations:
Check row counts, make sure the pg_dump client version is at least the server version (the pg_dump docs explain why), and once the last route has moved, delete legacy.ts and the LegacyBaseURL secret.
Railway alternatives covers platforms that keep the app on shared infrastructure, and Render vs Railway compares the two closest PaaS options.
Map each Railway service to an AWS one: the Node.js service to a container service, the Postgres service to RDS, variables to Secrets Manager and a service with a cron schedule to a scheduled job. With Encore you declare the database, secrets and cron job in TypeScript, forward routes you have not ported yet to the Railway service with an api.raw endpoint on the path /!rest, copy the data with pg_dump and pg_restore and switch DNS.
Yes. Deploy the new backend in front of the Railway service and forward every request it does not handle yet. Routes then move over one pull request at a time, and the forwarding shrinks until you can remove it.
Put API keys and connection strings in AWS Secrets Manager or SSM Parameter Store and inject them into the container as environment variables in the ECS task definition. Plain configuration values can stay as environment variables.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.