CI/CD with GitHub Actions & Railway: Full Setup Guide

This article contains affiliate links. We may earn a commission if you purchase through them, at no extra cost to you.

You’ve got a Node app (or Python, or whatever) sitting on Railway. Every time you push a fix, you’re either manually triggering a deploy from the dashboard or relying on Railway’s auto-deploy, which runs even when your tests are failing. That’s not a CI/CD pipeline — that’s just hoping for the best.

This guide walks you through building a real CI/CD pipeline: GitHub Actions runs your tests on every push, and only if they pass does Railway get the deploy signal. No more shipping broken code to production because you forgot to run tests locally. I’ll give you the actual workflow files, explain every non-obvious decision, and flag the mistakes I’ve seen (and made) along the way.

What We’re Actually Building

Before touching any config files, let’s be precise about what this pipeline does:

  • On every push to any branch: GitHub Actions runs your test suite
  • On push to main (and only if tests pass): Railway deploys the new version
  • On pull requests: Tests run, but no deploy happens — you get a pass/fail check before merging

This is the pattern that actually matters. Railway has a built-in GitHub integration that auto-deploys on push, but it has no concept of “only deploy if tests pass.” You need GitHub Actions in the middle to enforce that gate.

Prerequisites

  • A Railway project with at least one service deployed from a GitHub repo
  • A GitHub repo with some kind of test suite (even a single test is fine to start)
  • Railway CLI installed locally (npm install -g @railway/cli)
  • Node.js 18+ if you’re following the Node examples (adapt as needed for Python/Go/etc.)

If you’re still in the middle of migrating to Railway, check out this honest account of migrating 14 projects from Heroku to Railway — it covers the gotchas you’ll hit before you even get to CI/CD setup.

Step 1: Disable Railway’s Auto-Deploy

This is the step most tutorials skip, and it’s the most important one. If Railway is already auto-deploying on every push, your GitHub Actions pipeline will race against it — or worse, Railway will deploy broken code while Actions is still running tests.

Go to your Railway project → select your service → SettingsSource → toggle off Auto Deploy.

Now Railway will only deploy when you explicitly tell it to. That’s exactly what you want — your GitHub Actions workflow will be the thing that tells it when.

Get the dev tool stack guide

A weekly breakdown of the tools worth your time — and the ones that aren’t. Join 500+ developers.



No spam. Unsubscribe anytime.

Step 2: Get Your Railway API Token and Service Details

You’ll need three things from Railway to trigger deploys from GitHub Actions:

  1. API Token: Railway dashboard → Account Settings → Tokens → Create Token. Give it a descriptive name like github-actions-deploy.
  2. Service ID: In your Railway project, click your service → Settings → scroll down to find the Service ID (a UUID like abc123de-...).
  3. Environment ID: Same Settings page, just below Service ID.

You can also grab the last two via the Railway CLI:

railway status
# Shows project, environment, and service info

Now add these to your GitHub repo as secrets: Settings → Secrets and variables → Actions → New repository secret.

  • RAILWAY_TOKEN
  • RAILWAY_SERVICE_ID
  • RAILWAY_ENVIRONMENT_ID

Never hardcode these in your workflow files. That’s not paranoia — it’s just basic hygiene.

Step 3: Create the GitHub Actions Workflow

Create the file .github/workflows/deploy.yml in your repo. Here’s the full workflow for a Node.js app:

name: Test and Deploy

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  test:
    name: Run Tests
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

  deploy:
    name: Deploy to Railway
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install Railway CLI
        run: npm install -g @railway/cli

      - name: Deploy to Railway
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
        run: |
          railway up \
            --service ${{ secrets.RAILWAY_SERVICE_ID }} \
            --environment ${{ secrets.RAILWAY_ENVIRONMENT_ID }} \
            --detach

Let’s break down the non-obvious parts:

  • needs: test — This is the critical line. The deploy job will not start unless the test job succeeds. If tests fail, deploy never runs.
  • if: github.ref == 'refs/heads/main' && github.event_name == 'push' — Deploy only on direct pushes to main, not on PRs. Without this, Railway would try to deploy every time someone opens a PR against main.
  • --detach — Tells Railway CLI not to stream logs back to the runner. Without this, the GitHub Action will hang waiting for the deploy to finish and potentially time out. Railway handles the deploy asynchronously; you can watch progress in the Railway dashboard.
  • npm ci instead of npm installci is stricter: it uses package-lock.json exactly and fails if there’s a mismatch. This prevents “works on my machine” situations in CI.

Step 4: Handle Environment Variables in Tests

This is where most people hit a wall. Your app probably needs environment variables to run tests — database URLs, API keys, etc. You have two good options:

Option A: Add test env vars as GitHub Secrets

For anything genuinely secret (API keys, database credentials), add them as GitHub secrets and reference them in your workflow:

      - name: Run tests
        env:
          DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
          NODE_ENV: test
        run: npm test

Option B: Use a test-specific .env file with a service container

For database-dependent tests, spin up a real database as a service container. Here’s how to add a Postgres container to the test job:

  test:
    name: Run Tests
    runs-on: ubuntu-latest
    
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        env:
          DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
          NODE_ENV: test
        run: npm test

This approach is cleaner than pointing your CI tests at a real database, and it means your tests are fully isolated — no shared state between runs.

Step 5: Add a Staging Environment (Optional but Recommended)

If you’re working on anything beyond a hobby project, you want a staging environment that deploys on every merge to main, with production deploys gated behind a manual approval or a separate branch like production.

Railway makes this straightforward because it has first-class environment support. Create a staging environment in Railway, then update your workflow:

on:
  push:
    branches:
      - main
      - production

jobs:
  test:
    # ... same as before

  deploy-staging:
    name: Deploy to Staging
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm install -g @railway/cli
      - name: Deploy to Staging
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
        run: |
          railway up \
            --service ${{ secrets.RAILWAY_SERVICE_ID }} \
            --environment ${{ secrets.RAILWAY_STAGING_ENVIRONMENT_ID }} \
            --detach

  deploy-production:
    name: Deploy to Production
    needs: test
    if: github.ref == 'refs/heads/production' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production  # Requires manual approval in GitHub
    steps:
      - uses: actions/checkout@v4
      - run: npm install -g @railway/cli
      - name: Deploy to Production
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
        run: |
          railway up \
            --service ${{ secrets.RAILWAY_SERVICE_ID }} \
            --environment ${{ secrets.RAILWAY_PROD_ENVIRONMENT_ID }} \
            --detach

The environment: production line in the production deploy job enables GitHub’s environment protection rules — you can require a manual approval before the deploy runs. Go to your repo Settings → Environments → production → add yourself as a required reviewer.

Common Mistakes and How to Fix Them

Mistake 1: Railway CLI version mismatches

Railway updates their CLI fairly aggressively. If a deploy starts failing in CI with cryptic errors, pin the CLI version:

run: npm install -g @railway/cli@3.x.x

Check the Railway CLI releases page for the latest stable version.

Mistake 2: Forgetting to commit package-lock.json

If package-lock.json is in your .gitignore, npm ci will fail every time. Remove it from .gitignore. Yes, I know some older tutorials say to ignore it. They’re wrong.

Mistake 3: The deploy job times out waiting for Railway

Without --detach, the Railway CLI streams deploy logs back to your runner. If your build takes more than 6 hours (unlikely but possible for large builds), GitHub Actions will kill it. More practically, it just holds your runner hostage while Railway does its thing. Use --detach and check Railway’s dashboard for deploy status.

Mistake 4: Not caching node_modules

The cache: 'npm' option in actions/setup-node caches your npm cache between runs. On a cold run, installing dependencies might take 30-60 seconds. With caching, it’s often under 5 seconds. Always enable this.

Mistake 5: Running the full test suite on every branch

If you have a slow test suite (say, 10+ minutes), you might want to run a fast subset on feature branches and the full suite only on PRs to main. Split your tests with npm scripts:

// package.json
"scripts": {
  "test": "jest",
  "test:fast": "jest --testPathPattern='unit'",
  "test:full": "jest"
}

Adapting This for Python

The Railway deployment steps are identical — it’s just the test job that changes:

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: 'pip'

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run tests
        run: pytest
        env:
          DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}

Verifying the Pipeline Works

After pushing your workflow file, here’s how to confirm everything is wired up correctly:

  1. Check the Actions tab in your GitHub repo — you should see a workflow run triggered by your push of the workflow file itself.
  2. Intentionally break a test and push to main. Confirm the deploy job never runs (it should show as “skipped” in the Actions UI).
  3. Fix the test and push again. Confirm the deploy job runs and Railway shows a new deployment in the dashboard.
  4. Open a PR against main. Confirm tests run but no deploy happens.

If step 2 doesn’t work — if Railway deploys even when tests fail — double-check that auto-deploy is disabled in Railway’s settings. That’s almost always the culprit.

When Railway Isn’t the Right Choice

Railway is excellent for small-to-medium projects where you want zero infrastructure management. But if you’re running something with serious traffic requirements or need fine-grained control over your infrastructure, you’ll eventually hit Railway’s limits.

For those cases, the same GitHub Actions pattern works with other platforms. You’d swap the Railway CLI deploy step for a doctl command if you’re on DigitalOcean, or an SSH deploy step for a VPS. The test gate logic is identical regardless of where you’re deploying. If you’re evaluating hosting options, our best cloud hosting for side projects guide covers Railway alongside the other serious contenders.

Speeding Up Your Workflow with AI

Writing workflow files, Dockerfiles, and test boilerplate is exactly the kind of repetitive work where AI coding assistants pay off. If you’re not already using one for this kind of config generation, it’s worth reading our Claude vs ChatGPT comparison for developers — both are genuinely useful for generating GitHub Actions YAML, though Claude tends to produce fewer hallucinated action versions in my experience.

Final Recommendation

The workflow I’ve shown here is production-grade for most projects. Here’s the exact sequence to get it running:

  1. Disable Railway auto-deploy
  2. Create Railway API token, grab Service ID and Environment ID
  3. Add all three as GitHub secrets
  4. Copy the workflow file into .github/workflows/deploy.yml
  5. Adapt the test job for your language/framework
  6. Push and verify with an intentional test failure

The whole thing takes about 20 minutes to set up and saves you from shipping broken code indefinitely. That’s one of the better ROIs in software development tooling.

If you’re building out a more complete DevOps stack and want to understand what else belongs in it, the AI tools that save developers time article covers several tools that integrate well with this kind of automated workflow — particularly around code review and documentation generation.

Get the dev tool stack guide

A weekly breakdown of the tools worth your time — and the ones that aren’t. Join 500+ developers.



No spam. Unsubscribe anytime.

Leave a Comment

Stay sharp.

A weekly breakdown of the tools worth your time — and the ones that aren't.

Join 500+ developers. No spam ever.