Railway - Kompletny Przewodnik po Nowoczesnej Platformie Deployment
Czym jest Railway?
Railway to nowoczesna platforma infrastructure-as-a-service (IaaS), która pozwala deployować aplikacje, bazy danych i serwisy z jednego miejsca. Railway łączy prostotę Heroku z elastycznością i przystępną ceną - jest to idealne rozwiązanie dla developerów, którzy chcą skupić się na kodzie, a nie na infrastrukturze.
Railway wyróżnia się wyjątkowo prostym procesem deploymentu - wystarczy połączyć repozytorium GitHub, a platforma automatycznie wykryje framework, zbuduje aplikację i wdroży ją na produkcję. Każdy push do main branch automatycznie triggeruje nowy deployment, co czyni Railway idealnym narzędziem dla continuous deployment.
Platforma została założona w 2020 roku i szybko zdobyła popularność wśród startupów i indywidualnych developerów jako alternatywa dla Heroku (który znacząco podniósł ceny i usunął darmowy tier).
Dlaczego Railway?
Kluczowe zalety Railway
- Deploy w minutę - Z GitHub lub CLI, automatyczna detekcja frameworków
- Bazy danych jednym klikiem - PostgreSQL, MySQL, MongoDB, Redis natychmiast
- Automatyczne skalowanie - Horizontal i vertical scaling według potrzeb
- Preview Environments - Oddzielne środowiska dla każdego PR
- Zero konfiguracji - Nixpacks automatycznie wykrywa i buduje projekty
- Prosty pricing - Pay-as-you-go bez niespodzianek
- Monorepo support - Deploy wielu serwisów z jednego repo
- Private networking - Bezpieczna komunikacja między serwisami
Railway vs Heroku vs Vercel vs Render
| Cecha | Railway | Heroku | Vercel | Render |
|---|---|---|---|---|
| Free tier | $5 credit | ❌ (usunięty) | ✅ (limited) | ✅ (limited) |
| Backend | ✅ Pełne wsparcie | ✅ Pełne | ❌ Serverless only | ✅ Pełne |
| Bazy danych | ✅ Wbudowane | ✅ Add-ons | ❌ Brak | ✅ Wbudowane |
| Docker | ✅ Natywne | ✅ Stack | ❌ Brak | ✅ Natywne |
| Preview envs | ✅ Tak | ❌ Brak | ✅ Tak | ✅ Tak |
| Auto-scaling | ✅ Tak | $$ Drogie | ✅ Tak | ✅ Tak |
| Pricing model | Usage-based | Dyno hours | Invocations | Usage-based |
| Sleep policy | ❌ Brak sleepingu | ✅ Free śpi | N/A | ✅ Free śpi |
Rozpoczęcie pracy z Railway
Instalacja CLI
# macOS (Homebrew)
brew install railway
# npm
npm install -g @railway/cli
# curl (Linux/macOS)
curl -fsSL https://railway.app/install.sh | sh
# Weryfikacja instalacji
railway --versionLogowanie
# Logowanie przez przeglądarkę
railway login
# Logowanie przez token (CI/CD)
export RAILWAY_TOKEN=your-token
railway login --tokenPierwszy deployment
# Inicjalizacja projektu
railway init
# Wybierz:
# 1. Empty Project - czysty projekt
# 2. From Template - z gotowego szablonu
# Deploy z bieżącego katalogu
railway up
# Otwórz dashboard projektu
railway openDeploy z GitHub
# 1. Przejdź do railway.app
# 2. Kliknij "New Project"
# 3. Wybierz "Deploy from GitHub repo"
# 4. Autoryzuj GitHub i wybierz repo
# 5. Railway automatycznie wykryje framework i zdeployuje
# Każdy push do main = automatyczny deploy
git push origin mainStruktura projektu Railway
Projekt i serwisy
Railway Project
│
├── Service: API (Node.js)
│ ├── Deployments
│ ├── Variables
│ ├── Logs
│ └── Metrics
│
├── Service: Web (Next.js)
│ ├── Deployments
│ └── Variables
│
├── Service: PostgreSQL
│ └── Connection string (auto-exposed)
│
├── Service: Redis
│ └── Connection string (auto-exposed)
│
└── Service: Worker (Background jobs)
└── Cron scheduleEnvironment Variables
# Ustawianie zmiennych z CLI
railway variables set API_KEY=secret123
railway variables set DATABASE_URL=postgres://...
# Wyświetl zmienne
railway variables
# Usuń zmienną
railway variables delete API_KEY
# Reference do innych serwisów (w dashboard)
# ${{postgres.DATABASE_URL}} - automatycznie dostępnerailway.toml - konfiguracja projektu
# railway.toml
[build]
# Komenda budowania
builder = "NIXPACKS"
buildCommand = "npm run build"
[deploy]
# Komenda startowa
startCommand = "npm start"
# Health check endpoint
healthcheckPath = "/health"
# Timeout health checku (sekundy)
healthcheckTimeout = 300
# Liczba replik
numReplicas = 1
# Sleep po nieaktywności (tylko hobby)
sleepApplication = false
# Restart policy
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 10
[build.nixpacksPlan]
# Custom Nix packages
phases.setup.nixPkgs = ["...", "ffmpeg"]
# Environment variables dla build
[build.variables]
NODE_ENV = "production"Bazy danych na Railway
Railway oferuje pełne zarządzane bazy danych jednym klikiem.
PostgreSQL
# Dodaj PostgreSQL do projektu
railway add postgresql
# Automatycznie dostępne zmienne:
# DATABASE_URL=postgres://user:pass@host:port/db
# PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASEPrzykład użycia z Prisma:
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
createdAt DateTime @default(now())
}// src/db.ts
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function getUsers() {
return prisma.user.findMany()
}
export async function createUser(email: string, name?: string) {
return prisma.user.create({
data: { email, name }
})
}Migracje na Railway:
# Uruchom migracje przed deploymentem
# W railway.toml:
[deploy]
startCommand = "npx prisma migrate deploy && npm start"
# Lub w package.json
"scripts": {
"start": "npx prisma migrate deploy && node dist/index.js"
}MySQL
railway add mysql
# Zmienne:
# MYSQL_URL=mysql://user:pass@host:port/db
# MYSQLHOST, MYSQLPORT, MYSQLUSER, MYSQLPASSWORD, MYSQLDATABASE// Drizzle ORM z MySQL
import { drizzle } from 'drizzle-orm/mysql2'
import mysql from 'mysql2/promise'
const connection = await mysql.createConnection(process.env.MYSQL_URL!)
const db = drizzle(connection)
// Użycie
const users = await db.select().from(usersTable)MongoDB
railway add mongodb
# Zmienne:
# MONGO_URL=mongodb://user:pass@host:port/db// Mongoose
import mongoose from 'mongoose'
await mongoose.connect(process.env.MONGO_URL!)
const UserSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
name: String,
createdAt: { type: Date, default: Date.now }
})
export const User = mongoose.model('User', UserSchema)Redis
railway add redis
# Zmienne:
# REDIS_URL=redis://default:pass@host:port// ioredis
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL!)
// Cache
await redis.set('user:1', JSON.stringify(user), 'EX', 3600)
const cached = await redis.get('user:1')
// Pub/Sub
const pub = new Redis(process.env.REDIS_URL!)
const sub = new Redis(process.env.REDIS_URL!)
sub.subscribe('notifications')
sub.on('message', (channel, message) => {
console.log(`Received: ${message}`)
})
await pub.publish('notifications', 'Hello!')
// Queue (BullMQ)
import { Queue, Worker } from 'bullmq'
const queue = new Queue('emails', {
connection: { url: process.env.REDIS_URL }
})
const worker = new Worker('emails', async (job) => {
await sendEmail(job.data)
}, {
connection: { url: process.env.REDIS_URL }
})Docker na Railway
Railway natywnie wspiera Dockerfiles.
Podstawowy Dockerfile
# Dockerfile
FROM node:20-alpine
WORKDIR /app
# Cache dependencies
COPY package*.json ./
RUN npm ci --only=production
# Copy source
COPY . .
# Build
RUN npm run build
# Run
EXPOSE 3000
CMD ["npm", "start"]Multi-stage build
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nodeuser
# Copy only necessary files
COPY /app/dist ./dist
COPY /app/node_modules ./node_modules
COPY /app/package.json ./
USER nodeuser
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "dist/index.js"]Python z Dockerfile
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy source
COPY . .
# Run with gunicorn
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]Go z Dockerfile
# Build stage
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o main .
# Production stage
FROM alpine:latest
WORKDIR /app
COPY /app/main .
EXPOSE 8080
CMD ["./main"]Monorepo na Railway
Railway doskonale wspiera monorepo z wieloma serwisami.
Struktura monorepo
my-monorepo/
├── apps/
│ ├── api/
│ │ ├── package.json
│ │ ├── railway.toml
│ │ └── src/
│ ├── web/
│ │ ├── package.json
│ │ ├── railway.toml
│ │ └── src/
│ └── worker/
│ ├── package.json
│ ├── railway.toml
│ └── src/
├── packages/
│ ├── shared/
│ └── ui/
├── package.json
└── turbo.jsonKonfiguracja serwisów
# apps/api/railway.toml
[build]
buildCommand = "cd ../.. && npm run build --filter=api"
[deploy]
startCommand = "node dist/index.js"# apps/web/railway.toml
[build]
buildCommand = "cd ../.. && npm run build --filter=web"
[deploy]
startCommand = "npm start"Root directory w dashboard
W Railway Dashboard:
1. Przejdź do serwisu
2. Settings → General → Root Directory
3. Ustaw: apps/api (dla API)
4. Ustaw: apps/web (dla frontendu)
Railway wykryje zmiany tylko w odpowiednim katalogu.Turborepo + Railway
// turbo.json
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"dev": {
"cache": false
}
}
}// package.json (root)
{
"scripts": {
"build": "turbo run build",
"build:api": "turbo run build --filter=api",
"build:web": "turbo run build --filter=web"
}
}Preview Environments
Railway automatycznie tworzy środowiska preview dla każdego PR.
Konfiguracja
1. W Dashboard → Settings → Deployments
2. Włącz "Enable PR Deploys"
3. Każdy PR otrzyma unikalny URL
Format URL:
https://[service-name]-pr-[number].up.railway.appZmienne dla preview
# Zmienne specyficzne dla preview environments
# Ustawiane w Dashboard → Environment: Preview
DATABASE_URL=${{postgres-preview.DATABASE_URL}}
API_URL=https://api-pr-${{ RAILWAY_PR_NUMBER }}.up.railway.appIzolacja danych
Dla preview environments możesz:
1. Używać shared development database
2. Tworzyć osobne bazy dla każdego PR (drogie)
3. Używać seeded test data
Rekomendacja:
- Shared dev database dla prostych projektów
- Database branching (jak Neon) dla izolacjiCron Jobs i Workers
Scheduled Tasks (Cron)
# W Dashboard → Service → Settings → Cron
# Lub w railway.toml:
[deploy]
cronSchedule = "0 0 * * *" # Codziennie o północy// worker/src/cron.ts
import { CronJob } from 'cron'
// Cleanup old sessions
const cleanupJob = new CronJob('0 0 * * *', async () => {
console.log('Running cleanup...')
await db.session.deleteMany({
where: {
expiresAt: { lt: new Date() }
}
})
console.log('Cleanup complete')
})
cleanupJob.start()Background Workers
// worker/src/index.ts
import { Worker, Queue } from 'bullmq'
const connection = { url: process.env.REDIS_URL }
// Email worker
const emailWorker = new Worker('emails', async (job) => {
const { to, subject, body } = job.data
await sendEmail({ to, subject, body })
console.log(`Email sent to ${to}`)
}, { connection })
// Image processing worker
const imageWorker = new Worker('images', async (job) => {
const { imageUrl, userId } = job.data
const processed = await processImage(imageUrl)
await uploadToS3(processed)
await notifyUser(userId, 'Image processed!')
}, {
connection,
concurrency: 5 // Process 5 jobs simultaneously
})
console.log('Workers started')// api/src/routes/images.ts
import { Queue } from 'bullmq'
const imageQueue = new Queue('images', {
connection: { url: process.env.REDIS_URL }
})
app.post('/api/images/process', async (req, res) => {
const { imageUrl } = req.body
const userId = req.user.id
await imageQueue.add('process', { imageUrl, userId })
res.json({ message: 'Processing started' })
})Private Networking
Railway umożliwia bezpieczną komunikację między serwisami.
Internal URLs
Każdy serwis otrzymuje internal URL:
[service-name].railway.internal
Przykład:
api.railway.internal:3000
postgres.railway.internal:5432
redis.railway.internal:6379Konfiguracja
// api/src/config.ts
export const config = {
// Internal URL dla komunikacji między serwisami
authServiceUrl: process.env.AUTH_SERVICE_URL || 'http://auth.railway.internal:3001',
// External URL dla klientów
publicApiUrl: process.env.PUBLIC_API_URL || 'https://api.example.com',
// Database - zawsze internal
databaseUrl: process.env.DATABASE_URL // postgres.railway.internal
}// api/src/services/auth.ts
import { config } from '../config'
export async function verifyToken(token: string) {
// Wywołanie do internal auth service
const response = await fetch(`${config.authServiceUrl}/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
})
return response.json()
}Service Discovery
# docker-compose.yml (dla local development)
services:
api:
environment:
- AUTH_SERVICE_URL=http://auth:3001
- DATABASE_URL=postgres://postgres:postgres@db:5432/app
auth:
environment:
- DATABASE_URL=postgres://postgres:postgres@db:5432/app
db:
image: postgres:15Skalowanie
Vertical Scaling
Railway Dashboard → Service → Settings
Resources:
├── vCPU: 0.5 → 8 vCPUs
├── Memory: 512MB → 32GB
└── Disk: Persistent volumes
Autoscaling:
├── Min replicas: 1
├── Max replicas: 10
├── Target CPU: 70%
└── Target Memory: 80%Horizontal Scaling
# railway.toml
[deploy]
numReplicas = 3
# Lub przez dashboard → ScaleHealth Checks
# railway.toml
[deploy]
healthcheckPath = "/health"
healthcheckTimeout = 300// api/src/routes/health.ts
import express from 'express'
const router = express.Router()
router.get('/health', async (req, res) => {
try {
// Check database connection
await db.$queryRaw`SELECT 1`
// Check Redis
await redis.ping()
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
services: {
database: 'connected',
redis: 'connected'
}
})
} catch (error) {
res.status(503).json({
status: 'unhealthy',
error: error.message
})
}
})
export default routerLogowanie i Monitoring
Railway Logs
# CLI
railway logs
# Z filtrowaniem
railway logs --filter "error"
# Follow (live)
railway logs -fStructured Logging
// logger.ts
import pino from 'pino'
export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
formatters: {
level: (label) => ({ level: label })
},
timestamp: pino.stdTimeFunctions.isoTime
})
// Użycie
logger.info({ userId: user.id }, 'User logged in')
logger.error({ err, requestId }, 'Request failed')Metrics
// metrics.ts
import { collectDefaultMetrics, Registry, Counter, Histogram } from 'prom-client'
const register = new Registry()
collectDefaultMetrics({ register })
// Custom metrics
export const httpRequestsTotal = new Counter({
name: 'http_requests_total',
help: 'Total HTTP requests',
labelNames: ['method', 'path', 'status'],
registers: [register]
})
export const httpRequestDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP request duration',
labelNames: ['method', 'path'],
buckets: [0.1, 0.5, 1, 2, 5],
registers: [register]
})
// Endpoint dla Prometheus
app.get('/metrics', async (req, res) => {
res.set('Content-Type', register.contentType)
res.end(await register.metrics())
})Integracja z zewnętrznymi serwisami
// Sentry
import * as Sentry from '@sentry/node'
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.RAILWAY_ENVIRONMENT || 'development',
release: process.env.RAILWAY_GIT_COMMIT_SHA
})
// Datadog
import tracer from 'dd-trace'
tracer.init({
service: process.env.DD_SERVICE || 'api',
env: process.env.RAILWAY_ENVIRONMENT,
version: process.env.RAILWAY_GIT_COMMIT_SHA
})CI/CD z Railway
GitHub Actions
# .github/workflows/deploy.yml
name: Deploy to Railway
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Railway CLI
run: npm install -g @railway/cli
- name: Deploy
run: railway up --service api
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
- name: Run migrations
run: railway run npx prisma migrate deploy
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}Deploy z testami
name: Test and Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run test
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to Railway
run: |
npm install -g @railway/cli
railway up
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}Rollback
# Z CLI
railway rollback
# Wybierz poprzedni deployment z listy
# Lub przez dashboard → Deployments → RollbackIntegracje z frameworkami
Next.js
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone', // Optymalizacja dla Railway
experimental: {
serverComponentsExternalPackages: ['@prisma/client']
}
}
module.exports = nextConfig# railway.toml
[build]
builder = "NIXPACKS"
[deploy]
startCommand = "node server.js"
healthcheckPath = "/api/health"NestJS
// main.ts
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
async function bootstrap() {
const app = await NestFactory.create(AppModule)
// Health check
app.get('/health', (req, res) => res.send('OK'))
const port = process.env.PORT || 3000
await app.listen(port, '0.0.0.0')
console.log(`Application running on port ${port}`)
}
bootstrap()FastAPI (Python)
# main.py
from fastapi import FastAPI
import os
app = FastAPI()
@app.get("/health")
def health():
return {"status": "healthy"}
@app.get("/")
def root():
return {"message": "Hello from Railway!"}
if __name__ == "__main__":
import uvicorn
port = int(os.getenv("PORT", 8000))
uvicorn.run(app, host="0.0.0.0", port=port)# railway.toml
[build]
builder = "NIXPACKS"
[deploy]
startCommand = "uvicorn main:app --host 0.0.0.0 --port $PORT"
healthcheckPath = "/health"Go (Gin)
// main.go
package main
import (
"os"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "healthy"})
})
r.GET("/", func(c *gin.Context) {
c.JSON(200, gin.H{"message": "Hello from Railway!"})
})
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
r.Run(":" + port)
}Django
# settings.py
import os
import dj_database_url
DEBUG = os.getenv('DEBUG', 'False') == 'True'
ALLOWED_HOSTS = [
'.railway.app',
'localhost',
'127.0.0.1'
]
# Database
DATABASES = {
'default': dj_database_url.config(
default=os.getenv('DATABASE_URL'),
conn_max_age=600
)
}
# Static files
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
# WhiteNoise for static files
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
# ...
]
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'# Procfile (alternatywa dla railway.toml)
web: gunicorn myproject.wsgi --bind 0.0.0.0:$PORTCustom Domains
Dodawanie domeny
1. Railway Dashboard → Service → Settings → Domains
2. Kliknij "Add Custom Domain"
3. Wpisz domenę: api.example.com
4. Dodaj rekordy DNS:
CNAME api -> [your-service].up.railway.app
Lub dla root domain:
A record -> Railway IPSSL/TLS
Railway automatycznie:
- Generuje certyfikaty Let's Encrypt
- Obsługuje HTTPS
- Redirect HTTP → HTTPS
- Odnawia certyfikatyWildcard domains
Dla *.example.com:
1. Dodaj CNAME *.example -> [service].up.railway.app
2. W Railway dodaj *.example.com jako custom domain
3. Railway obsłuży wszystkie subdomenyPersistent Storage
Volumes
Railway wspiera persistent volumes dla:
- Plików uploadowanych przez użytkowników
- Cache
- Lokalnych baz danych (SQLite)
Dashboard → Service → Volumes → Add Volume
Mount path: /data
Size: 1GB - 100GB// Użycie volume
import fs from 'fs/promises'
import path from 'path'
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads'
export async function saveFile(file: Buffer, filename: string) {
const filepath = path.join(UPLOAD_DIR, filename)
await fs.mkdir(path.dirname(filepath), { recursive: true })
await fs.writeFile(filepath, file)
return filepath
}Object Storage (S3-compatible)
// Rekomendacja: Używaj S3/R2/MinIO dla plików
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
const s3 = new S3Client({
region: 'auto',
endpoint: process.env.S3_ENDPOINT, // Cloudflare R2, MinIO, etc.
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!
}
})
export async function uploadFile(file: Buffer, key: string) {
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
Body: file
}))
return `${process.env.S3_PUBLIC_URL}/${key}`
}Cennik Railway
Plany
Trial:
├── Cena: $0
├── $5 credit jednorazowy
├── Pełny dostęp do platformy
├── Ograniczenie: 500 execution hours/miesiąc
└── Idealne do testowania
Hobby:
├── Cena: $5/miesiąc
├── Includes $5 usage
├── 8GB RAM per service
├── 8 vCPU per service
├── Unlimited deployments
└── Dla side projects
Pro:
├── Cena: $20/miesiąc per member
├── Includes $10 usage per member
├── 32GB RAM per service
├── 32 vCPU per service
├── Teams & collaboration
├── Priority support
└── Dla produkcji
Enterprise:
├── Custom pricing
├── Dedicated infrastructure
├── SLA guarantees
├── SSO/SAML
└── Advanced securityUsage Pricing
Compute:
├── vCPU: $0.000463/minute ($20/month for 1 vCPU 24/7)
└── Memory: $0.000231/GB/minute ($10/month for 1GB 24/7)
Network:
├── Egress: $0.10/GB (first 100GB free)
└── Ingress: Free
Databases:
├── PostgreSQL: compute + storage ($0.25/GB/month)
├── Redis: compute only
└── MongoDB: compute + storageKalkulacja kosztów
Przykład: Mały SaaS
API (Node.js):
├── 0.5 vCPU: ~$10/month
├── 512MB RAM: ~$5/month
└── Subtotal: ~$15/month
PostgreSQL:
├── 0.25 vCPU: ~$5/month
├── 256MB RAM: ~$2.50/month
├── 10GB storage: ~$2.50/month
└── Subtotal: ~$10/month
Redis:
├── 0.25 vCPU: ~$5/month
├── 256MB RAM: ~$2.50/month
└── Subtotal: ~$7.50/month
Total: ~$32.50/month (z $5 included w Hobby)
Actual cost: ~$27.50/monthBest Practices
Bezpieczeństwo
// 1. Używaj zmiennych środowiskowych
const apiKey = process.env.API_KEY // Nigdy hardcoded!
// 2. Waliduj input
import { z } from 'zod'
const UserSchema = z.object({
email: z.string().email(),
password: z.string().min(8)
})
// 3. Używaj HTTPS only
app.use((req, res, next) => {
if (req.headers['x-forwarded-proto'] !== 'https') {
return res.redirect(`https://${req.headers.host}${req.url}`)
}
next()
})
// 4. Rate limiting
import rateLimit from 'express-rate-limit'
app.use(rateLimit({
windowMs: 15 * 60 * 1000,
max: 100
}))Optymalizacja
# 1. Multi-stage builds
FROM node:20-alpine AS builder
# ... build steps ...
FROM node:20-alpine AS runner
# ... tylko production dependencies ...
# 2. .dockerignore
node_modules
.git
.env*
*.md
tests/// 3. Connection pooling
import { PrismaClient } from '@prisma/client'
// Singleton pattern dla Prisma
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query'] : []
})
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}Graceful Shutdown
// Obsługa SIGTERM
process.on('SIGTERM', async () => {
console.log('SIGTERM received, shutting down gracefully')
// Stop accepting new requests
server.close()
// Close database connections
await prisma.$disconnect()
await redis.quit()
console.log('Cleanup complete')
process.exit(0)
})FAQ - Często zadawane pytania
Czy Railway ma darmowy tier?
Tak, Railway oferuje $5 jednorazowego kredytu na trial. Po jego wykorzystaniu możesz przejść na plan Hobby ($5/miesiąc) który zawiera $5 usage credit. Nie ma jednak permanentnie darmowego planu jak kiedyś Heroku.
Jak Railway wypada vs Heroku?
Railway jest generalnie tańszy i nowocześniejszy:
- Brak "dyno sleeping" nawet na najtańszym planie
- Natywne wsparcie dla Docker
- Preview environments
- Prostszy pricing (pay-as-you-go)
- Lepsze developer experience
Heroku może być lepszy dla enterprise z wymaganiami compliance.
Czy mogę deployować statyczne strony?
Tak, ale Railway nie jest do tego zoptymalizowany. Dla statycznych stron lepsze są:
- Vercel
- Netlify
- Cloudflare Pages
Railway świeci przy backendach i full-stack apps.
Jak debugować problemy z deploymentem?
- Sprawdź logi:
railway logs - Sprawdź build logs w dashboard
- Uruchom lokalnie z tymi samymi zmiennymi:
railway run npm start - Zweryfikuj Dockerfile/Nixpacks configuration
- Sprawdź health check endpoint
Czy Railway obsługuje WebSockets?
Tak, Railway w pełni obsługuje WebSockets bez dodatkowej konfiguracji. Pamiętaj o sticky sessions przy skalowaniu horyzontalnym.
Jak zrobić backup bazy danych?
# PostgreSQL dump
railway run pg_dump $DATABASE_URL > backup.sql
# Lub przez plugin pg_dump w dashboard
# Settings → Backups → Create BackupPodsumowanie
Railway to nowoczesna platforma deployment, która łączy prostotę Heroku z elastycznością i przystępną ceną:
- Szybki start - Deploy w minutę z GitHub lub CLI
- Bazy danych jednym klikiem - PostgreSQL, MySQL, MongoDB, Redis
- Docker native - Pełne wsparcie dla kontenerów
- Preview environments - Automatyczne środowiska dla PR
- Skalowanie - Horizontal i vertical scaling
- Prosty pricing - Pay-as-you-go bez niespodzianek
Railway jest idealny dla startupów, side projects i zespołów, które chcą skupić się na kodzie, a nie na infrastrukturze.
Railway - a complete guide to the modern deployment platform
What is Railway?
Railway is a modern infrastructure-as-a-service (IaaS) platform that lets you deploy applications, databases, and services from one place. Railway combines the simplicity of Heroku with flexibility and affordable pricing - it is an ideal solution for developers who want to focus on code, not infrastructure.
Railway stands out with an exceptionally simple deployment process - just connect your GitHub repository and the platform will automatically detect the framework, build the application, and deploy it to production. Every push to the main branch automatically triggers a new deployment, making Railway the perfect tool for continuous deployment.
The platform was founded in 2020 and quickly gained popularity among startups and individual developers as an alternative to Heroku (which significantly raised prices and removed its free tier).
Why Railway?
Key advantages of Railway
- Deploy in a minute - From GitHub or CLI, automatic framework detection
- One-click databases - PostgreSQL, MySQL, MongoDB, Redis instantly
- Automatic scaling - Horizontal and vertical scaling as needed
- Preview Environments - Separate environments for each PR
- Zero configuration - Nixpacks automatically detects and builds projects
- Simple pricing - Pay-as-you-go with no surprises
- Monorepo support - Deploy multiple services from one repo
- Private networking - Secure communication between services
Railway vs Heroku vs Vercel vs Render
| Feature | Railway | Heroku | Vercel | Render |
|---|---|---|---|---|
| Free tier | $5 credit | ❌ (removed) | ✅ (limited) | ✅ (limited) |
| Backend | ✅ Full support | ✅ Full | ❌ Serverless only | ✅ Full |
| Databases | ✅ Built-in | ✅ Add-ons | ❌ None | ✅ Built-in |
| Docker | ✅ Native | ✅ Stack | ❌ None | ✅ Native |
| Preview envs | ✅ Yes | ❌ None | ✅ Yes | ✅ Yes |
| Auto-scaling | ✅ Yes | $$ Expensive | ✅ Yes | ✅ Yes |
| Pricing model | Usage-based | Dyno hours | Invocations | Usage-based |
| Sleep policy | ❌ No sleeping | ✅ Free sleeps | N/A | ✅ Free sleeps |
Getting started with Railway
CLI installation
# macOS (Homebrew)
brew install railway
# npm
npm install -g @railway/cli
# curl (Linux/macOS)
curl -fsSL https://railway.app/install.sh | sh
# Verify installation
railway --versionLogging in
# Log in through the browser
railway login
# Log in with a token (CI/CD)
export RAILWAY_TOKEN=your-token
railway login --tokenFirst deployment
# Initialize a project
railway init
# Choose:
# 1. Empty Project - a clean project
# 2. From Template - from a ready-made template
# Deploy from the current directory
railway up
# Open the project dashboard
railway openDeploy from GitHub
# 1. Go to railway.app
# 2. Click "New Project"
# 3. Select "Deploy from GitHub repo"
# 4. Authorize GitHub and select your repo
# 5. Railway will automatically detect the framework and deploy
# Every push to main = automatic deploy
git push origin mainRailway project structure
Projects and services
Railway Project
│
├── Service: API (Node.js)
│ ├── Deployments
│ ├── Variables
│ ├── Logs
│ └── Metrics
│
├── Service: Web (Next.js)
│ ├── Deployments
│ └── Variables
│
├── Service: PostgreSQL
│ └── Connection string (auto-exposed)
│
├── Service: Redis
│ └── Connection string (auto-exposed)
│
└── Service: Worker (Background jobs)
└── Cron scheduleEnvironment variables
# Set variables from CLI
railway variables set API_KEY=secret123
railway variables set DATABASE_URL=postgres://...
# Display variables
railway variables
# Delete a variable
railway variables delete API_KEY
# Reference to other services (in dashboard)
# ${{postgres.DATABASE_URL}} - automatically availablerailway.toml - project configuration
# railway.toml
[build]
# Build command
builder = "NIXPACKS"
buildCommand = "npm run build"
[deploy]
# Start command
startCommand = "npm start"
# Health check endpoint
healthcheckPath = "/health"
# Health check timeout (seconds)
healthcheckTimeout = 300
# Number of replicas
numReplicas = 1
# Sleep after inactivity (hobby only)
sleepApplication = false
# Restart policy
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 10
[build.nixpacksPlan]
# Custom Nix packages
phases.setup.nixPkgs = ["...", "ffmpeg"]
# Environment variables for build
[build.variables]
NODE_ENV = "production"Databases on Railway
Railway offers fully managed databases with a single click.
PostgreSQL
# Add PostgreSQL to the project
railway add postgresql
# Automatically available variables:
# DATABASE_URL=postgres://user:pass@host:port/db
# PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASEExample usage with Prisma:
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
createdAt DateTime @default(now())
}// src/db.ts
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function getUsers() {
return prisma.user.findMany()
}
export async function createUser(email: string, name?: string) {
return prisma.user.create({
data: { email, name }
})
}Migrations on Railway:
# Run migrations before deployment
# In railway.toml:
[deploy]
startCommand = "npx prisma migrate deploy && npm start"
# Or in package.json
"scripts": {
"start": "npx prisma migrate deploy && node dist/index.js"
}MySQL
railway add mysql
# Variables:
# MYSQL_URL=mysql://user:pass@host:port/db
# MYSQLHOST, MYSQLPORT, MYSQLUSER, MYSQLPASSWORD, MYSQLDATABASE// Drizzle ORM with MySQL
import { drizzle } from 'drizzle-orm/mysql2'
import mysql from 'mysql2/promise'
const connection = await mysql.createConnection(process.env.MYSQL_URL!)
const db = drizzle(connection)
// Usage
const users = await db.select().from(usersTable)MongoDB
railway add mongodb
# Variables:
# MONGO_URL=mongodb://user:pass@host:port/db// Mongoose
import mongoose from 'mongoose'
await mongoose.connect(process.env.MONGO_URL!)
const UserSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
name: String,
createdAt: { type: Date, default: Date.now }
})
export const User = mongoose.model('User', UserSchema)Redis
railway add redis
# Variables:
# REDIS_URL=redis://default:pass@host:port// ioredis
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL!)
// Cache
await redis.set('user:1', JSON.stringify(user), 'EX', 3600)
const cached = await redis.get('user:1')
// Pub/Sub
const pub = new Redis(process.env.REDIS_URL!)
const sub = new Redis(process.env.REDIS_URL!)
sub.subscribe('notifications')
sub.on('message', (channel, message) => {
console.log(`Received: ${message}`)
})
await pub.publish('notifications', 'Hello!')
// Queue (BullMQ)
import { Queue, Worker } from 'bullmq'
const queue = new Queue('emails', {
connection: { url: process.env.REDIS_URL }
})
const worker = new Worker('emails', async (job) => {
await sendEmail(job.data)
}, {
connection: { url: process.env.REDIS_URL }
})Docker on Railway
Railway natively supports Dockerfiles.
Basic Dockerfile
# Dockerfile
FROM node:20-alpine
WORKDIR /app
# Cache dependencies
COPY package*.json ./
RUN npm ci --only=production
# Copy source
COPY . .
# Build
RUN npm run build
# Run
EXPOSE 3000
CMD ["npm", "start"]Multi-stage build
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nodeuser
# Copy only necessary files
COPY /app/dist ./dist
COPY /app/node_modules ./node_modules
COPY /app/package.json ./
USER nodeuser
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "dist/index.js"]Python with Dockerfile
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy source
COPY . .
# Run with gunicorn
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]Go with Dockerfile
# Build stage
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o main .
# Production stage
FROM alpine:latest
WORKDIR /app
COPY /app/main .
EXPOSE 8080
CMD ["./main"]Monorepo on Railway
Railway has excellent support for monorepos with multiple services.
Monorepo structure
my-monorepo/
├── apps/
│ ├── api/
│ │ ├── package.json
│ │ ├── railway.toml
│ │ └── src/
│ ├── web/
│ │ ├── package.json
│ │ ├── railway.toml
│ │ └── src/
│ └── worker/
│ ├── package.json
│ ├── railway.toml
│ └── src/
├── packages/
│ ├── shared/
│ └── ui/
├── package.json
└── turbo.jsonService configuration
# apps/api/railway.toml
[build]
buildCommand = "cd ../.. && npm run build --filter=api"
[deploy]
startCommand = "node dist/index.js"# apps/web/railway.toml
[build]
buildCommand = "cd ../.. && npm run build --filter=web"
[deploy]
startCommand = "npm start"Root directory in the dashboard
In the Railway Dashboard:
1. Go to the service
2. Settings → General → Root Directory
3. Set: apps/api (for the API)
4. Set: apps/web (for the frontend)
Railway will detect changes only in the relevant directory.Turborepo + Railway
// turbo.json
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"dev": {
"cache": false
}
}
}// package.json (root)
{
"scripts": {
"build": "turbo run build",
"build:api": "turbo run build --filter=api",
"build:web": "turbo run build --filter=web"
}
}Preview environments
Railway automatically creates preview environments for each PR.
Configuration
1. In Dashboard → Settings → Deployments
2. Enable "Enable PR Deploys"
3. Each PR will receive a unique URL
URL format:
https://[service-name]-pr-[number].up.railway.appVariables for preview
# Variables specific to preview environments
# Set in Dashboard → Environment: Preview
DATABASE_URL=${{postgres-preview.DATABASE_URL}}
API_URL=https://api-pr-${{ RAILWAY_PR_NUMBER }}.up.railway.appData isolation
For preview environments you can:
1. Use a shared development database
2. Create separate databases for each PR (expensive)
3. Use seeded test data
Recommendation:
- Shared dev database for simple projects
- Database branching (like Neon) for isolationCron jobs and workers
Scheduled tasks (Cron)
# In Dashboard → Service → Settings → Cron
# Or in railway.toml:
[deploy]
cronSchedule = "0 0 * * *" # Every day at midnight// worker/src/cron.ts
import { CronJob } from 'cron'
// Cleanup old sessions
const cleanupJob = new CronJob('0 0 * * *', async () => {
console.log('Running cleanup...')
await db.session.deleteMany({
where: {
expiresAt: { lt: new Date() }
}
})
console.log('Cleanup complete')
})
cleanupJob.start()Background workers
// worker/src/index.ts
import { Worker, Queue } from 'bullmq'
const connection = { url: process.env.REDIS_URL }
// Email worker
const emailWorker = new Worker('emails', async (job) => {
const { to, subject, body } = job.data
await sendEmail({ to, subject, body })
console.log(`Email sent to ${to}`)
}, { connection })
// Image processing worker
const imageWorker = new Worker('images', async (job) => {
const { imageUrl, userId } = job.data
const processed = await processImage(imageUrl)
await uploadToS3(processed)
await notifyUser(userId, 'Image processed!')
}, {
connection,
concurrency: 5 // Process 5 jobs simultaneously
})
console.log('Workers started')// api/src/routes/images.ts
import { Queue } from 'bullmq'
const imageQueue = new Queue('images', {
connection: { url: process.env.REDIS_URL }
})
app.post('/api/images/process', async (req, res) => {
const { imageUrl } = req.body
const userId = req.user.id
await imageQueue.add('process', { imageUrl, userId })
res.json({ message: 'Processing started' })
})Private networking
Railway enables secure communication between services.
Internal URLs
Each service receives an internal URL:
[service-name].railway.internal
Example:
api.railway.internal:3000
postgres.railway.internal:5432
redis.railway.internal:6379Configuration
// api/src/config.ts
export const config = {
// Internal URL for service-to-service communication
authServiceUrl: process.env.AUTH_SERVICE_URL || 'http://auth.railway.internal:3001',
// External URL for clients
publicApiUrl: process.env.PUBLIC_API_URL || 'https://api.example.com',
// Database - always internal
databaseUrl: process.env.DATABASE_URL // postgres.railway.internal
}// api/src/services/auth.ts
import { config } from '../config'
export async function verifyToken(token: string) {
// Call to internal auth service
const response = await fetch(`${config.authServiceUrl}/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
})
return response.json()
}Service discovery
# docker-compose.yml (for local development)
services:
api:
environment:
- AUTH_SERVICE_URL=http://auth:3001
- DATABASE_URL=postgres://postgres:postgres@db:5432/app
auth:
environment:
- DATABASE_URL=postgres://postgres:postgres@db:5432/app
db:
image: postgres:15Scaling
Vertical scaling
Railway Dashboard → Service → Settings
Resources:
├── vCPU: 0.5 → 8 vCPUs
├── Memory: 512MB → 32GB
└── Disk: Persistent volumes
Autoscaling:
├── Min replicas: 1
├── Max replicas: 10
├── Target CPU: 70%
└── Target Memory: 80%Horizontal scaling
# railway.toml
[deploy]
numReplicas = 3
# Or through dashboard → ScaleHealth checks
# railway.toml
[deploy]
healthcheckPath = "/health"
healthcheckTimeout = 300// api/src/routes/health.ts
import express from 'express'
const router = express.Router()
router.get('/health', async (req, res) => {
try {
// Check database connection
await db.$queryRaw`SELECT 1`
// Check Redis
await redis.ping()
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
services: {
database: 'connected',
redis: 'connected'
}
})
} catch (error) {
res.status(503).json({
status: 'unhealthy',
error: error.message
})
}
})
export default routerLogging and monitoring
Railway logs
# CLI
railway logs
# With filtering
railway logs --filter "error"
# Follow (live)
railway logs -fStructured logging
// logger.ts
import pino from 'pino'
export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
formatters: {
level: (label) => ({ level: label })
},
timestamp: pino.stdTimeFunctions.isoTime
})
// Usage
logger.info({ userId: user.id }, 'User logged in')
logger.error({ err, requestId }, 'Request failed')Metrics
// metrics.ts
import { collectDefaultMetrics, Registry, Counter, Histogram } from 'prom-client'
const register = new Registry()
collectDefaultMetrics({ register })
// Custom metrics
export const httpRequestsTotal = new Counter({
name: 'http_requests_total',
help: 'Total HTTP requests',
labelNames: ['method', 'path', 'status'],
registers: [register]
})
export const httpRequestDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP request duration',
labelNames: ['method', 'path'],
buckets: [0.1, 0.5, 1, 2, 5],
registers: [register]
})
// Endpoint for Prometheus
app.get('/metrics', async (req, res) => {
res.set('Content-Type', register.contentType)
res.end(await register.metrics())
})Integration with external services
// Sentry
import * as Sentry from '@sentry/node'
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.RAILWAY_ENVIRONMENT || 'development',
release: process.env.RAILWAY_GIT_COMMIT_SHA
})
// Datadog
import tracer from 'dd-trace'
tracer.init({
service: process.env.DD_SERVICE || 'api',
env: process.env.RAILWAY_ENVIRONMENT,
version: process.env.RAILWAY_GIT_COMMIT_SHA
})CI/CD with Railway
GitHub Actions
# .github/workflows/deploy.yml
name: Deploy to Railway
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Railway CLI
run: npm install -g @railway/cli
- name: Deploy
run: railway up --service api
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
- name: Run migrations
run: railway run npx prisma migrate deploy
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}Deploy with tests
name: Test and Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run test
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to Railway
run: |
npm install -g @railway/cli
railway up
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}Rollback
# From CLI
railway rollback
# Choose a previous deployment from the list
# Or through dashboard → Deployments → RollbackFramework integrations
Next.js
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone', // Optimization for Railway
experimental: {
serverComponentsExternalPackages: ['@prisma/client']
}
}
module.exports = nextConfig# railway.toml
[build]
builder = "NIXPACKS"
[deploy]
startCommand = "node server.js"
healthcheckPath = "/api/health"NestJS
// main.ts
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
async function bootstrap() {
const app = await NestFactory.create(AppModule)
// Health check
app.get('/health', (req, res) => res.send('OK'))
const port = process.env.PORT || 3000
await app.listen(port, '0.0.0.0')
console.log(`Application running on port ${port}`)
}
bootstrap()FastAPI (Python)
# main.py
from fastapi import FastAPI
import os
app = FastAPI()
@app.get("/health")
def health():
return {"status": "healthy"}
@app.get("/")
def root():
return {"message": "Hello from Railway!"}
if __name__ == "__main__":
import uvicorn
port = int(os.getenv("PORT", 8000))
uvicorn.run(app, host="0.0.0.0", port=port)# railway.toml
[build]
builder = "NIXPACKS"
[deploy]
startCommand = "uvicorn main:app --host 0.0.0.0 --port $PORT"
healthcheckPath = "/health"Go (Gin)
// main.go
package main
import (
"os"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "healthy"})
})
r.GET("/", func(c *gin.Context) {
c.JSON(200, gin.H{"message": "Hello from Railway!"})
})
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
r.Run(":" + port)
}Django
# settings.py
import os
import dj_database_url
DEBUG = os.getenv('DEBUG', 'False') == 'True'
ALLOWED_HOSTS = [
'.railway.app',
'localhost',
'127.0.0.1'
]
# Database
DATABASES = {
'default': dj_database_url.config(
default=os.getenv('DATABASE_URL'),
conn_max_age=600
)
}
# Static files
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
# WhiteNoise for static files
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
# ...
]
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'# Procfile (alternative to railway.toml)
web: gunicorn myproject.wsgi --bind 0.0.0.0:$PORTCustom domains
Adding a domain
1. Railway Dashboard → Service → Settings → Domains
2. Click "Add Custom Domain"
3. Enter the domain: api.example.com
4. Add DNS records:
CNAME api -> [your-service].up.railway.app
Or for root domain:
A record -> Railway IPSSL/TLS
Railway automatically:
- Generates Let's Encrypt certificates
- Handles HTTPS
- Redirects HTTP → HTTPS
- Renews certificatesWildcard domains
For *.example.com:
1. Add CNAME *.example -> [service].up.railway.app
2. In Railway add *.example.com as a custom domain
3. Railway will handle all subdomainsPersistent storage
Volumes
Railway supports persistent volumes for:
- Files uploaded by users
- Cache
- Local databases (SQLite)
Dashboard → Service → Volumes → Add Volume
Mount path: /data
Size: 1GB - 100GB// Using a volume
import fs from 'fs/promises'
import path from 'path'
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads'
export async function saveFile(file: Buffer, filename: string) {
const filepath = path.join(UPLOAD_DIR, filename)
await fs.mkdir(path.dirname(filepath), { recursive: true })
await fs.writeFile(filepath, file)
return filepath
}Object storage (S3-compatible)
// Recommendation: Use S3/R2/MinIO for files
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
const s3 = new S3Client({
region: 'auto',
endpoint: process.env.S3_ENDPOINT, // Cloudflare R2, MinIO, etc.
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!
}
})
export async function uploadFile(file: Buffer, key: string) {
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
Body: file
}))
return `${process.env.S3_PUBLIC_URL}/${key}`
}Railway pricing
Plans
Trial:
├── Price: $0
├── $5 one-time credit
├── Full platform access
├── Limit: 500 execution hours/month
└── Ideal for testing
Hobby:
├── Price: $5/month
├── Includes $5 usage
├── 8GB RAM per service
├── 8 vCPU per service
├── Unlimited deployments
└── For side projects
Pro:
├── Price: $20/month per member
├── Includes $10 usage per member
├── 32GB RAM per service
├── 32 vCPU per service
├── Teams & collaboration
├── Priority support
└── For production
Enterprise:
├── Custom pricing
├── Dedicated infrastructure
├── SLA guarantees
├── SSO/SAML
└── Advanced securityUsage pricing
Compute:
├── vCPU: $0.000463/minute ($20/month for 1 vCPU 24/7)
└── Memory: $0.000231/GB/minute ($10/month for 1GB 24/7)
Network:
├── Egress: $0.10/GB (first 100GB free)
└── Ingress: Free
Databases:
├── PostgreSQL: compute + storage ($0.25/GB/month)
├── Redis: compute only
└── MongoDB: compute + storageCost calculation
Example: Small SaaS
API (Node.js):
├── 0.5 vCPU: ~$10/month
├── 512MB RAM: ~$5/month
└── Subtotal: ~$15/month
PostgreSQL:
├── 0.25 vCPU: ~$5/month
├── 256MB RAM: ~$2.50/month
├── 10GB storage: ~$2.50/month
└── Subtotal: ~$10/month
Redis:
├── 0.25 vCPU: ~$5/month
├── 256MB RAM: ~$2.50/month
└── Subtotal: ~$7.50/month
Total: ~$32.50/month (with $5 included in Hobby)
Actual cost: ~$27.50/monthBest practices
Security
// 1. Use environment variables
const apiKey = process.env.API_KEY // Never hardcoded!
// 2. Validate input
import { z } from 'zod'
const UserSchema = z.object({
email: z.string().email(),
password: z.string().min(8)
})
// 3. Use HTTPS only
app.use((req, res, next) => {
if (req.headers['x-forwarded-proto'] !== 'https') {
return res.redirect(`https://${req.headers.host}${req.url}`)
}
next()
})
// 4. Rate limiting
import rateLimit from 'express-rate-limit'
app.use(rateLimit({
windowMs: 15 * 60 * 1000,
max: 100
}))Optimization
# 1. Multi-stage builds
FROM node:20-alpine AS builder
# ... build steps ...
FROM node:20-alpine AS runner
# ... only production dependencies ...
# 2. .dockerignore
node_modules
.git
.env*
*.md
tests/// 3. Connection pooling
import { PrismaClient } from '@prisma/client'
// Singleton pattern for Prisma
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query'] : []
})
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}Graceful shutdown
// SIGTERM handling
process.on('SIGTERM', async () => {
console.log('SIGTERM received, shutting down gracefully')
// Stop accepting new requests
server.close()
// Close database connections
await prisma.$disconnect()
await redis.quit()
console.log('Cleanup complete')
process.exit(0)
})FAQ - frequently asked questions
Does Railway have a free tier?
Yes, Railway offers a one-time $5 credit on the trial plan. After using it up, you can switch to the Hobby plan ($5/month) which includes $5 in usage credit. However, there is no permanently free plan like Heroku used to offer.
How does Railway compare to Heroku?
Railway is generally cheaper and more modern:
- No "dyno sleeping" even on the cheapest plan
- Native Docker support
- Preview environments
- Simpler pricing (pay-as-you-go)
- Better developer experience
Heroku may be a better fit for enterprises with compliance requirements.
Can I deploy static sites?
Yes, but Railway is not optimized for that. For static sites, better options include:
- Vercel
- Netlify
- Cloudflare Pages
Railway shines with backends and full-stack apps.
How do I debug deployment issues?
- Check logs:
railway logs - Check build logs in the dashboard
- Run locally with the same variables:
railway run npm start - Verify the Dockerfile/Nixpacks configuration
- Check the health check endpoint
Does Railway support WebSockets?
Yes, Railway fully supports WebSockets without any additional configuration. Keep in mind sticky sessions when scaling horizontally.
How do I back up the database?
# PostgreSQL dump
railway run pg_dump $DATABASE_URL > backup.sql
# Or through the pg_dump plugin in the dashboard
# Settings → Backups → Create BackupSummary
Railway is a modern deployment platform that combines the simplicity of Heroku with flexibility and affordable pricing:
- Quick start - Deploy in a minute from GitHub or CLI
- One-click databases - PostgreSQL, MySQL, MongoDB, Redis
- Docker native - Full container support
- Preview environments - Automatic environments for PRs
- Scaling - Horizontal and vertical scaling
- Simple pricing - Pay-as-you-go with no surprises
Railway is ideal for startups, side projects, and teams that want to focus on code, not infrastructure.