Skip to content

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
{
  "mcpServers": {
    "umbra": {
      "url": "https://api.umbra-py.space/mcp"
    }
  }
}

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:

docker compose -f deploy/docker-compose.yml up   # http://localhost:8000  (/docs)

Point any STAC API client at it:

curl http://localhost:8000/search?limit=2

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 fetch into the /data volume before starting the server, so a fresh container is queryable in seconds rather than after a full bucket walk. Every later start runs umbra index fetch --if-changed: one HEAD request 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 runs umbra index fetch-thumbnails --if-changed the same way for the baked-preview sidecar, re-merging it whenever the sidecar or the index changed, so MCP quicklook / describe_scene can 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.merge and their journals). It folds the live index's -wal back into the database, and checks free space before every download and merge. The merge goes into a copy of catalog.db that replaces the index only after it passes PRAGMA 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 sets XDG_CACHE_HOME=/data), so restarts are instant and the archive is never re-crawled.
  • Exposes liveness and readiness. GET /livez (also /healthz/live) returns 200 and {"status":"ok"} with no catalog read. The image HEALTHCHECK and Railway's deploy check use it. GET /healthz (also /readyz and /healthz/ready) is readiness: the body's ready flag reports whether the search backend can answer queries yet. Its thumbnails object (count, the merged snapshot, the on-disk sidecar_snapshot, merged) and top-level degraded flag say whether the boot's thumbnail merge landed. A skipped or failed merge is still 200 with degraded: true. built_at, snapshot, and stale (true when 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 exceeds UMBRA_REQUEST_TIMEOUT_S (default 15s), or that cannot be started because too many reads are already running, is a 503. Only an index that exists but cannot be opened is a 503 with status: "error".
  • Runs unprivileged. The entrypoint starts as root only long enough to chown a mounted /data (Railway Volumes are root-owned and hide the image's chown), then drops to uid 10001 (umbra) before any umbra command.
  • 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:

docker build -f deploy/Dockerfile --build-arg UMBRA_EXTRAS=serve,viz -t umbra-py:full .

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 interactive umbra demo explorer, reading the whole-catalog catalog.pmtiles archive (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 .png composite, or a .html swipe 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 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 reads RAILWAY_TOKEN as 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 in RAILWAY_API_TOKEN instead 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 --service argument.

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.