We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide14 min read

Railway, or infrastructure without configuring infrastructure

Railway runs applications and databases without infrastructure configuration. Per second billing, own hardware, spending limits, and how it compares.

Railway, or infrastructure without configuring infrastructure

Railway occupies the space between site hosting and a full cloud. You connect a repository, the platform detects the technology, builds an image, and starts the service, and a database arrives with one click.

The difference from frontend oriented platforms is that here you run long lived processes: a server in any language, a background job, a queue consumer. There is no split between functions and static files, there is a container running as long as needed.

The difference from a full cloud lies in what you need not do. There are no virtual networks, security groups, roles, or load balancer configuration. There is a repository, environment variables, and an address the application answers on.

The billing model, the thing to grasp at the start

Here lies the biggest difference from the competition and equally the most common cause of unpleasant surprises.

You pay not for a plan with allocated resources but for actual consumption of processor, memory, disk, and outbound traffic, billed by the second. A service sitting idle costs a fraction of a service under load.

The entry plan costs five dollars a month and includes usage of the same value. The team plan is twenty dollars a month per workspace, also with a credit equal to the subscription. Above the included threshold you pay for actual consumption at the same rates: roughly fourteen cents per gigabyte hour of memory, roughly twenty eight cents per core hour, and five cents per gigabyte of egress. Billing runs per second, so a service switched off overnight genuinely costs nothing.

There is no permanent free tier. A new account receives a one off trial credit for a short period, and further work requires a paid plan. Worth knowing before promising somebody a free test environment.

This model carries one advantage and one drawback, both worth naming. The advantage is predictable saving under uneven load: test environments, internal tools, and services used during working hours cost noticeably less than a flat machine fee. The drawback is the absence of an upper bound unless you set one.

Set a spending limit before your first deployment. A loop in the code, a query without an index, or a job running more often than you assumed can turn a ten dollar bill into a three hundred dollar one, and a limit is the only mechanism working without your attention.

Deployment and builds

Connecting a repository suffices for a first launch, since the build system recognises popular technologies itself.

Code
Bash
railway login
railway init
railway up

Detection works well on typical projects and stops sufficing on unusual ones. You then reach for your own image definition file, and that is the right route, since it gives full control and carries over to other platforms.

Code
DOCKERFILE
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]

A few things deserve setting from the start, since they are corrected reluctantly later. All of them fit in one configuration file kept in the repository next to the code.

Code
JSON
{
  "$schema": "https://railway.com/railway.schema.json",
  "build": {
    "builder": "DOCKERFILE",
    "dockerfilePath": "Dockerfile",
    "watchPatterns": ["src/**", "package.json"]
  },
  "deploy": {
    "startCommand": "node dist/server.js",
    "healthcheckPath": "/health",
    "healthcheckTimeout": 30,
    "restartPolicyType": "ON_FAILURE",
    "restartPolicyMaxRetries": 3
  }
}

A health check lets the platform distinguish a started process from one ready to take traffic. Without it a deployment succeeds while the application has not yet come up, and users see an error for a dozen or so seconds. The route itself can be trivial, as long as it answers only once dependencies are wired up.

Code
TypeScript
app.get('/health', async (_req, res) => {
  try {
    await db.query('SELECT 1')
    res.status(200).json({ status: 'ok' })
  } catch {
    res.status(503).json({ status: 'degraded' })
  }
})

Read the port from an environment variable rather than hardcoding it. The platform assigns it itself, and a fixed number works locally while producing a service answering no traffic in production. You also have to listen on all interfaces, since the default loopback binding cuts off traffic from outside the container.

Code
TypeScript
const port = Number(process.env.PORT) || 3000

app.listen(port, '0.0.0.0', () => {
  console.log(JSON.stringify({ level: 'info', msg: 'server up', port }))
})

Split environment variables by environment. A production key entered into a test environment is the most common cause of a test sending real messages to real customers.

Code
Bash
railway variable set STRIPE_KEY=sk_test_...
railway variable list
railway run npm start

That last command is the most useful day to day: it runs the process locally but with variables pulled from the chosen environment, so you avoid keeping a second copy of the configuration in a local file.

Databases and volumes

A database starts from a ready template, and the connection address appears as an environment variable available to the project's other services.

Code
Bash
railway add --database postgres

Once the service exists, you read the connection address in code like any other variable, without a host or password anywhere in the repository.

Code
TypeScript
import { Pool } from 'pg'

export const db = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
  idleTimeoutMillis: 30_000
})

Set the connection pool size deliberately, since it is the most common cause of exhausting the limit on the database side. Instance count multiplied by pool size has to fit within the database connection limit, and the default in many libraries is too high for that.

That is convenient, and understanding what you get pays off. The database runs in a container with data on a persistent volume, so it survives restarts and deployments. There is no automatic failover and no read replicas, though, so this suits applications that can absorb a few minutes of downtime.

Plan backups yourself before you need them, and test a restore. A backup nobody has ever restored is a backup in name only, and that sentence has already cost many teams a great deal.

For more demanding requirements, consider a database with a specialist provider, such as those covered in the pieces on Neon or PlanetScale, and keep only the application here. You gain database branches, backups as standard, and failure resistance mechanisms, at the cost of a second bill.

Volumes help beyond databases too: for user uploaded files, caches, and working data. Note, though, that a volume pins a service to one instance, so horizontal scaling stops being possible.

That limitation carries a simple design consequence. Files uploaded by users are better sent to object storage from the start, even when a volume looks simpler. Moving them later requires a data migration and a code change, and in the meantime the volume blocks running a second instance precisely when one starts being needed.

The other side of that dependency is worth knowing too. Some ready made backends are designed around a single data file, such as PocketBase, which packs the database, authentication, and files into one program. A service like that deploys here in minutes and uses a volume exactly as intended, provided you accept one instance and scaling only upwards.

Environments and teamwork

A project can hold several environments, each a separate set of services with its own variables and its own database. That solves a problem many platforms leave you to build yourself.

Code
Bash
railway environment new staging
railway environment
railway logs --build

Settings can be overridden per environment in the same configuration file, which keeps the differences in the repository rather than in somebody's head. A dedicated pr entry covers environments created for merge requests.

Code
JSON
{
  "$schema": "https://railway.com/railway.schema.json",
  "deploy": { "startCommand": "node dist/server.js" },
  "environments": {
    "staging": {
      "deploy": { "startCommand": "node dist/server.js --seed" }
    },
    "pr": {
      "deploy": { "healthcheckTimeout": 90 }
    }
  }
}

The most common arrangement covers three environments: production, staging, and a separate one per code branch needing a preview. Enabling that third is worthwhile, since reviewing changes against a running version beats reading code and guessing by a wide margin. Not every reason calls for a separate environment though: to show somebody a change for a quarter of an hour or to receive a webhook from an external service, a localhost tunnel such as tunnl.gg is enough, raised with a single SSH command, no account and no install. It suits nothing more permanent, since the tunnel expires after a day, the subdomain is random, and traffic is decrypted on somebody else's server.

A matter to settle at the start concerns data in side environments. A copy of the production database gives realistic testing and introduces personal data into a place with weaker protections. An artificially generated data set is safe and less convincing. The third route, usually the best, is a production copy with sensitive data masked, prepared by a script run when the environment is created.

Mind the cleanup too. Preview environments appear automatically with a branch and remain after it merges unless somebody removes them, and each one accrues usage. Tying environment removal to closing the request in the repository settles that once and relies on nobody's memory.

Monitoring and diagnostics

The platform shows logs and basic usage charts, and that suffices until something starts misbehaving in a non obvious way.

Three things deserve adding early. The first is structured logs rather than plain text. An entry carrying a request identifier, a level, and a timestamp can be filtered, while a sentence written for humans is only good for reading in order.

Code
TypeScript
import pino from 'pino'

export const log = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  base: { service: 'api', env: process.env.RAILWAY_ENVIRONMENT_NAME }
})

log.info({ requestId, userId, durationMs: 142 }, 'order created')

Adding the environment name to every entry costs one line and saves an hour the first time it turns out you are looking at logs from the wrong environment.

The second is error tracking in a separate tool. Logs show that something happened, while an error tool shows how often, across how many users, and since which deployment, which is entirely different information.

The third is watching usage against traffic. A memory chart rising linearly under steady traffic means a leak, and that is the only way to spot one before the service restarts in the middle of the day.

Set an alert on failed deployments as well. By default a deployment that fails simply does not replace the previous version, so the application keeps running and nobody notices until somebody checks why a fix from two days ago still has no effect.

Railway against the alternatives

OptionStrengthWeaknessPick it when
RailwayLong lived processes, databases included, per second billingNo free tier, an unbounded bill unless you cap itBackends, background jobs, internal tools
VercelBest support for frontends and edge renderingLong lived processes are outside its main purposeA Next.js application
A container with a cloud providerFull control, lowest price at scaleConfiguration and upkeep are yoursA team with an infrastructure person
Your own serverCheapest under steady loadYou maintain everythingSteady, predictable load

The first criterion is the kind of process you run. If the application is a continuously running server, a queue consumer, or a scheduled job, the first row fits better than platforms designed around functions invoked on demand.

The second is load over time. Under uneven traffic per second billing comes out cheaper than a flat machine fee. Under steady high load the proportion reverses, and your own server or a container with a cloud provider can be several times cheaper.

The third is how many people will maintain it. A team without an infrastructure person gains most here, since the whole configuration layer simply does not exist.

How to actually lower the bill

Since billing follows usage, knowing what drives it pays off, because intuition misleads here regularly.

Memory usually costs more than processor, since a service holds it occupied for its whole runtime while the processor works only on requests. An application in a garbage collected runtime can occupy several hundred megabytes for no reason, and setting a heap ceiling often halves that with no effect on behaviour.

Outbound traffic is the second line nobody thinks about. Images and files served directly from the application pass through it, and moving them to object storage with a content delivery network removes it almost entirely.

Side environments are the third line and usually the easiest to trim. A database in a staging environment runs around the clock though nobody touches it outside working hours. Shutting side environments down overnight can cut the bill by a third.

The fourth is forgotten services. A project from a year ago, a prototype meant to run for a week, a database left after a migration you no longer use. Reviewing the service list once a quarter takes fifteen minutes and regularly finds something that has been billing for months.

Check too whether the application genuinely needs to run continuously. A job running once a day need not sit as a service waiting in readiness; it can be a scheduled task that starts, does the work, and exits.

Common mistakes

The first is no spending limit. Usage billing has no upper bound, so a loop in the code can generate a bill out of proportion to the project's scale.

The second is a hardcoded port. The platform assigns it through an environment variable, and a fixed value works locally and fails after deployment.

The third is no health check. A deployment succeeds before the application comes up, so users see an error for a dozen or so seconds on every deploy.

The fourth is relying on the built in database without your own backups. Plan them and test a restore before you need them.

The fifth is mixing environment variables across environments. A production key in a test environment is a situation noticed after the fact.

The sixth is assuming a free tier in your plans. The trial credit is one off and short lived, so cost must be accounted for from the start.

The seventh is leaving preview environments behind after a branch is merged. They appear automatically, do not disappear automatically, and each accrues usage until somebody notices it.

FAQ

Does Railway have a free plan?

Not as a permanent free tier. A new account receives five dollars of trial credit for thirty days, with no card required, and further work requires a paid plan. The entry plan costs five dollars a month and includes usage of the same value.

How does billing work?

By actual consumption of processor, memory, disk, and outbound traffic, charged by the second. An idle service costs a fraction of a loaded one, so under uneven traffic it comes out cheaper than a flat machine fee. Set a spending limit before your first deployment.

How does it differ from frontend platforms?

In the kind of processes run. Here a container runs as long as needed, so a server, a queue consumer, and a scheduled job are natural. Platforms like Vercel are designed around functions invoked on demand and page rendering.

Is the built in database enough for production?

For applications that can absorb a few minutes of downtime, yes. There is no automatic failover and no read replicas, so for higher requirements consider a specialist database provider and keep only the application here.

Can it scale horizontally?

Yes, provided the service uses no volume, since that pins it to one instance. Keep state in a database or external storage, and then raising the instance count is a single setting change.

Documentation sits on the project site, and plan details on the pricing page.