Oct 28, 20268 min read

How to Move a Node.js Backend from Railway to AWS

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.

Railway to AWS mapping for a Node.js app

RailwayEncore on AWSManual AWS
Node.js serviceEncore service on FargateECS service behind a load balancer
Postgres serviceSQLDatabase, provisioned as RDSRDS instance in private subnets
Reference variablesDatabase handle, no connection string to wireSecrets Manager entries per task
Service variablessecret(), stored in Secrets ManagerSecrets Manager or Parameter Store
Cron schedule on a serviceCronJob calling an endpointEventBridge Scheduler and a task
PR environmentsPreview environments per pull requestA separate stack per branch

Encore: the Railway services as declarations in the TypeScript code

Encore

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.

Encore declares infrastructure in application code, runs it locally and provisions it in your AWS or GCP account
Infrastructure declared in application code, running locally under encore run and provisioned in your own AWS or GCP account on deploy.

Install the CLI and create an Encore app next to the existing code:

~/shop
$ brew install encoredev/tap/encore$ encore app create

Step 1: put the new backend in front

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.

Step 2: the database

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" });

Step 3: port routes one at a time

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":

~/shop
$ encore runYour API is running at: http://127.0.0.1:4000$ curl localhost:4000/orders/99{"code":"not_found","message":"order not found",...}$ curl localhost:4000/health{"ok":true,"from":"legacy"}$ curl -X POST localhost:4000/echo -H 'content-type: application/json' -d '{"a":1}'{"a":1}

/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.

Step 4: the cron service

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.

Step 5: deploy

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.

The Infrastructure page in the Encore dashboard with a database's configuration panel open, showing fields for vCPUs, memory, storage, availability type, max connections and backups.
Each provisioned resource can be reviewed and its configuration edited per environment in the Encore dashboard. The screenshot shows a GCP Cloud SQL database.

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.

The manual AWS path

Without Encore, the Railway project turns into AWS resources defined in Terraform or CDK:

  • An ECS service on Fargate behind an Application Load Balancer, from an image in ECR built with a non-root user.
  • An RDS for PostgreSQL instance in private subnets, with a security group that only the service can reach and the password in Secrets Manager.
  • Each Railway variable as a Secrets Manager entry referenced from the task definition, with secretsmanager:GetSecretValue on the task execution role.
  • An EventBridge Scheduler schedule that runs the cleanup script as an ECS task.
  • For a gradual move, a reverse proxy such as nginx in front of both backends, with a location block per ported path and a default that forwards to Railway.

Moving the data

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:

~/shop
$ pg_dump -Fc --no-owner "$RAILWAY_DATABASE_PUBLIC_URL" -f shop.dump$ pg_restore --data-only --no-owner \ --dbname "$(encore db conn-uri shop --env=prod --write)" shop.dump

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.

How to choose

  • Encore if you want to move off Railway one route at a time, keep the database, secrets and schedule in TypeScript, and run everything in your own AWS account.
  • Manual AWS if your team already maintains ECS and RDS modules and has a reverse proxy pattern for gradual cutovers.

Railway alternatives covers platforms that keep the app on shared infrastructure, and Render vs Railway compares the two closest PaaS options.

Frequently asked questions

How do I migrate a Node.js app from Railway to AWS?

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.

Can I move from Railway to AWS gradually?

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.

What replaces Railway reference variables like ${{Postgres.DATABASE_URL}}?

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.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders