Configuration
All configuration lives in a .env file at the project root.
Environment variables
Section titled “Environment variables”| Variable | Default | Description |
|---|---|---|
JWT_SECRET | required | Min 32-character secret for signing auth tokens. |
CORS_ORIGIN | http://localhost | Comma-separated allow-list of client origins. Use * to allow any (the API is Bearer-token based, no cookies). |
PORT | 80 | Host port for the web interface. |
BACKEND_ORIGIN | backend:3000 | Docker service name:port the frontend proxies /api to — not a host IP. Only change the port, to match a custom backend PORT. |
REGISTRATION | open | Who may create an account: open, first-user, or closed. See below. |
DEMO_MODE | false | Seeds the demo account and sample data. Leave off — its password is public. |
Locking down registration
Section titled “Locking down registration”By default /api/v1/auth/register accepts anyone. That is fine on a LAN and wrong the moment
your instance has a public hostname, which is what the reverse proxy guide gets you.
Set REGISTRATION in your .env and restart:
REGISTRATION=first-userdocker compose up -d # no rebuild — it is read at startup| Value | Behaviour |
|---|---|
open | Anyone can sign up. The default, and what every version before this one did. |
first-user | Open only while no account exists. The first person to register claims the instance; everyone after gets a 403. |
closed | Nobody can register. Where you should end up. |
first-user is a setup mode, not a resting state. Use it for a fresh install — a plain
on/off switch would make you choose between starting open, and racing whatever scanner finds
your hostname, and starting closed, unable to create your own account at all. Then switch to
closed once you have signed up, for the reason below.
A typo is a startup failure, not a fallback: REGISTRATION=frst-user refuses to boot rather than
quietly leaving you wide open. Check docker compose logs backend if the container will not start.
This is enforced by the API, not the interface. The apps also hide the “Create account” link when the server reports registration closed, but that is politeness — the 403 is the lock.
Changing your password
Section titled “Changing your password”Settings → Account → Password, on the web app and in the Android app. It asks for your current password, so a stolen session alone cannot lock you out of your own account.
The web form has its own address, /settings/password, and /.well-known/change-password
redirects to it. That is the W3C Change Password URL,
so Safari’s Keychain, Chrome’s Password Checkup and 1Password can send you straight to the form
instead of dropping you on the front page. Nothing to configure — the bundled nginx serves the
redirect. Behind your own reverse proxy, pass the path through to the frontend like any other
route.
Password length
Section titled “Password length”Between 8 characters and 72 bytes. The minimum is Lyftr’s; the maximum is bcrypt’s, which refuses anything longer rather than silently truncating it.
72 bytes is 72 ordinary characters, but accented letters cost two and most emoji cost four — so a 19-character emoji passphrase is already over. Both apps say so while you type rather than on submit. Worth knowing if your password manager generates long passwords: cap it at 72.
Changing it signs you out everywhere else. The device you changed it on stays signed in. That is deliberate — a password change is what you reach for when you think someone else has a session, so it has to actually end their session. Sign back in on your other devices with the new password.
Other devices stop as soon as their current access token expires, which is JWT_EXPIRY seconds
(one hour by default). Raising JWT_EXPIRY widens that window by exactly the same amount, so a
long expiry trades a little convenience for a longer tail after a password change.
If you forget your password
Section titled “If you forget your password”There is no self-service reset. Nothing here can email you a link, and an instance with no mail server could not send one. Recovery belongs to whoever has a shell on the server — which, since you self-host, is you:
docker compose exec backend ./lyftr-api reset-password you@example.comIt prompts for the new password twice, with no echo, and prints:
Password reset for you@example.com.Every existing session for that account has been signed out.The account and everything in it are untouched — only the password changes.
The password is prompted for rather than passed as a flag, so it never reaches your shell history or the container’s process list.
If you need it unattended, pipe it in. Read it into a variable first rather than typing it into the
command — echo 'your-new-password' | ... puts the password straight into your shell history, which
is the thing the prompt exists to avoid:
read -rs NEWPWdocker compose exec -T backend ./lyftr-api reset-password you@example.com <<< "$NEWPW"unset NEWPWPiped input skips the confirmation prompt — there is nothing to type twice — so whatever arrives on stdin becomes the password, subject only to the same 8-character minimum and 72-byte maximum the apps enforce.
Sessions are signed out because a reset is often prompted by someone else having got in, and a new password is worthless while their existing token still works.
If the instance will not start at all, restoring lyftr.db from a backup predating the password
change also works, at the cost of everything logged since — see Backups.
The demo account
Section titled “The demo account”Every version before this one seeded demo@lyftr.local with a password published in this
documentation, on every install. That account is now behind DEMO_MODE, which is off unless
you ask for it.
If you are upgrading, the account you already have is not deleted — deleting it might take real
workouts with it. The backend logs a warning at startup while it exists. To remove it: sign in as
demo@lyftr.local, then Settings → Delete account. If that account has real data in it, change
its password instead — that closes the published-credential hole without losing anything.
Turning registration off does nothing about this: a published password is a working login whatever
REGISTRATION says.
The BACKEND_ORIGIN gotcha
Section titled “The BACKEND_ORIGIN gotcha”BACKEND_ORIGIN is resolved over the internal Docker network, so it must use the backend’s
service name (backend), not your server’s host or LAN IP.
The default compose only exposes the backend on the Docker network — it isn’t published to the
host — so pointing BACKEND_ORIGIN at something like 192.168.1.10:3000 produces:
502 Bad Gatewayconnect() failed (111: Connection refused)If you run the backend on a custom PORT, change only the port (e.g. backend:3008).
Re-syncing exercises
Section titled “Re-syncing exercises”Go to Settings → Exercise Library to see the current exercise count and a seeding progress indicator. Hit Re-sync to pull the latest exercises — it’s a safe upsert, so existing workout data is untouched.