Railway is a modern Platform-as-a-Service that makes deploying applications incredibly simple. In this guide, we'll walk through deploying a Node.js/TypeScript application using Docker, based on how we deploy the Skyscraper push notification server.

Why Railway?

Railway stands out for several reasons:

  • Simple deployment - Connect your GitHub repo and deploy automatically
  • Docker support - Use your own Dockerfile for full control
  • Built-in databases - PostgreSQL, MySQL, Redis with one click
  • Environment variables - Easy secret management
  • Health checks - Automatic monitoring and restart on failure
  • Transparent pricing - Pay for what you use, starting free

Project Structure

Here's the structure of a typical Node.js/TypeScript project ready for Railway deployment:

my-app/
├── src/
│   └── index.ts          # Main application entry point
├── dist/                 # Compiled JavaScript (generated)
├── Dockerfile            # Docker build instructions
├── railway.json          # Railway configuration
├── package.json          # Node.js dependencies
├── tsconfig.json         # TypeScript configuration
└── .env                  # Local environment variables (not deployed)

Step 1: Create Your Dockerfile

A multi-stage Dockerfile keeps your production image small and secure. Here's the pattern we use:

# Build stage
FROM node:20-alpine AS builder

WORKDIR /app

# Copy package files first for better caching
COPY package*.json ./

# Install all dependencies (including devDependencies for building)
RUN npm ci

# Copy source code
COPY tsconfig.json ./
COPY src ./src

# Build TypeScript
RUN npm run build

# Production stage
FROM node:20-alpine AS production

WORKDIR /app

# Copy package files
COPY package*.json ./

# Install only production dependencies
RUN npm ci --only=production

# Copy built files from builder stage
COPY --from=builder /app/dist ./dist

# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001

USER nodejs

# Expose the port your app runs on
EXPOSE 3000

# Start the application
CMD ["node", "dist/index.js"]

Key Points:

  • Multi-stage build - Separate build and production stages for smaller images
  • Alpine base - Lightweight Linux distribution (~5MB vs ~900MB for full Node image)
  • Non-root user - Security best practice
  • Production dependencies only - No devDependencies in final image

Step 2: Configure railway.json

The railway.json file tells Railway how to build and run your application:

{
    "$schema": "https://railway.app/railway.schema.json",
    "build": {
        "builder": "DOCKERFILE",
        "dockerfilePath": "Dockerfile"
    },
    "deploy": {
        "startCommand": "node dist/index.js",
        "healthcheckPath": "/health",
        "healthcheckTimeout": 30,
        "restartPolicyType": "ON_FAILURE",
        "restartPolicyMaxRetries": 3
    }
}

Configuration Explained:

  • builder: "DOCKERFILE" - Use Docker instead of Nixpacks
  • dockerfilePath - Path to your Dockerfile
  • startCommand - Command to start your app
  • healthcheckPath - Endpoint Railway pings to verify your app is running
  • healthcheckTimeout - Seconds to wait for health check response
  • restartPolicyType - When to restart (ON_FAILURE, ALWAYS, or NEVER)
  • restartPolicyMaxRetries - How many restart attempts before giving up

Step 3: Implement Health Check Endpoint

Railway uses health checks to monitor your application. Add a /health endpoint to your Express app:

import express from 'express';

const app = express();
const PORT = process.env.PORT || 3000;

// Health check endpoint for Railway
app.get('/health', (req, res) => {
    // Optionally check database connectivity
    res.status(200).json({
        status: 'healthy',
        timestamp: new Date().toISOString(),
        uptime: process.uptime()
    });
});

// Your other routes...
app.get('/', (req, res) => {
    res.json({ message: 'Hello from Railway!' });
});

app.listen(PORT, () => {
    console.log(`Server running on port ${PORT}`);
});

Advanced Health Checks

For production apps, check your dependencies:

app.get('/health', async (req, res) => {
    try {
        // Check database connection
        await db.query('SELECT 1');

        // Check Redis if used
        await redis.ping();

        res.status(200).json({
            status: 'healthy',
            database: 'connected',
            redis: 'connected'
        });
    } catch (error) {
        res.status(503).json({
            status: 'unhealthy',
            error: error.message
        });
    }
});

Step 4: Set Up PostgreSQL

Railway makes adding a database trivial:

  1. In your Railway project, click "New"
  2. Select "Database""PostgreSQL"
  3. Railway automatically provisions the database
  4. Click on the database service to see connection details

Railway provides a DATABASE_URL environment variable automatically. Use it in your app:

import { Pool } from 'pg';

const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
    ssl: process.env.NODE_ENV === 'production'
        ? { rejectUnauthorized: false }
        : false
});

export default pool;

Step 5: Environment Variables

Set environment variables in Railway's dashboard:

  1. Go to your service in Railway
  2. Click "Variables"
  3. Add your variables (API keys, secrets, etc.)

Common variables for a Node.js app:

NODE_ENV=production
PORT=3000
DATABASE_URL=${{Postgres.DATABASE_URL}}
API_KEY=your-secret-key

Note: The ${{Postgres.DATABASE_URL}} syntax references another service's variable.

Step 6: Deploy

Connect your GitHub repository:

  1. Go to railway.app and create a new project
  2. Select "Deploy from GitHub repo"
  3. Authorize Railway and select your repository
  4. Railway automatically detects your Dockerfile and deploys

Every push to your main branch triggers a new deployment automatically.

Real-World Example: Skyscraper Push Server

Here's how we structure the Skyscraper push notification server that runs on Railway:

Architecture Overview

  • Express server - Handles API requests and webhooks
  • WebSocket connection - Connects to Bluesky Jetstream for real-time events
  • PostgreSQL - Stores user subscriptions and notification preferences
  • Firebase Admin SDK - Sends push notifications to iOS devices

Key Endpoints

// Health check
app.get('/health', healthCheck);

// Register device for push notifications
app.post('/register', registerDevice);

// Webhook from Bluesky for account events
app.post('/webhook', handleBlueskyWebhook);

// Get notification settings
app.get('/settings/:did', getSettings);

WebSocket Connection

The server maintains a WebSocket connection to Bluesky's Jetstream service:

import WebSocket from 'ws';

const JETSTREAM_URL = 'wss://jetstream2.us-east.bsky.network/subscribe';

function connectToJetstream() {
    const ws = new WebSocket(`${JETSTREAM_URL}?wantedCollections=app.bsky.feed.post`);

    ws.on('message', (data) => {
        const event = JSON.parse(data.toString());
        processEvent(event);
    });

    ws.on('close', () => {
        // Reconnect after delay
        setTimeout(connectToJetstream, 5000);
    });
}

Monitoring and Logs

Railway provides built-in monitoring:

  • Logs - Real-time log streaming in the dashboard
  • Metrics - CPU, memory, and network usage
  • Deployments - History of all deployments with rollback capability

Access logs via CLI:

# Install Railway CLI
npm install -g @railway/cli

# Login
railway login

# View logs
railway logs

Cost Optimization Tips

Keep your Railway bills low:

  • Use small images - Alpine-based images use less memory
  • Right-size resources - Don't over-provision CPU/memory
  • Consolidate services - Run multiple functions in one service if appropriate
  • Monitor usage - Check the usage tab regularly
  • Use sleep mode - For non-critical services, allow them to sleep when idle

Troubleshooting Common Issues

Build Fails

Check your Dockerfile syntax and ensure all dependencies are in package.json. View build logs in Railway dashboard.

Health Check Fails

Ensure your /health endpoint returns 200 within the timeout period. Check that your app starts and listens on the correct port.

Database Connection Issues

Verify DATABASE_URL is set correctly. Enable SSL for production PostgreSQL connections.

App Crashes on Start

Check logs for the actual error. Common issues: missing environment variables, wrong start command, or TypeScript not compiled.

Frequently Asked Questions

What is Railway?

Railway is a modern Platform-as-a-Service (PaaS) for deploying applications. It supports Docker, multiple languages, and includes managed databases.

How much does Railway cost?

Railway has a free tier with $5/month of credit. Paid plans start at $5/month for Hobby tier. You pay based on actual resource usage.

Can Railway deploy Docker containers?

Yes. Add a Dockerfile to your repo and configure railway.json to use the DOCKERFILE builder.

How do I add a database to Railway?

Click "New" in your project and select a database (PostgreSQL, MySQL, Redis, MongoDB). Railway provisions it automatically and provides connection strings.

Start Building

Railway makes deployment simple so you can focus on building features. Whether you're deploying a simple API or a complex real-time system, Railway scales with your needs.

Check out railway.app to get started with your own deployments.