Docker recipe: your own Dockerfile
This recipe deploys a real app from a Dockerfile that you write yourself.
This guide is one half of a pair. Both guides describe the same app, and they
divide the subject between them.
- This guide owns the
dockertemplate. It tells you how to write the Dockerfile. It covers the warm base images, the image size cap, and the content ofCMD. It also gives the rule that environment variables exist at run time only. It showsDockerfile,requirements.txtandapp.py. - The Postgres recipe
owns the data layer. It covers
DATABASE_URL, the psycopg driver mismatch, the alembic migrations at container start, and the automatic snapshots before a deploy. It showsalembic.iniand all the files undermigrations/.
Read this guide first if the docker template is new to you. Read the other
guide first if your app builds correctly and the problem is the database.
What you get
You get a FastAPI service. Your own Dockerfile builds it, and it connects to
the Postgres database that xhostd gives the channel. It serves a JSON health
response at /. It lists the notes at GET /notes. It makes a note at
POST /notes. It marks one note done at POST /notes/{id}/done.
Alembic migrations create the schema and change it, and they run at container start. The first boot of a new app thus makes its own tables. A later deploy with a new migration applies that migration. Neither step is manual. The build takes two seconds when the layer cache is warm.
The worked example is live at recipe-docker-pg-docs.randomimity.app. Read it, but do not write to it. It is a real database behind a real write API. The note that it lists is the note that this guide's transcript made. If you obey the recipe on your own account, you get the same service under your own name.
The files
The app is recipe-docker-pg, and its template is docker. This guide shows
three of its eight files.
The Postgres recipe shows
alembic.ini, migrations/env.py, migrations/script.py.mako and the two
migrations in migrations/versions/ in full. Those files are the data layer,
not the build. All eight files ship in one commit. The two guides divide the
prose only, never the deploy.
Dockerfile
The docker template builds the Dockerfile at your repo root on every
deploy. It then runs that image with the image's own ENTRYPOINT and CMD.
The platform makes no file for you, and it puts nothing into the build.
# python:3.13-slim is a platform warm base — its layers are exempt from
# the per-plan charged image size, and it is already on the cell so the
# build starts instantly.
FROM python:3.13-slim
WORKDIR /app
# uv resolves and installs the Python dependencies. Pinned to an exact
# version tag — append @sha256:<digest> if you need it immutable.
COPY --from=ghcr.io/astral-sh/uv:0.5.14 /uv /usr/local/bin/uv
COPY requirements.txt .
RUN uv pip install --system --no-cache -r requirements.txt
COPY . .
# Migrations run in the START command, never at build time. The build has
# no DATABASE_URL at all — xhostd injects env at run time only, never as
# build args — so a migration at build time has no database to connect to.
CMD ["sh", "-c", "alembic upgrade head && exec uvicorn app:app --host 0.0.0.0 --port $XHOSTD_HTTP_PORT"]
Four parts of that file do real work.
The base is a warm base. Every cell already holds these eight images:
| Warm base |
|---|
node:22-slim |
node:24-slim |
node:26-slim |
python:3.11-slim |
python:3.12-slim |
python:3.13-slim |
python:3.14-slim |
debian:trixie-slim |
Match every FROM in your Dockerfile to this list, including a
build-only stage in a multi-stage build. A stage that names a warm base starts
with no pull, so the build begins at once. Any other base pulls over the
network first, whether or not its layers reach the final image.
The final stage's warm-base layers are also exempt from your plan's charged
size, so only the layers that you add count against the cap. That exemption
applies to the final stage alone. The pull applies to every stage, so match
every FROM, not only the one that ships.
A multi-stage build needs a warm base in every stage. It keeps build tools out of the shipped image, and the builder stage still pulls its own base first:
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]
A cold builder base never shows up in the charged size, so the size report gives no hint that it is there. That base still costs a pull on every build that misses the cache.
COPY requirements.txt . comes before COPY . .. Docker then repeats
the dependency step only when requirements.txt changes. If you copy your
whole tree first, every source edit makes that layer invalid. A two-second
deploy then becomes a two-minute deploy.
xhostd injects environment variables at run time only, never as build
args. There is no --build-arg path, and the build can read no secret
store. Your DATABASE_URL, your S3_* credentials and every variable that
you set appear in one place: the container's environment at container start.
That is why alembic upgrade head is in CMD and not in a RUN step. A
migration at build time has no database to connect to. The migration reads
DATABASE_URL_DIRECT, which reaches the database server itself, and the app
reads DATABASE_URL, which can go through a transaction pooler. The
Postgres recipe shows both.
CMD uses the sh -c form on purpose. The shell expands
$XHOSTD_HTTP_PORT. The exec replaces the shell with uvicorn, so the server
gets the stop signals directly. Your app needs both details. See
When it goes wrong.
requirements.txt
This file pins every version exactly. Each later deploy thus builds the same image that you tested.
fastapi==0.115.6
uvicorn==0.34.0
sqlalchemy==2.0.36
alembic==1.14.0
psycopg[binary]==3.2.3
psycopg[binary] is psycopg 3. It is not a replacement for psycopg2, which
is the driver name that SQLAlchemy selects by default. A Python app on this
platform often fails on that mismatch.
The Postgres recipe tells
you how to correct it.
app.py
import os
from fastapi import FastAPI
from pydantic import BaseModel
from sqlalchemy import create_engine, text
def database_url() -> str:
"""xhostd injects DATABASE_URL with the bare ``postgresql://`` scheme.
SQLAlchemy maps that scheme to psycopg2, which we do not ship. Name
the driver explicitly so it loads psycopg 3.
"""
return os.environ["DATABASE_URL"].replace(
"postgresql://", "postgresql+psycopg://", 1
)
# pool_pre_ping discards connections the database closed while idle,
# which is what otherwise surfaces as a stray 500 after a quiet period.
engine = create_engine(database_url(), pool_pre_ping=True)
app = FastAPI()
class NoteIn(BaseModel):
body: str
# The health check probes GET / and needs a 2xx or 3xx, so / must not 404 —
# unless the app creates the file named by $XHOSTD_READY_FILE.
@app.get("/")
def root():
with engine.connect() as conn:
row = conn.execute(
text("SELECT count(*) AS total, count(*) FILTER (WHERE done) AS done FROM notes")
).mappings().one()
return {"ok": True, "notes": row["total"], "done": row["done"]}
@app.get("/notes")
def list_notes():
with engine.connect() as conn:
rows = conn.execute(
text("SELECT id, body, done, created_at FROM notes ORDER BY id")
).mappings().all()
return {"notes": [dict(r) for r in rows]}
@app.post("/notes")
def create_note(note: NoteIn):
with engine.begin() as conn:
new_id = conn.execute(
text("INSERT INTO notes (body) VALUES (:body) RETURNING id"),
{"body": note.body},
).scalar_one()
return {"id": new_id}
@app.post("/notes/{note_id}/done")
def mark_done(note_id: int):
with engine.begin() as conn:
updated = conn.execute(
text("UPDATE notes SET done = true WHERE id = :id"), {"id": note_id}
).rowcount
return {"updated": updated}
Note the route at /. The deploy's health check probes GET / on the port
that $XHOSTD_HTTP_PORT names, and the value is 3000. The probe needs a 2xx
or 3xx answer within 120 seconds. An API with all its routes under /api
fails its deploy, although the process runs correctly. The one alternative is
the file that $XHOSTD_READY_FILE names, which the probe also accepts.
The deploy
Every app owns a git repo. With git, push the code, then call deploy. A
push sends only the diff: git transfers the few lines that changed, and needs
no anchor to place them. Use commit_files where git is not available on the
machine where you work, or where neither git transport can push.
Both paths keep two acts separate. A push stores your code, but it does not
deploy your code. deploy is its own explicit call, and that is the point:
you name the commit that goes live.
Do these four steps in order. All eight files ship in the one push.
1. Create the app. This call makes the prod channel, its hostname, its
git repo and its Postgres database.
create_app(name="recipe-docker-pg", template="docker")
→ {"name": "recipe-docker-pg",
"owner_username": "docs",
"role": "owner",
"repo_url": "https://git.randomimity.com/docs/recipe-docker-pg.git",
"template": "docker",
"channels": [{"name": "prod",
"hostname": "recipe-docker-pg-docs.randomimity.app",
"git_ref_binding": null,
"follows_branch": false,
"current_sha": null,
"status": "provisioning",
"pending_deploy": null}],
...}
Later calls take the app's name and the channel's name, such as prod. repo_url is the repo for your push. get_app returns that URL
again if you lose it.
2. Have your token ready. The token authenticates git, Postgres, and the
platform API. Use your own token from the
XHOSTD_TOKEN environment variable. A person mints it on the console
/tokens page and exports it; a registered
agent exports the token that registration returned. No tool result carries a
token. You can also mint a scoped, short-lived token with the request that
get_credential_request describes, and send it the same way from a variable of its
own; the Git guide shows the commands.
This recipe shows the HTTPS steps. With a shell, push over SSH: the private half of the key never enters a tool call, and one registration covers every app on that machine. The git guide holds the transport branch and the SSH commands.
Send the token as a header on each git command. That detail is important:
GIT_CONFIG_COUNT=1 \
GIT_CONFIG_KEY_0=http.https://git.randomimity.com/.extraHeader \
GIT_CONFIG_VALUE_0="Authorization: Bearer $XHOSTD_TOKEN" \
git <command>
git reads the header from its environment, so the token is on no process's
command line, where any user of the machine can read it with ps. The key
names https://git.randomimity.com/, so git sends the header to that host alone.
This form needs git 2.31 or later.
3. Clone the repo, then commit and push. A new xhostd repo is empty, and git tells you so. This warning is normal, not a fault:
$ GIT_CONFIG_COUNT=1 \
GIT_CONFIG_KEY_0=http.https://git.randomimity.com/.extraHeader \
GIT_CONFIG_VALUE_0="Authorization: Bearer $XHOSTD_TOKEN" \
git clone https://git.randomimity.com/docs/recipe-docker-pg.git
Cloning into 'recipe-docker-pg'...
warning: You appear to have cloned an empty repository.
Write the eight files into that directory. Then commit and push them:
$ cd recipe-docker-pg
$ git add -A
$ git commit -m "notes API on the docker template"
$ GIT_CONFIG_COUNT=1 \
GIT_CONFIG_KEY_0=http.https://git.randomimity.com/.extraHeader \
GIT_CONFIG_VALUE_0="Authorization: Bearer $XHOSTD_TOKEN" \
git push origin master
Never write the token value itself into a command, a file that you commit, or
text that you paste. $XHOSTD_TOKEN stays in the environment, so the token
never lands in .git/config, in your shell history, or in the output of
git remote -v.
4. Deploy the branch.
deploy(app_name="recipe-docker-pg",
channel="prod",
ref="master")
→ {"deploy_id": "a6baf3fc-f573-40f7-82c6-e74912525228",
"channel_id": "16df8282-8498-4567-a595-fe769090b8b6",
"status": "queued"}
ref is a branch name, and xhostd resolves it to that branch's current head.
After a push you thus do not need the sha. sha is also valid when you want
an exact commit, and sha wins if you give both.
The deploy runs asynchronously. Follow it with
get_logs(app_name=..., channel=..., source="deploy", deploy_id=...). The first line
of the reply states the outcome — status is one of queued, running,
success, failed, superseded, or cancelled. Poll while the status is
queued or running, and on failed the reason is in the log tail. On
superseded, a newer deploy request for this channel replaced this one: poll
the id the header names. On cancelled, a database restore of the channel
started before the deploy ran: deploy again once the restore ends.
Verify it
This is the deploy above, a6baf3fc-f573-40f7-82c6-e74912525228. The reply
starts with a status header, then the log. The transcript shows short ids,
and it omits the buildkit lines that teach nothing.
deploy a6baf3fc — success (sha 9a045a4ced33)
started: 2026-10-04T06:53:59.684877Z finished: 2026-10-04T06:54:10.384603Z
[2026-10-04T06:53:59+00:00] deploy begin id=a6baf3fc-... channel=16df8282-... sha=9a045a4c...
[2026-10-04T06:54:00+00:00] git_sync ok: synced app=2ea4b560-... channel=16df8282-... sha=9a045a4c...
[2026-10-04T06:54:00+00:00] [build] start sha=9a045a4ced33d6fe7e0dd5db787cf6b69c50f989
[2026-10-04T06:54:00+00:00] [build] queued 0s, starting
[2026-10-04T06:54:02+00:00] [build] #5 [stage-0 1/6] FROM docker.io/library/python:3.13-slim@sha256:bb2988715db2...
[2026-10-04T06:54:02+00:00] [build] #8 [stage-0 5/6] RUN uv pip install --system --no-cache -r requirements.txt
[2026-10-04T06:54:02+00:00] [build] #8 CACHED
[2026-10-04T06:54:02+00:00] [build] #11 [stage-0 3/6] COPY --from=ghcr.io/astral-sh/uv:0.5.14 /uv /usr/local/bin/uv
[2026-10-04T06:54:02+00:00] [build] #11 CACHED
[2026-10-04T06:54:02+00:00] [build] #12 [stage-0 6/6] COPY . .
[2026-10-04T06:54:02+00:00] [build] #12 CACHED
[2026-10-04T06:54:03+00:00] [build] finished in 2s
[2026-10-04T06:54:03+00:00] [build] queue wait 0s, build 3s
[2026-10-04T06:54:03+00:00] [build] image 273.28 MB total, 94.64 MB charged — base python:3.13-slim exempt
[2026-10-04T06:54:03+00:00] channel snapshot marker recorded
[2026-10-04T06:54:04+00:00] start_container template=docker
[2026-10-04T06:54:04+00:00] health_check container=1d6bdbce248e... port=3000 timeout=120.0s
[2026-10-04T06:54:08+00:00] [container] INFO [alembic.runtime.migration] Running upgrade -> 0001, create notes
[2026-10-04T06:54:08+00:00] [container] INFO [alembic.runtime.migration] Running upgrade 0001 -> 0002, add done flag to notes
[2026-10-04T06:54:09+00:00] [container] INFO: Uvicorn running on http://0.0.0.0:3000 (Press CTRL+C to quit)
[2026-10-04T06:54:09+00:00] health_check ok
[2026-10-04T06:54:09+00:00] [container] INFO: 10.77.1.7:38162 - "GET / HTTP/1.1" 200 OK
[2026-10-04T06:54:09+00:00] caddy ensure_route hostname=recipe-docker-pg-docs.randomimity.app upstream=10.77.1.7:32085
[2026-10-04T06:54:10+00:00] deploy success
Read these six lines on every deploy.
#8 CACHED, and build 3s. Docker used the cached dependency layer
again, and it did not repeat the step. That is the result of the COPY order.
Docker copies requirements.txt on its own, so it builds the dependency layer
again only when your dependencies change. #12 COPY . ., your source, is
cached here too, because an earlier build on the same cell used the same files.
A deploy that changes the source runs that one step, and it still costs a few
seconds.
image 273.28 MB total, 94.64 MB charged — base python:3.13-slim exempt.
This line gives a docker user the image size figures. Total is the size of the
image on disk. Charged is the size that counts against your plan's image
size cap. The platform subtracts the layers of the largest warm base under
your image. Here that base is the ~179 MB difference, and the platform
charges you for the 94.64 MB that you added. It subtracts one base only, and
only if your image is truly built on that base.
Call get_stats(view="overview") for the current account's charged image-size cap.
Do not infer it from a subscription catalog.
channel snapshot marker recorded. Every non-static deploy records a
snapshot of the channel's Postgres schema before the new container starts.
You do not ask for the snapshot, and you cannot forget it. The marker copies
no data. It records the moment that a restore returns the database to. A
snapshot holds the state before its own deploy, and on the app's first
deploy the database holds nothing at that moment. See
the Postgres recipe for
the calls that list and restore a snapshot.
The two Running upgrade lines. These lines are alembic upgrade head
inside the container. It makes the schema from nothing on the app's first
boot: -> 0001, then 0001 -> 0002, in that order. A deploy that adds a
third migration prints one more line, and nothing else changes. If you see
the alembic banner with no Running upgrade line, the database is already at
head and alembic had no work.
health_check ... port=3000 timeout=120.0s, then health_check ok five
seconds later. In those five seconds the migrations run, and then uvicorn
binds its port. The probe gets its first 2xx after that. The 120-second
window gives a migration the time to finish.
caddy ensure_route. xhostd points the hostname at the new container
only after the health check passes. On a redeploy, the line that retires the
previous container, stop_and_remove old container=..., comes after that.
That order is the reason why a redeploy loses no requests.
Then the two-line proof, against the live demo:
$ curl -sS https://recipe-docker-pg-docs.randomimity.app/
{"ok":true,"notes":1,"done":0}
$ curl -sS https://recipe-docker-pg-docs.randomimity.app/notes
{"notes":[{"id":1,"body":"first note from the recipe","done":false,"created_at":"2026-10-04T06:54:14.952930+00:00"}]}
POST /notes wrote that note after the deploy. The
Postgres recipe shows the
call, and what then happened to the row.
To learn whether the app is alive, call get_logs with source="runtime"
and no command:
get_logs(app_name="recipe-docker-pg", channel="prod", source="runtime")
Without a command, the call starts no container on the host. It returns the
status header alone. The header gives the process state, the exit code if the
process stopped, and whether the kernel OOM-killed it. It also gives the
restart count and the container indices that you can still read. Use it to
check whether the app crashed.
Those indices are useful after a redeploy. The platform archives the previous
container's log, so you can still read the output of the version that
crashed. Give its container_index to get_logs.
Your public hostnames
The platform sets three variables in every container, so your app can know the names it answers on:
XHOSTD_HOSTNAMEis the channel's platform hostname, for examplerecipe-docker-pg-docs.randomimity.app.XHOSTD_URLishttps://plus that hostname, with no trailing slash. It never names a custom domain.XHOSTD_DOMAINSholds that hostname and each verified custom domain of the channel, separated by commas with no spaces. Treat the value as a set: don't infer a primary domain from its position, and read the platform hostname fromXHOSTD_HOSTNAMEorXHOSTD_URL.
A framework that checks the Host header reads its list from
XHOSTD_DOMAINS. For Django, put this in settings.py:
import os
ALLOWED_HOSTS = os.environ.get("XHOSTD_DOMAINS", "localhost").split(",")
# The platform ends TLS at its edge, so a visitor's browser sends an https
# origin. A local run on http needs no entry: Django accepts a same-origin
# request without this list.
CSRF_TRUSTED_ORIGINS = ["https://" + host for host in ALLOWED_HOSTS]
BASE_URL = os.environ.get("XHOSTD_URL", "http://localhost:8000")
Build an absolute link from the request's own host when the visitor might
arrive on a custom domain. BASE_URL always names the platform hostname.
The deploy health probe sends the channel's platform hostname as its Host
header. An app that rejects that host fails the probe, so the deploy fails.
GET / must answer with a 2xx or 3xx status, or the app must create the
file that $XHOSTD_READY_FILE names.
The values are fixed when the platform creates the container. After you
verify or remove a custom domain, deploy the channel again, so that
XHOSTD_DOMAINS includes the change. Until then, the running container
keeps the old list, and an app that checks the host rejects a newly
verified domain.
Compress your responses
The platform does not compress your app's responses for you. Your image runs your server, so compression is a setting in that server, and turning it on compresses every hop from your server to the visitor. Visitors wait less and you spend less bandwidth.
The app in "The files" is FastAPI, which ships the middleware, so this needs no
change to requirements.txt:
from fastapi.middleware.gzip import GZipMiddleware
app.add_middleware(GZipMiddleware, minimum_size=1000)
On another stack, look for the equivalent: compression for Express, gzip on
for nginx, CompressionMiddleware for ASP.NET Core. Leave already-compressed
types alone, images among them, and skip any endpoint that streams — buffering a
stream to compress it defeats the point of streaming.
Skip one more: any route that renders a signed-in visitor's own details — their email, an API token, a CSRF field — beside a value taken from the URL, such as a search term echoed back into the form. The compressed size of the two together leaks the secret a character at a time to a site that can make the visitor's browser fetch the page. Either exclude that path, or stop reflecting the URL value on it. Both halves have to be present for it to matter, so a page that renders no identity, or reflects nothing, is unaffected.
When it goes wrong
You expected build args to carry secrets or DATABASE_URL
They cannot. xhostd injects the environment at run time only. There is no
--build-arg and no build-time secret mount. The build cannot read
DATABASE_URL, because that value does not exist at build time. The symptom
is a RUN step that fails on an absent variable, or an os.environ[...]
KeyError in the build. Move the work into CMD.
A second rule is as important. A build can never read a secret, so never put a secret into the image yourself.
The image is over your plan's cap
The deploy fails at the build step. The message names the charged size and
current cap, and the platform removes the image. Read the
total/charged line first. If charged is near total, your base is not
a warm base, and the platform charges you for all of it. Change FROM to one
of the eight warm bases above, which usually corrects the whole problem. If
charged is truly large, your runtime image holds build tools. Use a
multi-stage build that copies only the artifact, and give the builder stage a
warm FROM as well.
The log says a stage is not a warm base
The build log ends with a line like this:
note: stage 'build-stage' FROM node:20-slim is not a warm base — node:22-slim,
node:24-slim and node:26-slim pull instantly
The note tells you that one stage started with a network pull that it did not need. It does not fail anything. A build-only stage never reaches the charged size, so the size line above it cannot tell you this — the note is the only place it shows.
Change that stage's FROM to one of the named tags. The note names the warm
tags that share the repository of the base you used, so it answers a
python:3.10-slim builder with the python warm bases.
The note repeats on every deploy until the FROM changes, and it appears on a
failed build too, where a cold base is one more thing to rule out.
CMD does not exec the final process
The form CMD ["sh", "-c", "alembic upgrade head && uvicorn ..."] has no
exec. The shell then stays alive as PID 1, and uvicorn is its child. The
stop signals go to the shell, and the shell does not send them to uvicorn.
The server thus gets no clean stop: the platform kills it, and every
redeploy waits for the full stop timeout. Put exec before the last command
in the chain.
GET / returns 404, so the deploy fails even though the app started
The health check needs a 2xx or 3xx answer from GET / on the health port
within 120 seconds. As an alternative, the file that $XHOSTD_READY_FILE
names must exist. An app that serves /api/... only, and makes no ready
file, fails the deploy. Its own logs still show a correct start. Add a root
route, and a JSON {"ok": true} is enough. This recipe's deploy succeeded,
so its log does not hold the message below. The message has this shape, with
a short container id:
health check failed for container ...: no 2xx/3xx response at GET / on port 3000 and no readiness file created at $XHOSTD_READY_FILE within 120s.
The server listens on a hardcoded port
Bind the port that $XHOSTD_HTTP_PORT names, not a literal port. The platform
selects the value, and the health check probes that port only. The probe
cannot see a server on any other port.
This failure is easy to miss. CMD uses the sh -c form for one reason: the
shell expands the variable. The pure exec form —
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "$XHOSTD_HTTP_PORT"]
— expands nothing. uvicorn gets the literal 16-character string
$XHOSTD_HTTP_PORT, and it stops on that value. Use sh -c, or read the
variable in your own code.
404 and 502 from the hostname mean different things
A 404 means the channel has no route yet, and a 502 means the container does not answer. The address returns 404 or 502 explains each cause.
Single-page apps: your server serves every route
A single-page app serves one index.html, and a client-side router
interprets paths such as /dashboard. The hosts that such an app migrates
from often supply a rewrite — nginx with try_files, or a platform's
automatic SPA rewrite — that serves index.html for every unknown path.
xhostd has no such rewrite. The edge proxies every path of your hostname
straight to your container, and it rewrites nothing. Your server decides
what every path means, so your server must serve index.html for every
client-side route.
The symptom is precise. The app works while the visitor navigates inside it,
because the router changes the path without a request. A refresh, a deep
link, or a direct navigation to /dashboard then sends a real request for
that path, and your server answers its own 404 — from FastAPI, a raw
{"detail":"Not Found"}. That response looks like a routing bug in the app.
Its cause is a server that does not serve index.html for that path.
The platform sign-in flow depends on the same catch-all route. After the sign-in, the gateway returns the visitor with a real HTTP navigation to the path the visitor was on. If that path exists only in the client-side router, the return itself 404s, so a server without the catch-all also breaks login on those routes.
In FastAPI, register the API routes first, mount the built assets, and end
with a catch-all that serves index.html for every other GET path:
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
# The API routes are registered before this point. Starlette matches in
# registration order, so they match before the catch-all.
app.mount("/assets", StaticFiles(directory="dist/assets"), name="assets")
@app.get("/{path:path}")
def spa_index(path: str):
return FileResponse("dist/index.html")
GET / also lands in the catch-all and serves index.html, so the health
check gets its 2xx.