Flora
An operations console for regenerative farming, built on a real satellite pipeline
In plain English
A farm dashboard where a scheduled worker — never the app itself — asks satellites for ten different plant-health readings a field, so a farmer opens a map and sees exactly which corner of which field is struggling, backed by real Sentinel-2 imagery instead of a demo.
My role
Solo full stack developer
Timeline
Ongoing
Team
Solo
Status
Case study
Design
Product tour







Section 01
The problem
A first version of this idea existed already, built on Google Earth Engine — but Earth Engine only ships Python and JavaScript bindings, and it's a research compute platform, not an imagery API, which made it slow and awkward to build a real product on top of. Beyond that starting point, the actual product problem: a smaller regenerative farm has no in-house GIS or remote-sensing expertise, but still needs to know which part of which field is stressed, this week, without walking every hectare.
Section 02
How I approached it
The whole system is rebuilt around one invariant: no request ever calls the satellite provider. A NestJS worker polls Copernicus's Data Space Ecosystem on a schedule, decodes the returned imagery, computes ten spectral indices from a single Process API call, and writes pre-rendered PNGs to object storage plus rows to Postgres. The API — a separate NestJS service — only ever reads Postgres and Redis. That split is enforced by an actual test, not a comment. Every tenant table sits behind PostGIS row-level security *and* a repository-level filter, checked by a dedicated cross-tenant suite that asserts a 404 (not a 403) on another org's data. Contracts between the three services (web, API, worker) are Zod schemas in one shared package, imported by both TypeScript sides — never a hand-mirrored type.
Outcome
A working spine — register a field, watch a real stress zone appear from real Sentinel-2 data, act on it as a task — plus a home dashboard, a ten-index spectral switcher, and a weather screen reading a real Open-Meteo forecast. Several of the sharpest bugs never showed up in a code review or a unit test: they only appeared once a manual refresh ran against a real Copernicus account and a real field boundary, and got fixed the same way — by looking at what actually rendered.
Section 03
Going deeper
A read path that never touches the satellite provider
This is the one architectural rule the whole system is shaped around: Sentinel Hub is called from exactly one place, a scheduled BullMQ job in the worker, never from a request. The API reads only Postgres and Redis. A raster becomes a PNG in object storage — Cloudflare R2 in production, MinIO locally — referenced by its object key, never a signed URL, because a persisted signed URL just expires quietly in the database later. The rule is enforced by a test that asserts it, not by convention.
Ten indices for the cost of two
The pipeline started with one scheduled index (NDVI) and grew to ten — NDRE, EVI, MSAVI, RECI, MCARI, a PRI proxy, NDMI, NDWI, and VSDI alongside it, plus a true-colour render — by asking Sentinel Hub for all of them in a single Process API call instead of one call per index. Verified live against a real account: a refresh costs 4.667 Processing Units against a 4.0 baseline, a 17% increase for eight extra outputs, because output count turns out to be free and only the input bands matter. Two indices needed an honest product call rather than a formula: PRI has no real substitute on Sentinel-2 without a 531nm band, so it ships labelled 'PRI (proxy)' everywhere, never bare 'PRI'; a requested 'plain, fixed-domain NDVI' needs a client-side colour-mapped raster the pipeline doesn't build yet, so it's shown disabled with a tooltip explaining why, instead of a legend that quietly lies about what painted the pixels.
Bugs a code review can't catch
The Sentinel Hub client sent no `Accept` header on its Process API request. Copernicus silently returned a bare single TIFF instead of the requested two-file TAR, and `res.formData()` threw a parser error that read exactly like a token-endpoint failure — it took testing against the live account, not the mocked fixtures, to find the real cause. Separately, two indices without a division in their formula (VSDI, MSAVI2) evaluated to a finite, in-range number at pixels *outside* the field boundary, because the masking logic checked for a `0/0 = NaN`, which only NDVI-shaped ratios produce — so VSDI painted a field's whole bounding-box rectangle instead of clipping to its real, non-rectangular boundary. Caught from a live screenshot, fixed by switching the mask to the imagery's own SCL 'no data' class instead of a formula side-effect.
Tenancy enforced twice, not once
Every domain table — fields, crops, observations, stress zones, tasks — carries an `organization_id` filtered at the repository layer, and again by Postgres row-level security on the same table. A dedicated integration suite authenticates as one organization and asserts a 404, never a 403, on every other organization's resource, against real RLS, not a mock. The RLS catalog test is a named allowlist of exactly three `SECURITY DEFINER` functions the schema is allowed to have — anything else added later fails the build until it's explicitly reviewed and added to the list.
Section 04
The build process
Foundations, schema, tenancy
A pnpm/Turborepo monorepo, a Drizzle + PostGIS schema for ten domain tables with composite foreign keys, and identity with RLS enforced twice before any product screen existed. The design system's five PRO-tier Figma blocks were rebuilt as composites from free AlignUI components rather than paying for a seat.
The spine: Fields → Crop Stress → Tasks
Field CRUD and boundary drawing on a Mapbox map, then the satellite write path (a CDSE HTTP client, a decode-to-PNG raster pipeline, a BullMQ per-field scheduler), then the screen that reads it — raster overlay, stress-zone layer, colour-ramp legend — and finally a Kanban task board with a real @dnd-kit drag, verified against React 19 Strict Mode before any board code was written.
Home dashboard and a sourced score
Daily rollups, a weather snapshot table, and a Regeneration Score built from a real, cited formula — AAFC's agri-environmental performance index over soil cover days, Shannon evenness, and stress-free area share — instead of an invented composite metric.
Ten spectral indices
Widened the pipeline from one scheduled index to ten in a single Process API call, added a per-index colour-ramp registry and a vegetation-floor correction for indices that aren't NDVI-shaped, and built the on-demand true-colour render — which hit and got fixed by the identical SCL-masking bug for the identical reason.
Weather
An Open-Meteo hourly extension and six instrument cards — wind, UV, rain chance, sunrise/sunset, pressure, wind direction — each hand-verified against exported reference SVGs and real seeded data, down to fixing a dark-mode-only invisible-text bug from a static-versus-semantic colour token mismatch.
Section 05
How the pieces fit together
Each layer has one job. Read it top to bottom — that's roughly the path a request takes through the system.
Next.js 16 App Router
AlignUI shell, Mapbox map, shadcn/Recharts charts — reads only Postgres-backed API endpoints and Redis-cached data, never a satellite provider.
NestJS
A separate service from the web app so it can share domain logic with the worker through packages/ instead of Next.js route handlers.
NestJS standalone + BullMQ
The only service allowed to call Sentinel Hub or Open-Meteo — a scheduled per-field job, not a request handler.
PostgreSQL 16 + PostGIS 3.4
Drizzle ORM with a custom geography type; every tenant table behind RLS and a repository filter, both.
Cloudflare R2 (MinIO locally)
Pre-rendered raster PNGs stored by object key, never a persisted signed URL.
Zod, one shared package
The only shape crossing an app boundary — imported by both NestJS and Next.js, no codegen, no hand-mirrored types.
Section 06
What it does
Ten-index spectral switcher
NDVI, NDRE, EVI, MSAVI, RECI, MCARI, a labelled PRI proxy, NDMI, NDWI, and VSDI (shown as SMI), each with its own colour ramp — and an honest disabled state, with a tooltip, for the indices this pipeline can't actually produce yet.
Real stress-zone detection
A raster overlay clipped to the field's true polygon boundary via its imagery's own SCL 'no data' class, grouped detections with priority sorting, and a popover with real mutations against the task board.
Kanban tasks board
A three-column board with a real @dnd-kit drag, a server-computed midpoint for reordering, and a Water Used tile fed by the same `tasks.water_volume_m3` column the board writes.
A sourced Regeneration Score
Soil Cover Days, Shannon evenness, and stress-free area share, combined by AAFC's own published agri-environmental performance index — not a composite metric invented for the dashboard.
Section 07
The hard parts
A missing Accept header that looked like an auth failure
No `Accept: application/tar` on the Process API request meant Copernicus silently returned a single bare TIFF instead of the requested two-file archive, and undici's own parser error on `res.formData()` read exactly like a token problem. Only testing against the real, live account — not the recorded fixtures — surfaced the actual cause.
A masking bug that only two of ten indices had
Eight of the ten spectral indices are NDVI-shaped ratios, where a masked pixel's own math already produces `0/0 = NaN` outside the field boundary. VSDI and MSAVI2 have no division in their formulas, so that same masked pixel silently evaluated to a real, in-range number instead — painting a rectangle where a field polygon should have been. The fix switched masking to the imagery's own SCL 'no data' class instead of relying on a formula side-effect.
A synthetic seed that never clipped to a real boundary
The satellite seed script filled its field's entire bounding-box rectangle with valid pixels, since a synthetic raster has no reason to know about the field's real, often non-rectangular boundary the way a genuine CDSE response is clipped server-side. Found by looking at a rendered field, not by inspecting the script.
Section 08
What I took away
- An architectural invariant is only real once a test can fail it — 'no satellite calls on a request path' stayed true because a test asserts it, not because a comment says so.
- A missing header can produce an error message that points at the wrong layer entirely; the fix was verifying against the real live account instead of trusting where the stack trace pointed.
- A masking rule that works for nine formulas out of ten is still a bug, not a rounding error — the tenth one needs its own real signal (SCL), not an assumption borrowed from the other nine.
Satellite pipeline
10 indices, one Sentinel-2 call
Tenancy
PostGIS RLS + repo filter, enforced twice
Request path
Zero calls to the satellite provider
Scoring
AAFC-sourced Regeneration Score, not invented
Let's build something worth shipping
Available for freelance projects, full-time roles, and technical consulting. Tell me what you're building and I'll tell you honestly whether I'm the right person for it.
Based in Rio de Janeiro, Brazil · Working with teams in any timezone · Fluent in English and Portuguese