Docs

Deploy

Pick a deployment target, then follow the guide for it.

Open Markdown

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

RailwayCoolifyDocker, self-managed
Who owns the machineRailwayYou, one VPSYou
Who compiles RustRailwayYour VPS, or GitHub ActionsYou
Preview URL per pull requestOne toggle, domains auto-provisioned, deleted on mergeYes: wildcard DNS + GitHub App + URL templateBuild it yourself
TLS certificatesAutomaticAutomaticCaddy or nginx, renewal yours
Zero-downtime swapHealth-check gatedHealth-check gatedYours
RollbackOne clickOne clickRetag and restart
SecretsService variablesApp env + BuildKit secretsYours
Logs and metricsBuilt inBuilt inShip them yourself
Beside an existing Rails or Django appAwkwardPossibleNative, this is the sidecar shape
Floor costUsage-basedOne VPSOne VPS
Escape hatchIt is your DockerfileIt is your DockerfileYou 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 buildBecause otherwise
Reverse proxy and TLS renewalNothing terminates HTTPS
Zero-downtime container swapEvery deploy drops in-flight requests
Health-check gating before cutoverA broken build takes production with it
RollbackRecovery means finding the previous image tag under pressure
Secret injectionTokens end up in a .env on the box
Log shipping and retentiondocker logs is your only forensics, until the container is removed
Metrics and alertingYou learn about outages from users
Restart-on-crash policyOne panic ends the deployment
Image garbage collectionThe disk fills, quietly, around week six
Preview environmentsReviewers 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:

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:

src/main.rs
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:

src/components/nav.rs
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)
src/endpoints/redirect.rs
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

Search

Type at least 2 characters