---
title: Railway
description: Deploy a Proa app to Railway from a Dockerfile in about ten minutes.
---

Railway builds your Dockerfile, injects a `PORT`, waits for a health check, then swaps traffic to the new container. Proa apps are ordinary Rust HTTP servers, so nothing about Proa needs special handling: compile the binary in a build stage, run it in a slim runtime stage.

Ten minutes from an existing project to a public URL.

## What Railway needs from your app

Three things. Everything else is default.

| Railway gives you | Your app must |
|---|---|
| A `PORT` environment variable | Read `PORT` at startup. Never hardcode it. |
| An HTTPS edge in front of your container | Bind `0.0.0.0` for public traffic or `::` for dual-stack traffic, never `127.0.0.1` |
| A health check before routing traffic | Return `200` on a cheap path such as `/healthz` |

Scaffolded Proa sites already do all three. If you are wiring a server by hand:

```rust filename="src/main.rs"
use std::net::SocketAddr;

let port: u16 = std::env::var("PORT")
    .ok()
    .map(|value| value.parse::<u16>())
    .transpose()?
    .unwrap_or(3000);

// Railway recommends [::] when the service needs both public and private
// networking. New environments are dual-stack; legacy private networks are
// IPv6-only.
let addr = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 0], port));
let listener = tokio::net::TcpListener::bind(addr).await?;
proa::axum::serve(
        listener, app, std::time::Duration::from_secs(30),
    ).await?;
```

## Generate the Dockerfile

Proa's Railway preset writes the Dockerfile and the Railway config for you:

```bash
proa new site my-site --template marketing --deploy railway --tailwind --yes
cd my-site
```

That gives you:

```proa-filetree
Dockerfile
.dockerignore
railway.json
RAILWAY.md
src/main.rs
public/
```

If the generated Dockerfile's pinned binary archive is unavailable, use the
Cargo-based example below. For an existing scaffolded project, add it at the
root beside `proa.lock.json`.

## Adapt the Dockerfile

This Dockerfile reads the CLI version from your project's `proa.lock.json`,
compiles that pinned version from crates.io, and builds the app
through `proa build --locked --artifact-out`. The first CLI compilation takes
several minutes; Docker caches that layer separately from your application
source. The installer handles only Tailwind and verifies its standalone binary's
checksum. Proa dependencies resolve from crates.io without credentials.
Railway's cache mounts reuse downloaded crates and compiled dependencies:

```dockerfile filename="Dockerfile"
FROM rust:1.93-bookworm AS build
WORKDIR /app
RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates curl git jq \
    && rm -rf /var/lib/apt/lists/*

COPY proa.lock.json /tmp/proa.lock.json
RUN --mount=type=cache,id=s/<service-id>-/usr/local/cargo/registry,target=/usr/local/cargo/registry \
    proa_version="$(jq -er '.scaffold.cliVersion' /tmp/proa.lock.json)" \
    && cargo install proa-cli --version "=$proa_version" --locked

# Install the checksum-verified standalone Tailwind CLI.
RUN curl --proto '=https' --tlsv1.2 -fsSL https://proa.so/install.sh \
    | sh -s -- --tailwind-only --to /usr/local/bin --no-modify-path --yes

COPY . .

RUN --mount=type=cache,id=s/<service-id>-/usr/local/cargo/registry,target=/usr/local/cargo/registry \
    --mount=type=cache,id=s/<service-id>-/app/target,target=/app/target \
    proa build --locked --artifact-out /app/my-site

FROM debian:bookworm-slim AS runtime
WORKDIR /app
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=build /app/my-site /usr/local/bin/my-site
COPY --from=build /app/public ./public

ENV PROA_HOST=::
ENV RUST_LOG=info

CMD ["my-site"]
```

The Railway-specific settings are:

- **Cache mounts** reuse Cargo's registry downloads and build output.
- **`PROA_HOST=::`** replaces `PROA_HOST=0.0.0.0`, so private networking works.
- **No `ENV PORT`, no `EXPOSE`.** Railway sets `PORT` and routes to it.

`--artifact-out /app/my-site` matters: cache mounts do not persist into the final layer, so the binary must land outside `/app/target`. The generated server serves `public/` relative to its working directory, so keep `WORKDIR /app` and copy `public/` beside the binary.

Replace every `<service-id>` placeholder with the service ID shown by
`railway status --json`.
Railway requires that literal `s/<service-id>-` cache-key prefix and does not
allow variables in cache-mount IDs.

Railway detects a file named `Dockerfile` at the repository root. For a nested
Dockerfile, set `dockerfilePath` in `railway.json` (or set
`RAILWAY_DOCKERFILE_PATH` in the dashboard).

## Add railway.json

Config as code keeps deploy settings in review instead of in a dashboard. The preset writes this file; `railway.toml` with the same keys also works:

```json filename="railway.json"
{
  "$schema": "https://railway.com/railway.schema.json",
  "deploy": {
    "healthcheckPath": "/readyz",
    "healthcheckTimeout": 120,
    "restartPolicyType": "ON_FAILURE"
  }
}
```

The generated site answers `/healthz` (liveness) and `/readyz` (readiness, which also checks the database when one is configured). Choose a health-check timeout that covers application startup and dependency readiness. This timeout starts after the image is built; increasing it does not extend the image-build deadline.

## Deploy

From the CLI:

```bash
pnpm add -g @railway/cli
railway login
railway link
railway up
```

Or connect the GitHub repository in the Railway dashboard and push. Railway builds on every commit to the linked branch.

Then open **Settings → Networking → Generate Domain** to get a public URL. Without this step the service runs but nothing reaches it.

Under **Variables**, add:

```dotenv
RUST_LOG=info
```

Do not set `PORT` or `PROA_HOST`. Railway manages `PORT`, and the Dockerfile sets `PROA_HOST`.

## Health checks

Railway polls `healthcheckPath` during deployment until it receives a successful `2xx` response, then switches traffic. It does not monitor that endpoint after activation. See [Railway healthchecks](https://docs.railway.com/deployments/healthchecks).

Keep the liveness route cheap:

```rust filename="src/main.rs"
async fn healthz() -> &'static str {
    "ok\n"
}
```

Checking your database here means a slow database fails your deploy. Put dependency checks on a separate `/readyz` route with `proa::axum::Readiness`, which probes fresh on every request and recovers without a restart.

Railway sends the `Host` header `healthcheck.railway.app`. If you add host filtering later, allowlist it.

> **Good to know**: To build an image once and promote it between environments, build and push it to a registry in CI, then deploy that image on Railway. Public Proa dependencies do not require a GitHub token for either build path.

## Verify before you deploy

```bash
docker build -t my-site .
docker run --rm -p 127.0.0.1:3000:3000 -e PORT=3000 my-site
curl -fsS http://127.0.0.1:3000/healthz
curl -fsS http://127.0.0.1:3000/static/styles.css -o /dev/null
```

## When it fails

| Symptom | Cause |
|---|---|
| Health check times out, logs look fine | App bound `127.0.0.1`, or ignored `PORT` |
| Build succeeds, service unreachable | No domain generated under Networking |
| Binary missing in runtime stage | `--artifact-out` pointed inside the cache-mounted `target/` |
| Another service cannot reach this one in a legacy environment | `PROA_HOST=0.0.0.0`; legacy IPv6-only private networking needs `::` |
| Every push rebuilds from scratch | Check the cache-mount IDs and paths, and exclude local `target/` output from the build context |

## Next steps

- [Deploy overview](/docs/framework/deployment)
  - Pick a deployment target, then follow the guide for it.
- [Environment variables](/docs/framework/environment)
  - Configure a deployed Proa app without rebuilding the binary.
- [CI / GitHub Actions](/docs/guides/ci)
  - Gate pull requests with proa check, then deploy from the same pipeline.
- [Coolify](/docs/framework/coolify)
  - Deploy a Proa app to Coolify on your own VPS.
