Browse documentation
All documentation
Getting started
Screens & scenes
Widgets
Integrations
The assistant
Household & presence
Reception & visitors
Menus, art & broadcast
Account, plans & data
API & self-hosting
Troubleshooting

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.

ScreenBrowser or Pi kiosk, port3102 in developmentRelayWebSockets, port 3101PortalPages, REST API, port 3100DatabaseSQLite for development,Postgres for real use
Which part talks to which. Home Assistant and WLED are reached by the screen on the local network, never by the server.

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.

DATABASE_URL
The database connection string. Required — Prisma cannot connect without it. Postgres for anything real; a file: URL is refused in production.Default: None. The development example uses file:./dev.db
JWT_SECRET
Signs session cookies and screen tokens. Required. Generate with openssl rand -base64 32.Default: None
INTERNAL_SECRET
The shared password between the portal and the relay, so nothing else can call the internal endpoints. Required, and it must be the same value on both.Default: None
SECRETS_KEY
Encrypts stored integration credentials at rest — Home Assistant tokens, calendar credentials, the SSO client secret. Must be 32 bytes, base64-encoded. With it unset, Mirra derives a key from JWT_SECRET instead, which is convenient in development and an error in production.Default: Derived from JWT_SECRET
PUBLIC_URL
The address people reach this install on. Every absolute link — photo URLs on screens, OAuth redirect URIs, the webhook address in the setup panel — is built from it. Preferred over the request's own headers deliberately: a Host header is attacker-controlled and configuration is not.Default: Guessed from the request's forwarded host, which behind a proxy can yield an address that points nowhere
RELAY_INTERNAL_URL
Where the portal reaches the relay, such as http://localhost:3101. Without it, screens are never told that anything changed and the assistant cannot work at all — the super-admin console says exactly that.Default: None
NEXT_PUBLIC_SCREEN_URL
The origin the screen app is served from. Read at build time, not at boot: it is inlined into the client bundle and into the content-security policy that lets the live preview be framed.Default: Varies by use — the live preview falls back to http://localhost:3102, the pairing and landing pages to the hosted screen app, and the container image's build argument to http://localhost:3102
PHOTOS_BUCKET
An S3 bucket for uploaded photos. With it unset, photos are written to an uploads directory on local disk, which is right for a single machine and wrong for a container that gets replaced.Default: Empty — local disk
AWS_REGION
The region used for S3 and for the voice preview.Default: eu-west-2
VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT
Browser push notifications to phones. Generate a pair with npx web-push generate-vapid-keys. Without them the settings panel tells a super admin how to set it up and tells everybody else that push is not available on this install.Default: Unset — push disabled
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_API_VERSION
Billing. Without a secret key the upgrade buttons disable themselves, plan limits still apply, and a super admin moves accounts between plans by hand. The webhook secret is what verifies events posted to /api/billing/webhook. Pinning the API version stops Stripe's own default changing under you.Default: Unset — billing off
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
Google Calendar connections.Default: Unset
MS_CLIENT_ID, MS_CLIENT_SECRET, MS_TENANT, MS_DEVICE_CLIENT_ID
Outlook and Microsoft 365 calendars and room booking. MS_TENANT of "common" accepts work and personal accounts; a tenant id restricts it. MS_DEVICE_CLIENT_ID is a separate registration for the device-code flow, and falls back to MS_CLIENT_ID.Default: MS_TENANT is "common"; the rest unset
SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET
The Spotify integration. Both must be present or the feature reports itself unconfigured.Default: Unset
TAVILY_API_KEY
The assistant's web search. Without it, search is unconfigured and the assistant answers from what it already knows.Default: Unset
GEMINI_API_KEY, GEMINI_IMAGE_MODEL
Generated artwork for the art frame. The model name is overridable because model names move.Default: Model gemini-3-pro-image-preview; key unset
INVITE_DOMAIN
The mail domain that gives each screen its own email address for calendar invitations. Unset means the feature is not offered.Default: Unset
MIRRA_DISABLE_PRUNE_TIMER
Set to 1 to stop the in-process retention sweep. Worth doing when you drive retention from a real scheduler instead.Default: Unset — the timer runs
PRICE_INPUT_PER_MTOK_USD, PRICE_OUTPUT_PER_MTOK_USD, PRICE_TTS_PER_MCHAR_USD, PRICE_STT_PER_MINUTE_USD, USD_TO_GBP
List prices used for the cost estimates in the super-admin console. They are a guide to unit economics, not an invoice.Default: 3.00, 15.00, 16.00, 0.024 and 0.79

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.

PORT
The port the relay listens on.Default: 3101
JWT_SECRET
The same value as the portal, so screen tokens issued by one are accepted by the other.Default: None
INTERNAL_SECRET
The same value as the portal. It is also what lets the portal ask the relay which brain is answering — the health endpoint tells anybody else nothing.Default: None
PORTAL_INTERNAL_URL
Where the relay reaches the portal for tool calls and logging.Default: http://localhost:3100
AWS_REGION
Region for Bedrock, Polly and Transcribe.Default: eu-west-2
LLM_PROVIDER
Which brain answers: bedrock, anthropic, ollama or local. auto prefers Anthropic when a key is present, then Bedrock, and drops to local if the cloud provider refuses.Default: auto
BEDROCK_MODEL_ID
The model used on Bedrock.Default: eu.anthropic.claude-sonnet-4-6
ANTHROPIC_API_KEY, ANTHROPIC_MODEL
Talk to Anthropic directly instead of through Bedrock.Default: Model claude-sonnet-4-6; key unset
OLLAMA_URL, OLLAMA_MODEL
An on-premises model, for an install that must not send anything to a cloud provider.Default: http://localhost:11434 and llama3.1
POLLY_VOICE
The default spoken voice, where a household has not chosen one.Default: Amy

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

  1. Install the dependencies

    npm install at the root. The repository is a workspace containing all three apps and the shared types.

  2. 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.

  3. 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.

  4. 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.

  5. Start the three parts

    npm run dev:relay, npm run dev:portal and npm run dev:screen, on ports 3101, 3100 and 3102.

  6. 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.

  7. 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.

Super adminConfigurationerror — PUBLIC_URL is not setTenantsPayingScreens pairedAI tokens (month)Tenants · AI usage & cost · Plans · Fleet · Flags · Audit
Super admin, with a configuration problem at the top where it cannot be missed.
  • 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.

  1. 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.

  2. 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.

  3. Reboot

    sudo reboot. The kiosk starts on its own. To try it without rebooting, run mirra-kiosk directly.

  4. Pair it

    The screen shows a six-character code on first launch. Enter that in the portal under Screens.

  5. Turn it portrait, if it is a mirror

    mirra-rotate left. The helper also accepts right, inverted and normal.

--url URL
The screen app to open. Identical to giving the address as a plain argument.Default: Whatever is already configured, else the hosted screen app
--user NAME
Install the per-user parts for somebody other than yourself. Only root may name another user.Default: The user running the script
--bin-dir DIR
Where mirra-kiosk, mirra-rotate and mirra-diag are written.Default: ~/.local/bin, or /usr/local/bin with --skip-user
--wifi-country CODE
The wireless regulatory domain. Without one the radio stays soft-blocked and will not transmit at all.Default: GB
--skip-packages
Do not touch apt. For a machine whose packages you manage yourself.Default: Packages are installed
--skip-user
System-wide parts only, needing no home directory. This is what the pre-built image builder uses inside a chroot.Default: Both halves are installed
--skip-cron
Do not install the nightly reboot.Default: A reboot is scheduled for 04:30

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.