All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
shell-lint / shellcheck (pull_request) Successful in 7s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / build-backend (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / gate (pull_request) Successful in 2s
PR build (required check) / validate-observability (pull_request) Successful in 18s
Environments stop being machines. Until now each box WAS an environment --
"beta" named both a deploy target and a host, "the desktop" named both the
operator's computer and the dev server -- so every path could assume one
environment per host and hardcode /opt/thermograph, /etc/thermograph.env and
ports 8137/8080. That assumption ends here:
vps1 75.119.132.91 Forgejo, Grafana/Loki, the portfolio site, and DEV
(own Postgres, mesh-only on 10.10.0.2:8137)
vps2 169.58.46.181 PROD and BETA as two Swarm stacks sharing one
TimescaleDB instance, plus Centralis, Postfix, backups
desktop AI model hosting + flex Swarm capacity, no environment
Nothing live has moved; the ordered cutover is in
infra/deploy/RUNBOOK-vps1-vps2-cutover.md.
deploy/env-topology.sh is the single source of truth: env -> host, checkout,
branch, deploy mode, stack name, env file, LB ports, DB role/database, service
prefix. THERMOGRAPH_ENV is the input, and on vps2 it is the only thing
distinguishing a beta deploy from a prod one -- deploy.sh refuses to run when it
disagrees with the checkout it was invoked from. The host-wide secrets-env and
deploy-mode markers survive only as a fallback for a single-environment box.
Beta gets its own stack file rather than an overlay (a merge cannot REMOVE the
db service, and beta having no database of its own is the point). Its services
are prefixed beta-*: Swarm registers a service's short name as a DNS alias on
every network it joins, so two stacks both calling a service `web` on the shared
data network would let prod's frontend resolve a beta task. Prod's stack, env
and LB config are untouched.
One Postgres, two databases with two roles: deploy/db/provision-env-db.sh
creates thermograph_beta (NOSUPERUSER, owns only its own database, CONNECT
revoked from PUBLIC) plus a <role>_ro for ad-hoc queries, and refuses to run for
the environment that owns the instance -- doing so would demote prod's bootstrap
superuser.
Fixes that co-residency would otherwise have broken silently:
- CI secrets are keyed by host (VPS1_SSH_*, VPS2_SSH_*). SSH_* meant "beta" and
also "the Forgejo box" because those shared a machine; that conflation is what
once had the prod backup dumping beta.
- The nightly backup dumps BOTH application databases to separate off-box
prefixes, and fails loudly on a missing one instead of skipping it.
- Alloy stopped deriving the `host` label from the node -- on vps2 that would
have filed every beta line as prod, feeding prod's alert rules with beta's
traffic. It is now derived per source, with a new `node` label for the machine,
and beta's log volume is mounted via a vps2-only overlay.
- ops/dbq.sh, ops/iceberg.sh and the secrets seed scripts derived their SSH
target and env-file path from the topology instead of hardcoding beta to
75.119.132.91 -- which is vps1's address now.
- Dev's overlay binds DEV_BIND_ADDR (loopback by default, the mesh address on
vps1) instead of 0.0.0.0: correct for a home LAN box, a public exposure of
unreviewed branches on a VPS.
Terraform's hosts variable is keyed by machine with a nested environments map,
and main.tf flattens (host, environment) pairs so two environments on one box
cannot share a checkout. Caddy's single reference config is split per host.
Docs, onboarding and the runbooks are updated throughout, including the security
rationales that co-residency makes false: separate SSH credentials no longer put
a host boundary between beta and prod, and the boundary that remains is the
database and the filesystem.
193 lines
7.4 KiB
Markdown
193 lines
7.4 KiB
Markdown
# Self-hosted Open-Meteo (ERA5 archive)
|
||
|
||
Operator runbook for running a private Open-Meteo instance that serves the
|
||
ERA5 historical archive to Thermograph, with the `.om` data held in object
|
||
storage and surfaced on the host through an rclone FUSE mount.
|
||
|
||
## 1. What this is and why
|
||
|
||
Thermograph reads daily historical weather from an ERA5 archive. Off the
|
||
shelf that means the public Open-Meteo archive API, which is rate-limited and
|
||
not something to lean on for a production workload. This overlay runs our own
|
||
Open-Meteo instance instead:
|
||
|
||
- `open-meteo-api` serves `era5_seamless` locally (internal to the compose
|
||
network). The app points at it via `THERMOGRAPH_ARCHIVE_URL`.
|
||
- Two sync workers (`open-meteo-sync-land`, `open-meteo-sync-era5`) pull `.om`
|
||
files from Open-Meteo's free AWS Open-Data bucket (no API key, no rate
|
||
limit) and write them into the archive.
|
||
|
||
`era5_seamless` is a blend: 0.1° ERA5-Land for temperature, precipitation,
|
||
humidity, and wind, plus 0.25° ERA5 for wind gusts (which ERA5-Land does not
|
||
carry) and as the over-water fallback. The 0.1° resolution is a hard
|
||
requirement for city-level accuracy.
|
||
|
||
The archive is ~1–1.5 TB of `.om`. That does not fit on the host's 400 GB
|
||
disk, so it lives in an object-storage bucket and is mounted read/write via
|
||
rclone. The host disk holds only the bounded rclone VFS cache, the app's own
|
||
parquet cache, and Postgres — never a full copy. The app never reads object
|
||
storage directly; it only talks to `open-meteo-api`, which reads the mount.
|
||
|
||
## 2. Object storage prerequisites
|
||
|
||
Provision a bucket of ~2 TB (holds the ~1–1.5 TB archive with headroom):
|
||
|
||
- **Co-located with the VPS** and **low- or no-egress** — e.g. Cloudflare R2,
|
||
or same-provider object storage in the VPS's region.
|
||
- Co-location and low egress matter because the mount serves per-request
|
||
**range reads**: every archive query pulls byte ranges out of many `.om`
|
||
files. Cross-region or metered egress turns each read into latency and cost.
|
||
Keep the bucket next to the compute and on a plan that does not bill egress.
|
||
|
||
You'll need S3-compatible credentials (access key id + secret) and the
|
||
bucket's S3 endpoint.
|
||
|
||
## 3. Host rclone mount setup
|
||
|
||
Install rclone:
|
||
|
||
```sh
|
||
curl https://rclone.org/install.sh | sudo bash
|
||
```
|
||
|
||
Create `/etc/rclone/rclone.conf` with an S3-compatible remote. Use real
|
||
values for your provider; **never commit real secrets**:
|
||
|
||
```ini
|
||
[om-archive]
|
||
type = s3
|
||
provider = Cloudflare
|
||
endpoint = https://<ACCOUNT_ID>.r2.cloudflarestorage.com
|
||
access_key_id = REPLACE_WITH_ACCESS_KEY_ID
|
||
secret_access_key = REPLACE_WITH_SECRET_ACCESS_KEY
|
||
```
|
||
|
||
Install and enable the mount unit (see `rclone-mount.service.example`):
|
||
|
||
```sh
|
||
sudo install -m0644 rclone-mount.service.example /etc/systemd/system/rclone-om.service
|
||
# edit BUCKET_NAME in the unit first; see the unit's header comments
|
||
sudo systemctl daemon-reload
|
||
sudo systemctl enable --now rclone-om
|
||
```
|
||
|
||
Verify the mount:
|
||
|
||
```sh
|
||
mountpoint -q /mnt/om-archive && echo mounted
|
||
ls /mnt/om-archive
|
||
```
|
||
|
||
The unit runs with `--vfs-cache-mode full` and a bounded
|
||
`--vfs-cache-max-size` (e.g. `80G`). Full VFS cache mode keeps hot cells on
|
||
local disk after first read so repeat range reads don't go back to the bucket,
|
||
and the size cap keeps that cache inside the 400 GB disk budget by evicting
|
||
cold data.
|
||
|
||
**Order Docker after the mount (reboots).** So the `restart: unless-stopped`
|
||
containers never start against an empty mount point, make Docker wait for the
|
||
mount (`rclone-om` is `Type=notify`, so this waits until the mount is actually
|
||
ready). Terraform installs this automatically; for a manual setup:
|
||
|
||
```sh
|
||
sudo install -d /etc/systemd/system/docker.service.d
|
||
printf '[Unit]\nWants=rclone-om.service\nAfter=rclone-om.service\n' \
|
||
| sudo tee /etc/systemd/system/docker.service.d/10-wait-rclone.conf
|
||
sudo systemctl daemon-reload
|
||
```
|
||
|
||
(If the mount ever drops and remounts *while* the containers are running, the
|
||
existing bind won't see the new mount — restart the Open-Meteo containers to
|
||
re-bind. The app stays safe either way: it rejects a short/empty archive and
|
||
falls back to NASA rather than caching a gap. See `make om-up`.)
|
||
|
||
## 4. Point the overlay at the mount
|
||
|
||
`OM_DATA_DIR` is read from the environment at `docker compose` time; in prod
|
||
it lives in `/etc/thermograph.env` (which the systemd unit sources). Set it to
|
||
the mount:
|
||
|
||
```sh
|
||
# /etc/thermograph.env
|
||
OM_DATA_DIR=/mnt/om-archive
|
||
```
|
||
|
||
All three services bind-mount `${OM_DATA_DIR}` to `/app/data`, so with this
|
||
set the archive reads and writes go to object storage.
|
||
|
||
## 5. One-time backfill
|
||
|
||
The sync workers only maintain a rolling recent window. To populate full
|
||
history, run the backfill once:
|
||
|
||
```sh
|
||
make om-backfill
|
||
```
|
||
|
||
This runs each dataset's `sync ... --past-days 17000` once via
|
||
`docker compose run --rm --no-deps`. It writes the full ~1–1.5 TB of `.om`
|
||
**to object storage**, is **hours-long**, and should be watched against the
|
||
2 TB budget. Run it **before** flipping the app over — if the app is pointed
|
||
at an empty instance it falls back to NASA POWER, so bring the archive up to
|
||
full history first.
|
||
|
||
For a smoke test, shorten the window with `OM_BACKFILL_DAYS`:
|
||
|
||
```sh
|
||
make om-backfill OM_BACKFILL_DAYS=30
|
||
```
|
||
|
||
## 6. Bring the overlay up
|
||
|
||
```sh
|
||
make om-up
|
||
```
|
||
|
||
This is `docker compose -f docker-compose.yml -f docker-compose.openmeteo.yml
|
||
up -d --build`. The overlay sets `THERMOGRAPH_ARCHIVE_URL=http://open-meteo-api:8080/v1/archive`
|
||
automatically.
|
||
|
||
Smoke test the internal API and confirm every daily field is present and
|
||
non-null. `open-meteo-api` has **no published host port**, so either run the
|
||
curl from inside the compose network, or temporarily publish the port:
|
||
|
||
```sh
|
||
docker compose -f docker-compose.yml -f docker-compose.openmeteo.yml \
|
||
exec app curl "http://open-meteo-api:8080/v1/archive?latitude=47.6&longitude=-122.3&start_date=2026-06-01&end_date=2026-06-10&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,wind_gusts_10m_max,apparent_temperature_max,apparent_temperature_min,relative_humidity_2m_mean&models=era5_seamless&temperature_unit=fahrenheit&wind_speed_unit=mph&precipitation_unit=inch"
|
||
```
|
||
|
||
If you've temporarily published the port instead, the same query works
|
||
against `http://127.0.0.1:8080/...`. Check that each `daily` array is present
|
||
and free of nulls across the date range.
|
||
|
||
## 7. Keeping current
|
||
|
||
The two sync workers re-sync `--past-days 14` every 1440 minutes (daily). If a
|
||
worker stalls, the recent tail of history goes stale — the last couple of
|
||
weeks stop updating. (The separate forecast path is unaffected; this only
|
||
touches the historical archive.)
|
||
|
||
Check the workers:
|
||
|
||
```sh
|
||
docker compose -f docker-compose.yml -f docker-compose.openmeteo.yml \
|
||
logs open-meteo-sync-land
|
||
docker compose -f docker-compose.yml -f docker-compose.openmeteo.yml \
|
||
logs open-meteo-sync-era5
|
||
```
|
||
|
||
## 8. Attribution
|
||
|
||
The ERA5 and ERA5-Land data is CC-BY-4.0 (Copernicus/ECMWF), sourced via
|
||
Open-Meteo. The Open-Meteo software is AGPLv3. The app already surfaces this
|
||
credit; keep it in place.
|
||
|
||
## 9. Not on dev/beta
|
||
|
||
This overlay runs only for prod specifically — not for every environment on
|
||
prod's host. Beta now shares vps2 with prod, but that doesn't extend the
|
||
self-hosted archive to it: beta is its own Swarm stack
|
||
(`thermograph-beta-stack.yml`), which never sets `THERMOGRAPH_ARCHIVE_URL`, so
|
||
it reaches the public Open-Meteo archive API like dev does. Dev leaves it
|
||
unset for the same reason on vps1. Do not bring this overlay up for beta or
|
||
dev, and don't assume co-residency with prod on vps2 changes that.
|