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.shwith 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_KEYis what turns accounts on. Everything below is about this mode.
Required when hosted
| Variable | What it does |
|---|---|
ELEC_SECRET_KEY | Signs 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_URL | The address people reach the app at. An https:// value also marks cookies Secure. |
ELEC_DATABASE_URL or ELEC_DATA_DIR | Postgres (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_PROXIES | How many proxies sit in front of the app (default 1). Affects which address the sign-in lockout counts. |
ELEC_ALLOW_SIGNUP | 0 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)
- In the Cloudflare dashboard for the zone: Email → Email Routing → enable it. This adds the MX records.
- 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. - In the worker's Settings → Variables and Secrets: a secret
INBOUND_SECRETholding the same value as the app'sELEC_INBOUND_SECRET, and a plain variableAPP_URLset to the app's https address with no trailing slash. Deploy again: settings only apply once deployed. - Email Routing → Routing rules → Catch-all: action Send to a Worker, pick the worker, enable it.
- Set
ELEC_INBOUND_SECRETon 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
- Add the domain to Mailgun as a receiving domain and set its MX records as Mailgun says.
- Receiving → Routes → create a route: match recipient
bills-.*@<your domain>, action forward tohttps://<app>/api/inbound/email. - Set
ELEC_MAILGUN_SIGNING_KEYon the app to the account's HTTP webhook signing key. Mailgun signs each request with it; a bad or replayed signature is refused.
Postmark
- Create an inbound stream and point the domain's MX at Postmark as it says.
- 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. - Set
ELEC_INBOUND_SECRETon 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
- In Google Cloud, create an OAuth client of type Web application, with the authorised redirect URI
<ELEC_BASE_URL>/auth/google/callback. - Set
ELEC_GOOGLE_CLIENT_IDandELEC_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
| Variable | Feature |
|---|---|
ELEC_SECRET_KEY, ELEC_BASE_URL | Accounts; the address served at |
ELEC_DATABASE_URL, ELEC_DATA_DIR | Where data lives |
ELEC_ALLOW_SIGNUP, ELEC_TRUSTED_PROXIES | Sign-ups; proxies in front |
ELEC_INBOUND_DOMAIN, ELEC_INBOUND_SECRET, ELEC_MAILGUN_SIGNING_KEY, ELEC_INBOUND_DKIM | Forwarding |
ELEC_GOOGLE_CLIENT_ID, ELEC_GOOGLE_CLIENT_SECRET | Google sign-in |
ELEC_VISION_KEY | Reading scans |
ELEC_WEATHER | off to make no weather lookups |
ELEC_SCHEDULER, ELEC_TASK_TOKEN | The daily check |
ELEC_SMTP_HOST, ELEC_SMTP_USER, ELEC_SMTP_PASS, ELEC_SMTP_FROM | Email to users |
PORT, WEB_CONCURRENCY | Port and worker count |