Docs
Deploy
Pick a deployment target, then follow the guide for it.
Proa builds one release binary plus a public/ directory. Every target below runs that same artifact, usually as a Docker image. The choice is not about Proa. It is about how much of the platform you want to operate yourself.
Pick a target
| Railway | Coolify | Docker, self-managed | |
|---|---|---|---|
| Who owns the machine | Railway | You, one VPS | You |
| Who compiles Rust | Railway | Your VPS, or GitHub Actions | You |
| Preview URL per pull request | One toggle, domains auto-provisioned, deleted on merge | Yes: wildcard DNS + GitHub App + URL template | Build it yourself |
| TLS certificates | Automatic | Automatic | Caddy or nginx, renewal yours |
| Zero-downtime swap | Health-check gated | Health-check gated | Yours |
| Rollback | One click | One click | Retag and restart |
| Secrets | Service variables | App env + BuildKit secrets | Yours |
| Logs and metrics | Built in | Built in | Ship them yourself |
| Beside an existing Rails or Django app | Awkward | Possible | Native, this is the sidecar shape |
| Floor cost | Usage-based | One VPS | One VPS |
| Escape hatch | It is your Dockerfile | It is your Dockerfile | You are already there |
Start with Railway unless you have a reason not to. Rust release builds are heavy, and Railway runs them for you. Coolify source builds run on your own VPS, which is why the Coolify guide recommends building in GitHub Actions and deploying a prebuilt image instead.
Choose Coolify when you want a fixed monthly bill, data residency on hardware you control, or several apps sharing one box.
Choose Docker when you are running Proa as a sidecar next to an existing app, deploying into Kubernetes, Nomad, or ECS, or working somewhere a PaaS is not an option. Read the next section first.
What self-managed Docker costs you
Plain Docker is the most flexible target and the most expensive one. Flexibility is not the tradeoff. Labour is.
A platform is roughly ten features in a trench coat. Take it away and you own all ten:
| You now build | Because otherwise |
|---|---|
| Reverse proxy and TLS renewal | Nothing terminates HTTPS |
| Zero-downtime container swap | Every deploy drops in-flight requests |
| Health-check gating before cutover | A broken build takes production with it |
| Rollback | Recovery means finding the previous image tag under pressure |
| Secret injection | Tokens end up in a .env on the box |
| Log shipping and retention | docker logs is your only forensics, until the container is removed |
| Metrics and alerting | You learn about outages from users |
| Restart-on-crash policy | One panic ends the deployment |
| Image garbage collection | The disk fills, quietly, around week six |
| Preview environments | Reviewers test on main |
None of these are hard. All of them are yours forever, and they are why a cheap VPS is rarely cheaper than a managed platform once your time is priced in.
Take Docker anyway when the alternative does not exist: the sidecar shape in the Rails and Django / Flask migrations puts Proa on the same host as your existing app, routed by your existing proxy. No platform models that well.
What every target needs
Whatever you pick, the app must:
- Read
PORTfrom the environment. Never hardcode it. - Bind
0.0.0.0, or::for dual-stack traffic, including legacy IPv6-only private networks. - Serve static assets from
public/beside the binary (the generated server reads them relative to its working directory). - Answer
200on a cheap/healthz, with dependency checks on a separate/readyz.
Verify these before you deploy anywhere:
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
Build a release binary with proa build --locked --artifact-out <path>, which runs the asset build and the linked RSJS pipeline (a plain cargo build --release skips the assets and fails at link time on any crate with islands). Public Proa dependencies resolve from crates.io without credentials. The exported binary and public/ are the deployable artifact.
Serving behind a URL path prefix
For a proxy that exposes /apps/demo/ but strips /apps/demo before forwarding,
configure the public prefix at startup. Keep your incoming routes at /,
/docs/start, /static/..., and so on:
use proa::axum::{BasePath, FrameworkBuilder};
let base_path = BasePath::new(
&std::env::var("PROA_BASE_PATH").unwrap_or_default(),
)?;
let app = FrameworkBuilder::new()
.with_base_path(base_path) // <-- new: set the public URL prefix
.with_rsjs_assets() // <-- new: serve assets with matching runtime imports
.with_static_service(static_router)
.with_dynamic_routes(router)
.build();
Set PROA_BASE_PATH=/apps/demo when starting the binary. This value is read by
your startup code, so the same build can run at different prefixes. Empty or /
keeps origin-root URLs; a trailing slash is normalized away. The framework does
not infer the prefix from forwarded headers.
The APIs are also covered in FrameworkBuilder configuration and linked island asset serving.
with_rsjs_assets() installs the linked runtime and generated module routes.
It rewrites runtime imports once at startup. Use it instead of registering your
own /rsjs-runtime.js, /rsjs-runtime-query.js, and /rsjs/island/:file handlers.
Runtime scripts, preloads, query runtime, dependency modules, and deferred island
loading use the same prefix, including islands arriving in streamed boundaries.
HTML responses remain streamed.
For a custom Axum router, merge rsjs_assets_router(&base_path) and apply
RouteContextLayer::new(metadata).with_base_path(base_path). Custom
asset pipelines can retain their handlers and call
rsjs::rewrite_runtime_import_specifiers with the two public runtime URLs.
Non-Axum adapters can use
proa::framework::response::append_rsjs_runtime_plan_with_base_path and
append_rsjs_runtime_plan_delta_with_base_path to emit prefixed asset tags.
Application-authored links, forms, images, and redirects opt into URL composition:
let start = cx.url("/docs/start");
let logo = cx.url("/static/logo.svg");
html_sync! {
<nav>
<a href={start}>"Start"</a>
<img src={logo} />
</nav>
}
.render(cx)
use axum::response::Redirect;
use proa::axum::app_url;
async fn go_home() -> Redirect {
Redirect::to(&app_url("/").to_string())
}
Pass unprefixed application paths to these helpers. BasePath::url produces an
attribute value without allocating an intermediate string. External URLs and
fragment/query-only references pass through. Treat URL input with the same
validation policy you would use without this helper.
The framework's CSS manifest URLs, React manifest URLs, and Markdown alternate
links also follow the configured prefix. The basic Axum site (including its
docs/blog shell), application scaffold, and ORIGIN template read PROA_BASE_PATH.
proa_fonts::Font::head(cx) uses the request prefix for both preload links and
the generated @font-face URLs. It writes CSS segments directly without
allocating a replacement stylesheet; the build-time font files stay unchanged.
Pass the page's context to head, rather than caching a rendered font fragment
from a context without the deployment setting.
proa_docs::DocsShell uses the request context for its brand, navigation,
section-menu, and pager links. For custom docs handlers, pass the deployment
mount to the adapter (the generated docs/blog handlers already do this):
proa_docs::adapter::axum::response_with_base_path(&DOCS, "/docs/start", &base_path)
The collection's build-time .base_path("/docs") remains its internal route.
Non-Axum callers can use proa_docs::render_with_base_path. ORIGIN serves linked
islands through the shared asset router and resolves its browser-side lab
request against the URL of its JavaScript module.
Coverage and limitations
The same setting covers framework assets, fonts, opted-in query/mutation
requests (cx.url(...) or FetchRequest.apply_base_path: true), docs
navigation and article URL attributes, docs search artifacts, and the ORIGIN
starter. proa dev uses a prefixed same-origin event stream when
PROA_BASE_PATH is set, so the proxy only needs to expose the app's mount.
The relay accepts both stripped incoming paths and preserved paths when the
application router is nested under that prefix.
with_static_service(...) automatically composes recognized URL syntax in
CSS and JavaScript assets when a prefix is configured. This includes CSS
url(...), image-set(...), and @import, JavaScript module imports, and native browser fetch,
Request, Worker, SharedWorker, EventSource, and WebSocket URL arguments.
new URL arguments also follow the mount when their base is import.meta.url
or browser location/location.href/location.origin. Explicit external bases
stay unchanged. Dynamic native URL arguments are composed when evaluated. External URLs,
protocol-relative URLs, relative references, and already-mounted browser URLs
stay unchanged. Comments, ordinary strings, and locally shadowed browser API
names stay unchanged too.
/* Served CSS: /fonts/icon.woff2 becomes /apps/demo/fonts/icon.woff2. */
@font-face { font-family: Icon; src: url(/fonts/icon.woff2); }
// Served JS: fetches /apps/demo/api/items, including a dynamic URL argument.
const endpoint = "/api/items";
const response = await fetch(endpoint);
For handwritten browser code using a custom client library, compose its URL explicitly with the helper exported by either linked runtime:
import { app_url } from "/rsjs-runtime.js";
// The managed asset service prefixes the import above.
client.load(app_url("/api/items")); // /apps/demo/api/items
Custom static routers can use base_path_assets(router, base_path); install
compression outside that router. Transformations are cached by content with a
bounded cache. GET and HEAD share the transformed length and ETag; conditional
requests validate those transformed bytes. CSS/JS assets are limited to 32 MiB.
Root deployments retain the original service without parsing or rewriting.
The recognized syntax is the contract. URLs in custom-library arguments,
inline scripts/styles, data files, or external third-party output need explicit
composition or their own pipeline. Subresource integrity hashes must match the
served, transformed bytes. Application templates use cx.url(...);
trusted compiled raw HTML can use proa::BasePathHtml. Compiled docs
articles use it automatically for links, images/srcsets, forms, and other URL
attributes, while text and script/style contents remain unchanged.
For example, at /apps/demo/docs/start, /static/styles.css requests a file
outside the app. The helper generates /apps/demo/static/styles.css. Replacing
the URL with ./static/styles.css would request
/apps/demo/docs/static/styles.css, so document-relative URLs are insufficient
for nested routes. The proxy should serve or redirect the mount root to its
trailing-slash form (/apps/demo/).
Next steps
- Railway
- Deploy a Proa app to Railway from a Dockerfile in about ten minutes.
- Coolify
- Deploy a Proa app to Coolify on your own VPS.
- Docker
- Run the Proa image yourself, and own everything a platform would have done.
- CI / GitHub Actions
- Gate pull requests with proa check, then deploy from the same pipeline.
- Environment variables
- Configure a deployed Proa app without rebuilding the binary.
- Page caching
- Cache public SSR pages at the edge and invalidate them after a deploy or content update.