Docs
Railway
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:
use std::net::SocketAddr;
let port: u16 = std::env::var("PORT")
.ok()
.and_then(|value| value.parse().ok())
.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?;
axum::serve(listener, app).await?;
Generate the Dockerfile
Proa's Railway preset writes the Dockerfile and the Railway config for you:
proa new site my-site --template marketing --deploy railway --tailwind --yes
cd my-site
That gives you:
Dockerfile.dockerignorerailway.jsonRAILWAY.mdsrc/main.rspublic/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:
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=::replacesPROA_HOST=0.0.0.0, so private networking works.- No
ENV PORT, noEXPOSE. Railway setsPORTand 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:
{
"$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). Rust release builds are slow on a cold cache; raise healthcheckTimeout toward 300 seconds if the first deploy times out before the server is up.
Deploy
From the CLI:
npm i -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:
GITHUB_TOKEN=<a token with read access to Proa-Labs/proa>
RUST_LOG=info
Do not set PORT or PROA_HOST. Railway manages PORT, and the Dockerfile sets PROA_HOST.
Health checks
Railway calls healthcheckPath once per deploy and waits for a 200 before routing traffic to the new container. It does not poll afterwards.
Keep the liveness route cheap:
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_framework_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: Building Rust on every push costs minutes and puts your GitHub token on Railway. Build and push to GHCR in GitHub Actions instead, then create the Railway service from a Docker image and remove
GITHUB_TOKEN. This is the same argument the Coolify guide makes.
Verify before you deploy
docker build --build-arg GITHUB_TOKEN=$GITHUB_TOKEN -t my-site .
docker run --rm -p 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
docker run --rm my-site env | grep -c GITHUB_TOKEN # expect 0
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 |
| Authentication failure fetching Proa git dependencies | ARG GITHUB_TOKEN missing from the build stage |
| 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 | No cache mounts, or .dockerignore omits target/ |
Next steps
- Deploy overview
- Pick a deployment target, then follow the guide for it.
- Environment variables
- Configure a deployed Proa app without rebuilding the binary.
- CI / GitHub Actions
- Gate pull requests with proa check, then deploy from the same pipeline.
- Coolify
- Deploy a Proa app to Coolify on your own VPS.