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 → Settings → Source → 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:
- API Token: Railway dashboard → Account Settings → Tokens → Create Token. Give it a descriptive name like
github-actions-deploy. - Service ID: In your Railway project, click your service → Settings → scroll down to find the Service ID (a UUID like
abc123de-...). - 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_TOKENRAILWAY_SERVICE_IDRAILWAY_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 ciinstead ofnpm install—ciis stricter: it usespackage-lock.jsonexactly 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:
- Check the Actions tab in your GitHub repo — you should see a workflow run triggered by your push of the workflow file itself.
- Intentionally break a test and push to main. Confirm the deploy job never runs (it should show as “skipped” in the Actions UI).
- Fix the test and push again. Confirm the deploy job runs and Railway shows a new deployment in the dashboard.
- 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:
- Disable Railway auto-deploy
- Create Railway API token, grab Service ID and Environment ID
- Add all three as GitHub secrets
- Copy the workflow file into
.github/workflows/deploy.yml - Adapt the test job for your language/framework
- 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.