Manual contentsDeployment RecipesBrowse 103 chapters
Manual 6 min read

Deploy to Railway

Railway is a Git-driven PaaS that builds your Dockerfile and runs it on managed infrastructure. Pair it with Railway's managed Postgres and Redis and you have a complete Suprnova production stack with no servers to babysit. This recipe takes a freshly scaffolded app from suprnova new to a live URL.

Prerequisites

  • A Railway account
  • A Suprnova project pushed to GitHub, GitLab, or Bitbucket
  • A Dockerfile and .dockerignore at the repo root, generated by:
    suprnova docker:init
    
  • A generated APP_KEY you can paste into Railway's variables:
    suprnova key:generate --show
    

suprnova is only needed locally - Railway builds the Dockerfile itself. The framework crate is pulled from git as a normal cargo dependency during the build.

Provision the project

  1. Open the Railway dashboard, click New Project, and choose Deploy from GitHub repo.
  2. Pick the repository. Railway detects the Dockerfile and starts the first build automatically.
  3. While it's building, add a database: NewDatabaseAdd PostgreSQL. Railway exposes DATABASE_URL as a reference variable on the project.
  4. Optionally add Redis the same way (NewDatabaseRedis) if your app uses the Redis cache, session, queue, or rate-limit driver. Railway exposes the connection URL as REDIS_URL.

Wire the variables

Open the web service, go to Variables, and add the production configuration. Use Railway's ${{ }} reference syntax to pull URLs from the database services so rotations don't require re-pasting.

APP_ENV=production
APP_KEY=<paste the output of `suprnova key:generate --show`>
SERVER_HOST=0.0.0.0
SERVER_PORT=8765
DATABASE_URL=${{ Postgres.DATABASE_URL }}
REDIS_URL=${{ Redis.REDIS_URL }}

A few things worth knowing:

  • APP_KEY is mandatory in non-development environments. Suprnova fails closed on boot when APP_ENV != local|dev|test and APP_KEY is missing or malformed. The server logs a remediation message and exits non-zero - Railway will mark the deploy failed. Generate the key with suprnova key:generate --show.
  • SERVER_HOST=0.0.0.0 is required. Railway routes traffic through the container's network interface; binding to 127.0.0.1 (the local default) will look like a refused connection.
  • SERVER_PORT matches EXPOSE in the Dockerfile. The generated Dockerfile exposes 8765. Railway maps it to a public URL automatically.

Build and deploy

Railway builds on every push to the connected branch. The Dockerfile generated by docker:init does:

  1. Stage 1 - Frontend. Runs npm ci and npm run build in frontend/. The Vite output lands in frontend/dist/.
  2. Stage 2 - Backend. Runs cargo build --release against your workspace; cached dependency layers keep iterative builds fast.
  3. Stage 3 - Runtime. A debian:bookworm-slim image with ca-certificates + libssl3, a non-root appuser, and the compiled ./app binary. Default CMD is ./app, which runs serve with auto-migrate.

The first build typically takes several minutes (cold Rust cache); follow-up builds are much faster thanks to Docker layer caching.

Add a scheduler service

If your app uses #[derive(Task)] schedules, the scheduler needs its own long-running process. Add a second service from the same repo:

  1. NewGitHub Repo → pick the same repository.
  2. Name it scheduler so it's easy to spot in the dashboard.
  3. Under SettingsDeploy set the Custom Start Command to:
    ./app schedule:work
    
  4. Copy the same variables (especially APP_KEY and the database references) so the worker reads the same configuration as the web service.

schedule:work is a daemon loop - it wakes once a minute, queries the schedule for due tasks, and runs them through the same bootstrap as the HTTP server. See Console and the scheduler chapter for the contract.

Run exactly one scheduler instance. Multiple schedule:work processes coordinate via cache-backed locks, but the default expectation is a single worker.

Why Suprnova diverges

A Laravel deploy on Forge or Vapor typically wires a webserver (php-fpm + nginx), a queue worker (php artisan queue:work), and a cron entry that invokes schedule:run every minute. Three components, three deployment surfaces.

Suprnova compiles every role into the same binary. The Railway service spec is ./app for the web role and ./app schedule:work for the scheduler - same image, same bootstrap, different argv. There is no separate php-fpm container, no separate worker image, no host cron. Add ./app queue:work as a third service if you have queued jobs and you have the full Laravel topology in three Railway services from one Dockerfile.

Health checks and railway.json

For more control over the deploy, commit a railway.json to the repo root. Railway picks it up automatically.

{
  "$schema": "https://railway.app/railway.schema.json",
  "build": {
    "builder": "DOCKERFILE",
    "dockerfilePath": "Dockerfile"
  },
  "deploy": {
    "startCommand": "./app",
    "healthcheckPath": "/_suprnova/health/live",
    "healthcheckTimeout": 300,
    "restartPolicyType": "ON_FAILURE",
    "restartPolicyMaxRetries": 10
  }
}

Suprnova ships built-in health endpoints that short-circuit before the middleware chain - they return a 200 JSON status without going through auth, CSRF, or rate-limiting. The /_suprnova/ prefix is reserved so they never collide with your routes.

healthcheckPath above points at /_suprnova/health/live, which touches nothing. That pairing is deliberate: this service is configured "restartPolicyType": "ON_FAILURE", so whatever the health check probes is a restart trigger. Pointing it at the database - via /_suprnova/health/ready or the older /_suprnova/health?db=true - means a database blip restarts every replica at the moment the database can least afford a reconnect storm. Probe the database from a separate readiness check or your monitoring, not from the path that restarts the process. See Use the right probe for the right question.

Both older paths keep working, so an existing Railway service needs no change; the named paths are simply clearer.

Custom domains and TLS

  1. In the web service, open SettingsNetworking.
  2. Click Generate Domain for a *.up.railway.app subdomain, or Custom Domain to point your own hostname at the service.
  3. Update DNS as Railway instructs (a CNAME for subdomains, an ANAME/ALIAS for apex domains).

Railway provisions and renews Let's Encrypt certificates for both generated and custom domains.

Migrations in CI/CD

The default CMD ["./app"] runs migrations on boot, which is fine for single-instance deploys. For multi-replica setups, decouple the migration step:

  1. Add a one-shot pre-deploy hook that runs ./app migrate against the production database before the new replicas start.
  2. Change the runtime start command to ./app serve --no-migrate so the replicas don't race each other.

The migration runner is idempotent - even if you don't split the steps, running migrations on every boot is safe across replicas. The split exists so you can fail the deploy early on a bad migration without holding the rollout open.

Logs, metrics, rollbacks

The web service tab exposes:

  • Deployments - every build in chronological order; the three-dot menu on a previous successful deploy is the one-click rollback path
  • Logs - tracing output from the container, with structured-log fields (request_id, route, status) ready for the log viewer's filters
  • Metrics - CPU, memory, network IO; useful for sizing the instance up or down

Troubleshooting

Build fails on cargo build --release. Reproduce locally with docker build -t myapp .. The most common cause is a workspace member that compiles on your machine but is missing from the repo - the Dockerfile copies Cargo.toml and Cargo.lock first, so missing crates fail loudly.

App returns "connection refused". Check SERVER_HOST=0.0.0.0 is set on the service. The default is 127.0.0.1, which Railway can't route to.

App boots then exits with a key error. APP_KEY is unset or malformed. The framework refuses to boot in production without one; re-paste the output of suprnova key:generate --show into the service's variables.

Migrations fail on boot. Check the logs for the underlying SQL error. Common causes are an unset DATABASE_URL (verify the ${{ Postgres.DATABASE_URL }} reference resolved) or a migration that ran against a stale baseline (./app migrate:status reports what's applied where).

Scheduler never fires. Verify the start command is exactly ./app schedule:work (not schedule:run, which runs due tasks once and exits). schedule:list from a one-shot deploy confirms your tasks are registered.

Next