Meter Matters / Docs / Running your own server

Sign in

Running your own server

For whoever hosts the app. Every setting is an environment variable, and anything left unset switches its feature off without breaking anything else. This page is what each one does and, for the features that need something set up elsewhere, the steps. The repository's deploy/README.md and docs/how-it-works.md have the deployment pipeline and the reasoning; this page is the checklist.

Two ways to run it

  • One person on your own machine. ./run.sh with nothing set. No accounts, no sign-in, bound to localhost, and the data lives in the checkout. The Add bills page does not exist in this mode: drop files on any dashboard instead. Forwarding needs a public address, so it is for the hosted mode.
  • Hosted, with accounts. Setting ELEC_SECRET_KEY is what turns accounts on. Everything below is about this mode.

Required when hosted

VariableWhat it does
ELEC_SECRET_KEYSigns session cookies and turns accounts on. 32+ random characters; the app refuses to start with less. python -c "import secrets; print(secrets.token_urlsafe(48))"
ELEC_BASE_URLThe address people reach the app at. An https:// value also marks cookies Secure.
ELEC_DATABASE_URL or ELEC_DATA_DIRPostgres (a Supabase transaction-pooler string works) for a host with no disk such as Cloud Run; or one directory on a host that has one. SQLite in that directory otherwise.
ELEC_TRUSTED_PROXIESHow many proxies sit in front of the app (default 1). Affects which address the sign-in lockout counts.
ELEC_ALLOW_SIGNUP0 closes sign-ups once your people are in. Existing accounts keep working.

Switching forwarding on

Forwarding needs a domain that receives mail, an inbound email service that turns each message into an HTTPS request, and a shared secret so the app can tell the service's requests from anyone else's. Users then get bills-<token>@<your domain> on their Add bills page.

Set ELEC_INBOUND_DOMAIN to the domain, then one of the following.

Cloudflare Email Routing (free)

  1. In the Cloudflare dashboard for the zone: Email → Email Routing → enable it. This adds the MX records.
  2. Workers & Pages → Create → start from Hello World, and replace the code with the repository's deploy/cloudflare-email-worker.js. Name it, say, bills-inbound, and deploy.
  3. In the worker's Settings → Variables and Secrets: a secret INBOUND_SECRET holding the same value as the app's ELEC_INBOUND_SECRET, and a plain variable APP_URL set to the app's https address with no trailing slash. Deploy again: settings only apply once deployed.
  4. Email Routing → Routing rules → Catch-all: action Send to a Worker, pick the worker, enable it.
  5. Set ELEC_INBOUND_SECRET on the app to the same secret.

The worker posts each message unchanged to /api/inbound/email with the secret as a bearer token and the recipient in a header. A message the app refuses is bounced with the reason, so it shows in Email Routing's activity log instead of being retried for a day.

Mailgun

  1. Add the domain to Mailgun as a receiving domain and set its MX records as Mailgun says.
  2. Receiving → Routes → create a route: match recipient bills-.*@<your domain>, action forward to https://<app>/api/inbound/email.
  3. Set ELEC_MAILGUN_SIGNING_KEY on the app to the account's HTTP webhook signing key. Mailgun signs each request with it; a bad or replayed signature is refused.

Postmark

  1. Create an inbound stream and point the domain's MX at Postmark as it says.
  2. Set the stream's webhook to https://any:<secret>@<app>/api/inbound/email, and switch Include raw email content in JSON payload on. Without it the app answers with a message saying so.
  3. Set ELEC_INBOUND_SECRET on the app to that secret.

Anything else

POST the raw message with Content-Type: message/rfc822, Authorization: Bearer <ELEC_INBOUND_SECRET>, and the recipient in X-Envelope-To. The app answers 200 for anything it accepted, including addresses it does not know, so a retrying service never loops and nobody can tell which addresses exist.

Checking the utility's signature

ELEC_INBOUND_DKIM decides what happens when the utility's DKIM signature on a forwarded message does not verify. warn (the default) files the bill and says so on the user's log line; require refuses it; off skips the check. Start on warn, look at what real forwarded mail reports in a few users' logs, and move to require once it is passing. A bill forwarded by hand is not checked this way, because the signature on it is the forwarder's; it is accepted on the strength of the utility's address quoted inside it.

Try it before anyone relies on it

Sign up, copy your own bills address, and forward one real bill email to it by hand. It should appear under What has arrived within a minute or two. If it does not: a bounce in the mail service's log means the app refused the request (wrong secret, wrong URL); nothing in that log means the mail never reached the service (MX records); a line in the app's log saying bad credentials means the secrets differ.

Google sign-in

  1. In Google Cloud, create an OAuth client of type Web application, with the authorised redirect URI <ELEC_BASE_URL>/auth/google/callback.
  2. Set ELEC_GOOGLE_CLIENT_ID and ELEC_GOOGLE_CLIENT_SECRET.

That enables Continue with Google. The app asks for the sign-in scopes only (name and email), which need no verification from Google.

Reading scans and photos of bills

Set ELEC_VISION_KEY to a Google Cloud API key with the Cloud Vision API enabled, restricted to that API. Each scan is one request; the image is sent and the text comes back, and the app stores neither. Without the key the upload box simply does not offer scans.

Weather

On by default, through Open-Meteo, which is free for non-commercial use and needs no key. What leaves the server is a ZIP code and the rounded position of the town it names, and the temperatures are fetched once and kept. ELEC_WEATHER=off makes no lookups at all.

The daily check

Once a day the app checks each account for a bill that should have arrived and has not, and refreshes the similar-homes pool. Pick one way to run it: ELEC_SCHEDULER=1 has the app run it itself (one always-on instance), or set ELEC_TASK_TOKEN and have an outside scheduler POST /api/tasks/check with that token as a bearer. The answer names nobody.

Email to users

Missing-bill and new-bill notices go out only once ELEC_SMTP_USER and ELEC_SMTP_PASS are set. ELEC_SMTP_HOST defaults to Gmail's server and ELEC_SMTP_FROM to the user; set both for any other provider. A message may name a bill amount.

What you are holding

Each account's bills, exports and settings sit under that account alone, and the app reads and writes one account's files per request. You can read the database, as with any site, so the security page tells users as much. Things worth doing on their behalf: back the data up, serve the app over HTTPS only, keep ELEC_SECRET_KEY and the inbound secret in a secret store rather than a file in the checkout, and close sign-ups once your people are in. /health says whether the app is up and can reach its data.

Every variable

VariableFeature
ELEC_SECRET_KEY, ELEC_BASE_URLAccounts; the address served at
ELEC_DATABASE_URL, ELEC_DATA_DIRWhere data lives
ELEC_ALLOW_SIGNUP, ELEC_TRUSTED_PROXIESSign-ups; proxies in front
ELEC_INBOUND_DOMAIN, ELEC_INBOUND_SECRET, ELEC_MAILGUN_SIGNING_KEY, ELEC_INBOUND_DKIMForwarding
ELEC_GOOGLE_CLIENT_ID, ELEC_GOOGLE_CLIENT_SECRETGoogle sign-in
ELEC_VISION_KEYReading scans
ELEC_WEATHERoff to make no weather lookups
ELEC_SCHEDULER, ELEC_TASK_TOKENThe daily check
ELEC_SMTP_HOST, ELEC_SMTP_USER, ELEC_SMTP_PASS, ELEC_SMTP_FROMEmail to users
PORT, WEB_CONCURRENCYPort and worker count

Every screenshot in these pages is the app on an invented household. No real bills.