# Deploy to Railway

[Railway](https://railway.app) 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](https://railway.app)
- A Suprnova project pushed to GitHub, GitLab, or Bitbucket
- A `Dockerfile` and `.dockerignore` at the repo root, generated by:
  ```bash
  suprnova docker:init
  ```
- A generated `APP_KEY` you can paste into Railway's variables:
  ```bash
  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](https://railway.app/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: **New** → **Database** →
   **Add PostgreSQL**. Railway exposes `DATABASE_URL` as a reference
   variable on the project.
4. Optionally add Redis the same way (**New** → **Database** → **Redis**)
   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.

```env
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. **New** → **GitHub Repo** → pick the same repository.
2. Name it `scheduler` so it's easy to spot in the dashboard.
3. Under **Settings** → **Deploy** set the **Custom Start Command** to:
   ```bash
   ./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](console.md) 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.

```json
{
  "$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](deployment.md#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 **Settings** → **Networking**.
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

- [Deployment Overview](deployment.md) - the unified-binary model your
  Railway services run
- [Docker CLI](cli-docker.md) - what `docker:init` and `docker:compose`
  actually generate
- [Configuration](configuration.md) - `.env` loading, typed config,
  required keys
- [Console](console.md) - `schedule:work`, `queue:work`,
  `workflow:work`, and the rest of the unified CLI
- [Deploy to Digital Ocean](deployment-digital-ocean.md) - the same
  recipe on a different PaaS
