---
title: Deploy
description: 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](/docs/framework/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](/docs/framework/coolify) recommends building in GitHub Actions and deploying a prebuilt image instead.

**Choose [Coolify](/docs/framework/coolify)** when you want a fixed monthly bill, data residency on hardware you control, or several apps sharing one box.

**Choose [Docker](/docs/framework/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](/docs/migration/rails) and [Django / Flask](/docs/migration/python) 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 `PORT` from 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 `200` on a cheap `/healthz`, with dependency checks on a separate `/readyz`.

Verify these before you deploy anywhere:

```bash
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:

```rust filename="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](/docs/framework/configuration#frameworkbuilder-base-paths-and-rsjs-assets)
and [linked island asset serving](/docs/framework/static-assets#serving-linked-island-assets).

`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:

```rust filename="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)
```

```rust filename="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):

```rust
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.

```css
/* Served CSS: /fonts/icon.woff2 becomes /apps/demo/fonts/icon.woff2. */
@font-face { font-family: Icon; src: url(/fonts/icon.woff2); }
```

```javascript
// 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:

```javascript
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](/docs/framework/railway)
  - Deploy a Proa app to Railway from a Dockerfile in about ten minutes.
- [Coolify](/docs/framework/coolify)
  - Deploy a Proa app to Coolify on your own VPS.
- [Docker](/docs/framework/docker)
  - Run the Proa image yourself, and own everything a platform would have done.
- [CI / GitHub Actions](/docs/guides/ci)
  - Gate pull requests with proa check, then deploy from the same pipeline.
- [Environment variables](/docs/framework/environment)
  - Configure a deployed Proa app without rebuilding the binary.
- [Page caching](/docs/framework/page-caching#cloudflare)
  - Cache public SSR pages at the edge and invalidate them after a deploy or content update.
