Croftura
in progressCroftura is a multi-tenant homestead management platform: a web and mobile app where households track gardens, plantings, livestock, supplies and chores, manage members, and build a shared plant encyclopedia. It runs in UAT at app.uat.croftura.com, provisioned entirely from Bicep IaC and deployed by GitHub Actions into a private, VNet-integrated Azure stack.
What It Is
A role-aware SaaS product for small farms and homesteads. Each organization (homestead) gets its own isolated workspace where owners invite workers and viewers, members track what they grow and raise, and everything is backed by a shared global plant encyclopedia that anyone can contribute to.
- Dashboard: a factual summary of the day ("Today: 3 gardens, 64°F and partly sunny") built from real data and the NOAA forecast
- Gardens: gardens, plots and plantings, with USDA hardiness zones
- Livestock: herds and individual animal profiles with photos, plus a supplies inventory
- Task planner: household chores with due dates and browser push reminders
- Plant encyclopedia and crop catalog: global plants plus each homestead's own custom varieties
- Data export: owners can download everything as CSV or JSON, photos included
The primary design constraint: authentication and multi-tenancy must require zero infrastructure per new customer. A new organization is created end-to-end by the user, with no developer involvement, no database seeding and no Entra portal work after the initial setup. The product promise is stated up front in onboarding: core tracking is free, always, and paid add-ons only come in when you want to see costs and yields like a business.
Architecture
Croftura has two React SPAs (customer and admin), a Flutter mobile app, a FastAPI backend running in Azure Container Apps, and a PostgreSQL database that has no public endpoint. All Azure resources are defined in Bicep and deployed to croftura-uat.
Browser (React 19 + MSAL.js v3) Flutter app (Android / iOS)
│ │
│ PKCE auth flow │
▼ ▼
Microsoft Entra External ID (CIAM)
│
│ JWT with groups[] claim
▼
Container Apps environment (VNet-integrated)
├── FastAPI API
│ Validates JWT (iss, aud, exp)
│ Resolves role: groups[] → tenant_groups table → (tenant_id, role)
├── Reminder job every 15 min, web push for due tasks
├── Umami self-hosted analytics (analytics.uat.croftura.com)
└── Umami purge job deletes visitor data older than 12 months
│
├── PostgreSQL Flexible Server private subnet + private DNS, no public endpoint
├── Azure Blob Storage observation, animal and plant photos (SAS uploads)
├── Azure Key Vault per-secret access via managed identities
├── Application Insights OpenTelemetry traces, 90-day retention
└── Microsoft Graph API group + user management for org provisioning
Multi-Tenancy Model
Tenant membership lives entirely in Entra, not in a custom users table. Each organization maps to three Entra security groups (owner, worker, viewer) named with the tenant UUID. When a user signs in, Entra includes their group memberships in the JWT groups claim. The API resolves the role on every request by joining those group IDs against the tenant_groups table.
JWT groups claim: ["group-obj-id-1", "group-obj-id-2"]
│
tenant_groups table
(group_id, tenant_id, role)
│
→ role = "owner" for tenant X
The client sends an X-Tenant-ID header on all tenant-scoped requests. A GET /api/v1/me call (no tenant header required) returns every tenant the signed-in user belongs to along with their role in each, which the frontend uses to present a homestead selector at login.
Self-Service Onboarding
New users who sign in with no group memberships land in an Onboarding Wizard. They can either create a new organization or join one with an invite code. Both paths are fully automated:
Create: a five-step saga: verify no existing membership → generate tenant UUID → create three Entra security groups via Graph API → add user to owner group → commit the tenant and tenant_groups rows in a single DB transaction. If any post-group-creation step fails, the saga deletes the created groups before returning a 503.
Join: invite codes support configurable expiry and use limits. The join endpoint uses SELECT FOR UPDATE on the invite row to prevent race conditions on limited-use codes, then adds the user to the relevant Entra group via Graph before committing.
Graph is eventually consistent: a group or user created seconds ago can return 404 on member writes for 10 to 30 seconds. A smoke test caught homestead creation failing exactly that way (the saga correctly cleaned up after itself). Adding a member now retries 404s, 5xx and network errors with backoff for about 44 seconds, and fails fast on anything else, such as a 403 for a missing Graph permission.
After either path, the user is redirected through a full MSAL sign-out and back in, which mints a fresh JWT containing the new group membership. No polling, no manual token refresh. The old laptop-run provisioning scripts were retired: organizations are created through onboarding and deleted from the admin panel.
Plant Encyclopedia
A global plant database backed by PostgreSQL, with fuzzy search powered by the pg_trgm extension. Search results merge global plants with the caller's homestead-scoped custom plants, ranked by trigram similarity against common and scientific name. The same search drives a "did you mean…" warning when someone creates a custom plant that looks like an existing one.
Custom plants are tenant-isolated by a database constraint:
-- scope='homestead' requires tenant_id; scope='global' requires tenant_id IS NULL
ALTER TABLE plants ADD CONSTRAINT plants_scope_tenant_check CHECK (
(scope = 'homestead' AND tenant_id IS NOT NULL)
OR (scope = 'global' AND tenant_id IS NULL)
);
Members can flag their custom plants for admin review. Platform admins can then import flagged plants into the global encyclopedia, where they land as unpublished drafts for editorial review before going live. Admins can also import species and care guides from the Perenual API. Its free plan allows about 100 requests a day, so a rate limit is reported as a 429 and a bad key as a 422, instead of both looking like a 502 outage.
Azure Flexible Server only creates extensions on its azure.extensions allowlist, and pg_trgm turned out never to have been installed: the original migration skipped it with only a warning, which quietly broke the duplicate check. The allowlist is now set in Bicep, and a newer migration creates the extension with no fallback, so a missing entry fails the deploy instead of hiding. Integration tests against real Postgres check the ranking and that other homesteads' custom plants and unpublished global plants never appear.
Task Reminders
Tasks can send a browser push reminder at 8am in each browser's own timezone, to the task's creator plus members who opted into reminders for all household tasks. The sender is a Container Apps Job that runs every 15 minutes from the API image:
- The timezone, 8am and recipient rules live in SQL.
- Before sending, it checks with Entra that the recipient is still a member. If Entra can't be reached, it skips that homestead and deletes nothing.
- Each delivery is claimed before it's sent, so overlapping runs can't send twice. A claim left by a crashed run expires after 15 minutes.
- A 404 or 410 from the push service deletes the subscription. Failures only count against a subscription when other sends to the same push service worked in that run, so an outage or a bad key can't wear down every subscription.
The VAPID private key goes from Key Vault to the job only; the API only gets the public key. In the browser, the permission prompt only follows a click, a soft prompt appears after creating a task rather than on first load, the service worker only opens same-origin paths, and signing out releases the browser's subscription. If push isn't configured on the server, the reminder controls are hidden instead of failing after the permission prompt.
Data Export
Owners can download everything recorded for their homestead, free, at any time, from Organization Settings → Export Data: one CSV per record type or a single JSON file, zipped with the observation, animal and custom plant photos and a README. Each table is queried with a fixed SELECT … WHERE tenant_id = $1, photo paths point inside the zip and never at internal storage, and other members' contact details, invite codes and the audit log are left out. Exports are owner-only, limited to one per homestead per minute, and audited.
Mobile Apps
The Flutter app covers the same ground as the web app: sign-in and homestead selection, onboarding, the dashboard, gardens, the task planner, the plant encyclopedia and crop catalog, livestock with animal photos, supplies, and profile and settings. It uses Riverpod, go_router and Dio, signs in with AppAuth, and keeps an offline read-only cache in drift: with no signal it shows what was saved on the device, without promising to sync. Task reminders use local notifications. A native Kotlin/Compose Android app is also in early development.
Privacy-First Analytics
The app launched with GA4, then removed it. The rule in ARCHITECTURE.md is now: no third-party analytics, session-replay or tracking SDK in the web or mobile apps, and no hosted analytics SaaS. GA4 is gone from the web app, and Firebase Analytics is gone from both mobile apps. Guard tests in web and Flutter CI fail the build if any known tracker, hosted analytics domain or Google Fonts host appears in the source, dependencies or CSP.
Fonts are self-hosted too, since loading them from Google sent every visitor's IP to Google. Both apps bundle Plus Jakarta Sans and a FILL-only Material Symbols build (1.1 MB instead of about 4 MB), and the customer site also serves the font to the Entra sign-in page's custom CSS. Both sites now send a strict CSP with self-only scripts, styles and fonts; the admin site previously sent none.
Product analytics run on self-hosted Umami in the same private network:
- Its own database and identities. Umami gets a dedicated database and a least-privilege login that owns only that database, created by an idempotent init job. The app and the init job each have their own managed identity, granted Key Vault access to individual secrets only, so Umami can't read the API's secrets or the Postgres admin credential.
- No default admin. Umami ships with a public
admin/umamilogin. A guard script rotates it on deploy and verifies the default is rejected, and fails the run if it can't check. - No remote script. The app doesn't load Umami's
script.js. A small module posts Umami's format itself, so every condition from the legal review is enforced in code we own: URLs are sent as route templates (/gardens/:id), the title is fixed, the only property ishousehold_type(solo or household), event names come from a fixed list, Global Privacy Control and Do Not Track are honoured before anything is sent, and there's a per-browser opt-out in Settings. - Campaign tracking that survives sign-in. Signed-out visitors go to Entra before any pageview, which lost UTM tags. Five
utm_*keys are now kept through the redirect, with values lowercased and limited to a short safe pattern, sent with the first pageview only, and stripped from the address bar. - Retention and audits. A scheduled job deletes visitor data older than 12 months. A monthly GitHub Actions workflow lists every distinct UTM value, flags anything suspicious and opens an issue for the owner, and a scrub script removes a bad value without deleting the visits.
Getting Umami running on Azure took three fixes, each found on live UAT: the database's public schema is owned by azure_pg_admin, not the database owner; pgcrypto has to be on the server's extension allowlist; and Container Apps rewrites $$ to $ in container arguments, which broke a DO $$ … $$ block until it switched to tagged dollar quotes.
Observability
Log Analytics and Application Insights collect OpenTelemetry traces, Azure SDK calls, exceptions and logs from the API and the reminder job, with 90-day retention, a 1 GB daily cap and masked client IPs. Privacy hardening from code review: query strings are dropped from request URLs, health probes aren't traced, and outbound HTTP isn't traced at all, because those URLs carry the Perenual API key, coordinates and push capability URLs. Graph failures log only the status and error code. Umami itself is still not logged.
Infrastructure and Delivery
Private Networking
UAT was moved to a VNet stack in three stages, with the new stack built beside the old one so nothing user-facing changed until the cutover:
- Build side by side: a VNet with a delegated subnet for the Container Apps environment and one for Postgres, a private DNS zone, and a Postgres server with no public endpoint and its own Key Vault secret.
- Cut over: move
api.uat.croftura.comto the new API, and switch which reminder job runs on a schedule so reminders never send twice. - Retire: stop deploying the legacy stack, then delete it by hand after a final
pg_dump.
A VNet-connected environment always bills for a Standard Load Balancer and two public IPs (about $26 a month), but scale-to-zero APIs and a smaller database brought UAT to roughly $38 a month all in. Non-prod APIs scale to zero after an hour idle, raised from five minutes after a smoke test showed mid-session cold starts. Production keeps one warm replica. Local psql can no longer reach UAT, which is the point.
UAT's power is controlled by a manual GitHub Actions workflow that wakes or sleeps Postgres, the reminder job and Umami in the right order. A weekday schedule was tried first, but GitHub started its runs hours late or not at all, so it was removed. Deploys wake UAT first.
CI/CD
- Deploy UAT: builds and pushes the API image, deploys the Bicep, then gates on
/healthfor every managed API, which also runs migrations at startup. It fails the job if the new revision isn't healthy within 5 minutes and prints its logs, so the previous revision keeps serving. It also runs the Umami init job and admin guard, and binds custom domains with a two-pass managed certificate once the DNS record is in place. - Backend CI:
uv lock --check, then pytest from the lockfile, then an image build. Every package is pinned inuv.lockand the Dockerfile installs withuv sync --locked, after an unpinned resolve crash-looped UAT. Alembic moved to psycopg 3. - Web CI: ESLint, Vitest (including the tracker guard) and a production build.
- Flutter CI: analyze, tests and a debug APK build, plus Android and iOS release workflows on version tags.
- Dependabot: weekly grouped updates for the uv lockfile only and for GitHub Actions, all moved to Node 24 versions.
- Tests: 172 backend tests passing, after the 22 that had been quarantined turned out to be stale mocks rather than product bugs.
Stack
- React 19: customer SPA and admin SPA, built with Vite, tested with Vitest
- MSAL.js v3: PKCE-based authentication against Entra External ID; no implicit flow
- Flutter: Android and iOS app with Riverpod, drift offline cache and local notifications
- FastAPI (Python 3.12): API tier; containerised, locked with uv, deployed to Azure Container Apps
- PostgreSQL Flexible Server: primary data store on a private subnet; Alembic for schema migrations
- Microsoft Entra External ID (CIAM): authentication and tenant group membership
- Microsoft Graph API: automated security group provisioning during org onboarding
- Container Apps Jobs: the push reminder sender, Umami init and the analytics retention purge
- Web Push (VAPID): browser task reminders
- Umami (self-hosted): first-party product analytics
- Application Insights + Log Analytics: OpenTelemetry telemetry with 90-day retention
- Azure Container Registry: stores and serves the API Docker image
- Azure Key Vault: secrets accessed by managed identities, scoped to individual secrets for Umami
- Azure Blob Storage: photo uploads via SAS URLs
- Azure Static Web Apps (×2): customer and admin frontends with custom domains and strict CSPs
- Azure Bicep: all infrastructure as code, with per-environment parameter files
- GitHub Actions: CI, UAT deploys, UAT power and the monthly UTM audit
UAT Environment
The UAT environment is live at:
- Customer app:
app.uat.croftura.com - Admin panel:
admin.uat.croftura.com - API:
api.uat.croftura.com(TLS via Container App managed certificate) - Analytics:
analytics.uat.croftura.com(self-hosted Umami)
Infrastructure is deployed to the croftura-uat resource group in eastus2, and every merge to the uat branch deploys automatically through GitHub Actions.
Status
UAT is live on the private VNet stack. Authentication, multi-tenancy, self-service onboarding, invite codes, the plant encyclopedia and custom plants, gardens, livestock, supplies, the task planner with push reminders, data export, the Flutter app with offline reading, first-party analytics and full CI/CD are all deployed. Current focus: the observations UI and harvest tracking, low-stock and overdue alerts, reporting, and the production environment.