CI/CD with GitHub Actions & Railway: Full Guide

This article contains affiliate links. We may earn a commission if you click through and make a purchase — at no extra cost to you.

You pushed broken code to production last week. Maybe it was a missing environment variable, a dependency that worked locally but exploded on the server, or a migration that ran halfway and left your database in a weird state. Whatever it was, the fix was manual, stressful, and took longer than it should have.

A proper CI/CD pipeline would have caught it — or at least made the rollback a one-click affair. This guide is going to show you exactly how to build one using GitHub Actions and Railway, from a blank repo to a fully automated deploy pipeline that runs tests, checks your build, and ships to production only when everything passes.

No hand-waving. No “and then configure your pipeline” nonsense. Actual YAML you can copy, actual mistakes you’ll avoid, and honest opinions about where this stack shines and where it doesn’t.

Why GitHub Actions + Railway?

Before we write a single line of config, let me justify the stack choice, because there are real alternatives worth considering.

GitHub Actions is the obvious CI choice if your code is already on GitHub — it’s deeply integrated, the free tier is genuinely useful (2,000 minutes/month on public repos, 500 minutes on private), and the marketplace has pre-built actions for almost everything. The YAML syntax is verbose but learnable, and unlike CircleCI or Jenkins, you’re not managing another service just to run your tests.

Railway is where the interesting part is. If you migrated from Heroku (or are thinking about it — I covered moving 14 projects from Heroku to Railway in one weekend and the mistakes I made), Railway’s deploy model will feel familiar but faster. It has its own built-in CI/CD through GitHub auto-deploys, but that’s too blunt for real projects — you want tests to pass before anything ships. That’s where wiring GitHub Actions into Railway’s deploy API gives you actual control.

If you need raw infrastructure control or you’re running a larger team, DigitalOcean with App Platform is worth a look — I compared the options in depth in Best Cloud Hosting for Side Projects 2026. But for solo developers and small teams who want fast deploys without managing servers, Railway is hard to beat.

What We’re Building

Here’s the pipeline we’ll set up by the end of this guide:

  • On every pull request: run linting and tests. Block merge if they fail.
  • On every push to main: run tests again, build the app, deploy to Railway staging environment.
  • On a manual trigger (or tagged release): deploy to Railway production.
  • If the production deploy fails: automatically trigger a rollback via Railway’s API.

I’m using a Node.js/Express app as the example, but the structure applies to any runtime Railway supports (Python, Go, Ruby, etc.) — you’ll just swap out the test and build commands.

Step 1: Set Up Your Railway Project

Assuming you already have a Railway account and a project, you need two things before touching GitHub Actions:

1a. Create Separate Environments

In your Railway project dashboard, go to Settings → Environments and create a staging environment alongside the default production one. This is non-negotiable — deploying untested code directly to production defeats the whole point.

Each environment gets its own set of environment variables. Set your staging database URL, API keys, etc. separately from production. Railway handles this cleanly; it’s one of the things it genuinely does better than the old Heroku pipeline setup.

1b. Grab Your Railway API Token and Service IDs

Go to Account Settings → Tokens and create a new token. Copy it — you’ll only see it once.

You also need your Service ID and Environment ID for both staging and production. Find these under each service’s settings panel in Railway. They look like UUIDs: abc12345-...

Store all of these as GitHub Secrets (repo → Settings → Secrets and variables → Actions):

  • RAILWAY_TOKEN
  • RAILWAY_STAGING_SERVICE_ID
  • RAILWAY_STAGING_ENVIRONMENT_ID
  • RAILWAY_PRODUCTION_SERVICE_ID
  • RAILWAY_PRODUCTION_ENVIRONMENT_ID

Don’t hardcode these in your YAML. Ever. I’ve seen repos with tokens committed directly. Don’t be that person.

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: The Pull Request Workflow

Create .github/workflows/pr-checks.yml in your repo:

name: PR Checks

on:
  pull_request:
    branches: [main]

jobs:
  test:
    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 linter
        run: npm run lint

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

A few things worth noting here:

Use npm ci instead of npm install — it installs from the lockfile exactly, which is what you want in CI. npm install can silently update packages and introduce drift between your CI environment and production.

The cache: 'npm' option on the Node setup action caches your node_modules between runs. On a project with 200+ dependencies, this can cut your CI time from 3 minutes to under 60 seconds. Worth it.

If your tests need a real database, use GitHub Actions’ services feature to spin up a Postgres container rather than hitting a shared test database. Add this to your job:

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: testpassword
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

Then set DATABASE_URL: postgresql://postgres:testpassword@localhost:5432/testdb in your env block instead of pulling from secrets.

Step 3: The Main Branch Deploy Workflow

Create .github/workflows/deploy-staging.yml:

name: Deploy to Staging

on:
  push:
    branches: [main]

jobs:
  test-and-deploy:
    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
        env:
          NODE_ENV: test

      - name: Deploy to Railway Staging
        if: success()
        run: |
          npm install -g @railway/cli
          railway up --service ${{ secrets.RAILWAY_STAGING_SERVICE_ID }} \
                     --environment ${{ secrets.RAILWAY_STAGING_ENVIRONMENT_ID }} \
                     --detach
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}

The if: success() condition on the deploy step is critical — it ensures Railway only gets a new deploy if tests passed. Without it, a failing test suite would still trigger a deploy.

The --detach flag tells the Railway CLI to kick off the deploy and not wait for it to finish. This keeps your Actions run fast. If you want to wait for the deploy to complete (useful if you’re running smoke tests afterward), remove --detach and add a timeout.

Step 4: Production Deploy with Manual Approval

This is where most tutorials get lazy and just auto-deploy to production on every merge. Don’t do that. You want a human in the loop for production, at minimum a manual trigger.

Create .github/workflows/deploy-production.yml:

name: Deploy to Production

on:
  workflow_dispatch:
    inputs:
      confirm:
        description: 'Type DEPLOY to confirm production deployment'
        required: true
  release:
    types: [published]

jobs:
  deploy-production:
    runs-on: ubuntu-latest
    environment: production

    steps:
      - name: Validate confirmation
        if: github.event_name == 'workflow_dispatch'
        run: |
          if [ "${{ github.event.inputs.confirm }}" != "DEPLOY" ]; then
            echo "Confirmation text did not match. Aborting."
            exit 1
          fi

      - 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 full test suite
        run: npm test

      - name: Deploy to Railway Production
        id: deploy
        run: |
          npm install -g @railway/cli
          railway up --service ${{ secrets.RAILWAY_PRODUCTION_SERVICE_ID }} \
                     --environment ${{ secrets.RAILWAY_PRODUCTION_ENVIRONMENT_ID }}
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}

      - name: Notify on failure
        if: failure()
        run: |
          echo "Production deploy failed. Check Railway dashboard for rollback options."
          # Add your Slack/Discord webhook call here

Notice the environment: production key on the job. In GitHub, you can configure Environments (repo Settings → Environments) with required reviewers — specific people who must approve before the job runs. This gives you a proper approval gate without any third-party tooling.

Step 5: Handling Rollbacks

Railway keeps a deploy history per service. If a production deploy goes sideways, you have two options:

Option A — Manual rollback via Railway dashboard: Go to your service, click Deployments, find the last good deploy, hit Redeploy. Takes about 30 seconds. This is fine for most situations.

Option B — Automated rollback via API: Add this to your production deploy job after the deploy step:

      - name: Health check
        id: healthcheck
        run: |
          sleep 30
          curl --fail https://your-app.railway.app/health || exit 1

      - name: Rollback on health check failure
        if: failure() && steps.deploy.outcome == 'success'
        run: |
          echo "Health check failed after deploy. Triggering rollback via Railway API."
          curl -X POST https://backboard.railway.app/graphql/v2 \
            -H "Authorization: Bearer ${{ secrets.RAILWAY_TOKEN }}" \
            -H "Content-Type: application/json" \
            -d '{
              "query": "mutation { deploymentRedeploy(id: \"PREVIOUS_DEPLOYMENT_ID\") { id } }"
            }'

The automated rollback via GraphQL API is more complex in practice because you need to dynamically fetch the previous deployment ID. For most side projects and small teams, the manual dashboard rollback is the pragmatic choice. Automate it when you’ve been burned by a slow manual rollback at 2am.

Step 6: Caching and Speeding Things Up

A CI pipeline that takes 8 minutes to run is a pipeline people will start ignoring. Here’s what actually moves the needle:

  • Cache dependencies: Already covered with cache: 'npm' in the Node setup action. Do the equivalent for pip, bundler, go modules, etc.
  • Parallelize jobs: Split lint and tests into separate jobs that run concurrently. If lint takes 2 minutes and tests take 4, running them in parallel saves 2 minutes per PR.
  • Use --only=production for build steps: Don’t install dev dependencies when you’re just building for deploy.
  • Matrix testing: If you need to test across multiple Node versions, use a matrix strategy rather than separate workflows.

Common Mistakes (That I’ve Made)

Not pinning action versions. Using actions/checkout@main instead of actions/checkout@v4 means a breaking change in that action can silently break your pipeline. Always pin to a specific major version tag.

Secrets in log output. If you ever need to debug a secret value, use echo "::add-mask::$SECRET_VALUE" before printing it. GitHub Actions will redact masked values from logs.

Running the full deploy on every commit, including docs changes. Use path filters to skip deploys when only markdown files changed:

on:
  push:
    branches: [main]
    paths-ignore:
      - '**.md'
      - 'docs/**'

Forgetting to set Railway environment variables before the first deploy. The Railway CLI deploy will succeed but your app will crash at runtime because DATABASE_URL is undefined. Always verify env vars in the Railway dashboard before triggering a pipeline deploy to a new environment. This is exactly the kind of thing I ran into during the Heroku-to-Railway migration — Railway’s environment variable system is great once configured, but the first-time setup bites you if you’re rushing.

Full Pipeline at a Glance

Trigger Workflow What Runs Deploy Target
Pull Request → main pr-checks.yml Lint + Tests None
Push to main deploy-staging.yml Tests → Deploy Railway Staging
Manual trigger / Release tag deploy-production.yml Tests → Deploy → Health check Railway Production

When This Stack Isn’t the Right Choice

Be honest with yourself about the limitations here:

If you need Docker-based builds with complex multi-stage pipelines, Railway handles Dockerfiles fine, but you might hit friction with GitHub Actions’ container support. At that point, look at whether a dedicated CI tool like CircleCI or a self-hosted runner makes more sense.

If you’re running a large team with compliance requirements, Railway’s audit logging and access controls may not be sufficient. You’ll want something like DigitalOcean App Platform or a full Kubernetes setup where you control the infrastructure entirely.

If your build minutes are burning through the GitHub free tier, check whether you can optimize first (caching, path filters, parallelization). If not, either upgrade GitHub or look at self-hosted runners on a cheap VPS — I cover the cost comparison in Best Cloud Hosting for Side Projects.

What About AI-Assisted Pipeline Setup?

A quick note since this comes up: yes, you can use AI coding assistants to generate GitHub Actions YAML, and they’re reasonably good at it. The gotcha is that they’ll often generate valid YAML that uses outdated action versions or misses Railway-specific nuances. Treat AI-generated CI config the same way you’d treat Stack Overflow answers — a starting point, not a finished product. If you’re evaluating AI tools for developer workflows more broadly, I’ve covered the best options in AI Tools That Save Developers Time in 2026.

Final Recommendation

This GitHub Actions + Railway stack is genuinely good for solo developers and teams up to maybe 10-15 engineers. The setup time is a few hours (less if you copy the configs above), the ongoing maintenance is low, and Railway’s deploy speed is fast enough that you won’t be watching a progress bar for 10 minutes every time you ship.

The specific setup I’d recommend for a new project:

  • Start with the PR checks workflow immediately — even before you have a staging environment. Tests on PRs is the highest-value thing you can add.
  • Add staging auto-deploy once you have a staging environment configured in Railway.
  • Add the production workflow with manual approval before you have real users. Retrofitting approval gates after the fact is annoying.
  • Revisit the rollback automation after your first production incident. You’ll know what you actually need by then.

The configs in this guide are production-tested — not theoretical. Copy them, adjust the runtime-specific commands, and you’ll have a working pipeline in an afternoon.

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.