Self-hosting Saldo
The Saldo server (apps/web) keeps the accounts. The app does not need a server: it works on the phone
alone, with no account. The server adds two things: sync between devices, and a backup of attachments.
With an account, the app stays local-first. It works offline, and it continues to work when the server
is down.
The app is not built for one server. On its first screen («With your Saldo server»), the app asks for
the address. This is the HTTPS address of Saldo in a browser, for example saldo.example.com. You can
also change it later, in Profile → Server and sync. The app checks /api/health at that address before
it shows the sign-in.
The server must know one thing about the app: its URL scheme. Put the scheme in APP_REDIRECT_SCHEMES.
The scheme is saldo, unless the app was built with a different SALDO_SCHEME. To build the app, read
Building Saldo for Android or Building Saldo for iOS.
What you need
- A machine with Docker. You can also run the server directly, with Node 22 and Postgres 17.
- A domain on HTTPS. Passkeys (WebAuthn) work only in a secure context, so this is a requirement.
Browsers make one exception:
http://localhost. For this reason, local development works without a certificate. - HTTPS also for «Install» and «Add to Home Screen». Browsers register the service worker only on HTTPS. You do not configure anything: the manifest uses the instance name, and the Docker image contains the icons. The service worker keeps only an «you are offline» page. The web app needs the server for all other pages, and it keeps no data in a cache.
Configuration
| Variable | Example | Purpose |
|---|---|---|
PUBLIC_URL |
https://saldo.example.com |
The origin for WebAuthn and for links. Use https, or http://localhost for development. |
WEBAUTHN_RP_ID |
saldo.example.com |
The relying party id: the registrable domain of PUBLIC_URL. No scheme, no port, no path. |
WEBAUTHN_RP_NAME |
Saldo |
The name that the passkey prompt shows. |
JWT_SECRET |
32+ random bytes, base64 | Signs the access tokens. Make one with openssl rand -base64 48. |
DATABASE_URL |
postgres://saldo:…@postgres:5432/saldo |
The Postgres database. |
STORAGE_DIR |
/data/attachments |
The directory for attachment files, with ATTACHMENT_STORAGE=fs. |
SETTINGS_ENCRYPTION_KEY |
32 random bytes, base64 | Required. Encrypts the secrets of the admin panel (today, the SMTP password). Make one with openssl rand -base64 32. Keep it: see Server settings. |
REGISTRATION |
invite |
Optional. open, closed, invite (an account needs a code) or first-user. The first account is always allowed, in all modes: a new instance opens on /setup, a short wizard that makes it. When you set the variable, it locks the choice in /admin/settings. When you do not set it, the wizard asks, and you can change it there later. The default is first-user. |
INSTANCE_NAME |
Casa Rossi |
Optional, 1–60 characters. The name on the sign-in page and in invites. When you set it, it locks the field in /admin/settings. When you do not set it, you choose the name there. The default is WEBAUTHN_RP_NAME. |
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM |
smtp.example.com, 587 |
Optional outgoing mail. When SMTP_HOST is set, all the group comes from the environment, and the panel locks it. Then SMTP_FROM is required, SMTP_PORT is 587 by default, and you set SMTP_USER and SMTP_PASSWORD together or not at all. |
APP_REDIRECT_SCHEMES |
saldo |
The URL schemes that a mobile redirect can use, separated by commas. Include each SALDO_SCHEME of your app builds (the default is saldo). If a scheme is missing, each mobile sign-in stops with invalid_redirect_uri. |
TRUSTED_PROXIES |
172.28.0.0/16 |
The IPs or CIDRs of the reverse proxy in front of this server, separated by commas. Required when a proxy such as Caddy is in front. See Security notes. |
DEFAULT_TIME_ZONE |
Europe/Rome |
Optional, the default is UTC. The time zone for «today» in the web app, for a user who has no time zone in the settings. Each phone sets its time zone at onboarding, so this applies only to older accounts. Do not use TZ: Vercel keeps that name. |
MCP_ENABLED |
false |
Optional, true or false. When you set it, it locks the switch in /admin/mcp. When you do not set it, the admin chooses there (the default is on). See Connecting an MCP client. |
Read these points before you start:
- When you change
WEBAUTHN_RP_ID, all existing passkeys stop working. A passkey works only for the relying party that made it. Choose the hostname that you will keep. - Keep
SETTINGS_ENCRYPTION_KEYwith your other secrets. Without it, the server does not start. The key is not in the database, and not in a backup of the database. To know what a lost key costs, see Server settings. - Use
REGISTRATION=invite. After your account exists, a new account needs a single-use code. Thus a stranger who finds your URL cannot register. The one exception is the first account: a new instance accepts the first person who opens/setup. Thus, open/setupimmediately after the server starts. Withopen, all persons who can reach the server can make an account.
Run it with Docker
-
Get the code:
Terminal window git clone https://github.com/saliougaye/saldo.gitcd saldo -
Set the variables, and start the stack:
Terminal window export PUBLIC_URL=https://saldo.example.comexport WEBAUTHN_RP_ID=saldo.example.comexport JWT_SECRET="$(openssl rand -base64 48)"export SETTINGS_ENCRYPTION_KEY="$(openssl rand -base64 32)"export POSTGRES_PASSWORD="$(openssl rand -base64 24)"docker compose -f apps/web/docker/docker-compose.yml up -d --build -
Keep
JWT_SECRET,SETTINGS_ENCRYPTION_KEYandPOSTGRES_PASSWORDin a password manager. Docker Compose needs the same values each time that you start the stack. -
Put a reverse proxy in front of the container, for HTTPS.
apps/web/docker/Caddyfile.exampleis a short Caddy configuration. Caddy gets and renews the certificate automatically. -
Set
TRUSTED_PROXIESto the address of the proxy, and start the stack again.Warning: Without
TRUSTED_PROXIES, all callers behind the proxy share one rate-limit counter. Then one caller can stop the sign-in of all other persons. See Security notes.Terminal window export TRUSTED_PROXIES=172.28.0.0/16docker compose -f apps/web/docker/docker-compose.yml up -dUse the address of the proxy, or the subnet of the Docker network that the proxy shares with the container.
docker network inspectshows the subnet. Do not use0.0.0.0/0or a full private range.
The compose file also passes DEFAULT_TIME_ZONE (default UTC) and MCP_ENABLED to the container.
When the container starts, it applies the database migrations. Then it serves on port 3000. The compose
file keeps attachments on the server, in the attachments volume (ATTACHMENT_STORAGE=fs). To keep
them only on the phones, set ATTACHMENT_STORAGE=none. If port 3000
is not free on the host, set WEB_PORT:
WEB_PORT=3007 docker compose -f apps/web/docker/docker-compose.yml up -dRun it on Vercel instead
You can also deploy the server on Vercel. The server is a usual Next.js app in a pnpm workspace, and
Vercel deploys it as a monorepo. The Root Directory of the project is apps/web, where next is a
dependency. vercel.json at the repository root has the install and build commands. If the Root
Directory is the repository root, the build stops with «No Next.js version detected», because the root
package.json does not depend on next.
Nobody has done all of this procedure yet. The Docker procedure is tested; this procedure comes from what the code does. If a step is wrong, please report it as a bug.
Four things are different from the compose file. Three of them cause failures if you forget them:
-
Use your own Postgres. The database of the compose file is local to the compose file. Use a managed database (Neon, Supabase, Vercel Postgres), and set
DATABASE_URLto it. -
Run the migrations yourself. The build command does not run them, because previews also build. A preview with the production database would apply its migrations to it. Run the migrations from your machine before the first deploy, and again for each deploy that adds a migration:
Terminal window DATABASE_URL='postgres://…' pnpm --filter web db:migrate -
Do not use
ATTACHMENT_STORAGE=fs. Vercel deletes its filesystem at each deploy. Usenone(attachments stay on the phone),s3, orvercel-blob. Withvercel-blob, uploads go through the server, and an attachment larger than some MB fails. For large attachments, uses3. Before you usevercel-blob, also read the caution in Attachments. -
PUBLIC_URLandWEBAUTHN_RP_IDmust name the production domain, not a preview URL. A passkey works only on the domain that made it. A passkey made on a preview URL does not work on the real domain, and the WebAuthn error does not tell you the cause.
Step by step
Caution: Choose the domain first. A passkey works only on the domain that made it. An account made on
saldo-abc123.vercel.appcannot sign in later onsaldo.example.com: the browser does not show the passkey, and no message tells you why. Do not make an account before the domain is in place.
-
Make the database. Use Neon, Supabase or Vercel Postgres. Copy its connection string.
-
Run the migrations from your machine, with that connection string. Nothing else makes the tables:
Terminal window DATABASE_URL='postgres://…' pnpm --filter web db:migrate -
Make the project. Set its Root Directory to
apps/web. Both settings of this step and the next are in Settings → Build and Deployment → Root Directory. -
Turn on «Include files outside the root directory in the Build Step». This setting is required. Without it, the build sees only
apps/web: pnpm finds no lockfile, andpackages/*is not there.From the CLI, run all commands from the repository root, not from
apps/web. The CLI applies the Root Directory itself:Terminal window vercel linkvercel project update --root-directory apps/webproject updatehas no option for «Include files outside the root directory». Turn it on in the dashboard.A deploy from your machine (
vercel --prod) sends your working tree, not a clone. The.vercelignoreat the root decides which files go. Without it, the upload includes the generatedios/andandroid/projects, and all local.envfiles. It also keepsturbo.jsonout: the deploy does not use Turborepo, and with that file the build breaks. Do not deleteturbo.jsonfrom the repository:pnpm typecheckandpnpm testuse it. -
Add the environment variables of the next section, for Production.
PUBLIC_URLandWEBAUTHN_RP_IDname the domain that you chose, not the URL that Vercel makes. -
Add the domain in the Domains tab of the project. Wait for its certificate. Passkeys need HTTPS.
-
Deploy. Then make sure that the server answers:
Terminal window curl -s https://saldo.example.com/api/healthWith
REGISTRATION=invite,registrationAllowedandinviteRequiredare bothtrue.schemaVersionis the number that the app must match. -
Make your own invite code. With
invite, each account needs a code, also yours. There is no shell on the server, so run the command from your machine, against the production database:Terminal window DATABASE_URL='postgres://…' pnpm --filter web invite new --label meThe command shows the code one time. The first account that registers is the admin of the instance.
This command and the migration command read
apps/web/.env, if you have one for development. A variable on the command line wins over the file, so the commands use the production database. Make sure of this: an invite in the wrong database is a code that never works, and no error tells you. -
Build the app, and install it. See Android or iOS:
Terminal window pnpm --filter mobile build:android com.example.saldoOn the first screen, choose «With your Saldo server». Type
saldo.example.com: the domain, not a preview URL. -
Sign in. The app opens your
/auth/mobilepage in a browser, and the passkey ceremony runs there. Then the browser goes back to the app with thesaldo://scheme. If the sign-in stops, compareAPP_REDIRECT_SCHEMESon the server with theSALDO_SCHEMEof the app build.
The environment variables Vercel needs
Set these variables in Environment Variables of the project, or with
vercel env add <NAME> production.
Warning: Do not put these variables in
vercel.json. That file is in the repository, and itsenvfield keeps the values as text. A secret in that file is a public secret.
| Variable | Value | Notes |
|---|---|---|
PUBLIC_URL |
https://saldo.example.com |
Your production domain. Not a preview URL. |
WEBAUTHN_RP_ID |
saldo.example.com |
The registrable domain: no scheme, no port. Passkeys work only for it. |
WEBAUTHN_RP_NAME |
Saldo |
The name of your instance in the passkey prompt. |
JWT_SECRET |
openssl rand -base64 48 |
Secret. |
DATABASE_URL |
postgres://… |
Secret. Your managed Postgres, not the compose database. |
ATTACHMENT_STORAGE |
none, s3 or vercel-blob |
Never fs on Vercel. |
SETTINGS_ENCRYPTION_KEY |
openssl rand -base64 32 |
Secret, and required. Keep it: see Server settings. |
REGISTRATION |
invite |
An account needs a code. The first account is the exception: /setup makes it. Optional: when not set, /admin/settings decides. |
APP_REDIRECT_SCHEMES |
saldo |
Must match the SALDO_SCHEME of the app build. |
TRUSTED_PROXIES |
— | Keep it empty until you know what Vercel sends. See the next list. |
DEFAULT_TIME_ZONE |
Europe/Rome |
Optional. Vercel does not let you set TZ; Saldo reads this variable. |
Also set the variables of your storage driver. For vercel-blob, Vercel adds BLOB_READ_WRITE_TOKEN.
For s3, see Attachments.
WEB_PORT, POSTGRES_PORT and STORAGE_DIR are for the compose file only. Vercel does not use them.
Set the variables for Production only. With the same DATABASE_URL, previews use your real data.
The production PUBLIC_URL is also wrong for a preview. Give previews their own database, or do not
configure them. Previews without configuration fail when they start, and that is safer.
Read these two points before other persons use the server:
- The rate limiter of the sign-in keeps its counters in memory. On Vercel, each serverless instance has its own counters. Thus the limit applies to each instance, not to the server. See Security notes.
- Nobody has checked
TRUSTED_PROXIESon Vercel. Better Auth finds the IP of the caller inx-forwarded-for. When the header has more than one value, Better Auth ignores it, unlessTRUSTED_PROXIESis set. This document does not know if Vercel sends one value or more. Look at the header before you decide. A wrong value gives a limiter that protects nothing, and no error tells you.
Check that it works
curl -s https://saldo.example.com/api/health# {"app":"saldo","apiVersion":1,"schemaVersion":1,"version":"0.1.0",# "registrationAllowed":true,"inviteRequired":true,"attachmentsEnabled":false}- The mobile app checks
appandapiVersionwhen it checks the address that you typed. registrationAllowedtells the app if it can show «create account».inviteRequiredtells the app if that form also asks for a code.
Open the domain in a browser too. The root of the instance is the web app. When you are signed out,
it opens /login. That page shows the instance name, and the version and the host at the bottom. When
you are signed in, it opens the app. You do not deploy anything else, and there is no second address.
Letting people in
The first account comes from the first-run wizard. On a new instance, /, /login and /register
go to /setup. The wizard asks for:
- The name of the instance, if
INSTANCE_NAMEdoes not set it. - Who can register, if
REGISTRATIONdoes not set it. - Your passkey. The wizard makes your account with no code, in all
REGISTRATIONmodes. Your account is the admin.
The last step tells you if email is configured. With invite, it also makes the first invite for a
different person. After the first account exists, /setup is not available, and the exception stops.
From then on, with invite, each account needs a code.
You can also use the command line. When no admin can sign in, the command line is the only way in:
# Dockerdocker compose exec web pnpm --filter web invite new --label "me"# or, from a checkoutpnpm --filter web invite new --label "me"The command shows the code one time. The server keeps only its hash. A code looks like
HNWF-SWHY-JM3T-ETQZ. /register?invite=HNWF-SWHY-JM3T-ETQZ fills in the code, so an invite can be
one link in a message.
Registration asks for an email address. The address is an identifier, not a credential: the passkey
is the only way in. The address names the account in /admin and in the account list.
- Without SMTP, the server sends nothing to the address. The address stays unverified, and there is no recovery by email. On a household instance, any address that the members know is good.
- With SMTP (see Server settings), the server sends a verification link. A verified address can recover the account.
Signing in on the web with the passkey of the phone. An account made on the phone has one passkey.
The passkey belongs to the domain of this server, because the phone makes it on the /auth/mobile page
of the server. Thus the web app accepts it too. Face ID is only how the phone unlocks the passkey. A
computer can use the passkey only if it can see it:
- A Mac or an iPad with the same Apple ID and with iCloud Keychain on already has the passkey. «Accedi con passkey» shows it.
- All other computers (a different Apple ID, Windows, Linux, iCloud Keychain off) have no passkey for this site. The browser shows «Usa un telefono» and a QR code. Scan the code with the camera of the phone, and confirm with Face ID. The browser is then signed in. The passkey does not leave the phone, and nothing syncs. This is the cross-device flow of WebAuthn. Bluetooth must be on, on the two devices: they use it to prove that they are near. Chrome, Edge and Safari support this flow.
After you sign in, add a passkey of that computer: open /profile/passkeys → «Aggiungi passkey»
(Touch ID, Windows Hello, the browser profile). The next sign-in then does not need the phone. Each
account should have a second passkey. With one passkey on one phone, a lost phone is a lost account,
unless email recovery is set up or an admin helps.
If the QR flow is not available (no Bluetooth, or a browser without the cross-device flow), use recovery
by email. Recovery needs SMTP and a verified address. If the phone saved the address when the server
had no SMTP, the server never sent the link. After you configure SMTP, sign in one time, and open
/profile/account → «Invia il link di verifica». Without the QR flow and without email, use the
command line below.
To invite a different person, make a new code and send it. An admin can also do this in the panel at
/admin: Inviti, «Nuovo invito» shows the code, a link and a QR one time. The Account page of
the panel manages accounts: promote, revoke, restore, delete. When no admin can sign in, use the
commands:
pnpm --filter web invite new --label "Marco" --days 3pnpm --filter web invite listpnpm --filter web invite revoke <id> # withdraw a code that nobody usedTo manage the accounts from the command line:
pnpm --filter web user listpnpm --filter web user revoke <id> --reason "left the household"pnpm --filter web user restore <id>pnpm --filter web user promote <id> # make the account an admin toopnpm --filter web user delete <id> # cannot be undone: deletes the synced data toorevoke works immediately. It deletes the sessions of the account, and the server refuses its bearer
token. The phone keeps its local data, but it does not sync again. If you lose the phone with the only
admin passkey, use promote on a second account that you control.
Server settings
In /admin/settings, an admin changes three settings, with no file to edit and no restart:
- the instance name;
- the registration mode;
- the outgoing email (SMTP).
The server_settings table keeps them. An environment variable for the same setting wins, and it locks
the setting. The panel names the variable. To change a locked setting, edit the variable where the
server runs and restart, or remove the variable and choose in the panel.
-
SMTP in the environment or in the panel. Set
SMTP_HOSTand the rest of the group in the environment, or leave them out and fill in the panel. Mail is optional: registration and sign-in work without it. Manda un’email di prova sends a message to your own address, with the saved configuration. If the send fails, the panel shows the error of the server. Port 465 uses implicit TLS. All other ports require STARTTLS, except tolocalhost(a development catcher such as Mailpit). -
What SMTP adds. A new account gets a link that verifies the address. Profilo → Account sends the same link on request. The link is valid 24 hours. It does not sign anybody in, and the app does not require a verified address.
/loginalso gets «Hai perso la passkey?». A person who lost a passkey asks for a link at/recover. The server sends that link only to a verified address of an account that is not revoked. The link is valid 15 minutes, and it works one time. It signs a browser in, and it asks for a new passkey. Thus the mailbox is as strong as the passkey (seeSECURITY.md).Without SMTP, none of this is available.
/recovertells you that recovery is not available, and/loginhas no link. For a lost passkey, an admin must help (pnpm --filter web user …).The server sends to each address a maximum of one message of a type each minute, and five each hour. Like the rate limiter, this counter is in the memory of the process. Thus more replicas multiply it.
PUBLIC_URLmust be the origin of the site, with no path: the server makes the links from it.Profilo → Account also gets a form, «Nuovo indirizzo email». The server sends a link to the new address. The address changes only when the person uses the link (valid 24 hours, one time). If the old address is verified, it gets a notice. Without SMTP, the form is not shown, and
POST /api/me/emailanswersemail_already_setfor a real address. -
Mail on your machine. Start Mailpit:
docker run --rm -d -p 1025:1025 -p 8025:8025 axllent/mailpit. In/admin/settings, set hostlocalhost, port1025, no user, and any sender. A plain connection tolocalhostis allowed. Each message from the server is at http://localhost:8025. -
Nobody can read the SMTP password back. The server encrypts it (AES-256-GCM) with
SETTINGS_ENCRYPTION_KEYbefore it stores it. The server never sends it to a browser. You can replace it, but you cannot read it. When you change the host or the user, you must type the password again. Thus a stolen session cannot send the stored password to a different server. -
The key.
SETTINGS_ENCRYPTION_KEYdecrypts that password. Keep it with your other secrets. If you lose or change the key, you lose only the stored SMTP password: enter it again in the panel. There is no key rotation. To change the key, change it, then enter the password again.
Provider via SMTP
Saldo sends mail only through SMTP. That is sufficient for a transactional provider: each provider has
an SMTP relay that takes the same five values as your own mail server. There is no MAIL_TRANSPORT
switch, and no API driver for each provider, because Saldo does not need them.
From one provider to the next, three things change:
- the host;
- the user and the password;
- what the provider asks of your domain before it accepts a sender.
The five values are the SMTP_* group of the environment, or the same fields of
/admin/settings?edit=smtp (Server, Porta, Utente, Password, Mittente). The sender
can be an address, or Name <address>. Its domain is the domain that you verify with the provider.
To check a configuration:
- Save it.
- Click Manda un’email di prova. The server sends a message to your own address, with the saved configuration.
- If the provider refuses (a wrong password, a sender that is not verified, a blocked port), read the error in the panel. The log of the provider tells you more.
Ports. transportSecurity in apps/web/src/server/mail/mailer.ts sets the rule:
- Port 465 uses implicit TLS: the connection is encrypted before the first byte.
- All other ports require STARTTLS. If the mail server does not offer STARTTLS, the send fails. Thus the server never sends a password on a connection that is not encrypted.
- A plain connection is allowed only to
localhost,127.0.0.1or::1, for Mailpit.
All providers here support 465 and 587, except Postmark (STARTTLS only: 587, 2525 or 25). If your host blocks the usual ports, use a different port:
| Provider | Other ports |
|---|---|
| Resend, Amazon SES | 2465 (implicit TLS), 2587 (STARTTLS) |
| Mailgun, Brevo, Postmark | 2525 (STARTTLS) |
Hosts often slow down or block port 25. Use a different port.
Domain verification, one time. The provider does not accept a sender that it has not verified. All providers ask for the same three records, with their own names and values:
- SPF: a
TXTrecord, sometimes also anMXrecord, on the domain or on a return-path subdomain. It names the provider as a permitted sender. - DKIM: one
TXTrecord or someCNAMErecords under<selector>._domainkey.<domain>. They contain the signing key of the provider. - DMARC: a
TXTrecord at_dmarc.<domain>, for examplev=DMARC1; p=none; rua=mailto:…. Brevo requires it. The other providers recommend it, and Gmail and Yahoo ask for it from large senders.p=noneis sufficient.
Copy each record from the domain page of the provider, not from a guide: the provider makes the values for your domain. Then wait until the provider shows the domain as verified, before the first test. This takes minutes to some hours; DNS can take up to 72 hours.
- Resend recommends a subdomain (
saldo.example.com,mail.example.com), not the apex. A subdomain also keeps the usual mail of the domain safe. - Postmark and SES can also verify one address with a confirmation link. That is sufficient to start.
- The sandboxes of Mailgun and SES send only to addresses that you verified. To leave the sandbox, add a domain (Mailgun) or ask for production access (SES).
Resend. The host is smtp.resend.com. The user is the word resend. The password is an API key with
permission to send. The sender must be on a verified domain. Use port 465 or 587 (2465, 2587 and 25 also
answer).
SMTP_HOST=smtp.resend.comSMTP_PORT=465SMTP_USER=resendSMTP_PASSWORD=re_xxxxxxxxxxxxxxxxxxxxxxxxSMTP_FROM=Saldo <saldo@example.com>In the panel: Server smtp.resend.com, Porta 465, Utente resend, Password the API key, Mittente
Saldo <saldo@example.com>. The Domains page of Resend shows DKIM (TXT) and SPF (MX and TXT).
By default, SPF is on a send. subdomain, which is the return path. Add DMARC after the verification.
Postmark. The host is smtp.postmarkapp.com. The user and the password are both the Server API
Token. Postmark uses the default transactional stream of the server, because Saldo sends no stream
header. A Sender Signature, confirmed by email, lets you send from that one address. A verified
domain lets you send from all its addresses. To verify the domain, add the DKIM TXT record and the
Return-Path CNAME (pm_bounces → pm.mtasv.net). The CNAME also aligns SPF. Postmark uses STARTTLS
only: port 587, or 2525 if 587 is blocked.
SMTP_HOST=smtp.postmarkapp.comSMTP_PORT=587SMTP_USER=<server API token>SMTP_PASSWORD=<the same server API token>SMTP_FROM=Saldo <saldo@example.com>In the panel: Server smtp.postmarkapp.com, Porta 587, Utente and Password the same token, Mittente a
confirmed signature or an address on the verified domain. Postmark also makes SMTP Tokens: an Access Key for
the user, and a Secret Key for the password. Use them if you do not want to give the server API token to
Saldo.
Amazon SES. The host is email-smtp.<region>.amazonaws.com, for the region where you verified the
identity. Examples: email-smtp.eu-west-1.amazonaws.com, email-smtp.eu-central-1.amazonaws.com. Some
regions have no SMTP endpoint, for example Milan.
Caution: The SMTP credentials are not your AWS keys.
In the SES console, open SMTP settings → Create SMTP credentials. SES makes an IAM user. Its SMTP user name is its access key id. Its SMTP password comes from its secret key for that region. SES shows them one time.
- Ports: 465 or 2465 (implicit TLS), 587 or 2587 (STARTTLS).
- The sender must be a verified identity: a domain or one address confirmed by email. For a domain, Easy
DKIM adds three
CNAMErecords. A custom MAIL FROM subdomain is optional; it adds anMXrecord and an SPFTXTrecord. - A new account is in the sandbox: 200 messages each day, and only to verified addresses. To leave the sandbox, ask for production access.
SMTP_HOST=email-smtp.eu-west-1.amazonaws.comSMTP_PORT=465SMTP_USER=AKIAxxxxxxxxxxxxxxxxSMTP_PASSWORD=<the SMTP password SES generated, not the secret access key>SMTP_FROM=Saldo <saldo@example.com>In the panel: Server the regional endpoint, Porta 465, Utente the SMTP user name, Password the SMTP
password, Mittente an address on the verified identity.
Mailgun. The host is smtp.mailgun.org, or smtp.eu.mailgun.org for a domain in the EU region. A
domain is in one region: use the host of its region. The credentials are for each domain: open
Domain settings → SMTP credentials. The default login is postmaster@<your domain>. Mailgun gives you
the password there, when you make or reset it.
- Ports: 465 (implicit TLS) or 587 (STARTTLS; 2525 and 25 also answer).
- To verify the domain, add an SPF
TXTrecord (v=spf1 include:mailgun.org ~all) and the DKIMTXTrecord from the control panel. Mailgun also shows anMXrecord and a trackingCNAME. They are for incoming mail and click tracking, and Saldo does not use them. - Each account starts with a sandbox domain. It sends only to a maximum of five authorized recipients.
SMTP_HOST=smtp.eu.mailgun.orgSMTP_PORT=465SMTP_USER=postmaster@mg.example.comSMTP_PASSWORD=<the SMTP password of that credential>SMTP_FROM=Saldo <saldo@mg.example.com>In the panel: Server smtp.eu.mailgun.org (or smtp.mailgun.org), Porta 465, Utente
postmaster@mg.example.com, Password the password of that credential, Mittente an address on the
verified domain.
Brevo. The host is smtp-relay.brevo.com. The login is the SMTP login in SMTP & API → SMTP.
It is the email of the account, or, on newer accounts, a generated …@smtp-brevo.com address. The
password is an SMTP key from the same tab. It is not an API key, and not the password of the
account.
- Ports: 465 (implicit TLS) or 587 (STARTTLS; 2525 also answers).
- Brevo authenticates a domain with three
TXTrecords: a Brevo code that proves that the domain is yours, DKIM, and DMARC. If you have no DMARC record, usev=DMARC1; p=none; rua=mailto:rua@dmarc.brevo.com. - The sender must be on an authenticated domain, or be a sender that you confirmed by email.
SMTP_HOST=smtp-relay.brevo.comSMTP_PORT=465SMTP_USER=<your SMTP login>SMTP_PASSWORD=<your SMTP key>SMTP_FROM=Saldo <saldo@example.com>In the panel: Server smtp-relay.brevo.com, Porta 465, Utente the SMTP login, Password the SMTP key,
Mittente an address on the authenticated domain.
Set all the group, or none of it. With SMTP_HOST in the environment:
SMTP_FROMis required;SMTP_PORTis 587 by default;- you set
SMTP_USERandSMTP_PASSWORDtogether; - the form of the panel is locked, and it tells you why.
Values that you type in the panel need no restart. The next message uses them. For local development,
use Mailpit (above): host localhost, port 1025, no user, any sender. This is the only case where the
server accepts a plain connection.
Connecting an MCP client
The server has a Model Context Protocol endpoint. Each user can connect an MCP client to their data on this instance: Claude Code, the custom connectors of Claude, Cursor, or any client with Streamable HTTP and OAuth. There is no key to give out. The client finds the other values, and it sends the user to sign in with a passkey.
-
The endpoint is
{PUBLIC_URL}/mcp, for examplehttps://saldo.example.com/mcp. Each user finds it in Profilo › Connettori, with the command for their client. For Claude Code:Terminal window claude mcp add --transport http saldo https://saldo.example.com/mcpThe first call opens
/oauth/consentin the browser. The user signs in, chooses read only or read and write, and the client is connected. A terminal with no browser can usenpx mcp-remote https://saldo.example.com/mcp. -
PUBLIC_URLmust start withhttps://. MCP clients do not authorize over plainhttp, except on the same machine (http://localhost).The client reads two documents to find the other values:
/.well-known/oauth-protected-resourceand/.well-known/oauth-authorization-server. Each is also at its path-aware form. This host serves them. The issuer in them is{PUBLIC_URL}/api/auth, and the OAuth endpoints are under/api/auth/oauth2/.Thus
PUBLIC_URLmust be the address that clients reach. A reverse proxy must pass/mcp,/.well-known/and/api/auth/like all other paths./admin/mcpshows the two documents. It also shows a warning whenPUBLIC_URLis plainhttpon a different host. -
Turning it off. The endpoint is on by default. An admin turns it off at
/admin/mcp. That page also shows how many users have connectors.MCP_ENABLED=falsedoes the same from the environment, and it locks the switch (truelocks it on).When the endpoint is off,
/mcp, the.well-knowndocuments, registration and authorization answer 404, and the consent page refuses. Token refresh and token revocation continue to work. Thus connectors start again when the endpoint is on again. Nothing is deleted. -
What a client can do (tokens, scopes, revocation, the 180-day expiry) is in
SECURITY.md, «MCP and connectors».
Local development
One compose file starts all the services that the server uses: Postgres, and Floci in place of S3.
-
Start the services:
Terminal window docker compose -f apps/web/docker/docker-compose.dev.yml up -d -
Copy the example environment:
Terminal window cp apps/web/.env.example apps/web/.env -
Fill
JWT_SECRETinapps/web/.env. Make the value withopenssl rand -base64 48. -
Apply the migrations, and start the server:
Terminal window pnpm --filter web db:migratepnpm --filter web dev # http://localhost:3000
The compose file does not contain the server. The server runs from source, so a change reloads without a rebuild.
Caution: Look at the port that the server uses. If port 3000 is not free, Next starts on 3001, and it does not tell you clearly. Then
PUBLIC_URLdoes not match the origin in the browser, and the passkey ceremony fails with a WebAuthn error that does not name the cause. Free the port, or setPUBLIC_URLto the new port.
- Postgres is on port 5432. If a different project uses that port, set
POSTGRES_PORT, and changeDATABASE_URLto match. - Floci is on port 4566. When it starts,
apps/web/docker/floci-init/01-create-bucket.shmakes thesaldo-attachmentsbucket. - To use S3 in place of a disk, remove the comments from the
s3block in.env. The block already has the values of Floci (test/test,us-east-1, path-style). The upload then uses the same presigned path as a real bucket. Test this path on your machine: an upload through the server never uses it.
pnpm --filter web test runs the tests of the route handlers on a Postgres in memory (PGlite). They need
no database. The S3 driver has its own integration test. That test runs against Floci when Floci is up,
and it skips itself when Floci is down.
Testing from a phone
The phone must reach the server over HTTPS. PUBLIC_URL and WEBAUTHN_RP_ID must match the hostname
that the phone uses. A tunnel is the easiest way:
cloudflared tunnel --url http://localhost:3000# or: tailscale funnel 3000Then start the server with PUBLIC_URL=https://<tunnel-hostname> and
WEBAUTHN_RP_ID=<tunnel-hostname>.
Caution: A passkey made on a temporary tunnel hostname stops working when the hostname changes.
Attachments
Receipts and photos are optional. You choose where their bytes go, with ATTACHMENT_STORAGE:
| Value | Also set | Notes |
|---|---|---|
none (default, but the Docker compose file sets fs) |
— | Attachments stay on the phone. The app still saves them, and their rows still sync. Thus a different device shows them as not available, not as missing. |
fs |
STORAGE_DIR |
A directory on the server. The Docker compose file uses /data/attachments, on the attachments volume. Back it up with the database. Not for Vercel: Vercel deletes its filesystem at each deploy. |
s3 |
S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, optional S3_ENDPOINT and S3_FORCE_PATH_STYLE |
Amazon S3. With S3_ENDPOINT, also Cloudflare R2, MinIO, Backblaze B2 and Hetzner. |
vercel-blob |
BLOB_READ_WRITE_TOKEN |
Vercel adds the token. Uploads go through the server, so the request limit of the platform applies: an attachment larger than some MB fails. Read the caution below. |
An upload uses the shortest path that the store allows. With fs and vercel-blob, the phone sends the
bytes to the server. With S3, the phone sends the bytes directly to
the bucket. It uses a URL that the server signs for that one object, its exact size and its exact type.
This is necessary: a serverless function accepts a request body much smaller than 10 MB, the maximum size
of an attachment. An upload through the server would fail there.
An example with Cloudflare R2:
ATTACHMENT_STORAGE=s3S3_BUCKET=saldoS3_REGION=autoS3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.comS3_ACCESS_KEY_ID=…S3_SECRET_ACCESS_KEY=…When you delete a transaction, its attachment bytes stay for a time. The row becomes a tombstone, and the server puts the file in a small queue. The bytes go one week later. In that week, a phone that was offline learns about the deletion first.
Caution: Each object in a Vercel Blob store is readable by all persons who have its URL. Blob has no private mode. Saldo gives each object a random pathname. It never gives those URLs to a client: the server sends downloads itself, and it checks who asks. Thus nobody can find a receipt. But if a URL becomes known, it works. If this is a problem for you, use
s3: there, the server signs a URL that expires in five minutes.
Limits
How many transactions a household can keep. The app reads all the rows of a date range from SQLite, and it does the calculations in memory. SQL does no aggregation. Thus, with too many rows, a screen takes a long time to show. We measured this limit:
| Fold | Where you see it | 5k rows | 20k rows | 50k rows |
|---|---|---|---|---|
accountBalances |
Accounts tab, account detail | 0.3ms | 1.9ms | 9.3ms |
summarizePeriod |
Home header, Statistics | 0.1ms | 0.6ms | 2.2ms |
spendingByCategory |
Statistics, budgets | 0.2ms | 0.4ms | 2.5ms |
spendingByTag |
Statistics tag ranking | 0.2ms | 1.0ms | 2.6ms |
dailyCumulative |
Home and Statistics chart | 0.2ms | 0.7ms | 2.8ms |
monthEndBalances (8 months) |
Account sparkline | 1.1ms | 5.2ms | 43.4ms |
periodHistory (6 periods) |
Statistics baselines | 0.4ms | 2.8ms | 19.8ms |
categorySpendHistory |
Statistics baselines | 0.1ms | 0.3ms | 1.1ms |
budgetProgress (8 budgets) |
Budgets tab | 0.0ms | 0.0ms | 0.0ms |
| Statistics screen, all its folds | 1.1ms | 4.7ms | 49.4ms |
What this means for you:
- 20k transactions is easy: the busiest screen uses less than 5ms for the calculations.
- 50k transactions is still fast: a screen does all its calculations in much less than 0.1 seconds.
- 20k rows is about fifteen years of a household that records four transactions each day. You will probably not get there.
- Above 50k rows, memory is the first problem, not the calculations. The app keeps all the range as objects. The 50k values change from run to run because of garbage collection.
These numbers are a minimum, not a prediction. They come from
pnpm --filter mobile measure:perf <rows>. That command runs the calculations in Node, on a development
Mac, with no database and no rendering. A phone runs them in Hermes, which is slower, and it also draws
the screen. Thus a phone is slower than this table. Nobody has measured how much slower on real hardware.
The table shows that the calculations are not the problem. If a screen is slow, look at the SQLite query or at the rendering first. The heaviest fold is the account sparkline: it reads all the ledger one time for each point that it draws.
Versions and compatibility
The app and the server come from the same repository. They must have the same
SYNC_SCHEMA_VERSION, from packages/core/src/sync/protocol.ts. It is currently 1. The two sides
import it from the same package. Thus they can only be different when they come from different commits:
for example, when you update one side and not the other.
The check requires the same number. When the numbers are different, sync stops, so that no data goes
wrong. The server answers 409, with the two numbers and the side to update:
| What happened | The app is told |
|---|---|
The server is newer (schema_too_old) |
This server speaks sync schema n; the app speaks n−1. Update the app. |
The app is newer (schema_too_new) |
This server speaks sync schema n; the app speaks n+1. Update the server. |
You lose nothing while the numbers are different. Saldo is local-first. The app continues to work, you continue to add transactions, and the phone keeps the changes in a queue. When a compatible server is available, the phone sends them. Only the exchange stops, not the app.
/api/health shows schemaVersion, apiVersion and the version of the server. Thus you can see the
schema of an instance without a sign-in.
Updating
- Update the server first. It is in one place, and the update takes minutes.
- Then build the app again for each phone. Until you update a phone, it keeps its changes in a queue.
The other order also works. But then the phone that you update first stops syncing until the server is updated.
Read these two points before you change a schema version:
- The two sides run migrations. The server migrates at the deploy. The app migrates at the next start. A phone that nobody opens again keeps its old database, and its queued changes.
- A rollback of the server has a cost. After a phone sends rows with a newer schema, an older server can possibly not keep them. Roll back only if no phone synced after the update. If a phone synced, update forward.
If you are changing Saldo yourself
Increase SYNC_SCHEMA_VERSION when the old code cannot read correctly a row that the new code writes:
a new required field, a field with a changed meaning, a removed field. Do not increase it for a
change that only the UI shows.
The check above stops an old peer from losing data that it cannot keep. An unnecessary increase makes all users rebuild for nothing. A missing increase can make a user lose data.
Security notes
-
Authentication is Better Auth, with its passkey plugin. It manages accounts, passkeys, sessions and revocation. Each use of a session renews it. Thus the app keeps one credential, not an access and refresh pair.
-
To sign in on the phone, the app runs the passkey ceremony on the
/auth/mobilepage of this server, because WebAuthn needs a browser. The server then gives the session to the app with a single-use, one-minute token. The token is in a link to the URL scheme of the app:saldo://, or theSALDO_SCHEMEof the build. This server must allow that scheme inAPP_REDIRECT_SCHEMES.A different app on the phone can register the same scheme. Thus this short window is a real risk, and we accept it. To close it, the app needs a verified app link (
webcredentials:/assetlinks.json). That needs a paid Apple Developer account. -
Better Auth limits the rate of the auth endpoints: 20 requests each minute. On
/sign-in,/sign-upand the routes that change a password or an email, the limit is 3 each 10 seconds.The limit uses the IP of the caller, from
X-Forwarded-For. Better Auth trusts that header only when it has one value, unlessTRUSTED_PROXIESnames the reverse proxy in front of this server. See the Configuration table andapps/web/docker/Caddyfile.example.Warning: Behind a reverse proxy, set
TRUSTED_PROXIES. If you do not set it, no caller has an IP, and all callers share one counter. Then one attacker can stop the/sign-inof all other persons. -
The limiter keeps its counters in memory, and this will not change: this repository has no shared store. That is correct for the one container of the
docker-compose.ymlof this repository. It is wrong when you run more than one replica, or deploy to Vercel. Each process (each replica, each serverless call) counts only the requests in its own memory. Thus the real limit is multiplied by the number of processes, and nothing tells you. Better Auth acceptsrateLimit.storage: 'secondary-storage'for this case. This repository does not connect one. -
The server checks the scheme of the mobile
redirect_uriagainst an allow-list before a ceremony can start. -
The sign-in uses discoverable credentials only. It asks for no identifier, so nobody can use the sign-in to learn if an account exists.