Instance guide¶
An instance repository packages the configuration and plugins for a specific operational context — a country, a region, or an organisation — and references open-climate-service as a versioned dependency rather than including it directly.
This keeps the core service separate from context-specific concerns, and means your configuration lives in its own repository that can be versioned, shared, and deployed independently.
When to use this pattern¶
Use an instance repository when you:
- Want to add custom datasets not included in open-climate-service (e.g. national meteorological data)
- Want to track your configuration in version control separately from the open-climate-service codebase
- Want to pin your service to a specific version of open-climate-service and upgrade deliberately
- Want to share your configuration with others, or deploy across multiple environments
This is the recommended path for running an actual climate service. If you only want to try Open Climate Service locally with the built-in datasets, the quick start (cloning open-climate-service directly) is faster.
Repository structure¶
my-climate-service/
├── pyproject.toml # declares open-climate-service as a dependency
├── uv.lock # locked dependency tree for reproducible installs
├── Makefile # install / run shortcuts
├── climate-service.yaml # instance config: extent, CRS, data_dir, plugins_dir
├── .env.example # committed template for environment variables
├── .gitignore
├── plugins/
│ ├── rasters/ # raster dataset templates (.yaml) + plugin classes (.py)
│ │ ├── enacts_rainfall.yaml
│ │ └── enacts.py
│ ├── vectors/ # vector dataset templates (.yaml) + feature providers (.py)
│ │ └── districts.yaml
│ ├── processes/ # @process-decorated functions (.py)
│ │ └── my_process.py
│ └── workflows/ # reusable process graph compositions (.json)
│ └── my_workflow.json
└── data/ # gitignored — downloaded files and Zarr stores
Step 1: Create the repository¶
Step 2: Declare open-climate-service as a dependency¶
Create pyproject.toml:
[project]
name = "my-climate-service"
version = "0.1.0"
requires-python = ">=3.12"
description = "Open Climate Service instance for [context]"
dependencies = [
"open-climate-service[server,dhis2]==0.1.1", # dhis2: the DHIS2 org unit provider and delivery
]
[tool.uv]
package = false
# Required for the [server] extra to resolve: these relax upstream transitive pins
# that uv applies but pip cannot — openeo-pg-parser-networkx pins geojson-pydantic<2
# (we need >=2.1.0), and openeo-processes-dask pins an older zarr
# (Open-EO/openeo-processes-dask#376). Drop entries as upstream releases catch up.
override-dependencies = [
"geojson-pydantic>=2.1.0",
"zarr>=3.1.6",
"pyarrow>=19.0",
"xarray>=2025.12.0",
"numpy>=2.2",
"dask>=2024.1.0",
"dask-geopandas>=0.4",
"geopandas>=1.1",
"xvec>=0.3",
"rioxarray>=0.17",
"pystac>=1.10",
]
The package = false setting tells uv that this repository is not itself a Python package — it only declares dependencies. It depends on open-climate-service[server,dhis2] from PyPI, pinned here to 0.1.1; the dhis2 extra is available from that release. Bump the version to upgrade. The override-dependencies block is required for uv to resolve the [server] extra (see the comment above) — this is also why pip install is not a supported install path for [server]. To track the latest unreleased code instead of a release, add a [tool.uv.sources] entry pinning open-climate-service to git (open-climate-service = { git = "https://github.com/dhis2/open-climate-service.git", branch = "main" }) and change the dependency to open-climate-service[server,dhis2].
Install dependencies:
This creates a .venv and a uv.lock file. Commit uv.lock so that everyone working with this repository installs exactly the same versions.
Step 3: Add a Makefile¶
.DEFAULT_GOAL := help
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-15s %s\n", $$1, $$2}'
install: ## Install dependencies with uv
uv sync
run: ## Start the API with uvicorn
set -a && . ./.env && set +a && \
uv run uvicorn open_climate_service.main:app --reload --reload-include "*.yaml" --reload-include "*.yml" --port 9000
Step 4: Configure the instance¶
Create climate-service.yaml:
id: rwanda-climate-service # unique identifier for this instance
name: Rwanda Climate Service # display name shown in the web UI
extent:
name: Rwanda
bbox: [28.8, -2.9, 30.9, -1.0] # [xmin, ymin, xmax, ymax] in WGS84
country_code: RWA # ISO 3166-1 alpha-3, required for WorldPop
data_dir: ./data
plugins_dir: ./plugins/
| Field | Required | Description |
|---|---|---|
id |
No | Unique instance identifier used as the STAC catalog id. Lowercase, hyphen-separated (e.g. rwanda-climate-service). Defaults to open-climate-service |
name |
No | Display name shown in the web UI. Defaults to Open Climate Service |
extent.bbox |
Yes | Bounding box in WGS84 decimal degrees |
extent.name |
No | Human-readable label shown in API responses |
extent.country_code |
No | ISO 3166-1 alpha-3 — required for WorldPop downloads |
data_dir |
Yes | Directory for downloaded files and Zarr stores, resolved relative to the config file |
plugins_dir |
No | Directory containing rasters/, vectors/, processes/ and workflows/ plugin subdirectories |
read_only |
No | Set true to refuse all state-changing requests — see Read-only instances. Defaults to false |
scheduler |
No | Instance-level scheduled dataset-sync configuration. See Scheduled dataset synchronization |
automation |
No | Event-driven workflow bindings for successful dataset updates. See Dataset-update workflow automation |
To find the bounding box for a region, bboxfinder.com is a useful tool.
Create .env:
And a committed .env.example as a template:
Step 5: Add a .gitignore¶
Step 6: Run the instance¶
Visit http://127.0.0.1:9000 to confirm the service is running. From there, open a data
source to ingest it and /map to view the result — see Using the web interface.
The /extent endpoint should return your configured bounding box.
Adding plugins¶
Plugins extend the instance with custom raster and vector datasets, processes, and workflows. They live in plugins_dir and are loaded automatically. The plugins_dir is added to sys.path, so Python modules placed directly inside it are importable.
plugins/
├── rasters/
│ ├── enacts_rainfall.yaml # raster dataset template
│ └── enacts.py # streaming plugin class
├── vectors/
│ └── districts.yaml # vector dataset template
├── processes/
│ └── spatial_stats.py # @process-decorated functions
└── workflows/
└── aggregate_for_dhis2.json
See Extensibility for the plugin types, and Adding custom datasets for the dataset template field reference and streaming plugin contract.
Upgrading and troubleshooting¶
Upgrading¶
To move to a newer release, bump the pin in pyproject.toml (e.g. open-climate-service[server]==0.2.0), then:
Commit the updated uv.lock so everyone gets the same versions.
uv syncalone does not upgrade. It only makes the environment match the lockfile. To actually move to a newer version you must re-lock first (uv lock --upgrade-package open-climate-service) — this is true both for a pinned release and for a git source.
If you track the latest unreleased code via a [tool.uv.sources] git entry, uv lock --upgrade-package open-climate-service re-resolves to the current branch head. To pin to a specific commit instead:
[tool.uv.sources]
open-climate-service = { git = "https://github.com/dhis2/open-climate-service.git", rev = "abc1234" }
Pinning to a released version (==X.Y.Z) is recommended over tracking main: a release is reproducible, whereas main's dependency tree shifts over time and can change under you between syncs.
Troubleshooting¶
-
Always run inside the synced environment — use
make runoruv run uvicorn …, never a bareuvicorn/python. If a stray virtual environment is activated (echo $VIRTUAL_ENV), deactivate it; uv warns whenVIRTUAL_ENVdoesn't match the project's.venv. -
Never
pip installinto the venv.uv syncmakes the environment exactly match the lockfile and removes anything else — so hand-installed packages disappear on the next sync, and a missing package "comes back" after every upgrade. If something is genuinely needed, add it to[project] dependencies(or the right extra/group) and re-lock. -
Verify the stack is complete:ModuleNotFoundErrorforxvec,odc,dask_geopandas,planetary_computer,pystac_client,stac_validator, … when running a job means the[server]extra is not fully installed.openeo-processes-daskeagerly imports its whole implementation stack, so all of these must be present. Don't add them one by one — the[server]extra is the complete, maintained set. Ensure your dependency isopen-climate-service[server](with the extra), then: -
pip install open-climate-service[server]does not work — the[server]extra needs dependency overrides (for upstream version caps) thatuvapplies butpipcannot. Install the server stack with uv (this guide) or Docker. The base client and[xarray]extras install fine withpip.
Deployment¶
For production deployments, the same repository can be used directly on a server:
git clone https://github.com/your-org/my-climate-service.git
cd my-climate-service
cp .env.example .env # fill in absolute paths and credentials
uv sync
make run
For containerised deployment, the core open-climate-service repository ships a Dockerfile
and a compose.yml that can serve as a starting point for packaging an instance. A
dedicated instance Docker guide is planned.
Compute limits¶
Background jobs share the machine with the web server, and usually with the rest of a laptop, so OCS bounds how much of it they take. The defaults are chosen for a laptop.
| Setting | Default | Bounds |
|---|---|---|
DASK_NUM_WORKERS |
half the cores | Threads for all dask computation in the process, shared by every job and request |
CLIMATE_SERVICE_MAX_CONCURRENT_JOBS |
2 | Ingestion, sync, feature refresh, export delivery and openEO batch jobs running at once, together |
TOKIO_WORKER_THREADS |
cores | Worker threads of Icechunk's storage runtime |
A job beyond the limit waits queued (accepted for ingestion jobs, with the message
"Waiting for a free job slot"; queued for openEO jobs) and starts when another finishes.
Synchronous requests such as POST /result are not counted as jobs, but their computation
uses the same dask threads.
Memory runs out before CPU does: a zonal statistics job over a country can peak at about
3 GB. On a machine with 8 GB or less, keep CLIMATE_SERVICE_MAX_CONCURRENT_JOBS at 1 or 2.
Docker Desktop gives its VM only part of the machine's memory, and compose.yml sets no
limit of its own, so size the limit to what the VM has. A dedicated server can raise both
settings; more dask threads than half the cores rarely makes reads faster.
Icechunk also starts short-lived I/O threads while it reads and writes files. Their number
follows the amount of concurrent reading, so DASK_NUM_WORKERS is what bounds them.
TOKIO_WORKER_THREADS is read once, when the process starts: set it in .env or the
environment, not at runtime.
Read-only instances¶
For an instance that should be browsable but not changeable — a public demo, a shared reference endpoint:
Every state-changing request is refused with 403, the openEO capabilities document stops
advertising the endpoints that would refuse, and GET /info reports read_only: true.
Still available: the catalogue and metadata (/collections, /stac, /datasets,
/processes, /process_graphs, /extent), the data (/zarr/…, /icechunk/…, downloads),
the landing page and /map, and POST /result for synchronous openEO process graphs.
Refused: ingestion and sync (the pages leave their forms out), stored process graph writes, and batch jobs.
Read-only applies to HTTP only, so ingestion becomes an operator task on the host. Until a CLI command exists, run it inside the container or virtualenv: