Repository navigation
Ops Demo
A demo installation lets anybody try MowDeck without an account: a click on Try the demo signs the visitor in to a prepared account with three simulated mowers and a week of history. No real mower, manufacturer account, push service or AI provider is reachable, nothing can be registered, and the whole demo is deleted and rebuilt every few hours. Design and the complete list of what the demo refuses: ADR 0068.
Run a demo only as its own stack: its own host or VM if possible, its own Compose project, database, volumes, network and secrets. Never on top of, next to the data of, or with the secrets of an installation with real mowers.
-
Three simulated mowers in three gardens: Clover (family garden with a pond and an apple tree, Bluetooth bridge for manual driving), Juniper (an orchard meadow with trees and beehives, Bluetooth bridge) and Pebble (two small lawns, a firmware update on offer).
-
Seven days of history per mower (
DEMO_HISTORY_DAYS): tracks, coverage and missed spots, Wi-Fi, mobile and RTK heatmaps, the health history and battery statistics. -
A weekly schedule per mower with angle rotation; static, dry weather.
-
Everything that works on simulated mowers: start, pause, resume, dock, emergency stop, manual driving with the camera test pattern, spot mowing, path previews, mowing defaults, area names, device settings, firmware update, sites and groups, schedules, the in-app notification inbox, theft tracking and the AI assistant with a built-in demo model.
-
A band above every page ("Demo · simulated mowers, shared with every visitor, resets every 6 h"), "Demo" in front of every page title and a demo badge on the sign-in page. None of them can be dismissed. Every response of the API carries
X-MowDeck-Demo: 1. -
The interactive API explorer at
/api-explorer(ADR 0072), linked from the band ("Try the API live") and the sign-in page: every service's OpenAPI document in Scalar (self-hosted, nothing from a CDN), "Test Request" with the visitor's demo session.docker-compose.demo.ymlturns it on (MOWDECK_WEB_API_EXPLORER=true); the API docs point visitors to/api-explorerof the demo, so once the demo has its public address, link that address in the README and the portfolio.
All visitors share the one demo account: they see each other's commands live. Not each other's sign-ins, though: the security center's session list, its security events and its session count, and the account's audit log (GET /accounts/{id}/audit, one click away in the API explorer) only show the visitor's own login session, and the audit log leaves out every address, so nobody sees another visitor's browser, network or times.
Live updates (the WebSocket of /api/v1/realtime/ws) are limited per login session instead of per user, since every visitor is the same user: 10 sockets per visitor session, 20 per client address (DEMO_REALTIME_MAX_CONNECTIONS_PER_ADDRESS, IPv6 per /64) and 500 for the whole demo (DEMO_REALTIME_MAX_CONNECTIONS). One visitor cannot take the live view from the others.
Names visitors type in (sites, groups, area names, zones, schedules) lose control and format characters such as right-to-left overrides and zero-width spaces, so nobody can spoof a label the other visitors read; a name of nothing but blanks and joiners counts as empty, and a planner template name with such characters is refused.
Every service enforces this itself (403, problem type urn:mowdeck:problem:demo-mode), whatever the web app shows:
- registration, password, passkey, GitHub and Apple sign-in, the app hand-off, every change of the account, its users, roles and security settings (the security center's alert switch, ending one or all other sessions; the overview stays readable), logout everywhere;
- personal access tokens, account API tokens, widget credentials, token exchange;
- linking manufacturer accounts, adding, renaming, reconfiguring or removing mowers, Bluetooth bridge links, every base station change (adding, renaming, removing, its firmware, which base station a mower uses);
- Web Push, APNs, Live Activities, ntfy and test notifications (the demo has no delivery channel at all);
- an own AI provider and picture uploads;
- plan and billing changes, the scheduler's development endpoints.
Requirements: Docker with Compose (2.24.4 or later: the demo uses !reset and !override), a checkout of the repository (the demo builds its images), a TLS proxy or tunnel in front if it is public, and python3 when you use a demo/demo.override.yml (reset.sh checks it with a standard-library script; Debian: apt install python3).
cd infra/compose
cp demo/demo.env.example demo/demo.env # PowerShell: Copy-Item demo/demo.env.example demo/demo.env
# fill in every change-me value with a fresh secret:
# openssl rand -hex 32 (POSTGRES_PASSWORD, SERVICE_DB_PASSWORD, OPENFGA_AUTHN_PRESHARED_KEYS, DEMO_SEED_TOKEN)
# openssl rand -base64 32 (IDENTITY_KEY_ENCRYPTION_KEY)
demo/reset.sh # deletes any previous demo, builds, starts, seedsThe demo answers at http://127.0.0.1:8580 (GATEWAY_HTTP_PORT). The seed job's last lines are printed at the end; docker compose -p mowdeck-demo logs demo-seed shows them again.
GATEWAY_HTTP_PORT is the only port the demo publishes. docker-compose.demo.yml drops the gateway's HTTPS port (the TLS proxy in front talks plain HTTP to the gateway; no demo router listens on websecure) and the host ports of NATS (client and monitor), Postgres and OpenFGA, which the regular stack binds to 127.0.0.1 for local tools. Only the demo's own containers reach them. GATEWAY_HTTPS_PORT, POSTGRES_PORT, NATS_*_PORT and OPENFGA_*_PORT in demo.env have no effect; for a look inside use docker compose -p mowdeck-demo exec postgres psql -U postgres and the like.
NATS has no authentication, in the demo and in the regular stack: any process that reaches it can publish commands and telemetry past every authorization check. That is why no stack publishes it beyond loopback and the demo not at all; never attach a NATS container to a network that other workloads share. Authenticating the services to NATS (one credential per service) is a planned follow-up with its own ADR.
reset.sh --dry-run prints the commands without running them; --no-build reuses the images of the last build. The script refuses the project name mowdeck, any COMPOSE_PROFILES (relay, agora, matter and billing do not belong in a demo) and the placeholder secrets. A git-ignored demo/demo.override.yml adds host specific settings (below).
Behind a proxy, set in demo/demo.env:
| Setting | Value |
|---|---|
GATEWAY_TRUSTED_PROXIES |
the proxy's address (as the gateway sees it), so Traefik keeps the X-Forwarded-For the proxy sends |
GATEWAY_CLIENT_IP_DEPTH |
1 for one proxy that sets or appends X-Forwarded-For (Caddy, nginx), so the rate limits and the services count each visitor instead of the proxy; see Client addresses behind a proxy
|
REALTIME_ALLOWED_ORIGINS |
the public address, e.g. https://demo.example.org, exactly as the browser sends it in Origin (scheme and host, no path) |
IDENTITY_REFRESH_COOKIE_SECURE |
true (HTTPS) |
REALTIME_ALLOWED_ORIGINS is checked on every WebSocket handshake of /api/v1/realtime/ws. A wrong or missing origin shows up as HTTP 403 on the handshake (the browser console reports the WebSocket as failed, the live status and the mower positions stop updating) while every other page works. Check it with the browser's network tab (filter ws) or docker compose -p mowdeck-demo logs realtime.
Traefik's rate limits (demo-ratelimit on every request, identity-ratelimit on "Try the demo" and the sign-in, identity-session-ratelimit, billing-webhook-ratelimit) and the client address of Identity and Telemetry depend on two settings:
-
GATEWAY_TRUSTED_PROXIES(forwardedHeaders.trustedIPs): from these peers Traefik keeps the incomingX-Forwarded-For; from every other peer it deletes it. This alone does not change the rate limits: without a source criterion Traefik counts the TCP peer, which behind a proxy is the proxy, so every visitor shares one bucket (10 "Try the demo" a minute and 1200 requests a minute for the whole internet). -
GATEWAY_CLIENT_IP_DEPTH(sourceCriterion.ipStrategy.depthof every rate limit,MOWDECK_CLIENT_IP_DEPTHof the services): the number of trusted proxies in front of the gateway. Traefik counts that many entries from the right of theX-Forwarded-Forit received; the services, which see one more entry (Traefik appends its own peer), count one further. With1and Caddy sendingX-Forwarded-For: <visitor>, both take the visitor.
GATEWAY_CLIENT_IP_DEPTH |
Rate limits count | Identity and Telemetry take |
|---|---|---|
| empty (default) | the TCP peer (as before) | the leftmost entry (as before) |
0 |
the TCP peer | the gateway's peer |
1 |
the last entry the proxy sent | the same entry |
Spoofing. A depth only reads entries that a trusted proxy wrote, because Traefik deletes the header of every untrusted peer, and it counts from the right, where a client cannot add anything as long as each proxy overwrites the header (Caddy with header_up X-Forwarded-For {client_ip}) or appends to it (nginx $proxy_add_x_forwarded_for). Do not set a depth when a trusted proxy passes the client's header on unchanged, and never list in GATEWAY_TRUSTED_PROXIES addresses that ordinary clients connect from. A request that reaches the gateway without the proxy has no X-Forwarded-For left, so all such requests share one bucket (stricter, not looser).
Why not excludedIPs. Traefik's alternative strategy walks X-Forwarded-For from the right and takes the first entry outside a list of proxy ranges. It is not safer here: a visitor whose real address falls inside such a range is skipped, so the next entry to the left, which the visitor wrote, becomes the key; the list has to be kept in step with GATEWAY_TRUSTED_PROXIES; and Traefik ignores it as soon as a depth is set. The depth is exact for a fixed chain of proxies, which is what a demo host has.
The settings for Cloudflare, Caddy and the gateway on an internal Docker network: Caddy resolves the visitor from CF-Connecting-IP (trusting Cloudflare's ranges) and sends exactly one X-Forwarded-For, so
GATEWAY_TRUSTED_PROXIES=10.250.80.0/28 # the internal edge network only Caddy and the gateway are on
GATEWAY_CLIENT_IP_DEPTH=1
REALTIME_ALLOWED_ORIGINS=https://mowdeck-demo.example.org
IDENTITY_REFRESH_COOKIE_SECURE=trueCheck it: more than 20 "Try the demo" sign-ins within a minute from one connection get 429, while a second connection (another network, a phone on mobile data) still signs in. Without the depth both would get 429.
IPv6. One IPv6 connection (a home line, a cheap server) usually owns a whole /64, so counting each address would give one client 2^64 buckets. Every rate limit therefore counts an IPv6 client by its /64 (sourceCriterion.ipStrategy.ipv6Subnet=64 in Compose and in the Helm chart's -auth-ratelimit), and so do the in-process limits of Identity (login, re-authentication) and Telemetry (public tracking page) through mowdeck_service_kit.client_address.limiter_key. IPv4 counts per address as before. Audit logs keep the whole address; the security center shows it shortened (IPv4 to three octets, IPv6 to its /48). A visitor behind Cloudflare reaches the demo over IPv6 whenever the host name has an AAAA record or Cloudflare's IPv6 compatibility is on; a Cloudflare rate-limiting rule on /api/v1/identity/demo/session is a cheap outer layer.
reset.sh uses demo/demo.override.yml as a last Compose file when it exists. The file is git-ignored and belongs to the host: join the gateway to an existing network of the proxy, or limit memory and CPU. Example for a proxy on the external network mowdeck-demo-edge that reaches the gateway as mowdeck-demo-gateway:
# infra/compose/demo/demo.override.yml (git-ignored)
services:
traefik:
networks:
mowdeck: {}
mowdeck-demo-edge:
aliases: [mowdeck-demo-gateway]
mem_limit: 256m
cpus: 0.5
postgres:
mem_limit: 512m
networks:
mowdeck-demo-edge:
external: trueCreate the network once (docker network create --driver bridge --internal --subnet 10.250.80.0/28 mowdeck-demo-edge); reset.sh never removes an external network. With the gateway on the proxy's network the reconnect step after each reset is no longer needed.
The override has the full power of a Compose file, so reset.sh checks it against an allow-list before it deletes anything (with --dry-run too). It renders the stack with docker compose config --format json without and with the override and compares the two structurally (demo/override_guard.py, standard library only, needs python3). The override may only:
| Allowed | Where |
|---|---|
| attach the gateway to networks outside the demo, with aliases or a fixed address; no alias may be the name of a service of the stack | services.traefik.networks.<external network> |
| add networks that exist outside the demo | top-level networks.<name> with external: true
|
| limit resources |
mem_limit, mem_reservation, memswap_limit, cpus, cpu_shares, cpu_quota, cpu_period, cpuset, pids_limit, deploy.resources of an existing service (the empty deploy.placement Compose renders along with it is accepted) |
| change logging | services.<name>.logging |
Every other difference refuses the reset: a service or a profile (relay, agora, matter, billing, whoami), environment (the demo mode, the seed secret, MOWDECK_CLIENT_IP_DEPTH, ...), labels (an extra router, a middleware, a rate limit), Traefik's command (API or dashboard flags, entry points, trusted headers), image, ports, volumes, network_mode, other deploy keys, networks of any service but the gateway (NATS or Postgres on the proxy's network, an alias that answers for another service) and any change of the demo's own network, including the gateway's entry on it. Settings like the demo's history or rate limits belong in demo.env; everything else in the repository. The refusal names the paths it refused (for example services.identity.labels.traefik.http.routers.zz.rule), never a value: the merged configuration holds the demo's secrets, and it is only compared in memory, never printed or written to a file.
The project name, profile and secret checks run before the override is read, so it cannot get around them. reset.sh prints using .../demo.override.yml when it takes the file. Without an override reset.sh needs no python3.
In infra/compose/demo/demo.env (read after ../.env.example):
| Setting | Default | Meaning |
|---|---|---|
DEMO_SEED_TOKEN |
(required) | Secret of the seed job; Traefik strips its header from every outside request |
DEMO_RESET_HOURS |
6 |
Shown in the banner; run the timer below at the same interval |
DEMO_HISTORY_DAYS |
7 |
Days of simulated history per mower (1 to 31) |
DEMO_SIM_TIME_SCALE |
3 |
Simulated seconds per real second of the live mowers |
DEMO_RATE_PER_MINUTE, DEMO_RATE_BURST
|
1200, 600
|
Requests per client address at the gateway; the web app's files count too, so the burst must hold a first visit with an empty cache (a few hundred requests) |
DEMO_USERNAME |
demo |
The shared demo user |
DEMO_REALTIME_MAX_CONNECTIONS |
500 |
Live-update sockets of the whole demo (per Realtime instance) |
DEMO_REALTIME_MAX_CONNECTIONS_PER_ADDRESS |
20 |
Live-update sockets per client address; each visitor's login session has at most 10 |
MOWDECK_DEMO_MODE (every service) and MOWDECK_WEB_DEMO_MODE (web app) are set by docker-compose.demo.yml; they are not meant for an installation with real mowers.
Every reset deletes the demo's volumes, so nothing a visitor did survives it, and every visitor's session ends. Run reset.sh with a systemd timer (or cron) at the interval of DEMO_RESET_HOURS:
# /etc/systemd/system/mowdeck-demo-reset.service
[Unit]
Description=Reset the MowDeck public demo
[Service]
Type=oneshot
User=mowdeck
WorkingDirectory=/opt/mowdeck/infra/compose
ExecStart=/opt/mowdeck/infra/compose/demo/reset.sh --no-build# /etc/systemd/system/mowdeck-demo-reset.timer
[Unit]
Description=Reset the MowDeck public demo every 6 hours
[Timer]
OnCalendar=00/6:00
Persistent=true
[Install]
WantedBy=timers.targetsudo systemctl daemon-reload && sudo systemctl enable --now mowdeck-demo-reset.timerUse --no-build in the timer and run reset.sh without it after updating the checkout. A reset takes about two minutes; visitors see the sign-in page meanwhile.
curl -sI http://127.0.0.1:8580/ | grep -i x-mowdeck-demo # X-MowDeck-Demo: 1
curl -s -X POST http://127.0.0.1:8580/api/v1/identity/register \
-H 'Content-Type: application/json' -d '{}' | grep demo-mode # refusedThe Playwright specs of the demo run against a test stack with the demo override: MOWDECK_E2E_DEMO=1 pnpm e2e:stack up, then pnpm e2e:stack demo (CI job e2e-demo).
Mirrored from docs/ops/demo.md at commit 0b51164ccae892daef8e7473da86ee072409d2f4. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit 0b51164ccae892daef8e7473da86ee072409d2f4. Edit the files in the repository, not this wiki.
MowDeck documentation
- Home
- Phases
- Architecture
-
Decisions (ADR)
- Architecture decisions
- ADR 0001: OpenFGA instead of self-built authorization
- ADR 0002: Self-built Identity service in FastAPI instead of Keycloak or Zitadel
- ADR 0003: SvelteKit for the web app
- ADR 0004: Monorepo with a uv workspace
- ADR 0005: AGPL-3.0 for services and apps, Apache-2.0 for libraries
- ADR 0006: PostgreSQL for all persistence, no SQLite
- ADR 0007: NATS JetStream as event bus
- ADR 0008: Traefik as gateway
- ADR 0009: Command and event envelopes, subject naming and de-duplication
- ADR 0010: Python 3.13 for all services and libraries
- ADR 0011: Separate GPL-3.0 package for the PyMammotion adapter
- ADR 0012: Mower control interface and safety rules
- ADR 0013: Device-Gateway registry, sharding and per-mower concurrency
- ADR 0014: Authorization model, tuple ownership and the mowdeck-authz client
- ADR 0015: Tokens, sessions and key rotation
- ADR 0016: Fleet commands and authorization per command kind
- ADR 0017: Telemetry storage and retention
- ADR 0018: Realtime WebSocket protocol
- ADR 0019: Mammotion adapter transports, manual drive and secrets
- ADR 0020: Web app architecture
- ADR 0021: Browser authentication flow
- ADR 0022: Notifications: rules, recipients and delivery
- ADR 0023: Map rendering and projection
- ADR 0024: Scheduler: recurrence, rain pause and command issuing
- ADR 0025: Home Assistant integration as a pure API client
- ADR 0026: Manual drive channel over the Realtime WebSocket
- ADR 0027: Health history and theft alarm
- ADR 0028: Entitlements: plans as data and enforcement
- ADR 0029: Helm chart and release pipeline
- ADR 0030: Mowing patterns as plug-ins and the Planner
- ADR 0031: Billing with a Stripe adapter
- ADR 0032: Live camera over Agora, joined directly by the browser
- ADR 0033: Vision-assisted recovery with human approval
- ADR 0034: Linked cloud accounts and device discovery
- ADR 0035: Server-side camera frames and automatic AI analysis on errors
- ADR 0036: Native app shells with Capacitor, loading the web app from the server
- ADR 0037: Camera panorama calibration in the browser, with the AI supplying points only
- ADR 0038: Dual link per mower and firmware updates
- ADR 0039: Spot mowing by MowDeck-driven paths
- ADR 0040: Link heartbeat for online status
- ADR 0041: Spot runs from the dock: undock, transit and return
- ADR 0042: Drive calibration
- ADR 0043: Turn styles
- ADR 0044: Drive view with camera
- ADR 0045: Camera relay
- ADR 0046: Device settings as capabilities
- ADR 0047: The RTK base station as a Fleet device
- ADR 0048: Maintenance commands
- ADR 0049: Manual driving over the Mammotion cloud
- ADR 0050: Passkeys and the app login hand-off
- ADR 0051: Sign in with GitHub
- ADR 0052: Sign in with Apple
- ADR 0053: Retention and downsampling of the position track
- ADR 0054: Aerial photo under the mower map
- ADR 0055: Deploy maintenance window
- ADR 0056: Camera photo in push notifications for errors and theft alarms
- ADR 0057: Step-up re-authentication for critical actions
- ADR 0058: Theft tracking mode
- ADR 0059: iOS widgets and the mowing Live Activity
- ADR 0060: Apple Watch app
- ADR 0061: Mowing angle rotation
- ADR 0062: Setup tour for new users
- ADR 0063: Matter server (mowers in Apple Home and Google Home)
- ADR 0064: Personal access tokens and the public API documentation
- ADR 0065: Security center and new-login alerts
- ADR 0066: Cloud provider plugins and adapters for more vendors
- ADR 0067: Error code lexicon, provider independent
- ADR 0068: Public demo mode
- ADR 0069: Provider conformance kit and provider package template
- ADR 0070: Virtual zones for mowers without a vendor map
- ADR 0071: Capability matrix generated from the adapters
- ADR 0072: API explorer in the web app, for the public demo
-
Development
- Getting started
- Service template
- Shared libraries
- Cloud providers: adding a vendor
- Adding a provider: template and conformance kit
- Adding an error lexicon for a vendor
- Design: identity and authorization
- Design: fleet, telemetry, realtime
- Design: web app
- Web app
- Home Assistant integration
- Camera source bridge protocol
- Fuzzing and property-based tests
- GitHub wiki mirror
-
Operations
- Operating the Compose stack
- Operating MowDeck on Kubernetes (Helm)
- Building the Bluetooth bridge (ESPHome on a XIAO ESP32-S3)
- Releases
- Vendor library updates
- Billing operations
- Telemetry: retention and position downsampling
- iPhone and Android apps
- Passkeys
- Sign in with GitHub
- Aerial photo under the map
- Sign in with Apple
- Theft tracking mode
- Matter server
- Source code offer (AGPL-3.0 section 13)
- Public demo
- Spikes
-
Guides
- Guides
- ESP32 bridge: purchase and setup
- Signing in with a passkey
- Signing in with GitHub
- Error codes in plain words
- Aerial photo under the map
- Signing in with Apple
- Confirming critical actions
- Your security page
- iPhone widgets and the mowing Live Activity
- Apple Watch
- The setup tour
- Apple Home and Google Home (Matter)
- Worx Landroid, Kress and LandXcape mowers
- Scripting MowDeck
- Capability matrix
- API reference
- Original plan (German)