Web Apps
Serve HTML pages and static assets from a Rebase ASGI app — and recognise the cases where Rebase is the wrong host.
An ASGI app does not have to return JSON. Anything ASGI can serve HTML, CSS, images, and fonts, so a dashboard, an admin page, or a small internal tool is a normal Rebase deployment — no separate hosting account, and the app sits next to the secrets, volumes, and buckets it already needs.
This page covers what changes when the client is a browser rather than a script. Start with ASGI Apps for the deployment model itself.
A Page, Not an API
import rebase as rb
image = rb.Image.python("3.12").uv_pip_install("fastapi==0.141.1")
@rb.asgi_app(project="grid", name="grid-console", auth="public", image=image)
def grid_console():
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
web_app = FastAPI()
@web_app.get("/", response_class=HTMLResponse)
def index():
return "<h1>Grid console</h1>"
return web_app
rb.deploy(grid_console)rebase deploy prints the URL. It has this shape:
/e/{workspace_id}/{environment}/{project_name}{base_path}Static Assets
Ship a directory into the image with add_local_dir and mount it with
Starlette's StaticFiles:
image = (
rb.Image.python("3.12")
.uv_pip_install("fastapi==0.141.1")
.add_local_dir("./assets", "/srv/assets")
)
@rb.asgi_app(project="grid", name="grid-console", auth="public", image=image)
def grid_console():
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
web_app = FastAPI()
web_app.mount("/assets", StaticFiles(directory="/srv/assets"), name="assets")
return web_appcopy=False (the default) mounts the directory at runtime, so changing an asset
redeploys without rebuilding the image. copy=True bakes it into an image layer
instead — correct when a build step needs the files present, and slower to
iterate on.
A few limits apply to everything the bundle carries: 25,000 files, 100 MB for any single file, and 250 MB expanded in total.
Some destinations are reserved and rejected at deploy time: /opt/rebase,
/mnt/rebase, /app/.venv, /app/app, /app/python, and the usual
/proc, /sys, /dev. Put assets somewhere of your own, such as /srv.
Links Under the Route Prefix
This is the one thing that reliably breaks a browser-facing app and not an API.
Rebase strips the route prefix before forwarding, so your app sees /assets/app.css
while the browser is sitting at
/e/{workspace_id}/{environment}/{project_name}/assets/app.css. An absolute link
in your HTML resolves against the browser's origin, not your app's, and 404s.
Emit relative links, or tell FastAPI what its public prefix is:
@rb.asgi_app(
project="grid",
name="grid-console",
auth="public",
image=image,
env={"PUBLIC_PREFIX": "/e/<workspace_id>/prod/grid"},
)
def grid_console():
import os
from fastapi import FastAPI
web_app = FastAPI(root_path=os.environ["PUBLIC_PREFIX"])
return web_approot_path changes only the URLs FastAPI generates — routing still matches the
stripped path the proxy delivers, which is exactly what arrives.
Do not build links from request.url_for(). The Host header is not forwarded,
so the app sees its private Cloud Run hostname and url_for returns an absolute
URL nobody outside the platform can reach. Use a path, not a URL.
Public Access
A browser cannot attach an API key to an image request, so a page meant for
anonymous readers needs auth="public". Anything behind a login must
authenticate callers itself — see the note in
ASGI Apps on why the Authorization header is
unavailable to your app, and carry your own session cookie or custom header
instead.
Cold Starts
An app with no traffic scales to zero, and the next visitor pays the cold start.
For an unbuilt image that includes a uv pip install on boot, which a person
waiting on a page will notice. Two ways to avoid it:
@rb.asgi_app(project="grid", name="grid-console", min_instances=1, image=image)min_instances=1 keeps an instance warm at the cost of running it continuously.
Warm instances are a per-workspace grant set by Rebase (default 0), so a deploy with
min_instances above the grant fails with 409.
Giving the app a built image — any add_local_* or uv_sync operation turns the
build on — moves dependency installation to deploy time, which shortens the cold
start whether or not you keep an instance warm.
What Rebase Does Not Host
The runtime is Python and the transport is a buffering HTTP proxy. That rules out a few things outright:
| Not supported | Why |
|---|---|
| Node, Next.js, Deno, static-site servers | rb.Image builds Python images only — there is no custom base image or Dockerfile |
| WebSockets | The proxy speaks HTTP only |
| Streaming responses, SSE, long polling | The proxy reads the full upstream response before replying |
| Custom domains | Apps are served under the API host's /e/... prefix |
| Response compression at the edge | The proxy does not compress what it forwards |
A JavaScript app can still be served here as long as something else builds it:
run the build in CI, commit or bundle the output, and hand the directory to
add_local_dir. What cannot move here is a framework that needs a Node server at
request time.
When to Host Elsewhere
Rebase is the right home for a page that talks to your Rebase data — the app runs beside your secrets and buckets, and gets deployment, auth, and metering for free.
It is the wrong home for a public, asset-heavy, latency-sensitive site. Every
request, including every image and stylesheet, crosses the platform API before it
reaches your app. Both run in europe-west3 (Frankfurt), but each request
still carries a route lookup, an auth check, and a usage charge. A CDN-backed host
will beat that comfortably for a documentation site or a marketing page, and the
gap is structural rather than something a bigger instance fixes.

