FlowerShop IoT Platform — Free-Tier Deployment Spec
Target:
findmyflowers.pldemo / early-access deployment Cost target: $0/month, accepting cold starts on non-critical surfaces Status: proposal — no code shipped against this yet
Diagrams
| # | View | Source | Render |
|---|---|---|---|
| 1 | Hosting topology (who lives where) | 01-hosting-topology.mmd | 01-hosting-topology.svg |
| 2 | Customer purchase flow (OIDC → cart → Stripe) | 02-customer-purchase-flow.mmd | 02-customer-purchase-flow.svg |
| 3 | IoT ingest → freshness push to browsers | 03-iot-ingest-flow.mmd | 03-iot-ingest-flow.svg |
| 4 | Network boundaries + DNS | 04-network-and-dns.mmd | 04-network-and-dns.svg |
Component → host matrix
| Component | Host | Tier | Why it goes there |
|---|---|---|---|
FlowerShop.API |
Fly.io VM (shared-cpu-1x, 512 MB) |
Free allowance | Hosts in-process MQTT subscriber + 5 background services (MqttDeviceCommunicationService, VaseHealthMonitoringService, BouquetFreshnessUpdateService, PerformanceAnalyticsService, OrderReservationExpiryService, SubscriptionMatchingService). Must stay awake; Render free sleep after 15 min would kill IoT ingest. |
| Keycloak 23 | Fly.io VM (shared-cpu-1x, 1 GB) |
Free allowance | JVM cold-start on Render free is ~60 s and makes every login painful. Fly keeps it warm. Uses the keycloak database on Aiven. |
FlowerShop.AdminPortal (Razor) |
Render free Docker web service | Free | Platform-admin traffic is rare — cold starts acceptable. |
FlowerShop.VendorPortal (Razor) |
Render free Docker web service | Free | Vendor logs in a few times a day — cold starts acceptable. |
FlowerShop.CustomerApp (MVC + OIDC) |
Render free Docker web service + cron-job.org ping every 10 min | Free | Customer-facing, so keep-warm ping mitigates UX hit. |
PostgreSQL (flowershop_iot + keycloak DBs) |
Aiven free PG | Free | 5 GB disk / 1 GB RAM covers both logical DBs — same split as docker-compose.dev.yml lines 130–133. |
| Redis cache | Upstash serverless | Free (500k cmd/mo, 256 MB) | Pay-per-command, scales to zero. Wire in once EP-09 T-09-003 lands; today CacheService still uses IMemoryCache. |
| RabbitMQ | CloudAMQP Little Lemur | Free (1M msg/mo, 20 conn) | Domain-event fan-out once EP-09 S-09-06 replaces InMemoryEventBus. Share the connection pool — 20-conn cap is the binding constraint. |
| MQTT broker | HiveMQ Cloud Serverless | Free (100 conn, 10 GB/mo) | Vases are MQTT clients; API subscribes as another client. Point MQTT__BrokerHost at HiveMQ + TLS. |
| Bouquet photo storage | Cloudflare R2 | Free (10 GB, zero egress) | Implemented (EP-15): FileStorageOptions.Provider=R2 → R2FileStorageService. Bucket flowershop-bouquets; photos served from the custom domain img.findmyflowers.pl (R2 → bucket → Settings → Custom Domains) so image bandwidth bypasses the API. Account ID + bucket + public URL live in appsettings.Production.json; the two credentials come from Fly secrets FileStorage__R2__AccessKeyId / FileStorage__R2__SecretAccessKey. Requires findmyflowers.pl DNS on Cloudflare (done). |
| Stripe webhook | Served by API — no separate host | — | POST /api/stripe/webhook piggybacks on Fly’s auto-HTTPS. Live signing secret only. |
DNS layout (all CNAME → provider apex, auto-TLS)
| Host | Target |
|---|---|
api.findmyflowers.pl |
Fly app — FlowerShop.API |
auth.findmyflowers.pl |
Fly app — Keycloak |
admin.findmyflowers.pl |
Render service — AdminPortal |
vendor.findmyflowers.pl |
Render service — VendorPortal |
app.findmyflowers.pl |
Render service — CustomerApp |
Code changes required before first deploy
These are blockers, not nice-to-haves.
src/backend/FlowerShop.API/Program.cs— addapp.UseForwardedHeaders(...)withXForwardedFor | XForwardedProtobeforeUseHttpsRedirection()(currently line 190). Without it,Request.Schemestayshttpbehind Fly’s proxy and Keycloak rejects the OIDC reply URL.src/backend/FlowerShop.API/Program.cs— addapp.UseWebSockets()before theMapHub<BouquetHub>()call (line 227). Belt-and-suspenders for SignalR over reverse proxies.- CORS — replace
Cors:AllowedOrigins: ["*"]in prod appsettings with the four portal / mobile origins above. The portals aren’t in the dev allow-list — they only work in dev because of the wildcard. - Secrets externalized to env vars on each host:
ConnectionStrings__DefaultConnection,ConnectionStrings__Redis,MQTT__*,RabbitMQ__*,Authentication__Keycloak__*,Stripe__SecretKey,Stripe__WebhookSecret. Never bake into Docker images. - Fix known VendorPortal bugs before exposing to a real vendor:
Bouquets/Index.cshtml.cs— readsVendorIdfrom session instead of JWT claim (EP-12 T-12-003).Vases/Details.cshtml.cs— POSTs to hardcodedhttp://localhost:8080/api/mock-vase/...(EP-12 T-12-004).
- Keycloak realm export — run
kc.sh export --dir /opt/keycloak/data/import --realm flowershopon the dev instance, commit the JSON, and mount it into the Fly Keycloak VM so it boots with the same clients (flowershop-api,flowershop-customer-app,flowershop-admin-portal,flowershop-vendor-portal) the code expects.
Runtime configuration per host
Fly — FlowerShop.API
ASPNETCORE_URLS=http://+:8080
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
ConnectionStrings__DefaultConnection=Host=<aiven>;Database=flowershop_iot;Username=...;Password=...;SslMode=Require
ConnectionStrings__ReadConnection=<same host, read-only user>
ConnectionStrings__Redis=<upstash rediss:// url>
MQTT__BrokerHost=<cluster>.hivemq.cloud
MQTT__BrokerPort=8883
MQTT__UseTls=true
MQTT__Username=<hivemq user>
MQTT__Password=<hivemq pass>
RabbitMQ__ConnectionString=<amqps cloudamqp url>
Authentication__UseKeycloak=true
Authentication__Keycloak__Authority=https://auth.findmyflowers.pl/realms/flowershop
Authentication__Keycloak__ClientId=flowershop-api
Authentication__Keycloak__ClientSecret=<secret>
Stripe__SecretKey=<sk_live_...>
Stripe__WebhookSecret=<whsec_...>
FileStorage__R2__AccessKeyId=<r2 access key id>
FileStorage__R2__SecretAccessKey=<r2 secret access key>
Cors__AllowedOrigins__0=https://app.findmyflowers.pl
Cors__AllowedOrigins__1=https://admin.findmyflowers.pl
Cors__AllowedOrigins__2=https://vendor.findmyflowers.pl
Migrations run automatically on startup (Program.cs calls Database.MigrateAsync() with 10-attempt retry).
Fly — Keycloak
KC_DB=postgres
KC_DB_URL=jdbc:postgresql://<aiven>:5432/keycloak?sslmode=require
KC_DB_USERNAME=<keycloak_db_user>
KC_DB_PASSWORD=<...>
KC_HOSTNAME=auth.findmyflowers.pl
KC_PROXY=edge
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=<strong>
# command: start --optimized --import-realm
Render — each portal
ASPNETCORE_URLS=http://+:8080
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
ApiSettings__BaseUrl=https://api.findmyflowers.pl
Authentication__Authority=https://auth.findmyflowers.pl/realms/flowershop # CustomerApp only
Authentication__ClientId=flowershop-customer-app # CustomerApp only
Authentication__ClientSecret=<secret> # CustomerApp only
Authentication__PostLoginRedirectUri=https://app.findmyflowers.pl/ # CustomerApp only
Known risks at this topology
| Risk | Impact | Mitigation |
|---|---|---|
| Fly free VM eviction (rare but possible) | API + MQTT subscriber goes down | Accept for demo; graduate API to paid Fly (~$2/mo) before onboarding real vendors |
| Render portal cold starts chain | CustomerApp cold + API cold = ~90 s first login | cron-job.org keep-warm ping every 10 min on CustomerApp and API |
| CloudAMQP 20-connection cap | New MassTransit consumers exceed cap once EP-05 lands | Single shared connection in IPublishEndpoint registration; audit before EP-05 merge |
| HiveMQ 100-device cap | Demo fleet >100 vases | Upgrade plan or self-host Mosquitto on a $4 DO droplet |
| Aiven free PG has no automated backups | Data-loss risk | Weekly pg_dump from a GitHub Actions cron → R2 |
| Stripe webhook replay attacks | Double-processing | Already signature-verified in StripeController; confirm idempotency key in CreateOrderCommandHandler before live keys |
| Keycloak realm drift between dev and Fly | Auth fails after redeploy | Commit realm export JSON; CI check that dev and prod clients match |
Alternatives considered
- Render free for everything — simplest, one provider, but cold starts break MQTT ingest. Rejected.
- Railway end-to-end — better DX than Render but only $5 one-time credit; not truly free long-term. Keep as upgrade path.
- Neon Postgres — 0.5 GB too tight once orders + analytics + Keycloak sessions share a box. Aiven’s 5 GB wins.
- Supabase Auth instead of Keycloak — removes a VM from the diagram, but the code already integrates with Keycloak (
KeycloakAdminClient,VendorOnboardingService). Re-platforming auth is a separate epic, not a deployment task. - Azure Container Apps free tier — generous free grant but requires a credit card and the 180k-vCPU-sec cap runs out fast with 5 background workers ticking. Rejected.
Upgrade path ($7–20/mo when demo graduates)
- Move
FlowerShop.APIto Fly paid (shared-cpu-1xdedicated, ~$2/mo) → SLA-backed uptime. - Move CustomerApp to Fly (~$2/mo) → no cold starts, drop the keep-warm cron.
- Upgrade Aiven PG to Startup tier (or move to Supabase Pro $25/mo) → automated backups.
- HiveMQ Starter ($5/mo) → 1k device cap.
- Keep AdminPortal + VendorPortal on Render free — internal traffic doesn’t need SLA.