Self-host the ERA5 archive via Open-Meteo (object storage) (#224)

Get the 45-year historical record off the rate-limited public Open-Meteo
archive API by running a private Open-Meteo instance that serves the
era5_seamless blend (0.1° ERA5-Land + 0.25° ERA5 for gusts) from the
compressed .om archive in object storage, mounted on the host with rclone.

- climate.py: make ARCHIVE_URL env-driven (THERMOGRAPH_ARCHIVE_URL) and pin
  models=era5_seamless on the archive fetches only, so a self-hosted instance
  serves the same 0.1° resolution; forecast path unchanged. The public API's
  default is already seamless, so dev/beta (URL unset) behave identically.
- docker-compose.openmeteo.yml: open-meteo-api + two rolling sync workers
  (era5_land 0.1°, era5 0.25° for gusts), bind-mounting the object-storage
  mount; the overlay points the app at the local instance.
- Makefile: om-up / om-down / om-backfill (one-time full-history backfill).
- Terraform: per-host openmeteo flag layers the overlay, renders OM_DATA_DIR,
  and provisions the host rclone systemd mount from the bucket credentials.
- deploy/openmeteo: operator runbook + rclone mount unit template.
This commit is contained in:
Emi Griffith 2026-07-20 06:16:56 -07:00 committed by GitHub
parent 47aea569ed
commit a26ca72834
10 changed files with 468 additions and 2 deletions

172
deploy/openmeteo/README.md Normal file
View file

@ -0,0 +1,172 @@
# 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 ~11.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 ~11.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.
## 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 ~11.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 on the self-hosting host (prod). Dev and beta leave
`THERMOGRAPH_ARCHIVE_URL` unset and use the public Open-Meteo archive API — do
not bring the overlay up there.

View file

@ -0,0 +1,32 @@
# rclone FUSE mount for the Open-Meteo (ERA5) archive bucket.
#
# Before installing:
# - Replace BUCKET_NAME below with the object-storage bucket name.
# - `--allow-other` requires `user_allow_other` to be set in /etc/fuse.conf.
# - The `om-archive` remote must exist in /etc/rclone/rclone.conf (S3 remote).
#
# Install:
# sudo install -m0644 rclone-mount.service.example /etc/systemd/system/rclone-om.service
# sudo systemctl daemon-reload
# sudo systemctl enable --now rclone-om
[Unit]
Description=rclone mount for Open-Meteo ERA5 archive
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
ExecStartPre=/bin/mkdir -p /mnt/om-archive
ExecStart=/usr/bin/rclone mount om-archive:BUCKET_NAME /mnt/om-archive \
--config /etc/rclone/rclone.conf \
--vfs-cache-mode full \
--vfs-cache-max-size 80G \
--dir-cache-time 12h \
--allow-other \
--umask 000
ExecStop=/bin/fusermount -u /mnt/om-archive
Restart=on-failure
[Install]
WantedBy=multi-user.target

View file

@ -21,6 +21,15 @@ POSTGRES_PASSWORD=change-me
# sync with POSTGRES_PASSWORD above). # sync with POSTGRES_PASSWORD above).
#THERMOGRAPH_DATABASE_URL=postgresql+asyncpg://thermograph:change-me@db:5432/thermograph #THERMOGRAPH_DATABASE_URL=postgresql+asyncpg://thermograph:change-me@db:5432/thermograph
# --- Historical archive (self-hosted Open-Meteo) --------------------------------
# Where the app fetches its 45-year daily history. Unset (default) → the public
# Open-Meteo archive API (rate-limited). On the self-hosting host, the
# docker-compose.openmeteo.yml overlay sets this to the internal service URL for
# you, so you normally DON'T set it here. Only pin it to run the app outside that
# overlay against a reachable Open-Meteo instance. Leave it UNSET rather than empty:
# an empty value is honored as-is and would break the fallback to the public API.
#THERMOGRAPH_ARCHIVE_URL=http://open-meteo-api:8080/v1/archive
# Mark the session cookie Secure — required behind Caddy's HTTPS. Set to 1 in prod; # Mark the session cookie Secure — required behind Caddy's HTTPS. Set to 1 in prod;
# leave unset only for plain-HTTP LAN dev (a Secure cookie is never sent over HTTP). # leave unset only for plain-HTTP LAN dev (a Secure cookie is never sent over HTTP).
THERMOGRAPH_COOKIE_SECURE=1 THERMOGRAPH_COOKIE_SECURE=1

View file

@ -0,0 +1,70 @@
# Self-hosted Open-Meteo overlay — serves the ERA5 archive locally so the app is
# off the rate-limited public archive API. Enable it only on the self-hosting host:
#
# docker compose -f docker-compose.yml -f docker-compose.openmeteo.yml up -d
#
# The hourly .om data (copernicus_era5_land 0.1° + copernicus_era5 0.25°) lives in
# OBJECT STORAGE, surfaced on the host as a local directory by an rclone FUSE mount
# (a host systemd unit — see deploy/openmeteo/README.md). OM_DATA_DIR points the
# containers at that mount; it defaults to ./data/om-archive so a local smoke test
# works against a plain directory. Object storage is never bind-mounted into the app
# — only Open-Meteo reads it; the app just talks HTTP to open-meteo-api and keeps its
# own small daily-per-cell parquet cache.
#
# One-time backfill (writes ~11.5 TB of .om to object storage — run once before the
# app is flipped over): `make om-backfill`.
services:
# Serves /v1/archive on the compose network. No host port: only `app` reaches it,
# as http://open-meteo-api:8080. Reads .om from the object-storage mount, on demand
# for any point, returning JSON byte-identical to the public archive API.
open-meteo-api:
image: ${OM_IMAGE:-ghcr.io/open-meteo/open-meteo}
command: ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"]
volumes:
- ${OM_DATA_DIR:-./data/om-archive}:/app/data
restart: unless-stopped
# Rolling keep-current worker for ERA5-Land (0.1°): the surface variables it
# carries. --past-days 14 re-syncs the recent tail every day, appending new days and
# absorbing ERA5T→final corrections. dew_point_2m → relative_humidity and
# shortwave_radiation → apparent_temperature are derived server-side at query time.
open-meteo-sync-land:
image: ${OM_IMAGE:-ghcr.io/open-meteo/open-meteo}
command:
- "sync"
- "copernicus_era5_land"
- "temperature_2m,dew_point_2m,precipitation,shortwave_radiation,wind_u_component_10m,wind_v_component_10m"
- "--past-days"
- "${OM_SYNC_PAST_DAYS:-14}"
- "--repeat-interval"
- "1440"
volumes:
- ${OM_DATA_DIR:-./data/om-archive}:/app/data
restart: unless-stopped
# Rolling keep-current worker for ERA5 (0.25°): wind gusts — absent from ERA5-Land —
# plus the seamless-blend fallback over water/coastline where ERA5-Land has no data.
open-meteo-sync-era5:
image: ${OM_IMAGE:-ghcr.io/open-meteo/open-meteo}
command:
- "sync"
- "copernicus_era5"
- "wind_gusts_10m,temperature_2m,dew_point_2m,precipitation,shortwave_radiation,wind_u_component_10m,wind_v_component_10m"
- "--past-days"
- "${OM_SYNC_PAST_DAYS:-14}"
- "--repeat-interval"
- "1440"
volumes:
- ${OM_DATA_DIR:-./data/om-archive}:/app/data
restart: unless-stopped
# Point the app's historical fetches at the local instance. Set here (not in the
# base file) so it applies only when this overlay is active — and so an unset var
# never reaches the app as an empty string, which would defeat climate.py's default.
app:
depends_on:
open-meteo-api:
condition: service_started
environment:
THERMOGRAPH_ARCHIVE_URL: http://open-meteo-api:8080/v1/archive

View file

@ -25,12 +25,19 @@ module "host" {
app_cpus = each.value.app_cpus app_cpus = each.value.app_cpus
db_cpus = each.value.db_cpus db_cpus = each.value.db_cpus
db_memory = each.value.db_memory db_memory = each.value.db_memory
openmeteo = each.value.openmeteo
om_data_dir = each.value.om_data_dir
# Shared infra config # Shared infra config
repo_root = local.repo_root repo_root = local.repo_root
repo_url = var.repo_url repo_url = var.repo_url
app_port = var.app_port app_port = var.app_port
# Shared object-storage config (only used where openmeteo = true)
om_bucket_remote = var.om_bucket_remote
om_rclone_conf = var.om_rclone_conf
om_vfs_cache_max = var.om_vfs_cache_max
# Shared secrets -> /etc/thermograph.env # Shared secrets -> /etc/thermograph.env
postgres_password = var.postgres_password postgres_password = var.postgres_password
auth_secret = var.auth_secret auth_secret = var.auth_secret

View file

@ -11,11 +11,14 @@ locals {
# Public base URL: the domain over HTTPS, else the raw host:port for a Caddy-less box. # Public base URL: the domain over HTTPS, else the raw host:port for a Caddy-less box.
base_url = var.domain != "" ? "https://${var.domain}" : "http://${var.host}:${var.app_port}" base_url = var.domain != "" ? "https://${var.domain}" : "http://${var.host}:${var.app_port}"
# Layer the self-hosted Open-Meteo overlay on hosts that self-host the archive.
effective_compose_files = var.openmeteo ? concat(var.compose_files, ["docker-compose.openmeteo.yml"]) : var.compose_files
# `-f a -f b` for the compose invocations (dev layers the dev overlay). # `-f a -f b` for the compose invocations (dev layers the dev overlay).
compose_flags = join(" ", [for f in var.compose_files : "-f ${f}"]) compose_flags = join(" ", [for f in local.effective_compose_files : "-f ${f}"])
# Hash the local compose files so a compose edit re-triggers the remote deploy. # Hash the local compose files so a compose edit re-triggers the remote deploy.
compose_files_sha = join(",", [for f in var.compose_files : filesha256("${var.repo_root}/${f}")]) compose_files_sha = join(",", [for f in local.effective_compose_files : filesha256("${var.repo_root}/${f}")])
# Rendered /etc/thermograph.env (sensitive carries every secret). # Rendered /etc/thermograph.env (sensitive carries every secret).
env_content = templatefile("${path.module}/templates/thermograph.env.tftpl", { env_content = templatefile("${path.module}/templates/thermograph.env.tftpl", {
@ -30,6 +33,8 @@ locals {
base = "/" base = "/"
base_url = local.base_url base_url = local.base_url
cookie_secure = local.cookie_secure cookie_secure = local.cookie_secure
openmeteo = var.openmeteo
om_data_dir = var.om_data_dir
vapid_private_key = var.vapid_private_key vapid_private_key = var.vapid_private_key
vapid_public_key = var.vapid_public_key vapid_public_key = var.vapid_public_key
vapid_contact = var.vapid_contact vapid_contact = var.vapid_contact
@ -55,6 +60,29 @@ locals {
domain = var.domain domain = var.domain
port = var.app_port port = var.app_port
}) : "# No public domain on this host; Caddy is not managed here.\n" }) : "# No public domain on this host; Caddy is not managed here.\n"
# systemd unit that keeps the object-storage bucket rclone-mounted at om_data_dir,
# so the Open-Meteo containers read the ERA5 .om archive from it. Only installed on
# openmeteo hosts; --allow-other lets the container (root) read the FUSE mount.
rclone_unit = <<-UNIT
[Unit]
Description=rclone mount ERA5 archive (Thermograph)
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
ExecStartPre=/bin/mkdir -p ${var.om_data_dir}
ExecStart=/usr/bin/rclone mount ${var.om_bucket_remote} ${var.om_data_dir} --config /etc/rclone/rclone.conf --vfs-cache-mode full --vfs-cache-max-size ${var.om_vfs_cache_max} --dir-cache-time 12h --allow-other --umask 000
ExecStop=/bin/fusermount -u ${var.om_data_dir}
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
UNIT
rclone_unit_content = var.openmeteo ? local.rclone_unit : "# openmeteo disabled on this host\n"
} }
resource "null_resource" "host" { resource "null_resource" "host" {
@ -69,6 +97,10 @@ resource "null_resource" "host" {
branch = var.git_branch branch = var.git_branch
app_dir = var.app_dir app_dir = var.app_dir
sizing = "${var.workers}/${var.app_cpus}/${var.db_cpus}/${var.db_memory}" sizing = "${var.workers}/${var.app_cpus}/${var.db_cpus}/${var.db_memory}"
# Re-provision when the archive self-hosting config changes. The rclone.conf is
# hashed (nonsensitive on a one-way digest) so a credential rotation redeploys.
openmeteo = "${var.openmeteo}/${var.om_data_dir}/${var.om_bucket_remote}/${var.om_vfs_cache_max}"
om_conf = var.openmeteo ? nonsensitive(sha256(var.om_rclone_conf)) : "off"
} }
connection { connection {
@ -90,6 +122,18 @@ resource "null_resource" "host" {
destination = "/tmp/thermograph.Caddyfile" destination = "/tmp/thermograph.Caddyfile"
} }
# rclone config (bucket credentials) + the mount unit. Pushed via `content` so the
# secret never touches local disk; harmless placeholders on non-openmeteo hosts.
provisioner "file" {
content = var.openmeteo ? var.om_rclone_conf : "# openmeteo disabled on this host\n"
destination = "/tmp/thermograph.rclone.conf"
}
provisioner "file" {
content = local.rclone_unit_content
destination = "/tmp/rclone-om.service"
}
# 1. Host setup: Docker + compose plugin, ufw firewall, and (domain hosts) Caddy. # 1. Host setup: Docker + compose plugin, ufw firewall, and (domain hosts) Caddy.
provisioner "remote-exec" { provisioner "remote-exec" {
inline = [ inline = [
@ -132,6 +176,49 @@ resource "null_resource" "host" {
] ]
} }
# 1b. Self-hosted archive: rclone-mount the ERA5 bucket before compose up so
# open-meteo-api can read .om from object storage. Skipped on non-openmeteo hosts.
provisioner "remote-exec" {
inline = [
<<-EOT
set -eu
if [ "${var.openmeteo}" != "true" ]; then
rm -f /tmp/thermograph.rclone.conf /tmp/rclone-om.service
echo "[${var.name}] openmeteo: disabled"
exit 0
fi
echo "[${var.name}] openmeteo: rclone mount ${var.om_bucket_remote} -> ${var.om_data_dir}"
if ! command -v rclone >/dev/null 2>&1; then
curl -fsSL https://rclone.org/install.sh | sudo bash
fi
# FUSE allow_other so the container (root) can read a mount owned by this user.
if ! grep -q '^user_allow_other' /etc/fuse.conf 2>/dev/null; then
echo user_allow_other | sudo tee -a /etc/fuse.conf >/dev/null
fi
sudo install -d -m 0755 /etc/rclone
sudo install -m 0600 /tmp/thermograph.rclone.conf /etc/rclone/rclone.conf
sudo install -m 0644 /tmp/rclone-om.service /etc/systemd/system/rclone-om.service
rm -f /tmp/thermograph.rclone.conf /tmp/rclone-om.service
sudo install -d -m 0755 ${var.om_data_dir}
sudo systemctl daemon-reload
sudo systemctl enable rclone-om
sudo systemctl restart rclone-om
# Wait for the mount before compose bind-mounts it.
i=0
while [ "$i" -lt 30 ]; do
if mountpoint -q ${var.om_data_dir}; then break; fi
i=$((i + 1)); sleep 2
done
if ! mountpoint -q ${var.om_data_dir}; then
echo "[${var.name}] RCLONE MOUNT FAILED" >&2
sudo systemctl status rclone-om --no-pager || true
exit 1
fi
echo "[${var.name}] openmeteo: mounted at ${var.om_data_dir}"
EOT
]
}
# 2. Sync the checkout to the host's branch (cloning first on a fresh box). # 2. Sync the checkout to the host's branch (cloning first on a fresh box).
provisioner "remote-exec" { provisioner "remote-exec" {
inline = [ inline = [

View file

@ -18,6 +18,13 @@ WORKERS=${workers}
APP_CPUS=${app_cpus} APP_CPUS=${app_cpus}
DB_CPUS=${db_cpus} DB_CPUS=${db_cpus}
DB_MEMORY=${db_memory} DB_MEMORY=${db_memory}
%{ if openmeteo ~}
# --- Self-hosted Open-Meteo archive (docker-compose.openmeteo.yml) ---------------
# Host rclone mount of the ERA5 object-storage bucket; the overlay bind-mounts it
# into the Open-Meteo containers. THERMOGRAPH_ARCHIVE_URL is set by the overlay.
OM_DATA_DIR=${om_data_dir}
%{ endif ~}
# --- Serving -------------------------------------------------------------------- # --- Serving --------------------------------------------------------------------
THERMOGRAPH_BASE=${base} THERMOGRAPH_BASE=${base}

View file

@ -40,6 +40,37 @@ variable "compose_files" {
type = list(string) type = list(string)
} }
variable "openmeteo" {
description = "Self-host the ERA5 archive: layer docker-compose.openmeteo.yml + provision the host rclone mount."
type = bool
default = false
}
variable "om_data_dir" {
description = "Host rclone mount point for the archive bucket (OM_DATA_DIR the overlay bind-mounts)."
type = string
default = "/mnt/om-archive"
}
variable "om_bucket_remote" {
description = "rclone remote:path for the archive bucket (mounted at om_data_dir)."
type = string
default = ""
}
variable "om_rclone_conf" {
description = "rclone.conf contents installed to /etc/rclone/rclone.conf. Sensitive."
type = string
default = ""
sensitive = true
}
variable "om_vfs_cache_max" {
description = "rclone --vfs-cache-max-size for the mount's on-disk hot cache."
type = string
default = "80G"
}
variable "app_dir" { variable "app_dir" {
description = "Checkout path on the host." description = "Checkout path on the host."
type = string type = string

View file

@ -25,6 +25,10 @@ hosts = {
app_cpus = 8 app_cpus = 8
db_cpus = 4 db_cpus = 4
db_memory = "16g" db_memory = "16g"
# Self-host the ERA5 archive here: layers docker-compose.openmeteo.yml and
# provisions the rclone mount of the object-storage bucket (om_* vars below).
openmeteo = true
om_data_dir = "/mnt/om-archive"
} }
# Beta / testing: the OLD VPS, repurposed (branch `main`). # Beta / testing: the OLD VPS, repurposed (branch `main`).
@ -52,6 +56,23 @@ hosts = {
# repo_url = "https://github.com/griffemi/thermograph.git" # repo_url = "https://github.com/griffemi/thermograph.git"
# app_port = 8137 # app_port = 8137
# ---------------------------------------------------------------------------------
# Self-hosted Open-Meteo archive (only used by hosts with openmeteo = true) --------
# ---------------------------------------------------------------------------------
# The ERA5 .om archive lives in an object-storage bucket, rclone-mounted on the host.
# om_rclone_conf holds bucket credentials (sensitive; lands in state — keep out of git).
# See deploy/openmeteo/README.md for the bucket + mount setup.
om_bucket_remote = "om-archive:REPLACE_WITH_BUCKET_NAME"
om_vfs_cache_max = "80G"
om_rclone_conf = <<-RCLONE
[om-archive]
type = s3
provider = Cloudflare
endpoint = https://REPLACE.r2.cloudflarestorage.com
access_key_id = REPLACE_WITH_ACCESS_KEY
secret_access_key = REPLACE_WITH_SECRET_KEY
RCLONE
# --------------------------------------------------------------------------------- # ---------------------------------------------------------------------------------
# Shared secrets (DUMMY values — replace, and keep this file out of git) # Shared secrets (DUMMY values — replace, and keep this file out of git)
# --------------------------------------------------------------------------------- # ---------------------------------------------------------------------------------

View file

@ -30,6 +30,10 @@ variable "hosts" {
app_cpus = optional(number, 4) # app container CPU cap (APP_CPUS) app_cpus = optional(number, 4) # app container CPU cap (APP_CPUS)
db_cpus = optional(number, 2) # db container CPU cap (DB_CPUS) db_cpus = optional(number, 2) # db container CPU cap (DB_CPUS)
db_memory = optional(string, "8g") # db container memory cap (DB_MEMORY) db_memory = optional(string, "8g") # db container memory cap (DB_MEMORY)
# Self-host the ERA5 archive (docker-compose.openmeteo.yml + a host rclone mount
# of the object-storage bucket). Only the self-hosting host (prod) sets true.
openmeteo = optional(bool, false)
om_data_dir = optional(string, "/mnt/om-archive") # host rclone mount point (OM_DATA_DIR)
})) }))
} }
@ -45,6 +49,32 @@ variable "app_port" {
default = 8137 default = 8137
} }
# ---------------------------------------------------------------------------------
# Self-hosted Open-Meteo (object storage) consumed only by hosts with openmeteo=true
# ---------------------------------------------------------------------------------
# The ERA5 .om archive lives in an object-storage bucket, surfaced on the host by an
# rclone FUSE mount at each host's om_data_dir. These describe that bucket + mount.
# om_rclone_conf holds credentials, so it's sensitive and lands in state keep the
# real value in terraform.tfvars (gitignored), never committed.
variable "om_bucket_remote" {
description = "rclone remote:path for the archive bucket, e.g. \"om-archive:thermograph-era5\" (matches a [remote] in om_rclone_conf)."
type = string
default = ""
}
variable "om_rclone_conf" {
description = "Full rclone.conf contents defining the archive remote (installed to /etc/rclone/rclone.conf, 0600). Sensitive."
type = string
default = ""
sensitive = true
}
variable "om_vfs_cache_max" {
description = "rclone --vfs-cache-max-size: bounds the on-disk hot cache for the mount (keep within the disk budget)."
type = string
default = "80G"
}
# --------------------------------------------------------------------------------- # ---------------------------------------------------------------------------------
# Shared secrets (rendered into /etc/thermograph.env on every host) # Shared secrets (rendered into /etc/thermograph.env on every host)
# --------------------------------------------------------------------------------- # ---------------------------------------------------------------------------------