Deploy / self-host¶
Umbra publishes a static STAC catalog and no search API, so the standard STAC
tooling (pystac-client, the QGIS STAC plugin, stac-browser, leafmap) has
nothing to query. umbra serve restores that missing endpoint — a
read-only STAC API over a local catalog index.
A community instance of that API is hosted at
https://api.umbra-py.space/ (umbra serve
--public): STAC search at the service root and Streamable HTTP MCP at /mcp,
on one URL. This page covers using that instance, then standing up your own.
Hosted community API (no install)¶
The public instance is at https://api.umbra-py.space/:
STAC search at the service root and Streamable HTTP MCP at /mcp, on one URL.
# STAC search — any STAC API client
curl "https://api.umbra-py.space/search?limit=2"
curl "https://api.umbra-py.space/sites?top=5"
# OpenAPI
open "https://api.umbra-py.space/docs"
from pystac_client import Client
api = Client.open("https://api.umbra-py.space/")
for item in api.search(collections=["umbra-open-data"], max_items=3).items():
print(item.id, item.datetime)
The same API, with a bbox and GEC hrefs, in HTTP, pystac-client, and
DuckDB: Replace your crawler.
MCP is a different path on the same host. Copy-paste for Claude Desktop and Claude Code: Connect Claude (MCP).
# Claude Code (remote). Desktop uses the JSON below — do not omit type in Code.
claude mcp add --transport http umbra https://api.umbra-py.space/mcp --scope user
This is an unofficial community host of Umbra's open data, not an Umbra
product. Search is served from the weekly catalog snapshot. Asset hrefs
point at Umbra's public S3 bucket — the API does not proxy rasters (that is
the COG-streaming guardrail). Attribute CC BY 4.0.
A per-client rate limit (120 requests/minute) returns 429 with Retry-After.
The operational contract (uptime, the 500 page size, CORS, and the release
fallback) is Hosted API limits.
Do not set UMBRA_CANOPY_TOKEN or model API keys on this instance.
Self-host (one command)¶
The repository ships Dockerfiles and compose under deploy/.
docker compose -f deploy/docker-compose.yml up builds the image (context =
repo root), fetches the published catalog index snapshot on first boot (no
multi-minute S3 crawl), and serves the STAC API:
Point any STAC API client at it:
Or with plain Docker:
docker build -f deploy/Dockerfile -t umbra-py .
docker run -p 8000:8000 -v umbra-data:/data umbra-py
Both Dockerfiles start from official python:3.12-slim on Amazon ECR Public,
pinned by digest:
public.ecr.aws/docker/library/python:3.12-slim@sha256:aa943ce411423c7582288f18707ff66eddcbaeb510502d747a72aeb34f30d159
Docker publishes Official Images there, so a build does not depend on an
anonymous Docker Hub pull. The pin is the manifest list (looked up 2026-10-29;
the same digest is on mirror.gcr.io). CI retries ECR Public, then builds
from mirror.gcr.io/library/python:3.12-slim at that digest. Override the base
with --build-arg PYTHON_IMAGE=.... deploy/Dockerfile.mcp uses the same
default, so Railway needs no build-arg for it.
The hosted image installs .[mcp,viz,serve] from deploy/requirements.txt
with pip install --require-hashes. Hatchling and its dependencies come
from deploy/build-requirements.txt the same way, and this package is
installed with --no-deps --no-build-isolation. Regenerate each lock from
the repository root with the uv pip compile command in that file's header
(Python 3.12, linux x86_64, wheels only, hashes on).
The base digest is the ARG PYTHON_IMAGE line in both Dockerfiles.
.github/scripts/docker-build-ci.sh reads it from the file being built.
To bump it, look up the manifest-list digest of
public.ecr.aws/docker/library/python:3.12-slim, confirm the same value on
mirror.gcr.io, and replace it in both files. A Railway service variable
named PYTHON_IMAGE overrides the pin. deploy/Dockerfile is the self-host
image (docker compose, and the CI smoke test): extras there are the
UMBRA_EXTRAS build-arg. Railway's dockerfilePath is
deploy/Dockerfile.mcp.
What the image does¶
- Fetches the published index on first boot. The entrypoint runs
umbra index fetchinto the/datavolume before starting the server, so a fresh container is queryable in seconds rather than after a full bucket walk. Every later start runsumbra index fetch --if-changed: oneHEADrequest decides whether the weekly snapshot changed, and only then is it downloaded, validated and swapped in. If that check fails, the existing index keeps serving. It then runsumbra index fetch-thumbnails --if-changedthe same way for the baked-preview sidecar, re-merging it whenever the sidecar or the index changed, so MCPquicklook/describe_scenecan return baked previews without proxying Umbra COGs. A failed check keeps the existing sidecar. - Refreshes without filling the disk. The boot first logs the volume's
free space and its largest entries. Each refresh then deletes the scratch
files an interrupted boot left behind (
*.next,*.next.part,catalog.db.mergeand their journals). It folds the live index's-walback into the database, and checks free space before every download and merge. The merge goes into a copy ofcatalog.dbthat replaces the index only after it passesPRAGMA quick_check. A step that does not fit is skipped with one log line (Not enough free space ... Skipped; the current files were kept.), so a full volume costs fresh thumbnails, never the index. See Volume sizing. - Persists to a volume. The catalog index, any fetched snapshot and the
render-artifact cache all live under
/data(the image setsXDG_CACHE_HOME=/data), so restarts are instant and the archive is never re-crawled. - Exposes liveness and readiness.
GET /livez(also/healthz/live) returns200and{"status":"ok"}with no catalog read. The imageHEALTHCHECKand Railway's deploy check use it.GET /healthz(also/readyzand/healthz/ready) is readiness: the body'sreadyflag reports whether the search backend can answer queries yet. Itsthumbnailsobject (count, the mergedsnapshot, the on-disksidecar_snapshot,merged) and top-leveldegradedflag say whether the boot's thumbnail merge landed. A skipped or failed merge is still200withdegraded: true.built_at,snapshot, andstale(truewhen the build date is more than 9 days old) say which snapshot is loaded. The document is cached until the index file or its snapshot sidecar changes. A catalog read that exceedsUMBRA_REQUEST_TIMEOUT_S(default 15s), or that cannot be started because too many reads are already running, is a503. Only an index that exists but cannot be opened is a503withstatus: "error". - Runs unprivileged. The entrypoint starts as root only long enough to
chowna mounted/data(Railway Volumes are root-owned and hide the image'schown), then drops to uid 10001 (umbra) before anyumbracommand. - Doubles as the CLI. Any other command runs the full CLI:
docker run --rm umbra-py search --area "Beet Piler" --limit 5.
Configuration¶
All behaviour is driven by environment variables (set them in the compose file's
environment: block or with docker run -e):
| Variable | Default | Effect |
|---|---|---|
UMBRA_HOST |
0.0.0.0 |
Interface the server binds to inside the container. |
UMBRA_PORT |
8000 |
Port the server listens on. |
UMBRA_FETCH_INDEX |
1 |
Fetch the published index on boot, refreshing it when the snapshot changed; 0 skips. |
UMBRA_SERVE_LIVE |
unset | 1 serves from a live S3 walk per request — no index (correct, slow). |
UMBRA_INDEX_URL |
unset | Override the published-index asset URL (e.g. a fork or mirror). Must be https unless UMBRA_INSECURE_INDEX_URL=1. |
UMBRA_INDEX_DB |
/data/umbra-py/catalog.db |
Explicit index path. |
UMBRA_SERVE_ARGS |
unset | Extra flags forwarded to umbra serve (e.g. --no-artifacts). |
UMBRA_REQUEST_TIMEOUT_S |
15 |
Catalog-read deadline. Past it, /healthz and /search return 503. |
UMBRA_DB_CONCURRENCY |
4 |
Catalog reads in flight. Further DB requests get 503 immediately. |
UMBRA_WATCHDOG |
1 |
0 disables the event-loop stall exit. |
UMBRA_WATCHDOG_STALL_S |
90 |
Loop silence before the process exits. |
UMBRA_WATCHDOG_GRACE_S |
120 |
Startup window during which silence is ignored. |
UMBRA_TRUSTED_PROXY_HOPS |
1 |
Rightmost X-Forwarded-For hops used when X-Real-IP is absent. Railway does not promise a hop count; leave the default. |
UMBRA_PUBLIC_BASE_URL |
unset | Optional http(s)://host[:port] origin for STAC links on --public only. A trailing slash is stripped; any other value is ignored. Unset, --public uses X-Forwarded-Proto and X-Forwarded-Host or Host when the peer is not loopback. |
If the first-boot fetch fails (e.g. no outbound network), the entrypoint falls back to a live S3 walk so the server still answers.
Render endpoints¶
The default image installs only the lean serve extra, so it exposes the STAC
API alone; the on-demand /artifacts/... render endpoints return a clear "viz
extra not installed" error. To enable them, build with the viz stack:
or set UMBRA_EXTRAS: serve,viz under the compose build.args. For a public
instance that wants to bound COG-streaming egress, keep the lean image or set
UMBRA_SERVE_ARGS="--no-artifacts".
POST /artifacts/stats — the one artifact that answers in numbers rather than a
picture — needs the load extra instead of viz (it co-registers the passes
into a datacube and reduces it), so build with serve,viz,load for the full
artifact surface.
Bounding the stats endpoint's memory¶
/artifacts/stats is also the only endpoint whose cost grows with the number
of acquisitions rather than with one render: it stacks the whole series. To
measure a long series a slice at a time instead, add the dask extra and turn
the lazy path on for the instance:
docker build -f deploy/Dockerfile --build-arg UMBRA_EXTRAS=serve,viz,load,dask -t umbra-py:full .
docker run --rm -p 8000:8000 -v umbra-data:/data \
-e UMBRA_SERVE_ARGS="--stack-lazy --stack-chunk-size 1024" umbra-py:full
--stack-lazy makes each pass one deferred chunk; --stack-chunk-size N also
cuts each pass into N-square windows read independently, so one scene need
not fit either (at one range read per window instead of one per pass).
--stack-scheduler picks who evaluates the chunks: synchronous (the default)
uses the request's own worker, so the container's thread count stays whatever
its ASGI server was configured with; threads gives a single render dask's
thread pool, which is faster alone and multiplies under concurrent requests.
This is an operator setting, never a request field — it needs the extra
installed here and a decision about threads. A lazy cube's numbers are identical
to an eager one's (only the peak memory differs), so it is not part of the
artifact cache key: turn it on or off without invalidating anything. Without the
dask extra a stats request answers 501 naming it, exactly like a missing
load.
Building in windows only lowers what the build holds; the reduction still
reads a slice per pass unless a request asks otherwise. On an instance started
with --stack-chunk-size, a client may send "windowed": true to be measured
in those windows too:
curl -X POST http://127.0.0.1:8000/artifacts/stats \
-H 'content-type: application/json' \
-d '{"ids": ["…", "…"], "windowed": true}'
That one is a request field, because unlike the policy above it moves a
number: counts, means, spreads and every change figure stay exact, but each
pass's median / p5 / p95 become histogram estimates (quantile_method /
quantile_bin_db say so in the response), so it belongs in the cache key rather
than in an invisible server flag a cached artifact would depend on. Sent to an
instance with no --stack-chunk-size, it answers 400 naming the flag — there
would be no windows to walk, so it could only estimate percentiles for the same
memory. The complementary field is "speckle_filter", which needs each pass
whole and so is the 400 on a chunked instance.
Which of that pair a deployment honours is therefore a consequence of the flags above, and clients read it off the landing page rather than by probing:
curl -s http://127.0.0.1:8000/ | jq '.links[] | select(.rel=="stats")."umbra:options"'
# {"stacking": "lazy (1024px windows, synchronous scheduler)",
# "windowed": {"supported": true},
# "speckle_filter": {"supported": false, "reason": "speckle filtering needs an …"}}
The reason on an unsupported option is the same string the endpoint's 400
carries, so an operator changing --stack-chunk-size changes both together, and
stacking repeats the policy line the container logs at startup.
Behind a reverse proxy¶
The server sends a permissive read-only CORS policy, so a browser front end on
another origin (including a static umbra demo page) can call
/search and the render endpoints cross-origin. Terminate TLS at your proxy and
forward to the container's port; GET /healthz is a cheap upstream health check.
Static showcase (no server)¶
For a zero-install front door — no API, no container — umbra
showcase assembles a static site you drop on any static host:
umbra index fetch # pull the published catalog snapshot (no crawl)
umbra showcase \
--unified \
--fetch-pmtiles \
--featured 6 \
--out ./showcase
That writes a self-contained directory:
index.html— a landing page linking the pieces below plus install/docs/source;explore.html— the interactiveumbra demoexplorer, reading the whole-catalogcatalog.pmtilesarchive (copied in beside it, so the folder is relocatable): every acquisition in the catalog, with live filters;featured/*— one precomputed artifact per featured site (a.pngcomposite, or a.htmlswipe map with--featured-view swipe).
Every page is self-contained HTML, so it needs no extra and no backend. This is
what the repository's own hosted showcase
is: the .github/workflows/docs.yml Pages job runs umbra showcase after the
mkdocs build and publishes site/showcase/ beside the docs. Point --pmtiles
at a locally built basemap instead of --fetch-pmtiles for an offline build, or
pass --no-explore for a map-only page.
One page or two¶
--unified is the recommended shape and the one the hosted showcase uses: the
explorer reads the tiled archive itself, so a visitor gets the whole catalog
and the filters on a single page.
Drop --unified and you get the original pair instead — map.html, a MapLibre
viewer over the archive you can only pan and click, plus explore.html over a
gathered slice (--local --max-per-task 1 for a one-pin-per-site overview).
Both modes now draw footprint outlines — the unified one from the archive's
footprint polygons (tiled from zoom 6 up), the embedded one from the slice it
carries — and both offer the on-click "Get SAR image" COG overlay, the unified
one from the COG reference each tiled feature carries. What is left to the
embedded pair is only the two fields vector tiles do not encode: an
acquisition's polarizations and its per-product asset list. The two modes are
exclusive — in unified mode nothing is gathered, because the archive is the data
source.
The published basemap
--fetch-pmtiles pulls the weekly-published catalog.pmtiles, which gains
the COG references on its next publish-index.yml run. Until then the
unified explorer built from it shows no "Get SAR image" button (a page over
an archive without the references degrades to metadata, it does not error);
umbra tiles --local --out catalog.pmtiles builds one with them now.
Featured artifacts¶
--featured N precomputes an artifact for the N most repeat-imaged sites in
the catalog and puts them on the landing page, so a first-time visitor sees what
SAR change looks like immediately — no render round-trip, no server, and it
still works on a plain static host. Name the sites yourself with a repeatable
--featured-area instead:
umbra showcase --local --featured-area "Centerfield, Utah" \
--featured-area "Beet Piler" --featured-frames 3 --out ./showcase
--featured-view picks what is precomputed from the same marquee selection:
--featured-view |
Artifact | Site needs | Reads |
|---|---|---|---|
change (default) |
featured/<site>.png |
2 passes (3 with --featured-frames 3) |
green = new/brighter, magenta = gone/dimmer (or temporal RGB) |
timescan |
featured/<site>.png |
3+ passes | the whole series as one image — red = mean, green = peak, blue = variability, so anything that came and went glows blue/cyan |
swipe |
featured/<site>.html |
2 passes | an interactive before/after map with a draggable divider, linked from the gallery as a card |
--featured-frames applies to the change view only: a timescan summarises
every pass it has, and a swipe is always two. Sites that can't clear a view's
minimum are dropped before any render is attempted, so --featured-view
timescan quietly skips the two-pass sites that change would have used.
umbra showcase --local --featured 6 --featured-view timescan --out ./showcase
umbra showcase --local --featured 6 --featured-view swipe --out ./showcase
This is the one step that needs the viz extra
(pip install "umbra-py[viz]"); it streams only a downsampled overview of each
scene, and a site whose asset won't render is skipped with a warning rather than
failing the build. Without --featured the showcase is unchanged and stays
stdlib-only.
Remote STAC + MCP (Railway)¶
umbra serve --public is the hosted community bundle: STAC search at / and
Streamable HTTP MCP at /mcp, on-demand artifacts off (baked thumbnails at
GET /artifacts/thumbnail/{id}.png stay on), a per-client rate limit, CC-BY
license headers, and a refuse of --live and of Canopy / model API keys.
umbra mcp is still stdio by default (Claude Desktop / uvx --from 'umbra-py[mcp]' umbra-mcp).
--http serves MCP alone (no STAC) so a client can connect to a URL
without the serve extra:
umbra mcp --http --host 0.0.0.0 --port 8000
# POST http://127.0.0.1:8000/mcp
# GET http://127.0.0.1:8000/healthz
$PORT (Railway) or $UMBRA_PORT is used when --port is omitted.
Docker¶
The image entrypoint treats serve --public like serve: fetch the published
catalog index on first boot, then listen. deploy/Dockerfile.mcp bakes the
mcp, viz and serve extras:
docker build -f deploy/Dockerfile.mcp -t umbra-py:public .
docker run --rm -p 8000:8000 -v umbra-data:/data umbra-py:public
# STAC: http://127.0.0.1:8000/search
# MCP: POST http://127.0.0.1:8000/mcp
The base image is the same digest-pinned PYTHON_IMAGE default as
deploy/Dockerfile
(public.ecr.aws/docker/library/python:3.12-slim@sha256:aa943ce411423c7582288f18707ff66eddcbaeb510502d747a72aeb34f30d159).
Leave that build-arg unset unless that registry is unreachable, and do not
set a service variable named PYTHON_IMAGE (Railway passes it as the
build-arg and it replaces the pin). Dependencies come from
deploy/requirements.txt and deploy/build-requirements.txt
(pip install --require-hashes). The package itself is installed with
--no-deps --no-build-isolation.
Pass mcp as the first argument for MCP-only (no STAC).
Railway¶
The repo ships root railway.toml (default Config-as-Code discovery — no
custom Config File Path) and deploy/Dockerfile.mcp. Create a service from
this GitHub repo and deploy; extras and the start command (serve --public)
are already in those files. railway.toml sets dockerfilePath to
deploy/Dockerfile.mcp.
A volume is not required for the first boot. /data is writable in the
image, and the published catalog.db is ~126 MB (seconds, not a crawl). A
Railway Volume mounted at /data keeps that index, and the ~1.2 GB thumbnail
sidecar, across deploys; size it with Volume sizing. Do not
put VOLUME ["/data"] in the Dockerfile — Railway's Metal builder rejects it
even when a Railway Volume is attached. The volume itself is root-owned; the
entrypoint chowns it and drops to umbra so catalog.db can be written.
*.railway.internal is Railway's private mesh DNS — only other services in
the same project can reach it. Claude, the Anthropic directory, and
pystac-client cannot. After a green deploy: Settings → Networking →
Generate Domain, then point STAC clients at
https://<generated>.up.railway.app/ and MCP clients at
https://<generated>.up.railway.app/mcp (or a custom domain).
If a previous deploy crashed with exec: mcp: not found or exec: serve: not
found, Railway replaced the image entrypoint with a bare command.
railway.toml now wraps the entrypoint; you do not need a start command in
the dashboard.
Do not set a UMBRA_EXTRAS build argument in the Railway UI. The public
image hardcodes mcp,viz,serve. An old mcp,viz arg drops FastAPI and
umbra serve --public exits with "needs the 'serve' extra".
Do not set UMBRA_CANOPY_TOKEN or model API keys on a public instance —
--public refuses to start if they are set.
STAC links do not need a new variable. Unset, --public builds them from
X-Forwarded-Proto and X-Forwarded-Host or Host when the peer is the
edge, so https://api.umbra-py.space stays the origin. Optional
UMBRA_PUBLIC_BASE_URL overrides that only when it is an
http(s)://host[:port] origin; --public is the only mode that reads it.
The rate limit reads
X-Real-IP (Railway overwrites it) and falls back to the rightmost
UMBRA_TRUSTED_PROXY_HOPS of X-Forwarded-For (default 1). See
Hosted API limits.
MCP render tools (quicklook / change / timescan) still stream Umbra COGs through this host; keep an eye on egress. STAC search does not.
Volume sizing¶
Everything the boot refresh writes lives in one directory on the volume
(/data/umbra-py). As of October 2026:
| File | Size | Notes |
|---|---|---|
catalog.db (published) |
~126 MB (17,960 items) | What umbra index fetch downloads. |
catalog.rtree.db |
~1 MB | Built at umbra serve --public startup from catalog.db. Not downloaded and not listed in catalog.sha256. |
catalog.thumbs.db |
~1.2 GB at 4,050 previews; past 2 GiB by 5,550 | Reassembled locally. The release stores it as parts under 1.5 GiB (GitHub rejects an asset of 2 GiB or more). About 300 KB per 512 px preview. |
catalog.db (merged) |
~1.33 GB | The published index plus the sidecar's PNGs. |
With I the published index and S the sidecar, the refresh needs at its peak:
- Steady state:
I + 2S, about 2.5 GB (merged index + sidecar). - Weekly boot (new index and new sidecar): about
2I + 2S, or 2.7 GB. The new index is downloaded beside the old merged one, the new sidecar beside the old one, and the merge copy is written beside the bare index. - Worst case (the sidecar changed, the index did not): about
2I + 3S, or 3.9 GB. The merge copies the already-merged index. - Each step also keeps 64 MiB free so the server can still write its
-shm/-wal.
Recommendation: a 20 GB volume. The 5 GB volume covers today's worst case
(3.9 GB), but the sidecar grows by up to ~450 MB a week, so the worst case grows
by up to ~1.35 GB a week. Fully baked (all ~18,000 acquisitions, ~5.3 GB
sidecar), the weekly boot needs ~11 GB and the worst case ~16 GB. Below the
need, the refresh degrades rather than breaks: it skips the thumbnail download
or merge, serves the new index, and /healthz reports degraded: true. The
weekly publish run then warns "Hosted API thumbnails not merged".
The in-place merge older images ran needed the PNG payload twice over (once in
the -wal, once as checkpoint growth), so I + 3S at peak. That is ~3.7 GB
before anything else on the volume, and is how the October 9 boot filled the
5 GB volume and left catalog.db unopenable (disk I/O error).
Recovering a volume an older image filled. Its catalog.db-wal holds
committed pages that cannot be folded back into the database without free
space, and nothing else on the volume is a leftover. Either grow the volume
and redeploy (the boot then checkpoints the -wal itself), or remove the index
as a set and redeploy so the boot fetches a fresh one:
railway ssh --service umbra-py -- rm -f /data/umbra-py/catalog.db \
/data/umbra-py/catalog.db-wal /data/umbra-py/catalog.db-shm \
/data/umbra-py/catalog.db.source.json
Never delete the -wal alone: next to a part-checkpointed catalog.db, it is
the only consistent copy.
Weekly snapshot redeploy (RAILWAY_TOKEN)¶
The weekly publish-index.yml workflow publishes catalog.db, then the
thumbnail sidecar. A sidecar of 2 GiB or more cannot be one GitHub release
asset, so the workflow uploads catalog.thumbs.manifest.json and
catalog.thumbs.db.partNNN.<sha> parts. Before that upload it compares the new preview count — rows whose id or href
is in the index just built — with the previous manifest (its previews
field) or the legacy asset, the live /healthz thumbnails.count, and
.github/thumbnail-preview-floor.json (3355, the merged count after
duplicate and future-dated rows left the index). A lower count skips the
thumbnail upload, leaves the previous assets in place, and warns; the catalog
index is still published. Each run restores the previous bake from the
Actions cache, merges it, and saves the result, so a week that does not
publish still keeps its previews. When the release has no thumbnail sidecar
at all, a smaller bake may still be published so the next run can continue
from it. When an upload does run, the job fails if any of those assets is
missing afterwards. The entrypoint's umbra index fetch-thumbnails
--if-changed reads that manifest, writes catalog.thumbs.db, and will not
replace a local sidecar that has more previews. It redeploys whenever
catalog.db was published, even if the thumbnail bake or upload was skipped
or failed. It then polls https://api.umbra-py.space/healthz for up to
30 minutes, until snapshot equals the ETag of the released catalog.db.
The job fails if the redeploy fails or that snapshot never shows up. A
missing thumbnail ETag fails the job only when this run tried to publish
thumbnails; a skipped thumbnail publish warns instead. It warns ("Hosted
API thumbnails not merged") when the API is degraded, or when the sidecar
it merged is not the one just published.
The redeploy is npx -y @railway/cli@5.71.0 redeploy --service umbra-py --yes, run with
the repository secret RAILWAY_TOKEN:
- It must be a project token (project Settings → Tokens), created for
the environment that serves the API (
production). The Railway CLI readsRAILWAY_TOKENas a project token: the project and environment come from the token, so the workflow needs no project or environment ID. An account or workspace API token belongs inRAILWAY_API_TOKENinstead and does not work in this variable. - The service is selected by name,
umbra-py, which is hardcoded in the workflow. No service ID secret is needed. If you rename the service, update the--serviceargument.
Without the secret the run still publishes everything, but it emits a "Hosted API not redeployed" warning and a job-summary line. Restart the Railway service by hand to pick up the new snapshot.