Skip to content

Configuration

All configuration lives in a .env file at the project root.

VariableDefaultDescription
JWT_SECRETrequiredMin 32-character secret for signing auth tokens.
CORS_ORIGINhttp://localhostComma-separated allow-list of client origins. Use * to allow any (the API is Bearer-token based, no cookies).
PORT80Host port for the web interface.
BACKEND_ORIGINbackend:3000Docker service name:port the frontend proxies /api to — not a host IP. Only change the port, to match a custom backend PORT.
REGISTRATIONopenWho may create an account: open, first-user, or closed. See below.
DEMO_MODEfalseSeeds the demo account and sample data. Leave off — its password is public.

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:

Terminal window
REGISTRATION=first-user
Terminal window
docker compose up -d # no rebuild — it is read at startup
ValueBehaviour
openAnyone can sign up. The default, and what every version before this one did.
first-userOpen only while no account exists. The first person to register claims the instance; everyone after gets a 403.
closedNobody 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.

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.

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 on their next request to the server: it checks every request against the account’s current session version, so a device that was signed out gets a 401, fails to refresh, and drops to the sign-in screen. A device that cannot reach the server at that moment, say on poor wifi, stays on its old screens until it can. JWT_EXPIRY only sets how often a healthy session re-mints its access token, not how long a revoked one lingers.

Settings → Account → Email, on the web app and in the Android app. Like a password change it asks for your current password, so a stolen session alone cannot take the address over. The web form is at /settings/email.

You can change it to any address no other account holds. Letter case does not count as a difference, so Carter@example.com is taken if carter@example.com is registered, but you can change the case of your own address. The address is stored exactly as you type it.

Like a password change it signs you out everywhere else. The device you changed it on stays signed in, and you sign in with the new address from now on. The old one stops working at once. Lyftr cannot email the old address, so ending the other sessions is how a change made from a session that was not yours gets contained. Other devices stop on their next request to the server, as with a password change. Changing only the letter case, say carter@ to Carter@, is the one exception: sign-in already ignores case, so nothing needs containing and no session ends.

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:

Terminal window
docker compose exec backend ./lyftr-api reset-password you@example.com

It 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:

Terminal window
read -rs NEWPW
docker compose exec -T backend ./lyftr-api reset-password you@example.com <<< "$NEWPW"
unset NEWPW

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

An account can be removed from the server’s shell, for the case where nobody can (or should) sign in to it:

Terminal window
docker compose exec backend ./lyftr-api delete-account you@example.com

It prints what will be deleted — workouts, sets, food and weight entries, routines, saved foods — and asks you to type the address again. Anything else aborts with nothing deleted. The address must match letter case exactly; if it does not, the command lists the spellings it found and deletes nothing.

If an upgrade’s boot log says some accounts differ only in letter case, this is how to remove the one you do not want. The case-insensitive index is created on the next start.

If it is the only account, the command warns first: under REGISTRATION=first-user whoever registers next becomes the owner, and under REGISTRATION=closed nobody can sign in or register. Take a backup before you run it — see Backups. A device still signed in to the deleted account is signed out on its next request to the server.

Piping the exact address also works, with -T: echo you@example.com | docker compose exec -T backend ./lyftr-api delete-account you@example.com.

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.

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 Gateway
connect() failed (111: Connection refused)

If you run the backend on a custom PORT, change only the port (e.g. backend:3008).

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.