Running Mirra yourself
What the three parts of Mirra are, the environment variables they need, what production refuses to start without, and how to turn a Raspberry Pi into a screen.
What you are actually running
Mirra is three programs and a database. The portal is the Next.js application — the admin pages, the REST API and everything that touches the database. The relay is a small WebSocket hub every screen holds a connection to; it is also where the assistant's speech and language calls are made. The screen app is a static single-page build that runs in a browser on the wall.
The split matters for hosting. The portal needs the database and nothing else. The relay needs long-lived WebSocket connections, which some managed platforms refuse to proxy. The screen app is static files and can be served from anywhere, including the same machine.
The screen holds the connection to Home Assistant and to WLED controllers, not the server. That is why a self-hosted install never needs a tunnel into anybody's house, and why light control keeps working during an internet outage.
The portal's environment variables
Five of these decide whether Mirra will run at all. The rest switch on features, and leaving one out disables its feature rather than breaking the install — no Stripe key means the upgrade buttons disable themselves and limits still apply; no push keys mean the notifications panel says so.
What production refuses to run with
Development placeholders are convenient locally and dangerous in production, so Mirra checks its own configuration and, with NODE_ENV set to production, throws rather than carrying on. The check runs once per process, the first time an account registration is attempted — which is the earliest moment a bad secret would start doing damage.
Outside production the same problems are logged as warnings and the install keeps working.
- JWT_SECRET or INTERNAL_SECRET missing — an error everywhere, production or not.
- Either of them still set to a known development placeholder such as change-me.
- Either of them shorter than twenty-four characters.
- SECRETS_KEY unset, so integration secrets fall back to a key derived from JWT_SECRET.
- SECRETS_KEY still set to the placeholder.
- PUBLIC_URL unset.
- DATABASE_URL pointing at a file: URL — SQLite is not suitable for production.
Changing JWT_SECRET later signs everyone out and unpairs every screen. Changing SECRETS_KEY makes every stored integration credential unreadable — Home Assistant tokens, calendar credentials, the SSO client secret — and they have to be entered again. Generate these once, keep them somewhere you will still have them in a year, and do not rotate them casually.
The relay's environment variables
The relay is deliberately thin. It needs to agree with the portal about two secrets and to know where the portal is; everything else selects which brain answers the assistant.
With no cloud brain reachable, the assistant falls back to a rule-based stand-in that handles calendar, tasks, reminders, timers, weather, the lights and scenes but holds no real conversation. It does that quietly, which is why the super-admin console shows the configured and the active brain side by side.
Getting it running
Install the dependencies
npm install at the root. The repository is a workspace containing all three apps and the shared types.
Create the database
For development, npm run db:migrate creates a SQLite file and its tables. For production, point DATABASE_URL at Postgres and run prisma migrate deploy against the Postgres schema at apps/portal/prisma/postgres/schema.prisma.
Generate the three secrets
openssl rand -base64 32, three times, for JWT_SECRET, INTERNAL_SECRET and SECRETS_KEY. INTERNAL_SECRET goes into both the portal and the relay, identically.
Set PUBLIC_URL
The address people will actually type. Behind a proxy this is not optional — without it, absolute links are guessed from the request and quietly point at the address the server binds to.
Start the three parts
npm run dev:relay, npm run dev:portal and npm run dev:screen, on ports 3101, 3100 and 3102.
Register the first account
Open the portal and register. The first account on a fresh install becomes the super admin as well as the owner of its own household.
Pair a screen
Open the screen app; it shows a six-character code. Enter it in the portal under Screens.
There is a container image for the portal. It builds a standalone server, runs the Postgres migrations at boot so a deploy always matches the schema, and binds to 0.0.0.0 — a server bound to the container's own hostname is one a health check cannot reach. NEXT_PUBLIC_SCREEN_URL is a build argument on that image rather than a runtime variable, because it is compiled into the client bundle.
The super-admin console
The first account registered on an install can open Super admin from its own menu. It is the operator's view of the whole install rather than of one household, and it is where a misconfiguration becomes visible.
The configuration panel at the top runs the same checks described above, live, and prints each problem with its severity and what to do about it. A warning nobody knows how to action is just noise, so each one carries its own fix.
- Tenants — every household, its plan, status, screens, people and AI usage, with the plan editable.
- AI usage & cost — usage against allowance, with a brain-status panel that asks the relay which model is actually answering and shows the provider's own words when it has degraded.
- Plans — limits and prices, editable, because they live in the database rather than in a config file.
- Fleet — every paired screen across the install, its app version, platform and when it was last seen.
- Feature flags, Audit, Public pages, Art catalogue, Artists, Artist payouts, and the support queue, support documents and roadmap.
- A "Stripe off" pill when no Stripe key is configured, so an install with billing silently disabled is not mistaken for one where nobody has upgraded.
/api/health is the endpoint to point a load balancer at. It runs a query against the database, so an instance that has lost its connection pool is reported unhealthy rather than quietly serving errors.
Things that bite a self-hosted install
- Photos on local disk do not survive a container being replaced. Set PHOTOS_BUCKET for anything running in a container.
- Rate limiting is held in memory, so it is per instance. Behind two instances, the effective limits double.
- Camera and microphone need a secure origin. A screen served over plain HTTP will never get presence, gestures or the assistant unless the address is localhost.
- A browser cannot open a plain ws:// connection from an HTTPS page, so an HTTPS screen app needs Home Assistant over HTTPS too.
- Retention runs on a timer inside the portal process, which only covers instances that stay up. For anything real, drive /api/internal/prune from a scheduler instead — it takes the internal secret as a header.
- The SQLite and Postgres migrations are written by hand and separately. Do not generate one from the other with a text transform, and replay a new one against a real Postgres before deploying: a migration that fails is recorded as failed and then blocks every later one.
- A deploy that says it started proves nothing. Check a route that only exists in the new build.
The Raspberry Pi kiosk installer
The installer turns a machine into a wall display: a browser opens the screen app full screen at boot, the cursor is hidden, the display never blanks, and a watchdog restarts the browser if it dies. One script covers a Raspberry Pi and a Debian or Ubuntu desktop on x86, detected rather than asked — on x86 it installs Google Chrome, because Chrome bundles the media support that decides whether a mirror can act as a Spotify speaker.
Re-running it is safe. It overwrites its own files and nothing else.
Start with a desktop install
64-bit Raspberry Pi OS with desktop, or Debian or Ubuntu with a desktop on x86. There has to be something for a browser to open on.
Run the installer against your screen address
Piped from your own screen host, or from a clone as ./tools/pi/install.sh followed by the address. With no address given it keeps whatever the machine is already set to, and falls back to the hosted screen app.
Reboot
sudo reboot. The kiosk starts on its own. To try it without rebooting, run mirra-kiosk directly.
Pair it
The screen shows a six-character code on first launch. Enter that in the portal under Screens.
Turn it portrait, if it is a mirror
mirra-rotate left. The helper also accepts right, inverted and normal.
Alongside the kiosk itself the installer adds: an offline page baked into the machine, so a screen with no network says something useful rather than showing a browser error; a setup service that raises its own Wi-Fi access point when there is no route out, so a phone can hand it the password; go2rtc for RTSP cameras, bound to loopback; a browser policy granting camera, microphone and local-network access to your screen address and nothing else; a persistent system journal capped at 64 MB; a diagnostics file written to the boot partition on every boot, which is readable by pulling the card and plugging it into any laptop; and, on a Pi, the daemon for wired buttons.
Related
- The REST API and your keysHow to create an API key, what a single key is allowed to reach, and every endpoint the public v1 API exposes.
- Single sign-on with OpenID ConnectConnect Mirra to your own identity provider so people sign in with their work account, using OpenID Connect and email-domain matching.
- Pairing a screenHow a display joins your household: the six-character code it shows, what to type into Mirra, what pairing creates, and what happens when you unpair.
- A screen is blank or showing old informationHow to tell a screen that has lost its connection from one that is doing exactly what its schedule and scenes tell it to, and what to check in which order.
- Your dataSet how long Mirra keeps assistant transcripts, presence and the activity log, take a copy of everything held about your household, and close the account for good.